Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ separately by `model.SchemaVersion` (currently 1.2.0).
alone is enough to pick a database. pgx's `ParseConfig` already reads
`PGSERVICEFILE` (or the libpq default path); this just stops pgbot from
erroring out before pgx gets a chance to.
- **AWS Bedrock Mantle as an `explain` / `ask` provider** (#35, contributed by
@edwardsb). `PGBOT_AI_PROVIDER=bedrock` (alias `mantle`) routes `openai.*`
models through the Responses API and `anthropic.*` models through the
Messages API on `bedrock-mantle.<region>.api.aws`; the default is
`openai.gpt-5.6-terra`. Authenticate with `AWS_BEARER_TOKEN_BEDROCK` or with
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, from
which pgbot mints Bedrock's short-lived bearer token itself — a hundred lines
of SigV4 over the standard library, pinned to AWS's reference generator by a
golden-signature test. No AWS SDK, no config files, no STS or metadata calls:
a profile or SSO login is exported with
`eval "$(aws configure export-credentials --format env)"`. Access keys only
ever go to the Mantle host for the configured region, and Bedrock requests
never follow redirects.

## [0.8.1] - 2026-09-06

Expand Down
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ one SSH connection serves the whole run. Raise `--timeout` if the link is slow.
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Enables `ask` / `explain` via Google Gemini. |
| `ANTHROPIC_API_KEY` | Enables `ask` / `explain` via Anthropic. |
| `XAI_API_KEY` / `GROK_API_KEY` | Enables `ask` / `explain` via xAI. |
| `PGBOT_AI_PROVIDER` | `gemini`, `anthropic`, `openai`, or `xai` — picks one when several keys are set (auto-detection tries OpenAI first). |
| `AWS_BEARER_TOKEN_BEDROCK`, or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` | Enables `ask` / `explain` via AWS Bedrock Mantle with `PGBOT_AI_PROVIDER=bedrock` (never auto-detected). `AWS_REGION` picks the endpoint; `AWS_CREDENTIAL_EXPIRATION` bounds the minted token. |
| `PGBOT_AI_PROVIDER` | `gemini`, `anthropic`, `openai`, `xai`, or `bedrock` (alias `mantle`) — picks one when several keys are set (auto-detection tries OpenAI first). |
| `PGBOT_AI_MODEL` / `PGBOT_AI_BASE_URL` / `PGBOT_AI_API_KEY` | Model, endpoint, and key override for whichever provider is selected; the way to reach an OpenAI-compatible service (OpenRouter, Groq, Ollama, vLLM, …). |
| `PGBOT_AI_REASONING_EFFORT` | `none`, `low`, `medium`, `high`, `xhigh`, or `max` for reasoning models (OpenAI's default here is `xhigh`). |
| `PGBOT_OPENAI_MODEL` / `PGBOT_OPENAI_URL` | Still honored: OpenAI-scoped model/endpoint override. |
Expand Down Expand Up @@ -683,6 +684,7 @@ not require confirmation.
| Anthropic | `ANTHROPIC_API_KEY` | `claude-opus-5` | `/v1/messages` |
| OpenAI | `OPENAI_API_KEY` | `gpt-5.6-terra` | `/chat/completions` |
| xAI | `XAI_API_KEY` / `GROK_API_KEY` | `grok-4.6` | `/responses` |
| Bedrock Mantle | `AWS_BEARER_TOKEN_BEDROCK`, or `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | `openai.gpt-5.6-terra` | Responses (GPT) / Messages (Claude) |

The OpenAI provider also supports compatible services such as OpenRouter,
Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio.
Expand All @@ -698,6 +700,50 @@ override its defaults. Existing `PGBOT_GEMINI_MODEL` and `PGBOT_GEMINI_URL`
and `PGBOT_OPENAI_MODEL` and `PGBOT_OPENAI_URL` settings remain supported. Keys
are read only from environment variables.

For AWS Bedrock Mantle, select `bedrock` (or its alias `mantle`) and give pgbot
AWS credentials the same way you give it any other key — through the environment.
No AWS SDK, no config files read, no calls to STS or instance metadata: pgbot
mints Bedrock's bearer token itself from the standard three variables.

