Skip to content
Merged
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
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,26 +633,31 @@ carry every caveat into any recommendation. The AI text is printed below a
labeled rule (`🤖 generated by … — verify before acting`); if the model errors
or the key is unset, the deterministic report still stands.

This is the **only** command that sends data off the machine — the same PII-free
Context you can see with `inspect --json`. It works with **OpenAI or Google
Gemini**, and the key is always read from the environment (never a flag). pgbot
picks the provider automatically: `OPENAI_API_KEY` → OpenAI, `GEMINI_API_KEY` (or
`GOOGLE_API_KEY`) → Gemini. Set `PGBOT_AI_PROVIDER=openai|gemini` to force one when
both are present.
With a remote model, this sends the same PII-free Context shown by
`inspect --json`. Before sending it, pgbot identifies the provider, host, and
model and asks for confirmation. Local endpoints are identified as local and do
not require confirmation.

```
# OpenAI
export OPENAI_API_KEY=sk-…
pgbot explain "$DATABASE_URL" # gpt-4o-mini by default
| Provider | Key | Default model | API |
|---|---|---|---|
| Gemini | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `gemini-flash-latest` | `generateContent` |
| 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` |

The OpenAI provider also supports compatible services such as OpenRouter,
Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio.

# …or Google Gemini
export GEMINI_API_KEY=… # from Google AI Studio
```
export OPENAI_API_KEY=…
pgbot explain "$DATABASE_URL"
```

Override the model or endpoint per provider: `PGBOT_OPENAI_MODEL` /
`PGBOT_OPENAI_URL` (any OpenAI-compatible endpoint works — Azure OpenAI,
OpenRouter, a local server) and `PGBOT_GEMINI_MODEL` / `PGBOT_GEMINI_URL`.
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.

