Skip to content

BYOK: pluggable model providers (Gemini, Anthropic, OpenAI, xAI) - #29

Merged
alexshapalov merged 1 commit into
pgrundev:mainfrom
cavenine:feat/byok-model-providers
Sep 6, 2026
Merged

BYOK: pluggable model providers (Gemini, Anthropic, OpenAI, xAI)#29
alexshapalov merged 1 commit into
pgrundev:mainfrom
cavenine:feat/byok-model-providers

Conversation

@edwardsb

@edwardsb edwardsb commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Expand the existing OpenAI and Gemini integration to support Anthropic, xAI, and OpenAI-compatible services. The implementation uses net/http and adds no dependencies.

Providers

Provider Default model API
Gemini gemini-flash-latest generateContent
Anthropic claude-opus-5 /v1/messages
OpenAI gpt-5.6-terra /chat/completions
xAI grok-4.6 /responses

The OpenAI-compatible provider also supports OpenRouter, Groq, Together, DeepSeek, Mistral, Ollama, vLLM, and LM Studio.

Configuration

PGBOT_AI_PROVIDER selects a provider. If unset, pgbot detects one from the available API keys. OpenAI remains first in the detection order to preserve current behavior.

General overrides:

  • PGBOT_AI_MODEL
  • PGBOT_AI_BASE_URL
  • PGBOT_AI_API_KEY
  • PGBOT_AI_REASONING_EFFORT

Existing PGBOT_OPENAI_MODEL, PGBOT_OPENAI_URL, PGBOT_GEMINI_MODEL, and PGBOT_GEMINI_URL settings remain supported. Keys are read only from environment variables.

Behavior

  • Consent prompts identify the provider, host, and model receiving the findings.
  • Local endpoints do not require confirmation.
  • Model requests have a separate three-minute timeout from database collection.
  • AI failures do not suppress the deterministic report.
  • The Responses API sends store: false.
  • Anthropic requests omit temperature, which current models reject.
  • OpenAI reasoning models use max_completion_tokens and reasoning_effort.
  • OpenAI-compatible services continue to use /chat/completions.

The provider interface follows the Provider and LanguageModel structure from charmbracelet/fantasy, implemented directly over net/http.

Verification

Live end-to-end tests against PostgreSQL passed after rebasing onto current upstream main:

  • Gemini: gemini-flash-latest
  • Anthropic: claude-opus-5
  • OpenAI: gpt-5.6-terra
  • xAI: grok-4.6 via /responses

Ollama was also verified through /chat/completions, including local-endpoint consent behavior.

The repository gate passes: build, vet, golangci-lint, all Go tests, and Linux/macOS builds for amd64 and arm64.

The AI layer only spoke Gemini: NewFromEnv demanded GEMINI_API_KEY, Generate
wrote the generateContent wire format inline, and both `explain` and `ask`
hardcoded "this sends data to Google" in their consent prompts. Anyone holding
an Anthropic, OpenAI, OpenRouter or xAI key — or running a model locally —
couldn't use it at all.

internal/ai now has a provider seam modelled on charmbracelet/fantasy's
Provider/LanguageModel pair, implemented over net/http rather than depending on
fantasy: it wraps the real vendor SDKs (anthropic-sdk-go, openai-go, genai,
aws-sdk-go-v2) and measured at 23 MB → ~65 MB of binary and 61 → ~170 modules,
for one non-streaming POST in an optional feature. Four providers, two wire
formats, zero new dependencies:

- gemini      generateContent — unchanged behavior, default gemini-flash-latest
              (a moving alias, so it tracks 3.7 Flash without a re-pin)
- anthropic   /v1/messages, default claude-opus-5
- openai      /chat/completions, default gpt-5.6-terra — also the compatibility
              path for OpenRouter, Groq, Together, DeepSeek, xAI, Mistral and
              every local runtime (Ollama, vLLM, LM Studio)
- xai         /responses, default grok-4.6

Provider comes from PGBOT_AI_PROVIDER, else auto-detects from whichever key is
present (Gemini first, so existing setups are untouched). PGBOT_AI_MODEL,
PGBOT_AI_BASE_URL, PGBOT_AI_API_KEY and PGBOT_AI_REASONING_EFFORT override;
PGBOT_GEMINI_MODEL/URL still work. Keys are still read only from the
environment, never a flag — now enforced in one place for every provider.

Wire-format details that are load-bearing, each found by probing the live APIs:

- Anthropic rejects `temperature` on current models (400), so the provider never
  sends it — Call.Temperature is a hint, not a contract. A refusal is an HTTP 200
  with empty content, so stop_reason is checked before reading blocks.
- OpenAI reasoning models reject `max_tokens` ("use max_completion_tokens") and
  take reasoning_effort; their cap covers hidden reasoning as well as the answer,
  so it is floored at 32k — 8192 returns empty text with finish_reason "length".
  Dispatch is by model id, so local runtimes keep the plain shape.
- The Responses API defaults `store` to true, retaining the findings server-side
  after the call. pgbot always sends store:false — that retention isn't the
  disclosure the user consented to.
- xAI returns errors as {"code":…,"error":"<string>"} where OpenAI nests an
  object. Both providers now decode either shape, or the real message is lost.

