From 13f4db33850d314948d1358b9996256aa6b59539 Mon Sep 17 00:00:00 2001 From: Felix Wunderlich Date: Wed, 7 Oct 2026 15:34:04 +0200 Subject: [PATCH] feat(ai): add an opper provider alias Add Opper, an EU-hosted OpenAI compatible gateway, as a PGBOT_AI_PROVIDER alias for explain and ask, wired the same way as the requesty alias. No new client code: it reuses the /chat/completions provider and only picks the endpoint and key variable. - internal/ai/openai.go: defaultOpperURL = https://api.opper.ai/v3/compat - internal/ai/resolve.go: opper joins the /chat/completions case, maps to its endpoint and reads its key from OPPER_API_KEY (or PGBOT_AI_API_KEY) only. keyVarsFor names OPPER_API_KEY. It is not added to auto detection. - internal/ai/resolve_test.go: TestResolve_opper, a copy of the requesty test. - README environment table and explain section, CHANGELOG entry under Unreleased. The default model gpt-5.6-terra is served by Opper under the same id, with reasoning_effort up to max, so it works without PGBOT_AI_MODEL. Not run locally: scripts/gate.sh, go test, gofmt (no Go toolchain on this machine). The change copies the requesty alias line for line. Disclosure: Opper is my startup. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++++ README.md | 7 ++++++- internal/ai/openai.go | 1 + internal/ai/resolve.go | 14 +++++++++++--- internal/ai/resolve_test.go | 35 ++++++++++++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf33d2..0077b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2a154b5..8b66feb 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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=… diff --git a/internal/ai/openai.go b/internal/ai/openai.go index ac254e2..47c4f2c 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -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 diff --git a/internal/ai/resolve.go b/internal/ai/resolve.go index e92802c..d57fba4 100644 --- a/internal/ai/resolve.go +++ b/internal/ai/resolve.go @@ -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") } } @@ -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": @@ -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" } diff --git a/internal/ai/resolve_test.go b/internal/ai/resolve_test.go index 81b5486..644a890 100644 --- a/internal/ai/resolve_test.go +++ b/internal/ai/resolve_test.go @@ -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", } { @@ -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,