From d82b0368767f4a172c87804c2f078f10b28c6965 Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:29:10 +0000 Subject: [PATCH] feat(ai): add a runcrate provider alias --- CHANGELOG.md | 7 ++++++ README.md | 7 +++++- internal/ai/openai.go | 4 ++++ internal/ai/resolve.go | 12 +++++++++- internal/ai/resolve_test.go | 47 ++++++++++++++++++++++++++++++++++++- 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f136d71..6f1856d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ separately by `model.SchemaVersion` (currently 1.5.0). ## [Unreleased] +### Added +- **Runcrate as an `explain` / `ask` provider.** `PGBOT_AI_PROVIDER=runcrate` + sends `/chat/completions` to `https://api.runcrate.ai/v1` with + `RUNCRATE_API_KEY` (or `PGBOT_AI_API_KEY`), the same way the `opper` alias + works, and defaults to `deepseek-ai/DeepSeek-V4.1-Flash`. It is never + auto-detected, and it never picks up an OpenAI or OpenRouter key. + ## [0.9.0] - 2026-10-18 ### Added diff --git a/README.md b/README.md index 3f39509..49435ab 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,7 @@ one SSH connection serves the whole run. Raise `--timeout` if the link is slow. | `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). | +| `RUNCRATE_API_KEY` | Enables `ask` / `explain` via [Runcrate](https://www.runcrate.ai) with `PGBOT_AI_PROVIDER=runcrate` (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. | @@ -783,7 +784,7 @@ 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, Opper, Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio. +Requesty, Opper, Runcrate, 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 @@ -792,6 +793,10 @@ Requesty, Opper, Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio. `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`). +`PGBOT_AI_PROVIDER=runcrate` with `RUNCRATE_API_KEY` points it at +`https://api.runcrate.ai/v1` and defaults to `deepseek-ai/DeepSeek-V4.1-Flash`; +set `PGBOT_AI_MODEL` to any id from `https://api.runcrate.ai/v1/models` (for +example `zai-org/GLM-5.3`). ``` export OPENAI_API_KEY=… diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 47c4f2c..c1252b7 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -18,8 +18,12 @@ const ( defaultOpenRouterURL = "https://openrouter.ai/api/v1" defaultRequestyURL = "https://router.requesty.ai/v1" defaultOpperURL = "https://api.opper.ai/v3/compat" + defaultRuncrateURL = "https://api.runcrate.ai/v1" defaultOllamaURL = "http://localhost:11434/v1" + // Runcrate serves open models only, so its alias needs its own default. + defaultRuncrateModel = "deepseek-ai/DeepSeek-V4.1-Flash" + // Applied only to reasoning models. Override with $PGBOT_AI_REASONING_EFFORT // (none, low, medium, high, xhigh, max). defaultReasoningEffort = "xhigh" diff --git a/internal/ai/resolve.go b/internal/ai/resolve.go index d57fba4..9a7aa50 100644 --- a/internal/ai/resolve.go +++ b/internal/ai/resolve.go @@ -113,7 +113,7 @@ func Resolve() (LanguageModel, error) { ReasoningEffort: envOr("PGBOT_AI_REASONING_EFFORT", ""), } - case "openai", "openrouter", "requesty", "opper", "ollama", "openai-compatible": + case "openai", "openrouter", "requesty", "opper", "runcrate", "ollama", "openai-compatible": // One /chat/completions client serves them all; only the endpoint and the // conventional key variable differ. if key == "" { @@ -125,6 +125,9 @@ func Resolve() (LanguageModel, error) { case "opper": // Same for Opper. key = firstEnv("OPPER_API_KEY") + case "runcrate": + // Same for Runcrate. + key = firstEnv("RUNCRATE_API_KEY") default: key = firstEnv("OPENAI_API_KEY", "OPENROUTER_API_KEY") } @@ -143,6 +146,8 @@ func Resolve() (LanguageModel, error) { base = defaultRequestyURL case "opper": base = defaultOpperURL + case "runcrate": + base = defaultRuncrateURL case "ollama": base = defaultOllamaURL case "openai-compatible": @@ -157,6 +162,9 @@ func Resolve() (LanguageModel, error) { if model == "" { model = envOr("PGBOT_OPENAI_MODEL", "") } + if model == "" && name == "runcrate" { + model = defaultRuncrateModel + } p = &OpenAIProvider{ APIKey: key, BaseURL: trimURL(base), @@ -210,6 +218,8 @@ func keyVarsFor(name string) string { return "REQUESTY_API_KEY" case "opper": return "OPPER_API_KEY" + case "runcrate": + return "RUNCRATE_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 644a890..1f63334 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", "OPPER_API_KEY", "XAI_API_KEY", "GROK_API_KEY", + "OPENAI_API_KEY", "OPENROUTER_API_KEY", "REQUESTY_API_KEY", "OPPER_API_KEY", "RUNCRATE_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", } { @@ -315,6 +315,51 @@ func TestResolve_opper(t *testing.T) { } } +// runcrate is an explicit alias only: it goes to Runcrate with RUNCRATE_API_KEY +// or PGBOT_AI_API_KEY, never borrows an OpenAI key, is never auto-detected, and +// defaults to a model Runcrate serves. +func TestResolve_runcrate(t *testing.T) { + for _, keyVar := range []string{"RUNCRATE_API_KEY", "PGBOT_AI_API_KEY"} { + clearEnv(t) + t.Setenv("PGBOT_AI_PROVIDER", "runcrate") + t.Setenv(keyVar, "k") + m, err := Resolve() + if err != nil { + t.Fatalf("runcrate with %s: %v", keyVar, err) + } + if m.Provider() != "openai" { + t.Errorf("runcrate with %s: provider = %q, want openai", keyVar, m.Provider()) + } + if m.Endpoint() != defaultRuncrateURL { + t.Errorf("runcrate with %s: endpoint = %q, want %q", keyVar, m.Endpoint(), defaultRuncrateURL) + } + if m.Model() != defaultRuncrateModel { + t.Errorf("runcrate with %s: model = %q, want %q", keyVar, m.Model(), defaultRuncrateModel) + } + } + + clearEnv(t) + t.Setenv("PGBOT_AI_PROVIDER", "runcrate") + t.Setenv("RUNCRATE_API_KEY", "k") + t.Setenv("PGBOT_AI_MODEL", "zai-org/GLM-5.3") + if m, err := Resolve(); err != nil || m.Model() != "zai-org/GLM-5.3" { + t.Errorf("runcrate with PGBOT_AI_MODEL: model = %v, err = %v", m, err) + } + + clearEnv(t) + t.Setenv("PGBOT_AI_PROVIDER", "runcrate") + t.Setenv("OPENAI_API_KEY", "sk-openai") + if _, err := Resolve(); err == nil || !strings.Contains(err.Error(), "RUNCRATE_API_KEY") { + t.Errorf("runcrate with only OPENAI_API_KEY should ask for RUNCRATE_API_KEY, got: %v", err) + } + + clearEnv(t) + t.Setenv("RUNCRATE_API_KEY", "k") + if _, err := Resolve(); err == nil { + t.Error("RUNCRATE_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,