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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ 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 for `ask` and `explain`** (#35). Select
`PGBOT_AI_PROVIDER=bedrock` (alias `mantle`) to route OpenAI models through
Responses and Anthropic models through Messages. Authenticate with
`PGBOT_AI_API_KEY` / `AWS_BEARER_TOKEN_BEDROCK`, or use the AWS SDK credential
chain for profiles, SSO, and role credentials. IAM tokens are signed locally,
restricted to the configured regional Mantle HTTPS host, and never forwarded
through redirects. Adds the AWS SDK for credential resolution and signing;
inference continues to use the existing HTTP clients.

## [0.8.1] - 2026-09-06

Expand Down
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ 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). |
| `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 +683,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 credential chain or `AWS_BEARER_TOKEN_BEDROCK` | `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 @@ -696,7 +697,51 @@ Use `PGBOT_AI_PROVIDER` to select a provider explicitly. `PGBOT_AI_MODEL`,
`PGBOT_AI_BASE_URL`, `PGBOT_AI_API_KEY`, and `PGBOT_AI_REASONING_EFFORT`
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.
are read only from environment variables, except for Bedrock IAM credentials
resolved through the AWS SDK.

For AWS Bedrock Mantle, select `bedrock` (or its alias `mantle`) and an AWS
profile. pgbot resolves IAM credentials itself through the AWS SDK's default
credential chain, including environment credentials, shared profiles, SSO,
`aws login` sessions, and workload roles. No AWS CLI subprocess is used.

```sh
export PGBOT_AI_PROVIDER=bedrock
export AWS_PROFILE=your-profile
export AWS_REGION=us-east-1
export PGBOT_AI_MODEL=openai.gpt-5.6-terra
pgbot ask "What needs attention?" --url "$DATABASE_URL"

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

Authentication precedence is `PGBOT_AI_API_KEY`, then
`AWS_BEARER_TOKEN_BEDROCK`, then IAM credential resolution. Explicit tokens
bypass IAM and are not refreshed. Unset both token variables to use
`AWS_PROFILE`. For IAM authentication, the SDK caches and refreshes credentials;
pgbot mints a fresh bearer token per request, valid for at most 15 minutes and
never longer than the credentials. Renew an expired interactive login with
`aws login` or `aws sso login` as appropriate for your profile.

Region precedence is `AWS_REGION`, `AWS_DEFAULT_REGION`, the AWS profile's
region (when using IAM), then `us-east-1`. OpenAI GPT models use the base URL
`https://bedrock-mantle.<region>.api.aws/openai/v1`, 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`. IAM authentication
requires a Mantle HTTPS hostname matching the configured region. Bedrock
requests do not 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).
Expand Down Expand Up @@ -1128,11 +1173,18 @@ 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
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. A
local endpoint (Ollama, vLLM, LM Studio on this machine) is identified as local
and sends nothing off the box.

With Bedrock IAM authentication, the AWS SDK credential chain may also contact
AWS STS, SSO/OIDC, or container/instance metadata services to obtain or refresh
credentials. These authentication requests do not include the findings Context;
the Mantle bearer token is signed locally. Supplying `PGBOT_AI_API_KEY` or
`AWS_BEARER_TOKEN_BEDROCK` bypasses the AWS credential chain.

That Context is PII-free by construction: `pg_stat_statements` text is normalized
(`$1` placeholders), and the one raw-SQL source (`pg_stat_activity` for blocking
chains) is scrubbed of string/numeric literals, emails, and UUIDs before it can
Expand Down
14 changes: 14 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ toolchain go1.27.1

