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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ separately by `model.SchemaVersion` (currently 1.5.0).
`REQUESTY_API_KEY` (or `PGBOT_AI_API_KEY`), the same way the `openrouter`
alias works. It is never auto-detected, and it never picks up an OpenAI or
OpenRouter key.
- **Opper as an `explain` / `ask` provider.** `PGBOT_AI_PROVIDER=opper` sends
`/chat/completions` to `https://api.opper.ai/v3/compat` with `OPPER_API_KEY`
(or `PGBOT_AI_API_KEY`), the same way the `requesty` alias works. It is
never auto-detected, and it never picks up an OpenAI or OpenRouter key.

### Changed
- `model.ServerInfo` gains `instance` and `instance_role` (additive). JSON
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ one SSH connection serves the whole run. Raise `--timeout` if the link is slow.
| `PGBOT_CONFIG` | Path to `.pgbot.toml` (otherwise discovered from cwd upward, then `$XDG_CONFIG_HOME`). |
| `OPENAI_API_KEY` / `OPENROUTER_API_KEY` | Enables `ask` / `explain` via OpenAI or OpenRouter. Keys are never accepted as flags. |
| `REQUESTY_API_KEY` | Enables `ask` / `explain` via [Requesty](https://docs.requesty.ai) with `PGBOT_AI_PROVIDER=requesty` (never auto-detected). |
| `OPPER_API_KEY` | Enables `ask` / `explain` via [Opper](https://docs.opper.ai) with `PGBOT_AI_PROVIDER=opper` (never auto-detected). |
| `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. |
Expand Down Expand Up @@ -715,11 +716,15 @@ not require confirmation.
| 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,
Requesty, Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio.
Requesty, Opper, Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio.
`PGBOT_AI_PROVIDER=requesty` with `REQUESTY_API_KEY` points it at
`https://router.requesty.ai/v1`; set `PGBOT_AI_MODEL` to any Requesty model id
(for example `openai/gpt-4o-mini`), and `PGBOT_AI_BASE_URL` to
`https://router.eu.requesty.ai/v1` to keep requests in the EU.
`PGBOT_AI_PROVIDER=opper` with `OPPER_API_KEY` points it at
`https://api.opper.ai/v3/compat`, an EU-hosted gateway. The default
`gpt-5.6-terra` works there as is; set `PGBOT_AI_MODEL` to any Opper model id
(for example `claude-sonnet-4-6`).

```
export OPENAI_API_KEY=…
Expand Down
1 change: 1 addition & 0 deletions internal/ai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
// Endpoints the explicit PGBOT_AI_PROVIDER aliases stand for.
defaultOpenRouterURL = "https://openrouter.ai/api/v1"
defaultRequestyURL = "https://router.requesty.ai/v1"
defaultOpperURL = "https://api.opper.ai/v3/compat"
defaultOllamaURL = "http://localhost:11434/v1"

// Applied only to reasoning models. Override with $PGBOT_AI_REASONING_EFFORT
Expand Down
14 changes: 11 additions & 3 deletions internal/ai/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,19 @@ func Resolve() (LanguageModel, error) {
ReasoningEffort: envOr("PGBOT_AI_REASONING_EFFORT", ""),
}

case "openai", "openrouter", "requesty", "ollama", "openai-compatible":
case "openai", "openrouter", "requesty", "opper", "ollama", "openai-compatible":
// One /chat/completions client serves them all; only the endpoint and the
// conventional key variable differ.
if key == "" {
if name == "requesty" {
switch name {
case "requesty":
// Requesty only takes its own key, so an OpenAI or OpenRouter key
// is never sent to it.
key = firstEnv("REQUESTY_API_KEY")
} else {
case "opper":
// Same for Opper.
key = firstEnv("OPPER_API_KEY")
default:
key = firstEnv("OPENAI_API_KEY", "OPENROUTER_API_KEY")
}
}
Expand All @@ -137,6 +141,8 @@ func Resolve() (LanguageModel, error) {
base = defaultOpenRouterURL
case "requesty":
base = defaultRequestyURL
case "opper":
base = defaultOpperURL
case "ollama":
base = defaultOllamaURL
case "openai-compatible":
Expand Down Expand Up @@ -202,6 +208,8 @@ func keyVarsFor(name string) string {
return "XAI_API_KEY / GROK_API_KEY"
case "requesty":
return "REQUESTY_API_KEY"
case "opper":
return "OPPER_API_KEY"
default:
return "OPENAI_API_KEY / OPENROUTER_API_KEY"
}
Expand Down
35 changes: 34 additions & 1 deletion internal/ai/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func clearEnv(t *testing.T) {
for _, k := range []string{
"PGBOT_AI_PROVIDER", "PGBOT_AI_MODEL", "PGBOT_AI_BASE_URL", "PGBOT_AI_API_KEY",
"PGBOT_AI_REASONING_EFFORT", "GEMINI_API_KEY", "GOOGLE_API_KEY", "ANTHROPIC_API_KEY",
"OPENAI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY", "XAI_API_KEY", "GROK_API_KEY",
"OPENAI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY", "OPPER_API_KEY", "XAI_API_KEY", "GROK_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION",
"PGBOT_GEMINI_MODEL", "PGBOT_GEMINI_URL", "PGBOT_OPENAI_MODEL", "PGBOT_OPENAI_URL",
} {
Expand Down Expand Up @@ -282,6 +282,39 @@ func TestResolve_requesty(t *testing.T) {
}
}

// opper is an explicit alias only: it goes to Opper with OPPER_API_KEY or
// PGBOT_AI_API_KEY, never borrows an OpenAI key, and is never auto-detected.
func TestResolve_opper(t *testing.T) {
for _, keyVar := range []string{"OPPER_API_KEY", "PGBOT_AI_API_KEY"} {
clearEnv(t)
t.Setenv("PGBOT_AI_PROVIDER", "opper")
t.Setenv(keyVar, "k")
m, err := Resolve()
if err != nil {
t.Fatalf("opper with %s: %v", keyVar, err)
}
if m.Provider() != "openai" {
t.Errorf("opper with %s: provider = %q, want openai", keyVar, m.Provider())
}
if m.Endpoint() != defaultOpperURL {
t.Errorf("opper with %s: endpoint = %q, want %q", keyVar, m.Endpoint(), defaultOpperURL)
}
}

clearEnv(t)
t.Setenv("PGBOT_AI_PROVIDER", "opper")
t.Setenv("OPENAI_API_KEY", "sk-openai")
if _, err := Resolve(); err == nil || !strings.Contains(err.Error(), "OPPER_API_KEY") {
t.Errorf("opper with only OPENAI_API_KEY should ask for OPPER_API_KEY, got: %v", err)
}

clearEnv(t)
t.Setenv("OPPER_API_KEY", "k")
if _, err := Resolve(); err == nil {
t.Error("OPPER_API_KEY alone must not be auto-detected")
}
}

// A custom remote endpoint must come with a named provider. Auto-detecting one
// from an ambient vendor key would send that key — say the ANTHROPIC_API_KEY
// another tool left in the shell — to whatever host PGBOT_AI_BASE_URL names,
Expand Down
Loading