**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
28 changes: 11 additions & 17 deletions cmd/pgbot/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ func newAskCmd() *cobra.Command {
Short: "Ask an AI about your database, grounded on pgbot's findings",
Long: "Runs the same read-only inspection, then answers your question using ONLY the\n" +
"deterministic findings (the model can't reach into the database). Connection\n" +
"comes from --url or $DATABASE_URL. Sends the PII-free findings to an AI provider —\n" +
"set $OPENAI_API_KEY (OpenAI) or $GEMINI_API_KEY (Google Gemini).",
"comes from --url or $DATABASE_URL. Sends the PII-free findings to the model you\n" +
"configured — Gemini, Anthropic, OpenAI, xAI, or an OpenAI-compatible endpoint.",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAsk(cmd, strings.Join(args, " "), url, f, yes)
Expand All @@ -38,17 +38,16 @@ func newAskCmd() *cobra.Command {
fl.IntVar(&f.ashHz, "ash-hz", 10, "active-session sampling rate in Hz (0 disables)")
fl.BoolVar(&f.noStore, "no-store", false, "do not read or write the local baseline store")
fl.BoolVar(&f.strictPooler, "strict-pooler", false, "refuse (exit 3) behind a transaction pooler")
fl.BoolVar(&yes, "yes", false, "skip the 'this sends data to the AI provider' confirmation prompt")
fl.BoolVar(&yes, "yes", false, "skip the data-disclosure confirmation prompt")
return cmd
}

func runAsk(cmd *cobra.Command, question, url string, f inspectFlags, yes bool) error {
client, err := ai.NewFromEnv()
llm, err := ai.Resolve()
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "pgbot ask: this sends the PII-free findings to %s (model %s).\n", client.Vendor(), client.ModelName())
if !yes && isInteractive() && !confirm() {
if !confirmDisclosure("pgbot ask", llm, yes) {
return fmt.Errorf("aborted")
}

Expand All @@ -66,8 +65,12 @@ func runAsk(cmd *cobra.Command, question, url string, f inspectFlags, yes bool)
return err
}

answer, aiErr := ai.Ask(ctx, client, c, question)
printAnswer(useColor(false), client.ModelName(), answer, aiErr)
// Give the model its own deadline instead of the remainder of collection's
// budget. Local models may need substantially longer than hosted providers.
aiCtx, aiCancel := context.WithTimeout(cmd.Context(), 3*time.Minute)
defer aiCancel()
answer, aiErr := ai.Ask(aiCtx, llm, c, question)
printAnswer(useColor(false), llm.Model(), answer, aiErr)
if aiErr == nil {
// `ask` prints only the model's prose, so a destructive-action guard that
// the model may have reworded away must be reasserted here, verbatim from
Expand Down Expand Up @@ -126,12 +129,3 @@ func printAnswer(color bool, modelName, text string, aiErr error) {
fmt.Println()
fmt.Println(st.Dim("— " + modelName + " · a reading of pgbot's findings; verify before acting"))
}

// confirm reads a y/N from stdin.
func confirm() bool {
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
var resp string
fmt.Fscanln(os.Stdin, &resp)
r := strings.ToLower(strings.TrimSpace(resp))
return r == "y" || r == "yes"
}
53 changes: 34 additions & 19 deletions cmd/pgbot/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ func newExplainCmd() *cobra.Command {
Use: "explain <connection-string>",
Short: "Inspect, then have an AI explain the findings in plain language",
Long: "Runs the same read-only inspection as `pgbot inspect`, prints the deterministic\n" +
"report, then sends the PII-free findings to an AI provider for a plain-language\n" +
"report, then sends the PII-free findings to a model for a plain-language\n" +
"explanation. The findings are still computed locally in Go — the model only\n" +
"explains them, never invents them.\n\n" +
"The key is read from $OPENAI_API_KEY (OpenAI) or $GEMINI_API_KEY (Google Gemini),\n" +
"never a flag; set PGBOT_AI_PROVIDER to force one. This is the only pgbot command\n" +
"that sends data off the machine; the payload is the same PII-free Context you can\n" +
"Configure Gemini, Anthropic, OpenAI, xAI, or an OpenAI-compatible endpoint. Keys\n" +
"are read from the environment, never a flag. With a remote model, the payload is\n" +
"the same PII-free Context you can\n" +
"inspect with `pgbot inspect --json`.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -46,7 +46,7 @@ func newExplainCmd() *cobra.Command {
fl.BoolVar(&f.strictPooler, "strict-pooler", false, "refuse (exit 3) if connected through a transaction pooler")
fl.IntVar(&f.ashHz, "ash-hz", 10, "active-session sampling rate in Hz (0 disables the wait-event profile)")
fl.DurationVar(&f.window, "window", 5*time.Second, "active-session sampling window")
fl.BoolVar(&yes, "yes", false, "skip the 'this sends data to the AI provider' confirmation prompt")
fl.BoolVar(&yes, "yes", false, "skip the data-disclosure confirmation prompt")
fl.StringVar(&f.config, "config", "", "path to .pgbot.toml (default: discover from cwd upward)")
fl.StringArrayVar(&f.ignore, "ignore", nil, "suppress a finding for this run: finding[:object] (repeatable)")
fl.StringVar(&f.failOn, "fail-on", "warn", "exit non-zero on findings at/above this severity: critical|warn|info|none")
Expand All @@ -59,21 +59,12 @@ func runExplain(cmd *cobra.Command, args []string, f inspectFlags, yes bool) err
}
// Build the model client first — fail fast before we connect if the key is
// missing, so the user isn't surprised after a full inspection.
client, err := ai.NewFromEnv()
llm, err := ai.Resolve()
if err != nil {
return err
}

// This is the one command that sends data off the box. Say so, loudly, and
// require an explicit go-ahead unless --yes (or non-interactive).
fmt.Fprintf(os.Stderr, "pgbot explain: this sends the PII-free findings (same as `inspect --json`) to %s (model %s).\n", client.Vendor(), client.ModelName())
if !yes && isInteractive() {
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
var resp string
fmt.Fscanln(os.Stdin, &resp)
if r := strings.ToLower(strings.TrimSpace(resp)); r != "y" && r != "yes" {
return fmt.Errorf("aborted")
}
if !confirmDisclosure("pgbot explain", llm, yes) {
return fmt.Errorf("aborted")
}

connString := firstNonEmpty(argAt(args, 0), os.Getenv("DATABASE_URL"), os.Getenv("PGBOT_DATABASE_URL"))
Expand Down Expand Up @@ -126,8 +117,12 @@ func runExplain(cmd *cobra.Command, args []string, f inspectFlags, yes bool) err
// 2. The AI explanation — clearly labeled as model-generated. If it fails, the
// deterministic report above still stands; we just note the explanation is
// unavailable and exit on the findings' code.
explanation, aiErr := ai.Explain(ctx, client, c)
printAISection(color, client.ModelName(), explanation, aiErr)
// Give the model its own deadline instead of the remainder of collection's
// budget. Local models may need substantially longer than hosted providers.
aiCtx, aiCancel := context.WithTimeout(cmd.Context(), 3*time.Minute)
defer aiCancel()
explanation, aiErr := ai.Explain(aiCtx, llm, c)
printAISection(color, llm.Model(), explanation, aiErr)
// The destructive-action guards, reasserted by code AFTER the model text — so a
// reworded or truncated explanation can never be the thing that drops them.
if aiErr == nil {
Expand All @@ -138,6 +133,26 @@ func runExplain(cmd *cobra.Command, args []string, f inspectFlags, yes bool) err
return nil
}

// confirmDisclosure identifies the remote destination before data is sent.
// Local endpoints do not send findings off the machine and need no confirmation.
func confirmDisclosure(cmdName string, llm ai.LanguageModel, yes bool) bool {
if ai.Local(llm.Endpoint()) {
fmt.Fprintf(os.Stderr, "%s: using a local model at %s (%s) — the findings do not leave this machine.\n",
cmdName, ai.Host(llm.Endpoint()), llm.Model())
return true
}
fmt.Fprintf(os.Stderr, "%s: this sends the PII-free findings (same as `inspect --json`) to %s at %s (model %s).\n",
cmdName, llm.Provider(), ai.Host(llm.Endpoint()), llm.Model())
if yes || !isInteractive() {
return true
}
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
var resp string
fmt.Fscanln(os.Stdin, &resp)
r := strings.ToLower(strings.TrimSpace(resp))
return r == "y" || r == "yes"
}

// printAISection renders the labeled AI block. The banner makes it unmistakable
// that this text is model-generated and must be verified before acting.
func printAISection(color bool, modelName, text string, aiErr error) {
Expand Down
144 changes: 144 additions & 0 deletions internal/ai/anthropic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ai

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)

const (
// The most capable current model. `pgbot explain` is a one-shot call on a
// small payload, so this is cheap in absolute terms — but set
// $PGBOT_AI_MODEL=claude-haiku-4-5 if you run it in a tight loop.
defaultAnthropicModel = "claude-opus-5"
defaultAnthropicURL = "https://api.anthropic.com"
anthropicVersion = "2023-06-01"
)

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

func (p *AnthropicProvider) Name() string { return "anthropic" }

func (p *AnthropicProvider) LanguageModel(_ context.Context, modelID string) (LanguageModel, error) {
if modelID == "" {
modelID = defaultAnthropicModel
}
return &anthropicModel{provider: p, model: modelID}, nil
}

type anthropicModel struct {
provider *AnthropicProvider
model string
}

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

// ---- wire types (only the fields we use) ----

type messagesRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
// Required by the API, and it caps thinking + visible text together: current
// models think before they answer, so a tight value truncates the explanation
// mid-sentence. Same headroom, same reason, as the Gemini path.
MaxTokens int `json:"max_tokens"`
Messages []anthropicMessage `json:"messages"`
// Deliberately no `temperature`: it is REMOVED on current models (Opus 5,
// Sonnet 5, Opus 4.7+) and sending it returns a 400. Call.Temperature is a
// hint, and this provider ignores it.
}

type anthropicMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

type messagesResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
StopReason string `json:"stop_reason"`
StopDetails *struct {
Category string `json:"category"`
} `json:"stop_details"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}

// Generate sends one system + user turn and returns the model's text. No retries
// — a failed explanation must not hang the CLI.
func (m *anthropicModel) Generate(ctx context.Context, c Call) (*Response, error) {
maxTokens := 8192
if c.MaxOutputTokens != nil {
maxTokens = int(*c.MaxOutputTokens)
}
buf, err := json.Marshal(messagesRequest{
Model: m.model,
System: c.System,
MaxTokens: maxTokens,
Messages: []anthropicMessage{{Role: "user", Content: c.Prompt}},
})
if err != nil {
return nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.provider.BaseURL+"/v1/messages", bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", anthropicVersion)
req.Header.Set("x-api-key", m.provider.APIKey) // header, never a query param

resp, err := m.provider.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("calling Anthropic: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))

var mr messagesResponse
if err := json.Unmarshal(body, &mr); err != nil {
return nil, fmt.Errorf("anthropic returned unparseable response (HTTP %d)", resp.StatusCode)
}
if mr.Error != nil {
return nil, fmt.Errorf("anthropic error (%s): %s", mr.Error.Type, mr.Error.Message)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("anthropic HTTP %d", resp.StatusCode)
}
// A refusal is a successful HTTP 200 with empty content — check it before
// reading the blocks, or it looks like an unexplained empty answer.
if mr.StopReason == "refusal" {
reason := "safety"
if mr.StopDetails != nil && mr.StopDetails.Category != "" {
reason = mr.StopDetails.Category
}
return nil, fmt.Errorf("anthropic declined the prompt (%s)", reason)
}
var sb strings.Builder
for _, blk := range mr.Content {
if blk.Type == "text" {
sb.WriteString(blk.Text)
}
}
out := strings.TrimSpace(sb.String())
if out == "" {
return nil, fmt.Errorf("anthropic returned an empty explanation (finish: %s)", mr.StopReason)
}
return &Response{Text: out, FinishReason: mr.StopReason}, nil
}
Loading