feat(api): LLM provider adapters — Anthropic + OpenAI-compatible #337

Merged
james merged 1 commit from 336-llm-provider-adapters into main 2026-06-29 00:26:40 +00:00
Owner

Adds the thin non-streaming inference layer (ADR-0029 §1 + §4, #336): an LlmClient interface with an Anthropic adapter (official @anthropic-ai/sdk) and an OpenAI-compatible adapter (plain fetch — covers OpenAI, OpenRouter, and local Ollama). It consumes #333's per-user config/key and the #51 tool registry.

Rebased onto main after #334 merged, so the diff is just the adapters.

What's in it (apps/api/lib/llm/)

  • client.tsLlmClient interface (generate(req): Promise<LlmResult>), normalized types (LlmMessage/LlmToolDef/LlmGenerateRequest/LlmResult/LlmStopReason), a typed LlmError (not_configured/key_required/auth/rate_limited/overloaded/provider_error — carries only kind/message/status, never the key or raw error), and getLlmClientForUser(userId, db).
  • tools.ts — zod → JSON Schema (z.toJSONSchema, allOf-flattened, mirroring the MCP endpoint) + llmToolDefsFromRegistry() over #51's allTools().
  • anthropic.ts — translates normalized ↔ Anthropic Messages API (system, tool_use/tool_result blocks, tools); injectable client/fetch for tests; model-agnostic (no thinking/sampling params in v1). 529/overload detected via status === 529 || type === "overloaded_error" (the brief's OverloadedError class doesn't exist in SDK 0.106).
  • openai-compatible.tsfetch to ${baseUrl}/chat/completions, Authorization omitted when keyless; maps function-tools + tool_calls/tool role; injectable fetch.
  • getLlmClientForUser selects the adapter from the #333 config + decrypted key (anthropic → key required; openai_compatible → key optional).

Bounded to inference + provider selection — streaming, the agent loop, conversations/messages, the SSE endpoint, and the chat UI are later tickets that consume this.

Dependency

@anthropic-ai/sdk@^0.106.0 (latest stable; the requested ^0.69.0 is superseded). Established package (won't trip package-age); no install lifecycle script, so no onlyBuiltDependencies entry. OpenAI-compatible uses fetch — no openai dep. Lockfile updated; pnpm install --frozen-lockfile clean.

Verification

  • typecheck + lint clean; semgrep (full CI pack set) — 0 findings (incl. the user-baseUrl fetch); openapi:check up to date (lib-only, no contract change).
  • Full suite on BOTH engines: 1150 passed / 0 skipped (ephemeral Postgres — the provider-selection test touches #333's table via describePerEngine). Tests inject transport (fake Anthropic client / fake fetch) — no live API calls: request translation, response normalization (text + tool_calls + stop reason), tool-use round, error mapping, keyless Ollama, selection + not_configured/key_required, and an assertion that no tool def carries user_id.

Closes #336

🤖 Generated with Claude Code

Adds the thin **non-streaming inference layer** (ADR-0029 §1 + §4, #336): an `LlmClient` interface with an **Anthropic** adapter (official `@anthropic-ai/sdk`) and an **OpenAI-compatible** adapter (plain `fetch` — covers OpenAI, OpenRouter, and **local Ollama**). It consumes #333's per-user config/key and the #51 tool registry. Rebased onto `main` after #334 merged, so the diff is just the adapters. ## What's in it (`apps/api/lib/llm/`) - `client.ts` — `LlmClient` interface (`generate(req): Promise<LlmResult>`), normalized types (`LlmMessage`/`LlmToolDef`/`LlmGenerateRequest`/`LlmResult`/`LlmStopReason`), a typed `LlmError` (`not_configured`/`key_required`/`auth`/`rate_limited`/`overloaded`/`provider_error` — carries only kind/message/status, never the key or raw error), and `getLlmClientForUser(userId, db)`. - `tools.ts` — zod → JSON Schema (`z.toJSONSchema`, `allOf`-flattened, mirroring the MCP endpoint) + `llmToolDefsFromRegistry()` over #51's `allTools()`. - `anthropic.ts` — translates normalized ↔ Anthropic Messages API (system, `tool_use`/`tool_result` blocks, `tools`); injectable client/fetch for tests; **model-agnostic** (no `thinking`/sampling params in v1). 529/overload detected via `status === 529 || type === "overloaded_error"` (the brief's `OverloadedError` class doesn't exist in SDK 0.106). - `openai-compatible.ts` — `fetch` to `${baseUrl}/chat/completions`, `Authorization` omitted when keyless; maps function-tools + `tool_calls`/`tool` role; injectable `fetch`. - `getLlmClientForUser` selects the adapter from the #333 config + decrypted key (anthropic → key required; openai_compatible → key optional). Bounded to **inference + provider selection** — streaming, the agent loop, `conversations`/`messages`, the SSE endpoint, and the chat UI are later tickets that consume this. ## Dependency `@anthropic-ai/sdk@^0.106.0` (latest stable; the requested `^0.69.0` is superseded). Established package (won't trip package-age); **no install lifecycle script**, so no `onlyBuiltDependencies` entry. OpenAI-compatible uses `fetch` — no `openai` dep. Lockfile updated; `pnpm install --frozen-lockfile` clean. ## Verification - typecheck + lint clean; **semgrep (full CI pack set) — 0 findings** (incl. the user-`baseUrl` fetch); `openapi:check` up to date (lib-only, no contract change). - **Full suite on BOTH engines: 1150 passed / 0 skipped** (ephemeral Postgres — the provider-selection test touches #333's table via `describePerEngine`). Tests inject transport (fake Anthropic client / fake `fetch`) — **no live API calls**: request translation, response normalization (text + tool_calls + stop reason), tool-use round, error mapping, keyless Ollama, selection + `not_configured`/`key_required`, and an assertion that no tool def carries `user_id`. Closes #336 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api): LLM provider adapters — Anthropic + OpenAI-compatible
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / Static analysis (pull_request) Successful in 1m50s
PR / OpenAPI (pull_request) Successful in 2m22s
PR / OSV-Scanner (pull_request) Successful in 30s
PR / Test (sqlite) (pull_request) Successful in 3m41s
PR / Build (pull_request) Successful in 3m51s
PR / Client (web export smoke) (pull_request) Successful in 4m0s
PR / Test (postgres) (pull_request) Failing after 4m8s
PR / Lint (pull_request) Successful in 4m16s
PR / Typecheck (pull_request) Successful in 4m24s
PR / pnpm audit (pull_request) Successful in 4m17s
PR / Package age policy (soft) (pull_request) Successful in 47s
Secrets / gitleaks (pull_request) Successful in 40s
PR / Coverage (soft) (pull_request) Successful in 4m11s
PR / Trivy (image) (pull_request) Successful in 4m22s
PR / E2E (Playwright) (pull_request) Successful in 7m53s
e95bdbac11
Adds the thin non-streaming inference layer (ADR-0029 §1 + §4, #336): an
LlmClient interface with an Anthropic adapter (official @anthropic-ai/sdk)
and an OpenAI-compatible adapter (plain fetch — covers OpenAI, OpenRouter,
and local Ollama). Normalized messages + the #51 tool registry (zod →
JSON Schema) translate to each provider and back to
{ text, toolCalls, stopReason }.

getLlmClientForUser selects the adapter from the #333 per-user config +
decrypted key (anthropic → key required; openai_compatible → key optional
for keyless local). Typed LlmError never carries the key or the raw
provider error. Model-agnostic v1: no thinking/sampling params.

Bounded to inference + provider selection — streaming, the agent loop,
conversations/messages, the SSE endpoint, and the chat UI are later
tickets that consume this. New dep @anthropic-ai/sdk@^0.106.0 (no install
lifecycle script). Tests use injected transport (no live API calls).

Closes #336

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 78.9% ≥ 50%
Branches 70.8% ⚠️ ≥ 75%
Functions 79.3% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 78.9% ✅ | ≥ 50% | | Branches | 70.8% ⚠️ | ≥ 75% | | Functions | 79.3% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james force-pushed 336-llm-provider-adapters from e95bdbac11
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / Static analysis (pull_request) Successful in 1m50s
PR / OpenAPI (pull_request) Successful in 2m22s
PR / OSV-Scanner (pull_request) Successful in 30s
PR / Test (sqlite) (pull_request) Successful in 3m41s
PR / Build (pull_request) Successful in 3m51s
PR / Client (web export smoke) (pull_request) Successful in 4m0s
PR / Test (postgres) (pull_request) Failing after 4m8s
PR / Lint (pull_request) Successful in 4m16s
PR / Typecheck (pull_request) Successful in 4m24s
PR / pnpm audit (pull_request) Successful in 4m17s
PR / Package age policy (soft) (pull_request) Successful in 47s
Secrets / gitleaks (pull_request) Successful in 40s
PR / Coverage (soft) (pull_request) Successful in 4m11s
PR / Trivy (image) (pull_request) Successful in 4m22s
PR / E2E (Playwright) (pull_request) Successful in 7m53s
to 32c903d0b5
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 12s
PR / Static analysis (pull_request) Successful in 2m21s
PR / OpenAPI (pull_request) Successful in 3m9s
PR / Lint (pull_request) Successful in 3m21s
PR / Typecheck (pull_request) Successful in 3m28s
PR / Client (web export smoke) (pull_request) Successful in 3m37s
PR / Build (pull_request) Successful in 3m46s
PR / pnpm audit (pull_request) Successful in 3m41s
PR / OSV-Scanner (pull_request) Successful in 1m31s
PR / Test (sqlite) (pull_request) Successful in 4m4s
PR / Trivy (image) (pull_request) Successful in 55s
PR / Package age policy (soft) (pull_request) Successful in 39s
Secrets / gitleaks (pull_request) Successful in 29s
PR / Test (postgres) (pull_request) Failing after 4m19s
PR / E2E (Playwright) (pull_request) Successful in 5m32s
PR / Coverage (soft) (pull_request) Successful in 2m44s
2026-06-29 00:18:07 +00:00
Compare
james merged commit 5e3b1e9c30 into main 2026-06-29 00:26:40 +00:00
james deleted branch 336-llm-provider-adapters 2026-06-29 00:26:41 +00:00
Sign in to join this conversation.
No description provided.