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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ separately by `model.SchemaVersion` (currently 1.2.0).
## [Unreleased]

### Added
- **Bring your own model: Gemini, Anthropic, OpenAI, xAI, or any
OpenAI-compatible endpoint for `explain` and `ask`** (#29, contributed by
@edwardsb). Pick one with `PGBOT_AI_PROVIDER` or let pgbot detect it from
whichever key is set (OpenAI first, as before); `PGBOT_AI_MODEL`,
`PGBOT_AI_BASE_URL`, `PGBOT_AI_API_KEY`, and `PGBOT_AI_REASONING_EFFORT`
override the rest, and the existing `PGBOT_OPENAI_*` / `PGBOT_GEMINI_*`
settings keep working. Anthropic speaks `/v1/messages` (default
`claude-opus-5`), xAI the Responses API (default `grok-4.6`, sent with
`store: false`); OpenRouter, Groq, Together, DeepSeek, Mistral, Ollama, vLLM,
and LM Studio go through `/chat/completions`. The consent prompt now names
the provider, host, and model; a local endpoint is identified as local and
needs no confirmation. Keys still come only from the environment, and every
provider is plain `net/http` — no new dependencies. The model call gets its
own three-minute budget instead of what is left of collection's.
- **`--ssh-tunnel [user@]host[:port]` — reach a database through an SSH jump
host** (#28, contributed by @DiegoDAF). A global flag (or `$PGBOT_SSH_TUNNEL`)
for the RDS-in-a-VPC / Postgres-behind-a-bastion case. It is installed as
Expand All @@ -23,6 +37,11 @@ separately by `model.SchemaVersion` (currently 1.2.0).
`github.com/kevinburke/ssh_config` and `golang.org/x/crypto`.

### Changed
- **The OpenAI default model is now `gpt-5.6-terra`** (was `gpt-4o-mini`), sent
as a reasoning model with `reasoning_effort` `xhigh` and a 32k completion
cap. An existing setup with only `OPENAI_API_KEY` picks this up without any
other change — set `PGBOT_OPENAI_MODEL` (or `PGBOT_AI_MODEL`) to keep the old
model, or `PGBOT_AI_REASONING_EFFORT=low` to keep the cost down.
- **Builds with Go 1.26.** `golang.org/x/crypto` v0.56.0 — the first release
clearing the advisories `govulncheck` reports against the SSH package — needs
Go 1.26, so `go.mod` moves from 1.25.13 to 1.26.8. With the default
Expand Down
59 changes: 37 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ and (only if you want the optional `ask`/`explain` AI layer) one model key:
export DATABASE_URL="postgres://pgbot_ro:…@host:5432/db?sslmode=require"

# optional, for `pgbot ask` / `pgbot explain` — one of:
export OPENAI_API_KEY=sk-… # → OpenAI (gpt-4o-mini by default)
export OPENAI_API_KEY=sk-… # → OpenAI (gpt-5.6-terra by default)
export GEMINI_API_KEY=… # → Google Gemini (AI Studio key)
export ANTHROPIC_API_KEY=… # → Anthropic (claude-opus-5 by default)
export XAI_API_KEY=… # → xAI (grok-4.6 by default)
# …or any OpenAI-compatible endpoint, local ones included — see "explain — optional AI layer"
```

Everything else — `inspect`, `queries`, `indexes`, MCP, CI — is fully
Expand Down Expand Up @@ -428,11 +431,15 @@ one SSH connection serves the whole run. Raise `--timeout` if the link is slow.
| `XDG_STATE_HOME` | Where the baseline store lives; defaults to `~/.local/state`. |
| `PGBOT_SSH_TUNNEL` | SSH jump host used when `--ssh-tunnel` isn't passed (`[user@]host[:port]`, or a `~/.ssh/config` alias). |
| `PGBOT_CONFIG` | Path to `.pgbot.toml` (otherwise discovered from cwd upward, then `$XDG_CONFIG_HOME`). |
| `OPENAI_API_KEY` | Enables `ask` / `explain` via OpenAI. Keys are never accepted as flags. |
| `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | Enables `ask` / `explain` via OpenAI or OpenRouter. Keys are never accepted as flags. |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Enables `ask` / `explain` via Google Gemini. |
| `PGBOT_AI_PROVIDER` | Forces `openai` or `gemini` when both keys are set. |
| `PGBOT_OPENAI_MODEL` / `PGBOT_OPENAI_URL` | Model/endpoint override (any OpenAI-compatible endpoint works). |
| `PGBOT_GEMINI_MODEL` / `PGBOT_GEMINI_URL` | Model/endpoint override for 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_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. |
| `PGBOT_GEMINI_MODEL` / `PGBOT_GEMINI_URL` | Still honored: Gemini-scoped model/endpoint override. |
| `PGBOT_REQUIRE_SIGNATURE` | `install.sh` only: hard-fail unless the cosign signature verifies. |

## Connecting to managed providers
Expand Down Expand Up @@ -560,7 +567,7 @@ pgbot vacuum <connection-string> # autovacuum health per table — dead tuple
pgbot tune <connection-string> # config-tuning recommendations from the workload
pgbot explain <connection-string> # inspect, then have an AI explain the findings
pgbot ask "why is it slow?" # AI answer grounded on the findings ($DATABASE_URL)
--yes skip the "this sends data to Google" confirmation
--yes skip the data-disclosure confirmation prompt
pgbot mcp # run as an MCP server over stdio (for AI agents)
```

Expand Down Expand Up @@ -662,26 +669,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 Expand Up @@ -1113,7 +1125,10 @@ 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 — OpenAI or Gemini (and say so, with a confirmation prompt).
model — Gemini, Anthropic, OpenAI, xAI, 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.

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
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"
}
60 changes: 41 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,33 @@ 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 strings.HasPrefix(strings.ToLower(llm.Endpoint()), "http://") {
// Not local (that returned above), and not TLS: the API key and the
// findings cross the network in the clear. Say so; it is usually a LAN
// address copied from a "local model" recipe.
fmt.Fprintf(os.Stderr, "%s: WARNING — %s is plain http, so the key and the findings travel unencrypted.\n",
cmdName, ai.Host(llm.Endpoint()))
}
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
Loading