```sh
export PGBOT_AI_PROVIDER=bedrock
export AWS_REGION=us-east-1
# a profile, an SSO login, or an assumed role becomes the three variables:
eval "$(aws configure export-credentials --profile your-profile --format env)"
pgbot ask "What needs attention?" "$DATABASE_URL"

# Claude uses the Anthropic Messages API automatically:
export PGBOT_AI_MODEL=anthropic.claude-sonnet-5
pgbot ask "What needs attention?" "$DATABASE_URL"
```

Authentication precedence is `PGBOT_AI_API_KEY`, then `AWS_BEARER_TOKEN_BEDROCK`
(a Bedrock API key from the console), then `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN` for temporary credentials).
An explicit token is sent as-is. From access keys pgbot mints a fresh bearer
token per request, valid for at most 15 minutes and never past
`AWS_CREDENTIAL_EXPIRATION` when that is set (the export command sets it).
`AWS_PROFILE` on its own does not authenticate — export it as above.

Region precedence is `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. The
default model is `openai.gpt-5.6-terra`. OpenAI GPT models use
`https://bedrock-mantle.<region>.api.aws/openai/v1` and the Responses API, as
documented by
[AWS for OpenAI GPT models](https://aws.amazon.com/blogs/machine-learning/get-started-with-openai-gpt-5-6-sol-terra-and-luna-on-amazon-bedrock/);
models beginning with `anthropic.` use
`https://bedrock-mantle.<region>.api.aws/anthropic` and the Messages API. Set
`PGBOT_AI_MODEL` to the exact Bedrock model ID available to your account and
region, including for GPT-6 models. `PGBOT_AI_BASE_URL` overrides the base URL,
without the final `/responses` or `/v1/messages`. Access keys are only ever sent
to the Mantle HTTPS host for the configured region, and Bedrock requests never
follow redirects.

Responses requests set `store=false`, omit sampling temperature for GPT-5/6
reasoning models, and allow at least 32,000 output tokens (including hidden
reasoning). `PGBOT_AI_REASONING_EFFORT` is optional for Responses; when unset,
the service chooses its default. Claude keeps the existing Messages request
shape. Neither protocol retries inference automatically.

**Exit codes** (a stable contract for CI): `0` clean · `1` warnings · `2` critical
findings · `3` connection/execution failure · `64` usage error (bad flags/args).
Suppressed findings never contribute to the exit code.
Expand Down Expand Up @@ -1128,8 +1174,11 @@ package is scoped. Use `npx @pgbot/cli`.
Nothing leaves the machine unless you ask for it: every command except the AI
layer is entirely local. The only commands that make an outbound call are `pgbot
explain` and `pgbot ask`, which send the same PII-free Context to your configured
model — Gemini, Anthropic, OpenAI, xAI, or an OpenAI-compatible endpoint — and
say so, naming the provider, host, and model, with a confirmation prompt. A
model — Gemini, Anthropic, OpenAI, xAI, AWS Bedrock Mantle, or an
OpenAI-compatible endpoint — and say so, naming the provider, host, and model,
with a confirmation prompt. Bedrock's token is minted locally from your
environment credentials; pgbot never reads AWS config files or calls STS or
instance metadata. A
local endpoint (Ollama, vLLM, LM Studio on this machine) is identified as local
and sends nothing off the box.

Expand Down
10 changes: 8 additions & 2 deletions internal/ai/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ const (

// AnthropicProvider talks to the Messages API.
type AnthropicProvider struct {
Label string
APIKey string
BaseURL string
HTTP *http.Client
}

func (p *AnthropicProvider) Name() string { return "anthropic" }
func (p *AnthropicProvider) Name() string {
if p.Label != "" {
return p.Label
}
return "anthropic"
}

func (p *AnthropicProvider) LanguageModel(_ context.Context, modelID string) (LanguageModel, error) {
if modelID == "" {
Expand All @@ -47,7 +53,7 @@ type anthropicModel struct {
model string
}

func (m *anthropicModel) Provider() string { return "anthropic" }
func (m *anthropicModel) Provider() string { return m.provider.Name() }
func (m *anthropicModel) Model() string { return m.model }
func (m *anthropicModel) Endpoint() string { return m.provider.BaseURL }

Expand Down
219 changes: 219 additions & 0 deletions internal/ai/bedrock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package ai

// AWS Bedrock Mantle: OpenAI GPT models through the Responses API and Claude
// through the Messages API, on AWS's endpoints, with AWS credentials.
//
// No AWS SDK. Bedrock authenticates with a bearer token that is nothing more
// than a SigV4-presigned URL of `https://bedrock.amazonaws.com/?Action=
// CallWithBearerToken`, base64-encoded — about a hundred lines of HMAC below.
// Credentials come from the three variables every AWS tool understands
// (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN); a profile, an
// SSO login, or an assumed role becomes those with one command:
//
// eval "$(aws configure export-credentials --profile prod --format env)"
//
// That keeps pgbot's promise: keys only from the environment, no config files
// read, no calls to STS or instance metadata, and one static binary.

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)

const (
// A minted token lives this long at most (the service allows up to 12h;
// pgbot makes one call and has no reason to hold a longer-lived secret).
bedrockTokenTTL = 15 * time.Minute
bedrockSTSHost = "bedrock.amazonaws.com"
)

// awsCredentials are the environment credentials a token is minted from.
type awsCredentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string // empty for long-lived keys
Expires time.Time // zero when not known
}

// awsCredentialsFromEnv reads the standard variables. AWS_CREDENTIAL_EXPIRATION
// is what `aws configure export-credentials --format env` emits next to them;
// honoring it keeps a minted token from outliving the credentials behind it.
func awsCredentialsFromEnv() (awsCredentials, error) {
c := awsCredentials{
AccessKeyID: envOr("AWS_ACCESS_KEY_ID", ""),
SecretAccessKey: envOr("AWS_SECRET_ACCESS_KEY", ""),
SessionToken: envOr("AWS_SESSION_TOKEN", ""),
}
if c.AccessKeyID == "" || c.SecretAccessKey == "" {
return c, errors.New("no AWS credentials for Bedrock — set AWS_BEARER_TOKEN_BEDROCK (a Bedrock API key), " +
"or AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN) and pgbot mints a short-lived token; " +
"a profile or SSO login exports them with: eval \"$(aws configure export-credentials --format env)\"")
}
if exp := envOr("AWS_CREDENTIAL_EXPIRATION", ""); exp != "" {
t, err := time.Parse(time.RFC3339, exp)
if err != nil {
return c, fmt.Errorf("AWS_CREDENTIAL_EXPIRATION %q is not an RFC 3339 timestamp", exp)
}
c.Expires = t
}
return c, nil
}

func bedrockModel(model, base, key string, httpc *http.Client) (LanguageModel, error) {
region := firstEnv("AWS_REGION", "AWS_DEFAULT_REGION")
if region == "" {
region = "us-east-1"
}
if model == "" {
model = "openai." + defaultOpenAIModel
}
anthropic := strings.HasPrefix(model, "anthropic.")
if base == "" {
base = "https://bedrock-mantle." + region + ".api.aws"
if anthropic {
base += "/anthropic"
} else {
base += "/openai/v1"
}
}
base = trimURL(base)
// Never forward a supplied or minted bearer token through a redirect.
httpc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if key == "" {
creds, err := awsCredentialsFromEnv()
if err != nil {
return nil, err
}
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Host != "bedrock-mantle."+region+".api.aws" {
return nil, fmt.Errorf("AWS credentials are only sent to the Bedrock Mantle HTTPS endpoint for region %s; set AWS_REGION to the endpoint's region", region)
}
httpc.Transport = &bedrockAuth{creds: creds, region: region, host: u.Host, anthropic: anthropic, next: http.DefaultTransport}
}
if anthropic {
p := &AnthropicProvider{APIKey: key, BaseURL: base, HTTP: httpc, Label: "bedrock"}
return p.LanguageModel(context.Background(), model)
}
p := &ResponsesProvider{APIKey: key, BaseURL: base, HTTP: httpc, Label: "bedrock", ReasoningEffort: envOr("PGBOT_AI_REASONING_EFFORT", "")}
return p.LanguageModel(context.Background(), model)
}

// bedrockAuth mints a fresh token per request. Minting is local HMAC work, so
// there is nothing to cache or refresh; the token's TTL is bounded by the
// credentials' own expiry.
type bedrockAuth struct {
creds awsCredentials
region, host string
anthropic bool // Messages API takes x-api-key; the Responses API a Bearer
next http.RoundTripper
}

func (a *bedrockAuth) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" || req.URL.Host != a.host {
return nil, errors.New("refusing to send AWS credentials outside the configured Mantle endpoint")
}
token, err := bedrockToken(a.creds, a.region, time.Now().UTC())
if err != nil {
return nil, err
}
clone := req.Clone(req.Context())
if a.anthropic {
clone.Header.Set("x-api-key", token)
} else {
clone.Header.Set("Authorization", "Bearer "+token)
}
return a.next.RoundTrip(clone)
}

