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/promptsPrinciples:
- fetch-only, zero SDK dependencies (Bun-native, keeps install light) — use
src/utils/http.tsretry/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:
jsonmode accepts a JSON Schema; validate response and auto-retry once on mismatch - Token/cost accounting on every response (
usagenormalized), surfaced in logs and later in #1389 reports - Graceful absence:
createAiClientreturnsnullwhen 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 insrc/config-validation.ts; defaults insrc/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
AiBudgetExceededErrorwhenmaxTokensPerRunis 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.