import Anthropic from "@anthropic-ai/sdk"; import { env } from "../env"; import type { ChatRequest, ProviderAdapter, StreamEvent } from "./types"; let client: Anthropic | null = null; export function anthropicClient(): Anthropic { if (!client) client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY, timeout: 15 * 60 * 1000, maxRetries: 2 }); return client; } /** Adaptive thinking is accepted on 4.6+ families; older snapshots reject it. */ const supportsAdaptive = (id: string) => /-4-6|-4-7|-4-8|opus-5|sonnet-5|haiku-5|fable/.test(id); const supportsEffort = (id: string) => supportsAdaptive(id); /** Forced tool_choice is rejected on these; we always use auto anyway. */ const fallbackEligible = (id: string) => /fable-5-1|opus-5-5|opus-5$|sonnet-5-5/.test(id); const temperatureAllowed = (id: string) => /-4-5-|haiku-4-5/.test(id); async function toMessages(req: ChatRequest): Promise { const out: Anthropic.MessageParam[] = []; for (const m of req.messages) { if (m.role === "system") continue; // folded into system prompt by the engine if (m.role === "assistant") { const content: Anthropic.ContentBlockParam[] = []; const text = m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join(""); if (text) content.push({ type: "text", text }); const results: Anthropic.ToolResultBlockParam[] = []; for (const p of m.parts) if (p.type === "tool_call") { content.push({ type: "tool_use", id: p.id, name: p.name, input: p.input ?? {} }); if (p.status === "ok" || p.status === "error" || p.status === "denied") results.push({ type: "tool_result", tool_use_id: p.id, is_error: p.status !== "ok", content: typeof p.output === "string" ? p.output : JSON.stringify(p.status === "ok" ? p.output ?? null : { error: p.error ?? p.status }) }); } if (content.length) out.push({ role: "assistant", content }); if (results.length) out.push({ role: "user", content: results }); continue; } const content: Anthropic.ContentBlockParam[] = []; for (const p of m.parts) { if (p.type === "image") { const a = await req.loadAttachment(p.attachmentId); if (a) content.push({ type: "image", source: { type: "base64", media_type: a.mime as "image/png", data: a.data.toString("base64") } }); } else if (p.type === "file") { const a = await req.loadAttachment(p.attachmentId); if (!a) continue; if (a.mime === "application/pdf") content.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: a.data.toString("base64") }, title: a.name, citations: { enabled: true } } as Anthropic.DocumentBlockParam); else if (a.text) content.push({ type: "document", source: { type: "text", media_type: "text/plain", data: a.text }, title: a.name } as Anthropic.DocumentBlockParam); } } for (const p of m.parts) if (p.type === "text" && p.text) content.push({ type: "text", text: p.text }); if (content.length) { // merge consecutive user turns (tool results followed by a new user message are fine as separate turns) const last = out[out.length - 1]; if (last && last.role === "user" && Array.isArray(last.content) && !(last.content as Anthropic.ContentBlockParam[]).some(c => c.type === "tool_result")) (last.content as Anthropic.ContentBlockParam[]).push(...content); else out.push({ role: "user", content }); } } return out; } export const anthropicAdapter: ProviderAdapter = { provider: "anthropic", async *stream(req: ChatRequest): AsyncGenerator { const id = req.model.id; const s = req.settings ?? {}; const maxTokens = Math.min(s.maxOutputTokens ?? 32_000, req.model.maxOutputTokens ?? 64_000); const body: Anthropic.MessageStreamParams & Record = { model: id, max_tokens: maxTokens, messages: await toMessages(req), }; if (req.system) body.system = [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }]; if (supportsAdaptive(id)) body.thinking = { type: "adaptive", display: s.reasoningSummary === false ? "omitted" : "summarized" } as Anthropic.ThinkingConfigParam; if (s.effort && supportsEffort(id)) body.output_config = { effort: s.effort }; if (s.temperature != null && temperatureAllowed(id)) body.temperature = s.temperature; if (req.tools?.length) body.tools = req.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.inputSchema as Anthropic.Tool["input_schema"] })); let stream: ReturnType; try { if (fallbackEligible(id)) { // server-side refusal fallback (beta): on a safety decline the API re-runs on a fallback model in the same call stream = (anthropicClient().beta.messages.stream as unknown as (p: Record, o?: { signal?: AbortSignal }) => ReturnType)({ ...body, betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" }, { signal: req.signal }); } else stream = anthropicClient().messages.stream(body, { signal: req.signal }); } catch (e) { yield errorEvent(e); return; } const tools = new Map(); let sawTool = false; try { for await (const ev of stream) { switch (ev.type) { case "content_block_start": if (ev.content_block.type === "tool_use") tools.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, json: "" }); break; case "content_block_delta": if (ev.delta.type === "text_delta") yield { type: "text", delta: ev.delta.text }; else if (ev.delta.type === "thinking_delta") { if (ev.delta.thinking) yield { type: "reasoning", delta: ev.delta.thinking }; } else if (ev.delta.type === "input_json_delta") { const t = tools.get(ev.index); if (t) t.json += ev.delta.partial_json; } break; case "content_block_stop": { const t = tools.get(ev.index); if (t) { sawTool = true; let input: unknown = {}; try { input = JSON.parse(t.json || "{}"); } catch { input = { _raw: t.json }; } yield { type: "tool_call", id: t.id, name: t.name, input }; tools.delete(ev.index); } break; } case "message_delta": { const u = ev.usage as Anthropic.MessageDeltaUsage; yield { type: "usage", usage: { input: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output: u.output_tokens ?? 0, cached: u.cache_read_input_tokens ?? 0 } }; if (ev.delta.stop_reason) yield { type: "stop", reason: ev.delta.stop_reason === "tool_use" || sawTool ? "tool_use" : ev.delta.stop_reason, details: (ev.delta as { stop_details?: unknown }).stop_details }; break; } } } const final = await stream.finalMessage(); if ((final as { model?: string }).model && final.model !== id) yield { type: "meta", data: { servedBy: final.model } }; } catch (e) { yield errorEvent(e); } }, }; function errorEvent(e: unknown): StreamEvent { if (e instanceof Anthropic.APIError) return { type: "error", message: e.message, status: e.status, retryable: e.status === 429 || e.status === 529 || (e.status ?? 0) >= 500 }; if ((e as Error)?.name === "AbortError" || /aborted/i.test(String((e as Error)?.message))) return { type: "stop", reason: "cancelled" }; return { type: "error", message: String((e as Error)?.message ?? e) }; } export async function anthropicCountTokens(model: string, system: string | undefined, messages: Anthropic.MessageParam[]): Promise { const r = await anthropicClient().messages.countTokens({ model, messages, ...(system ? { system } : {}) }); return r.input_tokens; }