TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import Anthropic from "@anthropic-ai/sdk";2import type { MessageParam, ContentBlockParam, MessageCreateParamsStreaming, ToolUnion, ToolChoice, ThinkingConfigParam, RawMessageStreamEvent, OutputConfig } from "@anthropic-ai/sdk/resources/messages";3import type { ModelInfo } from "@anthropic-ai/sdk/resources/models";4import {5 type AIProviderAdapter,6 type PolyModel,7 type UnifiedChatRequest,8 type UnifiedChatResponse,9 type UnifiedStreamEvent,10 type ValidationResult,11 type TokenEstimate,12 type UnifiedMessage,13 type FinishReason,14 PolyProviderError,15 modelKey,16} from "@/lib/ai/core/types";17import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors";18import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize";19import { collectStream, safeJson } from "@/lib/ai/core/stream-utils";20import { isTextLike, inlineTextFile } from "@/lib/ai/core/content";21import { anthropicPricingFor, ANTHROPIC_DEPRECATED, anthropicSortWeight, anthropicFamily } from "./catalog";22import { log } from "@/lib/log";2324const DEFAULT_TIMEOUT_MS = 10 * 60_000;2526function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) {27 return new Anthropic({ apiKey, maxRetries: 1, timeout: timeoutMs });28}2930// ---------------------------------------------------------------------------31// Model normalization (Models API → PolyModel)32// ---------------------------------------------------------------------------33type ModelCaps = NonNullable<ModelInfo["capabilities"]> & {34 thinking?: { supported?: boolean; types?: { adaptive?: { supported?: boolean }; enabled?: { supported?: boolean } } };35 effort?: { supported?: boolean; low?: { supported?: boolean }; medium?: { supported?: boolean }; high?: { supported?: boolean }; xhigh?: { supported?: boolean }; max?: { supported?: boolean } };36 image_input?: { supported?: boolean };37 pdf_input?: { supported?: boolean };38 structured_outputs?: { supported?: boolean };39 code_execution?: { supported?: boolean };40 citations?: { supported?: boolean };41};4243export function normalizeAnthropicModel(m: ModelInfo): PolyModel {44 const caps = (m.capabilities ?? {}) as ModelCaps;45 const adaptive = caps.thinking?.types?.adaptive?.supported === true;46 const enabledBudget = caps.thinking?.types?.enabled?.supported === true;47 const thinkingSupported = caps.thinking?.supported === true;48 const effortLevels = (["low", "medium", "high", "xhigh", "max"] as const).filter((l) => caps.effort?.[l]?.supported);49 // Sampling parameters (temperature/top_p/top_k) were removed on Opus 4.7+, Sonnet 5 and Fable 5;50 // those generations are exactly the ones where `thinking.types.enabled` is no longer supported.51 const legacySampling = !thinkingSupported || enabledBudget;52 const isFableTier = /claude-(fable|mythos)-/.test(m.id);53 const isLegacy3x = /claude-3/.test(m.id);54 const status = ANTHROPIC_DEPRECATED.has(m.id) || isLegacy3x ? "deprecated" : "active";5556 return {57 key: modelKey("anthropic", m.id),58 id: m.id,59 provider: "anthropic",60 displayName: m.display_name,61 family: anthropicFamily(m.id),62 capabilities: {63 text: true,64 vision: caps.image_input?.supported ?? true,65 audioInput: false,66 audioOutput: false,67 imageGeneration: false,68 video: false,69 reasoning: thinkingSupported,70 tools: true,71 structuredOutput: caps.structured_outputs?.supported ?? false,72 streaming: true,73 files: caps.pdf_input?.supported ?? false,74 webSearch: !isLegacy3x,75 },76 limits: { contextTokens: m.max_input_tokens ?? undefined, maxOutputTokens: m.max_tokens ?? undefined },77 parameters: {78 temperature: legacySampling,79 topP: legacySampling,80 topK: legacySampling,81 maxTokens: true,82 stop: true,83 reasoningEffort: effortLevels.length > 0,84 reasoningEffortLevels: effortLevels.length > 0 ? [...(isFableTier ? [] : ["none"]), ...effortLevels] : undefined,85 thinkingBudget: enabledBudget && !adaptive,86 thinkingBudgetRange: enabledBudget && !adaptive ? { min: 1024, max: Math.max(1024, (m.max_tokens ?? 32000) - 1) } : undefined,87 temperatureRange: { min: 0, max: 1 },88 },89 status,90 pricing: anthropicPricingFor(m.id),91 metadata: {92 createdAt: m.created_at,93 adaptiveThinking: adaptive,94 alwaysThinking: isFableTier,95 noForcedToolChoice: /claude-(fable|mythos)-5-1/.test(m.id),96 codeExecution: caps.code_execution?.supported ?? false,97 citations: caps.citations?.supported ?? false,98 sortWeight: anthropicSortWeight(m.id),99 webSearchToolType: isLegacy3x || /claude-(opus-4-5|sonnet-4-5|haiku-4-5|opus-4-1|opus-4-2|sonnet-4-2)/.test(m.id) ? "web_search_20250305" : "web_search_20260209",100 },101 };102}103104// ---------------------------------------------------------------------------105// Request translation106// ---------------------------------------------------------------------------107function toAnthropicMessages(messages: UnifiedMessage[], modelId: string): MessageParam[] {108 const out: MessageParam[] = [];109 for (const m of messages) {110 if (m.role === "system") continue; // system handled top-level111 if (m.role === "tool") {112 const blocks: ContentBlockParam[] = m.content113 .filter((p) => p.type === "tool-result")114 .map((p) => ({115 type: "tool_result" as const,116 tool_use_id: p.type === "tool-result" ? p.toolCallId : "",117 content: typeof (p as { result: unknown }).result === "string" ? ((p as { result: string }).result as string) : JSON.stringify((p as { result: unknown }).result ?? null),118 is_error: (p as { isError?: boolean }).isError ?? false,119 }));120 if (blocks.length) pushMerged(out, { role: "user", content: blocks });121 continue;122 }123 const blocks: ContentBlockParam[] = [];124 for (const p of m.content) {125 switch (p.type) {126 case "text":127 if (p.text) blocks.push({ type: "text", text: p.text });128 break;129 case "image":130 blocks.push({ type: "image", source: { type: "base64", media_type: p.mimeType as "image/png" | "image/jpeg" | "image/webp" | "image/gif", data: p.data } });131 break;132 case "file":133 if (p.mimeType === "application/pdf") {134 blocks.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: p.data }, title: p.name });135 } else if (isTextLike(p.mimeType, p.name)) {136 blocks.push({ type: "document", source: { type: "text", media_type: "text/plain", data: Buffer.from(p.data, "base64").toString("utf8") }, title: p.name });137 } else {138 blocks.push({ type: "text", text: inlineTextFile(p.name, p.data) });139 }140 break;141 case "reasoning":142 // Replay thinking blocks only on the same model (other models ignore/reject them).143 if (m.role === "assistant" && p.signature && (m.providerData?.model === modelId || !m.providerData?.model)) {144 blocks.push({ type: "thinking", thinking: p.text, signature: p.signature });145 }146 break;147 case "tool-call":148 blocks.push({ type: "tool_use", id: p.id, name: p.name, input: p.arguments });149 break;150 case "tool-result":151 blocks.push({ type: "tool_result", tool_use_id: p.toolCallId, content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null), is_error: p.isError });152 break;153 }154 }155 if (blocks.length === 0) continue;156 // thinking blocks must precede text/tool_use in an assistant turn157 if (m.role === "assistant") blocks.sort((a, b) => rank(a) - rank(b));158 pushMerged(out, { role: m.role === "assistant" ? "assistant" : "user", content: blocks });159 }160 // Anthropic requires the conversation to start with a user turn.161 while (out.length && out[0].role !== "user") out.shift();162 return out;163}164165function rank(b: ContentBlockParam): number {166 return b.type === "thinking" || b.type === "redacted_thinking" ? 0 : b.type === "text" ? 1 : 2;167}168169/** Consecutive same-role turns are merged (Anthropic requires strict alternation). */170function pushMerged(list: MessageParam[], msg: MessageParam) {171 const last = list[list.length - 1];172 if (last && last.role === msg.role && Array.isArray(last.content) && Array.isArray(msg.content)) {173 last.content = [...last.content, ...msg.content];174 } else {175 list.push(msg);176 }177}178179function buildParams(req: UnifiedChatRequest): MessageCreateParamsStreaming {180 const { settings } = filterSettings(req.settings, req.modelInfo);181 const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;182 const maxOut = req.modelInfo?.limits?.maxOutputTokens ?? 32_000;183 const params: MessageCreateParamsStreaming = {184 model: req.model,185 max_tokens: settings.maxTokens ?? Math.min(maxOut, 16_000),186 messages: toAnthropicMessages(req.messages, req.model),187 stream: true,188 };189 if (req.system?.trim()) params.system = req.system;190 if (settings.temperature !== undefined) params.temperature = settings.temperature;191 if (settings.topP !== undefined) params.top_p = settings.topP;192 if (settings.topK !== undefined) params.top_k = settings.topK;193 if (settings.stop?.length) params.stop_sequences = settings.stop.slice(0, 4);194195 // --- reasoning ---------------------------------------------------------196 // Without a capability sheet we cannot know which thinking mode the model accepts → send none.197 const reasoningSupported = req.modelInfo ? req.modelInfo.capabilities.reasoning : false;198 const adaptive = meta.adaptiveThinking === true;199 const alwaysThinking = meta.alwaysThinking === true;200 const effort = settings.reasoningEffort;201 const display = settings.includeReasoning === false ? "omitted" : "summarized";202 const outputConfig: OutputConfig = {};203 if (reasoningSupported) {204 if (effort === "none" && !alwaysThinking) {205 // Opus 5 accepts {type:"disabled"} only at effort ≤ high; Opus 4.7/4.8/Sonnet 5 accept it always.206 params.thinking = { type: "disabled" } as ThinkingConfigParam;207 } else if (adaptive) {208 params.thinking = { type: "adaptive", display } as ThinkingConfigParam;209 if (effort && effort !== "none" && effort !== "minimal") outputConfig.effort = effort as OutputConfig["effort"];210 else if (effort === "minimal") outputConfig.effort = "low";211 } else if (settings.thinkingBudget) {212 // Haiku 4.5 / older: enabled + budget_tokens (< max_tokens, ≥ 1024)213 const budget = Math.max(1024, Math.min(settings.thinkingBudget, params.max_tokens - 1));214 if (params.max_tokens <= budget) params.max_tokens = budget + 1024;215 params.thinking = { type: "enabled", budget_tokens: budget } as ThinkingConfigParam;216 }217 }218219 // --- structured output ---------------------------------------------------220 if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {221 outputConfig.format = { type: "json_schema", schema: settings.responseFormat.schema };222 } else if (settings.responseFormat?.type === "json") {223 outputConfig.format = { type: "json_schema", schema: { type: "object", additionalProperties: true } };224 }225 if (Object.keys(outputConfig).length) params.output_config = outputConfig;226227 // --- tools ---------------------------------------------------------------228 const tools: ToolUnion[] = [];229 for (const t of req.tools ?? []) {230 tools.push({231 name: t.name,232 description: t.description,233 input_schema: { type: "object", ...(t.parameters as Record<string, unknown>) } as ToolUnion extends infer U ? (U extends { input_schema: infer S } ? S : never) : never,234 ...(t.strict ? { strict: true } : {}),235 } as ToolUnion);236 }237 if (settings.webSearch) {238 const type = (meta.webSearchToolType as string) ?? "web_search_20260209";239 tools.push({ type, name: "web_search", max_uses: 5 } as unknown as ToolUnion);240 } else if (settings.codeExecution && meta.codeExecution) {241 tools.push({ type: "code_execution_20260521", name: "code_execution" } as unknown as ToolUnion);242 }243 if (tools.length) {244 params.tools = tools;245 const tc = settings.toolChoice;246 const noForced = meta.noForcedToolChoice === true;247 if (tc === "none") params.tool_choice = { type: "none" } as ToolChoice;248 else if (tc === "required" && !noForced) params.tool_choice = { type: "any" } as ToolChoice;249 else if (tc && typeof tc === "object" && !noForced) params.tool_choice = { type: "tool", name: tc.name } as ToolChoice;250 }251 return params;252}253254function mapStop(reason: string | null | undefined): FinishReason {255 switch (reason) {256 case "end_turn":257 case "stop_sequence":258 case "pause_turn":259 return "stop";260 case "max_tokens":261 case "model_context_window_exceeded":262 return "length";263 case "tool_use":264 return "tool-calls";265 case "refusal":266 return "content-filter";267 default:268 return "other";269 }270}271272// ---------------------------------------------------------------------------273// Adapter274// ---------------------------------------------------------------------------275export const anthropicAdapter: AIProviderAdapter = {276 id: "anthropic",277 name: "Anthropic",278 keyDocsUrl: "https://platform.claude.com/settings/keys",279 keyPrefixHint: "sk-ant-",280281 async validateApiKey(apiKey, signal): Promise<ValidationResult> {282 const t0 = Date.now();283 try {284 const page = await client(apiKey, 20_000).models.list({ limit: 100 }, { signal });285 return { ok: true, modelsAvailable: page.data.length, latencyMs: Date.now() - t0 };286 } catch (e) {287 const err = this.normalizeError(e);288 return { ok: false, error: err.toJSON(), latencyMs: Date.now() - t0 };289 }290 },291292 async listModels(apiKey, signal): Promise<PolyModel[]> {293 try {294 const out: PolyModel[] = [];295 for await (const m of client(apiKey, 30_000).models.list({ limit: 100 }, { signal })) {296 if (!m.id.startsWith("claude-")) continue;297 out.push(normalizeAnthropicModel(m));298 }299 return out;300 } catch (e) {301 throw this.normalizeError(e);302 }303 },304305 async chat(req): Promise<UnifiedChatResponse> {306 return collectStream("anthropic", req.model, this.streamChat(req));307 },308309 async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {310 const params = buildParams(req);311 if (process.env.POLYLLM_DEBUG_PROVIDER === "1") log.debug("anthropic request", { model: params.model, max_tokens: params.max_tokens, thinking: params.thinking, output_config: params.output_config, tools: params.tools?.length, temperature: params.temperature });312 let stream: AsyncIterable<RawMessageStreamEvent>;313 try {314 stream = await client(req.apiKey, req.timeoutMs).messages.create(params, { signal: req.signal });315 } catch (e) {316 yield { type: "error", error: this.normalizeError(e).toJSON() };317 return;318 }319 const toolNames = new Map<number, { id: string; name: string; server?: boolean; args: string }>();320 let finish: FinishReason = "other";321 let inputTokens = 0;322 let cached = 0;323 let outputTokens = 0;324 let reasoningTokens: number | undefined;325 const blockTypes = new Map<number, string>();326 try {327 for await (const ev of stream) {328 switch (ev.type) {329 case "message_start": {330 const u = ev.message.usage;331 inputTokens = u.input_tokens ?? 0;332 cached = u.cache_read_input_tokens ?? 0;333 // cache_creation tokens are billed as input at 1.25x; we fold them into input for display.334 inputTokens += u.cache_creation_input_tokens ?? 0;335 yield { type: "start", id: ev.message.id, model: ev.message.model };336 break;337 }338 case "content_block_start": {339 const b = ev.content_block;340 blockTypes.set(ev.index, b.type);341 if (b.type === "tool_use") {342 toolNames.set(ev.index, { id: b.id, name: b.name, args: "" });343 yield { type: "tool-start", id: b.id, name: b.name };344 } else if (b.type === "server_tool_use") {345 toolNames.set(ev.index, { id: b.id, name: b.name, server: true, args: "" });346 yield { type: "server-tool", name: b.name, status: "started" };347 } else if (b.type === "web_search_tool_result") {348 yield { type: "server-tool", name: "web_search", status: "completed", data: summarizeSearch(b.content) };349 } else if (b.type === "text" && b.text) {350 yield { type: "text-delta", text: b.text };351 }352 break;353 }354 case "content_block_delta": {355 const d = ev.delta;356 if (d.type === "text_delta") yield { type: "text-delta", text: d.text };357 else if (d.type === "thinking_delta") yield { type: "reasoning-delta", text: d.thinking };358 else if (d.type === "signature_delta") yield { type: "reasoning-signature", signature: d.signature };359 else if (d.type === "input_json_delta") {360 const t = toolNames.get(ev.index);361 if (t && !t.server) {362 t.args += d.partial_json;363 yield { type: "tool-delta", id: t.id, argumentsDelta: d.partial_json };364 }365 } else if (d.type === "citations_delta") {366 const c = d.citation as { type: string; url?: string; title?: string | null; cited_text?: string };367 if (c.type === "web_search_result_location") yield { type: "citation", citation: { url: c.url, title: c.title ?? undefined, snippet: c.cited_text, source: "web_search" } };368 }369 break;370 }371 case "content_block_stop": {372 const t = toolNames.get(ev.index);373 if (t && !t.server) yield { type: "tool-end", id: t.id, name: t.name, arguments: safeJson(t.args), argumentsText: t.args };374 break;375 }376 case "message_delta": {377 finish = mapStop(ev.delta.stop_reason);378 outputTokens = ev.usage.output_tokens ?? outputTokens;379 const details = (ev.usage as { output_tokens_details?: { thinking_tokens?: number | null } }).output_tokens_details;380 if (details?.thinking_tokens != null) reasoningTokens = details.thinking_tokens;381 if (ev.usage.input_tokens != null) inputTokens = ev.usage.input_tokens + (ev.usage.cache_creation_input_tokens ?? 0) + (ev.usage.cache_read_input_tokens ?? 0);382 if (ev.usage.cache_read_input_tokens != null) cached = ev.usage.cache_read_input_tokens;383 const stopDetails = (ev.delta as { stop_details?: { category?: string | null; explanation?: string | null } | null }).stop_details;384 if (ev.delta.stop_reason === "refusal") {385 yield { type: "provider-data", data: { refusal: { category: stopDetails?.category ?? null, explanation: stopDetails?.explanation ?? null } } };386 }387 break;388 }389 case "message_stop":390 break;391 }392 }393 yield { type: "usage", usage: { inputTokens, outputTokens, cachedInputTokens: cached, reasoningTokens, totalTokens: inputTokens + outputTokens } };394 yield { type: "finish", reason: finish };395 } catch (e) {396 yield { type: "error", error: this.normalizeError(e).toJSON() };397 }398 },399400 async estimateTokens(req): Promise<TokenEstimate> {401 try {402 const params = buildParams(req);403 const res = await client(req.apiKey, 20_000).messages.countTokens({404 model: params.model,405 messages: params.messages,406 system: params.system,407 tools: params.tools,408 thinking: params.thinking,409 });410 return { inputTokens: res.input_tokens, method: "provider" };411 } catch {412 const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");413 return { inputTokens: heuristicTokens(text), method: "heuristic" };414 }415 },416417 normalizeError(error: unknown): PolyProviderError {418 if (error instanceof PolyProviderError) return error;419 if (error instanceof Anthropic.APIError) {420 const body = error.error as { error?: { type?: string; message?: string } } | undefined;421 const type = body?.error?.type;422 const message = body?.error?.message ?? error.message;423 let code = codeFromStatus(error.status);424 if (type === "authentication_error") code = "INVALID_API_KEY";425 else if (type === "permission_error") code = "PERMISSION_DENIED";426 else if (type === "not_found_error") code = "MODEL_NOT_FOUND";427 else if (type === "rate_limit_error") code = "RATE_LIMITED";428 else if (type === "overloaded_error") code = "PROVIDER_UNAVAILABLE";429 else if (type === "billing_error") code = "INSUFFICIENT_CREDITS";430 else if (type === "request_too_large") code = "CONTEXT_TOO_LONG";431 else if (type === "invalid_request_error") code = refineByMessage("INVALID_PARAMETER", message);432 const retryable = isRetryableStatus(error.status) || type === "overloaded_error";433 return new PolyProviderError({434 code,435 message: code === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600),436 provider: "anthropic",437 status: error.status,438 retryable: retryable && code !== "INSUFFICIENT_CREDITS",439 retryAfterMs: parseRetryAfter(error.headers ?? null),440 providerCode: type,441 cause: error,442 });443 }444 const err = normalizeGenericError("anthropic", error);445 if (err.code === "UNKNOWN_PROVIDER_ERROR") log.debug("anthropic unknown error", { name: (error as Error)?.name, message: err.message });446 return err;447 },448};449450function summarizeSearch(content: unknown): unknown {451 if (Array.isArray(content)) {452 return content.slice(0, 10).map((r: { url?: string; title?: string }) => ({ url: r.url, title: r.title }));453 }454 return content;455}456457export { safeJson };458