ReviewOS

stacks/buddy

AI: model-agnostic provider layer (BYOK — Anthropic, OpenAI, Google, OpenRouter)

#1373
Closed chris opened this 9 days ago ·
9 days ago

Part of #1367 (Phase 1 — AI Foundation). This is the keystone issue: every AI feature (I08–I16, I21, I22, I24) builds on it.

Goal

A model-agnostic, BYOK LLM provider layer, matching Pullfrog's model story (docs, keys) — their strongest differentiator vs CodeRabbit's managed-only models:

  • Providers: Anthropic, OpenAI, Google, OpenRouter, custom OpenAI-compatible endpoints (one interface covers DeepSeek/xAI/Groq/Mistral/etc. via base-URL override)
  • Curated model aliases resolving to latest versions (claude → claude-fable-5 / current flagship; gpt, gemini, …)
  • Auto-selection: explicit config > first provider with an available key > clear error
  • Reasoning effort as a separate axis (low|medium|high) mapped per-provider (thinking budgets / reasoning_effort)
  • Corporate gateways: ANTHROPIC_BASE_URL-style env overrides, Bedrock/Vertex can come later (leave seams)

Design

New directory src/ai/:

src/ai/
├─ index.ts          # public surface
├─ types.ts          # AiProvider, AiMessage, AiTool, AiResponse, ModelRef, Effort
├─ client.ts         # createAiClient(config): AiClient — resolution, retry, streaming
├─ providers/
│  ├─ anthropic.ts   # Messages API (fetch-based, no SDK dep)
│  ├─ openai.ts      # Chat Completions / Responses (also serves all OpenAI-compatible)
│  ├─ google.ts      # Gemini generateContent
│  └─ openrouter.ts  # thin wrapper over openai.ts with base URL + headers
├─ aliases.ts        # curated alias → concrete model id table (versioned, easily bumped)
└─ redact.ts         # secret masking for logs/prompts

Principles:

  • fetch-only, zero SDK dependencies (Bun-native, keeps install light) — use src/utils/http.ts retry/timeout fetch
  • Unified interface: complete(request: { system, messages, tools?, maxTokens?, effort?, json?: boolean | JsonSchema }) with normalized tool-calling across providers (needed by the agent runtime #1374)
  • Structured output: json mode accepts a JSON Schema; validate response and auto-retry once on mismatch
  • Token/cost accounting on every response (usage normalized), surfaced in logs and later in #1389 reports
  • Graceful absence: createAiClient returns null when no key is configured; all AI features must check and no-op with a helpful message (epic principle #4)

Config (src/types.ts)

ai?: {
  enabled?: boolean            // default: true when a key is present
  provider?: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'openai-compatible'
  model?: string               // alias or concrete id
  effort?: 'low' | 'medium' | 'high'
  apiKeyEnv?: string           // env var name override
  baseUrl?: string             // gateways / openai-compatible
  maxTokensPerRun?: number     // hard budget guard
}

Env conventions: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY / GEMINI_API_KEY, OPENROUTER_API_KEY; per-run override BUDDY_BOT_MODEL.

Tasks

  • src/ai/ per the layout above; validation in src/config-validation.ts; defaults in src/config.ts
  • Alias table + resolution-order logic with tests
  • Normalized tool-calling shim (Anthropic tool_use / OpenAI tool_calls / Gemini functionCall) with a conformance test suite run against recorded fixtures (no live API in CI)
  • Secret redaction: never log keys; mask sk-…/key-shaped strings in errors
  • Budget guard: abort with a typed AiBudgetExceededError when maxTokensPerRun is hit
  • docs/ai/providers.md: setup per provider, alias table, env matrix

Acceptance criteria

  • Same complete() call runs unmodified against all four providers (fixture-tested)
  • No key configured → features log "AI disabled (no API key)" and the dependency bot behaves exactly as today
  • Switching models is a one-line config change (Pullfrog parity claim)

Dependencies

None. Blocks #1374 and everything in Phase 2.

9 days ago

Implemented in 5c87092 / 67e3a3a.

One deviation from the issue spec worth recording: it called for a fetch-only implementation with zero SDK dependencies. Anthropic now goes through the official @anthropic-ai/sdk instead, because its request surface (thinking configuration, effort, structured outputs, error typing) has changed shape repeatedly across recent model generations and a hand-rolled client would drift. The other providers stay fetch-based — they share the OpenAI wire format, so one implementation covers OpenAI, OpenRouter and self-hosted gateways.

Two design decisions that differ from a naive reading of "model-agnostic":

Only Anthropic ships a default model. Resolving gpt or gemini to a specific ID would mean guessing at another vendor's catalogue and silently routing requests to a model the user never chose, so the other providers require an explicit model ID and are skipped during auto-selection without one. Unknown model strings pass through unchanged, so a model released after the alias table was written works immediately.

refusal is preserved as its own stop reason rather than folded into end. A refusal returns HTTP 200 with empty or partial content — a caller that cannot tell the two apart reads the emptiness as a valid answer.

createAiClient returns null rather than throwing when no key is configured, which is the normal state for a dependency bot; 36 tests cover resolution, per-provider mapping, budget enforcement and redaction, all fixture-driven with no live API calls.

Sign in to comment on this issue.