// bedrockToken builds the bearer token AWS's own token generators produce: a
// SigV4 query-presigned POST to bedrock.amazonaws.com?Action=CallWithBearerToken
// (empty-payload hash; UNSIGNED-PAYLOAD yields an invalid token), with the
// scheme stripped and "&Version=1" appended, base64-encoded, prefixed.
func bedrockToken(creds awsCredentials, region string, now time.Time) (string, error) {
ttl := bedrockTokenTTL
if !creds.Expires.IsZero() && creds.Expires.Sub(now) < ttl {
ttl = creds.Expires.Sub(now)
}
if ttl < time.Second {
return "", errors.New("AWS credentials have expired; renew your AWS login and export them again")
}
amzDate := now.UTC().Format("20060102T150405Z")
scope := amzDate[:8] + "/" + region + "/bedrock/aws4_request"
params := map[string]string{
"Action": "CallWithBearerToken",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": creds.AccessKeyID + "/" + scope,
"X-Amz-Date": amzDate,
"X-Amz-Expires": strconv.FormatInt(int64(ttl/time.Second), 10),
"X-Amz-SignedHeaders": "host",
}
if creds.SessionToken != "" {
params["X-Amz-Security-Token"] = creds.SessionToken
}
query := sigv4Query(params)
emptyPayload := sha256.Sum256(nil)
canonical := strings.Join([]string{
http.MethodPost, "/", query,
"host:" + bedrockSTSHost, "", // canonical headers, then the blank line
"host", hex.EncodeToString(emptyPayload[:]),
}, "\n")
canonicalHash := sha256.Sum256([]byte(canonical))
toSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, scope, hex.EncodeToString(canonicalHash[:])}, "\n")
key := []byte("AWS4" + creds.SecretAccessKey)
for _, part := range []string{amzDate[:8], region, "bedrock", "aws4_request"} {
key = hmacSHA256(key, part)
}
signature := hex.EncodeToString(hmacSHA256(key, toSign))
presigned := bedrockSTSHost + "/?" + query + "&X-Amz-Signature=" + signature
return "bedrock-api-key-" + base64.StdEncoding.EncodeToString([]byte(presigned+"&Version=1")), nil
}

// sigv4Query renders params as SigV4's canonical query string: keys sorted,
// every key and value RFC 3986-encoded (only unreserved characters bare, hex
// upper-case, space as %20 — not the form encoding net/url produces).
func sigv4Query(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, sigv4Escape(k)+"="+sigv4Escape(params[k]))
}
return strings.Join(parts, "&")
}

func sigv4Escape(s string) string {
const hexDigits = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case 'A' <= c && c <= 'Z', 'a' <= c && c <= 'z', '0' <= c && c <= '9', c == '-', c == '_', c == '.', c == '~':
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&15])
}
}
return b.String()
}

func hmacSHA256(key []byte, data string) []byte {
h := hmac.New(sha256.New, key)
h.Write([]byte(data))
return h.Sum(nil)
}
Loading