spb/chat-spboucher-ai
Public
1import Anthropic from "@anthropic-ai/sdk";2import { env } from "../env";3import type { ChatRequest, ProviderAdapter, StreamEvent } from "./types";45let client: Anthropic | null = null;6export function anthropicClient(): Anthropic {7 if (!client) client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY, timeout: 15 * 60 * 1000, maxRetries: 2 });8 return client;9}1011/** Adaptive thinking is accepted on 4.6+ families; older snapshots reject it. */12const supportsAdaptive = (id: string) => /-4-6|-4-7|-4-8|opus-5|sonnet-5|haiku-5|fable/.test(id);13const supportsEffort = (id: string) => supportsAdaptive(id);14/** Forced tool_choice is rejected on these; we always use auto anyway. */15const fallbackEligible = (id: string) => /fable-5-1|opus-5-5|opus-5$|sonnet-5-5/.test(id);16const temperatureAllowed = (id: string) => /-4-5-|haiku-4-5/.test(id);1718async function toMessages(req: ChatRequest): Promise<Anthropic.MessageParam[]> {19 const out: Anthropic.MessageParam[] = [];20 for (const m of req.messages) {21 if (m.role === "system") continue; // folded into system prompt by the engine22 if (m.role === "assistant") {23 const content: Anthropic.ContentBlockParam[] = [];24 const text = m.parts.filter(p => p.type === "text").map(p => (p as { text: string }).text).join("");25 if (text) content.push({ type: "text", text });26 const results: Anthropic.ToolResultBlockParam[] = [];27 for (const p of m.parts) if (p.type === "tool_call") {28 content.push({ type: "tool_use", id: p.id, name: p.name, input: p.input ?? {} });29 if (p.status === "ok" || p.status === "error" || p.status === "denied")30 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 }) });31 }32 if (content.length) out.push({ role: "assistant", content });33 if (results.length) out.push({ role: "user", content: results });34 continue;35 }36 const content: Anthropic.ContentBlockParam[] = [];37 for (const p of m.parts) {38 if (p.type === "image") {39 const a = await req.loadAttachment(p.attachmentId);40 if (a) content.push({ type: "image", source: { type: "base64", media_type: a.mime as "image/png", data: a.data.toString("base64") } });41 } else if (p.type === "file") {42 const a = await req.loadAttachment(p.attachmentId);43 if (!a) continue;44 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);45 else if (a.text) content.push({ type: "document", source: { type: "text", media_type: "text/plain", data: a.text }, title: a.name } as Anthropic.DocumentBlockParam);46 }47 }48 for (const p of m.parts) if (p.type === "text" && p.text) content.push({ type: "text", text: p.text });49 if (content.length) {50 // merge consecutive user turns (tool results followed by a new user message are fine as separate turns)51 const last = out[out.length - 1];52 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);53 else out.push({ role: "user", content });54 }55 }56 return out;57}5859export const anthropicAdapter: ProviderAdapter = {60 provider: "anthropic",61 async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {62 const id = req.model.id;63 const s = req.settings ?? {};64 const maxTokens = Math.min(s.maxOutputTokens ?? 32_000, req.model.maxOutputTokens ?? 64_000);65 const body: Anthropic.MessageStreamParams & Record<string, unknown> = {66 model: id,67 max_tokens: maxTokens,68 messages: await toMessages(req),69 };70 if (req.system) body.system = [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }];71 if (supportsAdaptive(id)) body.thinking = { type: "adaptive", display: s.reasoningSummary === false ? "omitted" : "summarized" } as Anthropic.ThinkingConfigParam;72 if (s.effort && supportsEffort(id)) body.output_config = { effort: s.effort };73 if (s.temperature != null && temperatureAllowed(id)) body.temperature = s.temperature;74 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"] }));7576 let stream: ReturnType<Anthropic["messages"]["stream"]>;77 try {78 if (fallbackEligible(id)) {79 // server-side refusal fallback (beta): on a safety decline the API re-runs on a fallback model in the same call80 stream = (anthropicClient().beta.messages.stream as unknown as (p: Record<string, unknown>, o?: { signal?: AbortSignal }) => ReturnType<Anthropic["messages"]["stream"]>)({ ...body, betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" }, { signal: req.signal });81 } else stream = anthropicClient().messages.stream(body, { signal: req.signal });82 } catch (e) { yield errorEvent(e); return; }8384 const tools = new Map<number, { id: string; name: string; json: string }>();85 let sawTool = false;86 try {87 for await (const ev of stream) {88 switch (ev.type) {89 case "content_block_start":90 if (ev.content_block.type === "tool_use") tools.set(ev.index, { id: ev.content_block.id, name: ev.content_block.name, json: "" });91 break;92 case "content_block_delta":93 if (ev.delta.type === "text_delta") yield { type: "text", delta: ev.delta.text };94 else if (ev.delta.type === "thinking_delta") { if (ev.delta.thinking) yield { type: "reasoning", delta: ev.delta.thinking }; }95 else if (ev.delta.type === "input_json_delta") { const t = tools.get(ev.index); if (t) t.json += ev.delta.partial_json; }96 break;97 case "content_block_stop": {98 const t = tools.get(ev.index);99 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); }100 break;101 }102 case "message_delta": {103 const u = ev.usage as Anthropic.MessageDeltaUsage;104 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 } };105 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 };106 break;107 }108 }109 }110 const final = await stream.finalMessage();111 if ((final as { model?: string }).model && final.model !== id) yield { type: "meta", data: { servedBy: final.model } };112 } catch (e) { yield errorEvent(e); }113 },114};115116function errorEvent(e: unknown): StreamEvent {117 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 };118 if ((e as Error)?.name === "AbortError" || /aborted/i.test(String((e as Error)?.message))) return { type: "stop", reason: "cancelled" };119 return { type: "error", message: String((e as Error)?.message ?? e) };120}121122export async function anthropicCountTokens(model: string, system: string | undefined, messages: Anthropic.MessageParam[]): Promise<number> {123 const r = await anthropicClient().messages.countTokens({ model, messages, ...(system ? { system } : {}) });124 return r.input_tokens;125}126