Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand All @@ -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=…
Expand Down
4 changes: 4 additions & 0 deletions internal/ai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion internal/ai/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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")
}
Expand All @@ -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":
Expand All @@ -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),
Expand Down Expand Up @@ -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"
}
Expand Down
47 changes: 46 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", "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",
} {
Expand Down Expand Up @@ -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,
Expand Down