feat(api): LlmProvider adapters — Anthropic + OpenAI-compatible inference layer #336
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol#336
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The inference layer ADR-0029 §1 calls for and #333 deferred: a thin provider abstraction with two adapters, consuming the per-user config + decrypted key from #333 and the #51 tool registry. Part of epic #47.
Bounded to non-streaming inference + provider selection. Streaming, the agent loop,
conversations/messages, and the chat UI are later tickets that build on this.Scope
apps/api/lib/llm/): a client abstraction — name it to avoid colliding with the existingLlmProviderenum type from #333 (e.g.LlmClient):generate(req): Promise<LlmResult>. Uses ADR-0029 §4 normalized messages:LlmMessage { role: "user"|"assistant"|"tool", content: string|null, toolCalls?: {id,name,input}[], toolCallId?: string }LlmGenerateRequest { system?: string, messages: LlmMessage[], tools: LlmToolDef[], model: string, maxTokens?: number }LlmResult { text: string, toolCalls: {id,name,input}[], stopReason: "end_turn"|"tool_use"|"max_tokens"|"refusal"|"other" }ToolListEntry.inputSchema(zod) → JSON Schema (zod v4z.toJSONSchema, as the MCP endpoint #331 did) → Anthropic tool / OpenAI function. A helper buildsLlmToolDef[]fromallTools().@anthropic-ai/sdk. Normalized → Anthropic Messages API (system, messages withtool_use/tool_resultblocks,toolsas{name,description,input_schema});messages.create; responsecontentblocks (text+tool_use {id,name,input}) +stop_reason→LlmResult. Model from config. Inject the client (custombaseURL/fetch) for tests. Model-agnostic: omitthinkingand sampling params for v1 (the loop ticket tunes effort/thinking/streaming).fetchto${baseUrl}/chat/completions(OpenAI, OpenRouter, local Ollama). Normalized → OpenAI chat messages (assistanttool_calls,toolrole withtool_call_id) + function-tools; parse →LlmResult. Injectfetchfor tests. Noopenaidependency.getLlmClientForUser(userId, db)reads the #333 config, decrypts the key (getDecryptedLlmKey), returns the right adapter, or throws a typed "not configured" / "key required" error.@anthropic-ai/sdk(pinned, established — vet against the install-script allowlist + package-age policy). No new env var, no migration, no UI.Acceptance criteria
LlmClientinterface + Anthropic and OpenAI-compatible adapters; both translate normalized messages + #51 tools → a provider call and normalize the response (text + tool_calls + stop reason).getLlmClientForUserselects the adapter from the user's stored config + decrypted key (#333/#334); keyless local (Ollama) supported.Out of scope
conversations/messages, the SSE chat endpoint, the chat UI (next tickets, which consume this).Depends on #333 (config +
getDecryptedLlmKey) and #51 (registry). Implements ADR-0029.