/** * @factory/ai — Anthropic adapter (ai-architecture §2.2). * * - `x-api-key` + `anthropic-version: 2023-06-01` headers * - `system` is a **top-level parameter**, not a message (mapped here) * - tool use via `tools` / `tool_choice` (JSON-Schema `input_schema`) * - **No embedding API** → `embed()` throws; the gateway routes embed to a * fallback provider (`openai` default, configurable via * `AiConfig.embedFallbackProvider`) */ import type { ChatChunk, ChatMessage, ChatRequest, ChatResult, EmbedRequest, EmbedResult, ModelInfo, ProviderAdapter, ToolCall, } from "../types.js"; import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js"; export interface AnthropicOptions extends HttpOptions { baseUrl?: string; apiKey: string; now?: () => number; } const ANTHROPIC_VERSION = "2023-06-01"; interface AnthropicTextBlock { type: "text"; text: string; } interface AnthropicToolUseBlock { type: "tool_use"; id: string; name: string; input: Record; } interface AnthropicToolResultPart { type: "tool_result"; tool_use_id: string; content: string; } type AnthropicSseEvent = { type: string; message?: { usage?: { input_tokens?: number; output_tokens?: number } }; index?: number; content_block?: { type: string; text?: string; id?: string; name?: string }; delta?: { type: string; text?: string; partial_json?: string }; usage?: { input_tokens?: number; output_tokens?: number }; error?: { type?: string; message?: string }; }; export class AnthropicAdapter implements ProviderAdapter { readonly id = "anthropic" as const; private readonly baseUrl: string; private readonly apiKey: string; private readonly http: HttpOptions; private readonly now: () => number; constructor(options: AnthropicOptions) { this.baseUrl = (options.baseUrl ?? "https://api.anthropic.com/v1").replace(/\/+$/, ""); this.apiKey = options.apiKey; this.http = { ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.idleMs !== undefined ? { idleMs: options.idleMs } : {}), }; this.now = options.now ?? Date.now; } private headers(): Record { return { "content-type": "application/json", "x-api-key": this.apiKey, "anthropic-version": ANTHROPIC_VERSION, }; } async models(): Promise { // Anthropic has no /models endpoint — static table of the claude family. const base = { provider: "anthropic" as const, kind: "chat" as const, toolCalls: true, vision: true, pricePer1kIn: 3, pricePer1kOut: 15, dimension: 0, }; return [ { id: "claude-sonnet-4-20250514", contextWindow: 200_000, maxOutput: 64_000, ...base }, { id: "claude-opus-4-20250514", contextWindow: 200_000, maxOutput: 32_000, ...base, pricePer1kIn: 15, pricePer1kOut: 75 }, { id: "claude-3-5-haiku-20241022", contextWindow: 200_000, maxOutput: 8_192, ...base, pricePer1kIn: 0.8, pricePer1kOut: 4 }, ]; } async *chat(req: ChatRequest): AsyncIterable { const system = extractSystem(req.messages); const messages = req.messages .filter((m) => m.role !== "system") .map(mapMessage); const body: Record = { model: req.model, messages, max_tokens: req.maxTokens ?? 4_096, stream: true, }; if (system) body.system = system; if (req.temperature !== undefined) body.temperature = req.temperature; if (req.tools && req.tools.length > 0) { body.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters, })); body.tool_choice = mapToolChoice(req.toolChoice); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.http.timeoutMs ?? 60_000); // TASK-0122: a caller abort (mid-request or mid-stream) aborts the fetch. const onCallerAbort = () => controller.abort(); if (req.signal) { if (req.signal.aborted) controller.abort(); else req.signal.addEventListener("abort", onCallerAbort, { once: true }); } let response: Response; try { const fetchImpl = this.http.fetchImpl ?? fetch; response = await fetchImpl(`${this.baseUrl}/messages`, { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: controller.signal, }); } finally { clearTimeout(timer); if (req.signal) req.signal.removeEventListener("abort", onCallerAbort); } if (!response.ok) { const text = await response.text().catch(() => ""); throw new ProviderHttpError(response.status, `HTTP ${response.status}: ${text.slice(0, 200)}`, response.status === 429 || response.status >= 500); } let inputTokens = 0; let outputTokens = 0; let stopReason: string | null = null; // tool_use blocks by content-block index (id/name at start, args as partial_json deltas) const toolBlocks = new Map(); try { for await (const event of sseData(response, this.http, req.signal)) { if (event.type === "message_start" && event.message?.usage) { inputTokens = event.message.usage.input_tokens ?? 0; } else if (event.type === "content_block_start" && event.content_block && event.content_block.type === "tool_use") { const idx = event.index ?? 0; toolBlocks.set(idx, { id: event.content_block.id ?? `toolu_${idx}`, name: event.content_block.name ?? "", args: "", }); } else if (event.type === "content_block_delta" && event.delta) { if (event.delta.type === "text_delta" && event.delta.text) { yield { delta: event.delta.text, done: false }; } else if (event.delta.type === "input_json_delta" && event.delta.partial_json) { const idx = event.index ?? 0; const block = toolBlocks.get(idx); if (block) block.args += event.delta.partial_json; } } else if (event.type === "message_delta") { if (event.usage) outputTokens = Math.max(outputTokens, event.usage.output_tokens ?? 0); const stop = (event as { delta?: { stop_reason?: string } }).delta?.stop_reason; if (stop) stopReason = stop; } else if (event.type === "error") { throw new ProviderHttpError(502, `provider error: ${event.error?.message ?? "unknown"}`, true); } } } catch (err) { // A caller abort passes through unwrapped (the gateway maps it to the // non-retryable `ai_aborted` error); provider error events + transport // failures become retryable provider errors. if (err instanceof ProviderHttpError || (err instanceof Error && err.name === "AbortError")) throw err; throw new ProviderHttpError(502, err instanceof Error ? err.message : "stream failed", true); } // emit completed tool calls (deterministic content-block index order) for (const [idx, block] of [...toolBlocks.entries()].sort((a, b) => a[0] - b[0])) { yield { toolCallDelta: { id: block.id, name: block.name, arguments: parseArgs(block.args) }, done: false, }; } const finalReason: ChatResult["finishReason"] = stopReason === "tool_use" || toolBlocks.size > 0 ? "tool_calls" : stopReason === "max_tokens" ? "length" : "stop"; yield { usage: { in: inputTokens, out: outputTokens, total: inputTokens + outputTokens }, finishReason: finalReason, done: true, }; } /** * Non-streaming chat (used by the gateway's `collect` path and by tests): * runs the streaming chat and folds tool_use blocks into completed * `ToolCall`s. This is the only place Anthropic's tool-fragment protocol is * reassembled, keeping the public `chat()` protocol text+usage-only. */ async chatCollected(req: ChatRequest): Promise<{ content: string; toolCalls: ToolCall[]; usage: { in: number; out: number }; finishReason: ChatResult["finishReason"] }> { const body: Record = { model: req.model, messages: req.messages.filter((m) => m.role !== "system").map(mapMessage), max_tokens: req.maxTokens ?? 4_096, }; const system = extractSystem(req.messages); if (system) body.system = system; if (req.temperature !== undefined) body.temperature = req.temperature; if (req.tools && req.tools.length > 0) { body.tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })); body.tool_choice = mapToolChoice(req.toolChoice); } const res = await httpJson<{ content: Array; usage: { input_tokens: number; output_tokens: number }; stop_reason: string | null; }>( `${this.baseUrl}/messages`, { method: "POST", headers: this.headers(), body: JSON.stringify(body) }, this.http, ); let content = ""; const toolCalls: ToolCall[] = []; for (const block of res.content) { if (block.type === "text") content += block.text; if (block.type === "tool_use") toolCalls.push({ id: block.id, name: block.name, arguments: block.input ?? {} }); } return { content, toolCalls, usage: { in: res.usage.input_tokens, out: res.usage.output_tokens }, finishReason: mapStopReason(res.stop_reason ?? "end_turn", toolCalls.length > 0), }; } /** Anthropic has no embedding API (arch §2.2) — the gateway routes embed elsewhere. */ async embed(_req: EmbedRequest): Promise { throw new ProviderHttpError( 501, "Anthropic has no embedding API; the gateway routes embed to the fallback provider", false, ); } async health(): Promise<{ ok: boolean; latencyMs: number }> { const started = this.now(); try { await httpJson(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, { ...this.http, timeoutMs: 5_000 }); return { ok: true, latencyMs: this.now() - started }; } catch { return { ok: false, latencyMs: this.now() - started }; } } } // ── mapping helpers ──────────────────────────────────────────────────────────── function extractSystem(messages: ChatMessage[]): string | null { const parts: string[] = []; for (const m of messages) { if (m.role === "system" && typeof m.content === "string") parts.push(m.content); } return parts.length > 0 ? parts.join("\n\n") : null; } function mapMessage(msg: ChatMessage): Record { if (msg.role === "tool") { const part: AnthropicToolResultPart = { type: "tool_result", tool_use_id: msg.toolCallId ?? "", content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content), }; return { role: "user", content: [part] }; } if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) { const content: Array> = []; if (typeof msg.content === "string" && msg.content.length > 0) { content.push({ type: "text", text: msg.content }); } for (const tc of msg.toolCalls) { content.push({ type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments }); } return { role: "assistant", content }; } return { role: msg.role === "assistant" ? "assistant" : "user", content: msg.content }; } function mapToolChoice(tc: ChatRequest["toolChoice"]): unknown { if (tc === undefined || tc === "auto") return { type: "auto" }; if (tc === "none") return { type: "none" }; return { type: "tool", name: tc.name }; } function mapStopReason(reason: string, sawToolUse: boolean): ChatResult["finishReason"] { if (reason === "tool_use" || sawToolUse) return "tool_calls"; if (reason === "max_tokens" || reason === "length") return "length"; return "stop"; } function parseArgs(args: string): Record { if (!args.trim()) return {}; try { const parsed: unknown = JSON.parse(args); return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; } catch { return {}; } }