`explain` and `ask` name the actual destination before sending ("…to xai at
api.x.ai (model grok-4.6)"), and with a local endpoint say nothing leaves the
machine and skip the prompt entirely. The model call also gets its own deadline
instead of the leftovers of the 45s collection budget, which a CPU-bound local
model always blew.

Verified end-to-end against live endpoints: Grok 4.6 via /responses and a local
Ollama via /chat/completions both produced real explanations for `explain` and
`ask`; Anthropic reached auth and surfaced its error; bad-key paths degrade to
the deterministic report with the vendor's message intact. Unit tests cover each
provider's request shape, error handling and env precedence. Binary unchanged at
16 MB, still 61 modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@edwardsb

edwardsb commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

@alexshapalov hey Alex, not sure if you're accepting PRs, but I thought it would be cool to give folks more options on inference providers.

I tested the ones listed in the description. I also recently tested Muse 1.3 from Meta using the OpenAI configuration (muse is openai compatible).

If this isn't something you'd like to maintain, feel free to close the PR, nbd

@alexshapalov

Copy link
Copy Markdown
Contributor

Code review

Found 5 issues, all fixed in 2c48f04 (pushed to this branch with maintainer edits, on top of a merge of main):

  1. An explicit PGBOT_AI_PROVIDER=xai (or grok) with no xAI key silently borrowed OPENAI_API_KEY; with PGBOT_AI_BASE_URL also set, that sent OpenAI's key to api.x.ai. Only the endpoint-shaped responses alias now falls back to the OpenAI key.

label := "xai"
if key == "" {
key = firstEnv("XAI_API_KEY", "GROK_API_KEY")
}
if key == "" && firstEnv("OPENAI_API_KEY") != "" {
key, label = firstEnv("OPENAI_API_KEY"), "openai"
if base == "" {
base = envOr("PGBOT_OPENAI_URL", defaultOpenAIURL)
}
if model == "" {
model = envOr("PGBOT_OPENAI_MODEL", defaultOpenAIModel)
}
}

  1. The openai-family aliases picked their endpoint from the raw key variables, not the alias: PGBOT_AI_PROVIDER=openrouter with the key given as PGBOT_AI_API_KEY went to api.openai.com, and ollama with no base URL went to OpenAI too. Each alias now names its endpoint, and openai-compatible asks for PGBOT_AI_BASE_URL.

if base == "" {
base = envOr("PGBOT_OPENAI_URL", "")
if base == "" {
base = defaultOpenAIURL
if os.Getenv("OPENAI_API_KEY") == "" && os.Getenv("OPENROUTER_API_KEY") != "" {
base = "https://openrouter.ai/api/v1"
}
}
}

  1. A remote PGBOT_AI_BASE_URL with no PGBOT_AI_PROVIDER let auto-detection choose the wire format and key from whatever vendor variable was in the shell (an ANTHROPIC_API_KEY left by another tool) and send that key to the named host, with nothing shown under --yes. It is now refused with a message asking for the provider; a local base URL still works unnamed.

func Resolve() (LanguageModel, error) {
name := strings.ToLower(envOr("PGBOT_AI_PROVIDER", ""))
if name == "" {
var err error
if name, err = detectProvider(); err != nil {
return nil, err
}
}

  1. The Anthropic and Responses paths forwarded the 8192-token hint as the output cap even though Opus 5 and grok-4 think inside that same budget; the /chat/completions path already floors reasoning models at 32k for exactly this reason. Both now floor it (16k Anthropic, 32k Responses).

func (m *anthropicModel) Generate(ctx context.Context, c Call) (*Response, error) {
maxTokens := 8192
if c.MaxOutputTokens != nil {
maxTokens = int(*c.MaxOutputTokens)
}

  1. The README was only partly updated: the quickstart still offered OpenAI (gpt-4o-mini) or Gemini, the environment table listed only the old keys and said PGBOT_AI_PROVIDER forces openai or gemini, and the privacy section still named two providers.

pgbot/README.md

Lines 62 to 68 in 0a8ade2

```sh
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 GEMINI_API_KEY=… # → Google Gemini (AI Studio key)
```

Also added a cleartext warning on the consent line for a plain-http remote endpoint, and CHANGELOG entries.

One thing for the maintainer rather than a defect: the OpenAI default moves from gpt-4o-mini to gpt-5.6-terra, classed as a reasoning model and sent with reasoning_effort: xhigh and a 32k completion cap. The commit that introduced the OpenAI provider chose a small cheap model on purpose ("a small, cheap chat model is plenty for an explanation task"). Anyone with only OPENAI_API_KEY set gets the new cost and latency with no config change. Left as submitted and documented in the changelog with the override; it is a one-constant change if you want the cheaper default.

Checked for bugs, consent and key handling, git history, prior PRs and issues (#32 is satisfied by this design without a vendor-specific case), and code-comment guidance; this repo has no CLAUDE.md. Verified the local-endpoint check fails safe against spoofed hostnames, keys travel only in headers, and response bodies are bounded.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@alexshapalov

Copy link
Copy Markdown
Contributor

Correction to the review above: this fork is organization-owned, so GitHub refused the maintainer push. The follow-up commit 2c48f04 lives in #34, which carries your commit 0a8ade2 unchanged plus the fixes; merging #34 lands this PR's commit and marks it merged. Thanks for the contribution.

alexshapalov added a commit that referenced this pull request Sep 6, 2026
BYOK: pluggable model providers (Gemini, Anthropic, OpenAI, xAI) — #29 plus review fixes
@alexshapalov
alexshapalov merged commit b53978c into pgrundev:main Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants