import Anthropic from "@anthropic-ai/sdk"; import type { MessageParam, ContentBlockParam, MessageCreateParamsStreaming, ToolUnion, ToolChoice, ThinkingConfigParam, RawMessageStreamEvent, OutputConfig } from "@anthropic-ai/sdk/resources/messages"; import type { ModelInfo } from "@anthropic-ai/sdk/resources/models"; import { type AIProviderAdapter, type PolyModel, type UnifiedChatRequest, type UnifiedChatResponse, type UnifiedStreamEvent, type ValidationResult, type TokenEstimate, type UnifiedMessage, type FinishReason, PolyProviderError, modelKey, } from "@/lib/ai/core/types"; import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors"; import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize"; import { collectStream, safeJson } from "@/lib/ai/core/stream-utils"; import { isTextLike, inlineTextFile } from "@/lib/ai/core/content"; import { anthropicPricingFor, ANTHROPIC_DEPRECATED, anthropicSortWeight, anthropicFamily } from "./catalog"; import { log } from "@/lib/log"; const DEFAULT_TIMEOUT_MS = 10 * 60_000; function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) { return new Anthropic({ apiKey, maxRetries: 1, timeout: timeoutMs }); } // --------------------------------------------------------------------------- // Model normalization (Models API → PolyModel) // --------------------------------------------------------------------------- type ModelCaps = NonNullable & { thinking?: { supported?: boolean; types?: { adaptive?: { supported?: boolean }; enabled?: { supported?: boolean } } }; effort?: { supported?: boolean; low?: { supported?: boolean }; medium?: { supported?: boolean }; high?: { supported?: boolean }; xhigh?: { supported?: boolean }; max?: { supported?: boolean } }; image_input?: { supported?: boolean }; pdf_input?: { supported?: boolean }; structured_outputs?: { supported?: boolean }; code_execution?: { supported?: boolean }; citations?: { supported?: boolean }; }; export function normalizeAnthropicModel(m: ModelInfo): PolyModel { const caps = (m.capabilities ?? {}) as ModelCaps; const adaptive = caps.thinking?.types?.adaptive?.supported === true; const enabledBudget = caps.thinking?.types?.enabled?.supported === true; const thinkingSupported = caps.thinking?.supported === true; const effortLevels = (["low", "medium", "high", "xhigh", "max"] as const).filter((l) => caps.effort?.[l]?.supported); // Sampling parameters (temperature/top_p/top_k) were removed on Opus 4.7+, Sonnet 5 and Fable 5; // those generations are exactly the ones where `thinking.types.enabled` is no longer supported. const legacySampling = !thinkingSupported || enabledBudget; const isFableTier = /claude-(fable|mythos)-/.test(m.id); const isLegacy3x = /claude-3/.test(m.id); const status = ANTHROPIC_DEPRECATED.has(m.id) || isLegacy3x ? "deprecated" : "active"; return { key: modelKey("anthropic", m.id), id: m.id, provider: "anthropic", displayName: m.display_name, family: anthropicFamily(m.id), capabilities: { text: true, vision: caps.image_input?.supported ?? true, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: thinkingSupported, tools: true, structuredOutput: caps.structured_outputs?.supported ?? false, streaming: true, files: caps.pdf_input?.supported ?? false, webSearch: !isLegacy3x, }, limits: { contextTokens: m.max_input_tokens ?? undefined, maxOutputTokens: m.max_tokens ?? undefined }, parameters: { temperature: legacySampling, topP: legacySampling, topK: legacySampling, maxTokens: true, stop: true, reasoningEffort: effortLevels.length > 0, reasoningEffortLevels: effortLevels.length > 0 ? [...(isFableTier ? [] : ["none"]), ...effortLevels] : undefined, thinkingBudget: enabledBudget && !adaptive, thinkingBudgetRange: enabledBudget && !adaptive ? { min: 1024, max: Math.max(1024, (m.max_tokens ?? 32000) - 1) } : undefined, temperatureRange: { min: 0, max: 1 }, }, status, pricing: anthropicPricingFor(m.id), metadata: { createdAt: m.created_at, adaptiveThinking: adaptive, alwaysThinking: isFableTier, noForcedToolChoice: /claude-(fable|mythos)-5-1/.test(m.id), codeExecution: caps.code_execution?.supported ?? false, citations: caps.citations?.supported ?? false, sortWeight: anthropicSortWeight(m.id), 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", }, }; } // --------------------------------------------------------------------------- // Request translation // --------------------------------------------------------------------------- function toAnthropicMessages(messages: UnifiedMessage[], modelId: string): MessageParam[] { const out: MessageParam[] = []; for (const m of messages) { if (m.role === "system") continue; // system handled top-level if (m.role === "tool") { const blocks: ContentBlockParam[] = m.content .filter((p) => p.type === "tool-result") .map((p) => ({ type: "tool_result" as const, tool_use_id: p.type === "tool-result" ? p.toolCallId : "", content: typeof (p as { result: unknown }).result === "string" ? ((p as { result: string }).result as string) : JSON.stringify((p as { result: unknown }).result ?? null), is_error: (p as { isError?: boolean }).isError ?? false, })); if (blocks.length) pushMerged(out, { role: "user", content: blocks }); continue; } const blocks: ContentBlockParam[] = []; for (const p of m.content) { switch (p.type) { case "text": if (p.text) blocks.push({ type: "text", text: p.text }); break; case "image": blocks.push({ type: "image", source: { type: "base64", media_type: p.mimeType as "image/png" | "image/jpeg" | "image/webp" | "image/gif", data: p.data } }); break; case "file": if (p.mimeType === "application/pdf") { blocks.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: p.data }, title: p.name }); } else if (isTextLike(p.mimeType, p.name)) { blocks.push({ type: "document", source: { type: "text", media_type: "text/plain", data: Buffer.from(p.data, "base64").toString("utf8") }, title: p.name }); } else { blocks.push({ type: "text", text: inlineTextFile(p.name, p.data) }); } break; case "reasoning": // Replay thinking blocks only on the same model (other models ignore/reject them). if (m.role === "assistant" && p.signature && (m.providerData?.model === modelId || !m.providerData?.model)) { blocks.push({ type: "thinking", thinking: p.text, signature: p.signature }); } break; case "tool-call": blocks.push({ type: "tool_use", id: p.id, name: p.name, input: p.arguments }); break; case "tool-result": 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 }); break; } } if (blocks.length === 0) continue; // thinking blocks must precede text/tool_use in an assistant turn if (m.role === "assistant") blocks.sort((a, b) => rank(a) - rank(b)); pushMerged(out, { role: m.role === "assistant" ? "assistant" : "user", content: blocks }); } // Anthropic requires the conversation to start with a user turn. while (out.length && out[0].role !== "user") out.shift(); return out; } function rank(b: ContentBlockParam): number { return b.type === "thinking" || b.type === "redacted_thinking" ? 0 : b.type === "text" ? 1 : 2; } /** Consecutive same-role turns are merged (Anthropic requires strict alternation). */ function pushMerged(list: MessageParam[], msg: MessageParam) { const last = list[list.length - 1]; if (last && last.role === msg.role && Array.isArray(last.content) && Array.isArray(msg.content)) { last.content = [...last.content, ...msg.content]; } else { list.push(msg); } } function buildParams(req: UnifiedChatRequest): MessageCreateParamsStreaming { const { settings } = filterSettings(req.settings, req.modelInfo); const meta = (req.modelInfo?.metadata ?? {}) as Record; const maxOut = req.modelInfo?.limits?.maxOutputTokens ?? 32_000; const params: MessageCreateParamsStreaming = { model: req.model, max_tokens: settings.maxTokens ?? Math.min(maxOut, 16_000), messages: toAnthropicMessages(req.messages, req.model), stream: true, }; if (req.system?.trim()) params.system = req.system; if (settings.temperature !== undefined) params.temperature = settings.temperature; if (settings.topP !== undefined) params.top_p = settings.topP; if (settings.topK !== undefined) params.top_k = settings.topK; if (settings.stop?.length) params.stop_sequences = settings.stop.slice(0, 4); // --- reasoning --------------------------------------------------------- // Without a capability sheet we cannot know which thinking mode the model accepts → send none. const reasoningSupported = req.modelInfo ? req.modelInfo.capabilities.reasoning : false; const adaptive = meta.adaptiveThinking === true; const alwaysThinking = meta.alwaysThinking === true; const effort = settings.reasoningEffort; const display = settings.includeReasoning === false ? "omitted" : "summarized"; const outputConfig: OutputConfig = {}; if (reasoningSupported) { if (effort === "none" && !alwaysThinking) { // Opus 5 accepts {type:"disabled"} only at effort ≤ high; Opus 4.7/4.8/Sonnet 5 accept it always. params.thinking = { type: "disabled" } as ThinkingConfigParam; } else if (adaptive) { params.thinking = { type: "adaptive", display } as ThinkingConfigParam; if (effort && effort !== "none" && effort !== "minimal") outputConfig.effort = effort as OutputConfig["effort"]; else if (effort === "minimal") outputConfig.effort = "low"; } else if (settings.thinkingBudget) { // Haiku 4.5 / older: enabled + budget_tokens (< max_tokens, ≥ 1024) const budget = Math.max(1024, Math.min(settings.thinkingBudget, params.max_tokens - 1)); if (params.max_tokens <= budget) params.max_tokens = budget + 1024; params.thinking = { type: "enabled", budget_tokens: budget } as ThinkingConfigParam; } } // --- structured output --------------------------------------------------- if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) { outputConfig.format = { type: "json_schema", schema: settings.responseFormat.schema }; } else if (settings.responseFormat?.type === "json") { outputConfig.format = { type: "json_schema", schema: { type: "object", additionalProperties: true } }; } if (Object.keys(outputConfig).length) params.output_config = outputConfig; // --- tools --------------------------------------------------------------- const tools: ToolUnion[] = []; for (const t of req.tools ?? []) { tools.push({ name: t.name, description: t.description, input_schema: { type: "object", ...(t.parameters as Record) } as ToolUnion extends infer U ? (U extends { input_schema: infer S } ? S : never) : never, ...(t.strict ? { strict: true } : {}), } as ToolUnion); } if (settings.webSearch) { const type = (meta.webSearchToolType as string) ?? "web_search_20260209"; tools.push({ type, name: "web_search", max_uses: 5 } as unknown as ToolUnion); } else if (settings.codeExecution && meta.codeExecution) { tools.push({ type: "code_execution_20260521", name: "code_execution" } as unknown as ToolUnion); } if (tools.length) { params.tools = tools; const tc = settings.toolChoice; const noForced = meta.noForcedToolChoice === true; if (tc === "none") params.tool_choice = { type: "none" } as ToolChoice; else if (tc === "required" && !noForced) params.tool_choice = { type: "any" } as ToolChoice; else if (tc && typeof tc === "object" && !noForced) params.tool_choice = { type: "tool", name: tc.name } as ToolChoice; } return params; } function mapStop(reason: string | null | undefined): FinishReason { switch (reason) { case "end_turn": case "stop_sequence": case "pause_turn": return "stop"; case "max_tokens": case "model_context_window_exceeded": return "length"; case "tool_use": return "tool-calls"; case "refusal": return "content-filter"; default: return "other"; } } // --------------------------------------------------------------------------- // Adapter // --------------------------------------------------------------------------- export const anthropicAdapter: AIProviderAdapter = { id: "anthropic", name: "Anthropic", keyDocsUrl: "https://platform.claude.com/settings/keys", keyPrefixHint: "sk-ant-", async validateApiKey(apiKey, signal): Promise { const t0 = Date.now(); try { const page = await client(apiKey, 20_000).models.list({ limit: 100 }, { signal }); return { ok: true, modelsAvailable: page.data.length, latencyMs: Date.now() - t0 }; } catch (e) { const err = this.normalizeError(e); return { ok: false, error: err.toJSON(), latencyMs: Date.now() - t0 }; } }, async listModels(apiKey, signal): Promise { try { const out: PolyModel[] = []; for await (const m of client(apiKey, 30_000).models.list({ limit: 100 }, { signal })) { if (!m.id.startsWith("claude-")) continue; out.push(normalizeAnthropicModel(m)); } return out; } catch (e) { throw this.normalizeError(e); } }, async chat(req): Promise { return collectStream("anthropic", req.model, this.streamChat(req)); }, async *streamChat(req: UnifiedChatRequest): AsyncIterable { const params = buildParams(req); 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 }); let stream: AsyncIterable; try { stream = await client(req.apiKey, req.timeoutMs).messages.create(params, { signal: req.signal }); } catch (e) { yield { type: "error", error: this.normalizeError(e).toJSON() }; return; } const toolNames = new Map(); let finish: FinishReason = "other"; let inputTokens = 0; let cached = 0; let outputTokens = 0; let reasoningTokens: number | undefined; const blockTypes = new Map(); try { for await (const ev of stream) { switch (ev.type) { case "message_start": { const u = ev.message.usage; inputTokens = u.input_tokens ?? 0; cached = u.cache_read_input_tokens ?? 0; // cache_creation tokens are billed as input at 1.25x; we fold them into input for display. inputTokens += u.cache_creation_input_tokens ?? 0; yield { type: "start", id: ev.message.id, model: ev.message.model }; break; } case "content_block_start": { const b = ev.content_block; blockTypes.set(ev.index, b.type); if (b.type === "tool_use") { toolNames.set(ev.index, { id: b.id, name: b.name, args: "" }); yield { type: "tool-start", id: b.id, name: b.name }; } else if (b.type === "server_tool_use") { toolNames.set(ev.index, { id: b.id, name: b.name, server: true, args: "" }); yield { type: "server-tool", name: b.name, status: "started" }; } else if (b.type === "web_search_tool_result") { yield { type: "server-tool", name: "web_search", status: "completed", data: summarizeSearch(b.content) }; } else if (b.type === "text" && b.text) { yield { type: "text-delta", text: b.text }; } break; } case "content_block_delta": { const d = ev.delta; if (d.type === "text_delta") yield { type: "text-delta", text: d.text }; else if (d.type === "thinking_delta") yield { type: "reasoning-delta", text: d.thinking }; else if (d.type === "signature_delta") yield { type: "reasoning-signature", signature: d.signature }; else if (d.type === "input_json_delta") { const t = toolNames.get(ev.index); if (t && !t.server) { t.args += d.partial_json; yield { type: "tool-delta", id: t.id, argumentsDelta: d.partial_json }; } } else if (d.type === "citations_delta") { const c = d.citation as { type: string; url?: string; title?: string | null; cited_text?: string }; 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" } }; } break; } case "content_block_stop": { const t = toolNames.get(ev.index); if (t && !t.server) yield { type: "tool-end", id: t.id, name: t.name, arguments: safeJson(t.args), argumentsText: t.args }; break; } case "message_delta": { finish = mapStop(ev.delta.stop_reason); outputTokens = ev.usage.output_tokens ?? outputTokens; const details = (ev.usage as { output_tokens_details?: { thinking_tokens?: number | null } }).output_tokens_details; if (details?.thinking_tokens != null) reasoningTokens = details.thinking_tokens; 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); if (ev.usage.cache_read_input_tokens != null) cached = ev.usage.cache_read_input_tokens; const stopDetails = (ev.delta as { stop_details?: { category?: string | null; explanation?: string | null } | null }).stop_details; if (ev.delta.stop_reason === "refusal") { yield { type: "provider-data", data: { refusal: { category: stopDetails?.category ?? null, explanation: stopDetails?.explanation ?? null } } }; } break; } case "message_stop": break; } } yield { type: "usage", usage: { inputTokens, outputTokens, cachedInputTokens: cached, reasoningTokens, totalTokens: inputTokens + outputTokens } }; yield { type: "finish", reason: finish }; } catch (e) { yield { type: "error", error: this.normalizeError(e).toJSON() }; } }, async estimateTokens(req): Promise { try { const params = buildParams(req); const res = await client(req.apiKey, 20_000).messages.countTokens({ model: params.model, messages: params.messages, system: params.system, tools: params.tools, thinking: params.thinking, }); return { inputTokens: res.input_tokens, method: "provider" }; } catch { const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? ""); return { inputTokens: heuristicTokens(text), method: "heuristic" }; } }, normalizeError(error: unknown): PolyProviderError { if (error instanceof PolyProviderError) return error; if (error instanceof Anthropic.APIError) { const body = error.error as { error?: { type?: string; message?: string } } | undefined; const type = body?.error?.type; const message = body?.error?.message ?? error.message; let code = codeFromStatus(error.status); if (type === "authentication_error") code = "INVALID_API_KEY"; else if (type === "permission_error") code = "PERMISSION_DENIED"; else if (type === "not_found_error") code = "MODEL_NOT_FOUND"; else if (type === "rate_limit_error") code = "RATE_LIMITED"; else if (type === "overloaded_error") code = "PROVIDER_UNAVAILABLE"; else if (type === "billing_error") code = "INSUFFICIENT_CREDITS"; else if (type === "request_too_large") code = "CONTEXT_TOO_LONG"; else if (type === "invalid_request_error") code = refineByMessage("INVALID_PARAMETER", message); const retryable = isRetryableStatus(error.status) || type === "overloaded_error"; return new PolyProviderError({ code, message: code === "INVALID_API_KEY" ? "Invalid API key" : message.slice(0, 600), provider: "anthropic", status: error.status, retryable: retryable && code !== "INSUFFICIENT_CREDITS", retryAfterMs: parseRetryAfter(error.headers ?? null), providerCode: type, cause: error, }); } const err = normalizeGenericError("anthropic", error); if (err.code === "UNKNOWN_PROVIDER_ERROR") log.debug("anthropic unknown error", { name: (error as Error)?.name, message: err.message }); return err; }, }; function summarizeSearch(content: unknown): unknown { if (Array.isArray(content)) { return content.slice(0, 10).map((r: { url?: string; title?: string }) => ({ url: r.url, title: r.title })); } return content; } export { safeJson };