require (
github.com/BurntSushi/toml v1.6.0
github.com/aws/aws-sdk-go-v2 v1.46.0
github.com/aws/aws-sdk-go-v2/config v1.33.3
github.com/charmbracelet/lipgloss v1.1.0
github.com/invopop/jsonschema v0.14.0
github.com/jackc/pgx/v5 v5.10.0
Expand All @@ -19,6 +21,18 @@ require (
)

require (
github.com/aws/aws-sdk-go-v2/credentials v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.9.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
Expand Down
28 changes: 28 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk=
github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw=
github.com/aws/aws-sdk-go-v2/config v1.33.3/go.mod h1:YYDB1kTejxbfAbEVUqgCtkVp26xvNCHev9cLKABMGAk=
github.com/aws/aws-sdk-go-v2/credentials v1.20.3 h1:tToOYM/LXev4NpfWlIYGDvBvjHmJ3HXpRU9ppl+pM6k=
github.com/aws/aws-sdk-go-v2/credentials v1.20.3/go.mod h1:wfGneWyncO7p67wqXV2IQhPk14JqIc25woKlaArT3WI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2 h1:Ldv7RPHs7qwwTscRjAl3YBud32f3BvdAGRmSvAx5L38=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.2/go.mod h1:XyK6UV8xbo66ysVqLd2783C09pBYHOm8aKTRV5DVJ30=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2 h1:q/PSLGuRWCChWg+dLnb9dWOnrCxJtnboXbBtFoqqRrI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.2/go.mod h1:TD1jvU2LvXkJexct5vBqcd8QlNXh5EmRUeL/Z32p0n4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 h1:6fl86IPqKEXoySqiOWdfgbEp9OVbn44zTfEICNEBDhY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2/go.mod h1:63HDfhFkdzBpI8WGXTSKUHPKS6mqldj4u3LJW7RZtSU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2 h1:XMgIRS+uW9F3yFKnXGRrI9pkHi99CXTmoz2kz2/TGBA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.2/go.mod h1:vorxDzK+n3jiv9a5ST/LG0Eu9cSv1CRdKTpG6pDMs+M=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2 h1:ZtHYnumr6QyxhzEzNZwzQTFJEXOswrZqTTkRxthwvr4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.2/go.mod h1:a1NXrYpBd311gBzn1UI5UzJyyvXktM4xNh/ydPiPpqY=
github.com/aws/aws-sdk-go-v2/service/signin v1.9.0 h1:c3k+k/CS4L+sAIH6fxikL+g5g2LpeNczaoyjjw1iMKI=
github.com/aws/aws-sdk-go-v2/service/signin v1.9.0/go.mod h1:AGIoQg99fBrOIQnF78TLx4lj18mc4gZ0hJx1UaLIFM4=
github.com/aws/aws-sdk-go-v2/service/sso v1.37.0 h1:+rqBaOq7jzInjY8M12hr+zEe85JpRll9BjMx38r33Ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.37.0/go.mod h1:XFlVwUsw3sYh8Hw37umYVJnrcWrwXWRxbNw/JY0bblw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0 h1:hzM3GslEAOBcLn3DHH6ENToFi+vXP+n02W+x6zejAIM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.42.0/go.mod h1:588e7skMkYIYkSUseT8E3WKFfbAfrA1bj3Zf+qxJtJY=
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0 h1:N7Ey8obY3uSui+cxl0OUzFlFmkxSucoJnrniFhw+cLc=
github.com/aws/aws-sdk-go-v2/service/sts v1.49.0/go.mod h1:zMBwjSf4Pt8a1OHYiZ5rPD0PJRK1kQrUaxhS/Dbld8E=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
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
117 changes: 117 additions & 0 deletions internal/ai/bedrock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package ai

import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/config"
)

func bedrockModel(model, base, key string, httpc *http.Client) (LanguageModel, error) {
region := firstEnv("AWS_REGION", "AWS_DEFAULT_REGION")
var cfg aws.Config
if key == "" {
var err error
cfg, err = config.LoadDefaultConfig(context.Background())
if err != nil {
return nil, fmt.Errorf("load AWS configuration: %w", err)
}
if region == "" {
region = cfg.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 IAM-derived bearer token through a redirect.
httpc.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if key == "" {
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("IAM authentication requires a Bedrock Mantle HTTPS endpoint matching AWS region %s; set AWS_REGION to the endpoint region", region)
}
httpc.Transport = &bedrockAuth{credentials: cfg.Credentials, 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)
}

// The SDK caches and refreshes credentials. Minting per request is local signing,
// so no separate token cache or refresh goroutine is needed.
type bedrockAuth struct {
credentials aws.CredentialsProvider
region, host string
anthropic bool
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, fmt.Errorf("refusing to send AWS credentials outside the configured Mantle endpoint")
}
creds, err := a.credentials.Retrieve(req.Context())
if err != nil {
return nil, fmt.Errorf("resolve AWS credentials (AWS_PROFILE or the default credential chain): %w", err)
}
token, err := bedrockToken(req.Context(), 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)
}

func bedrockToken(ctx context.Context, creds aws.Credentials, region string, now time.Time) (string, error) {
ttl := 15 * time.Minute
if creds.CanExpire && creds.Expires.Sub(now) < ttl {
ttl = creds.Expires.Sub(now)
}
if ttl < time.Second {
return "", fmt.Errorf("AWS credentials have expired; renew your AWS login")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://bedrock.amazonaws.com/?Action=CallWithBearerToken", nil)
if err != nil {
return "", err
}
query := req.URL.Query()
query.Set("X-Amz-Expires", strconv.FormatInt(int64(ttl/time.Second), 10))
req.URL.RawQuery = query.Encode()
// This request is only presigned, never sent. The empty payload hash matters:
// UNSIGNED-PAYLOAD yields a different signature and an invalid bearer token.
signed, _, err := v4.NewSigner().PresignHTTP(ctx, creds, req, fmt.Sprintf("%x", sha256.Sum256(nil)), "bedrock", region, now)
if err != nil {
return "", fmt.Errorf("sign Bedrock token: %w", err)
}
return "bedrock-api-key-" + base64.StdEncoding.EncodeToString([]byte(strings.TrimPrefix(signed, "https://")+"&Version=1")), nil
}
Loading