TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Anthropic from '@anthropic-ai/sdk';2import { z } from 'zod';3import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js';4import { recordCost } from '../costs.js';5import { AiNotConfiguredError, AiRefusalError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js';67export interface AnthropicProviderOptions {8 apiKey: string;9 /** model per role; defaults below */10 models?: Partial<Record<Role, string>>;11 /** stable system-prompt prefix cached across calls */12 defaultTimeoutMs?: number;13}1415/**16 * Default role → model mapping. Fast/cheap for bulk pipeline work, strong for research and vision.17 * All ids are the exact strings from the current model table (no date suffixes).18 */19export const ANTHROPIC_DEFAULT_MODELS: Record<Role, string> = {20 normalize: 'claude-haiku-4-5',21 classify: 'claude-haiku-4-5',22 summarize: 'claude-haiku-4-5',23 resolve: 'claude-sonnet-5',24 research: 'claude-opus-5',25 vision: 'claude-opus-5',26 embed: '',27};2829function usageOf(u: Anthropic.Usage | Anthropic.MessageDeltaUsage | null | undefined): Usage {30 return {31 inputTokens: u?.input_tokens ?? 0,32 outputTokens: u?.output_tokens ?? 0,33 cacheReadTokens: u?.cache_read_input_tokens ?? 0,34 cacheWriteTokens: u?.cache_creation_input_tokens ?? 0,35 };36}3738function toBlocks(content: string | ContentPart[]): string | Anthropic.ContentBlockParam[] {39 if (typeof content === 'string') return content;40 return content.map((p): Anthropic.ContentBlockParam => {41 if (p.type === 'text') return { type: 'text', text: p.text };42 const img = p.image;43 if (img.url) return { type: 'image', source: { type: 'url', url: img.url } };44 return { type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data ?? '' } };45 });46}4748function toMessages(messages: ChatMessage[]): Anthropic.MessageParam[] {49 return messages.map((m) => ({ role: m.role, content: toBlocks(m.content) }));50}5152/** Haiku 4.5 still uses budgeted thinking; current-generation models use adaptive thinking. */53function thinkingFor(model: string, effort: string | undefined): Anthropic.ThinkingConfigParam | undefined {54 if (model.startsWith('claude-haiku')) return undefined;55 if (effort === 'low') return { type: 'adaptive' };56 return { type: 'adaptive' };57}5859function outputConfig(model: string, effort: string | undefined, format?: Anthropic.JSONOutputFormat): Anthropic.OutputConfig | undefined {60 const cfg: Anthropic.OutputConfig = {};61 if (effort && !model.startsWith('claude-haiku')) cfg.effort = effort as Anthropic.OutputConfig['effort'];62 if (format) cfg.format = format;63 return Object.keys(cfg).length ? cfg : undefined;64}6566export class AnthropicProvider implements ModelProvider {67 readonly id = 'anthropic';68 private readonly client: Anthropic;69 private readonly models: Record<Role, string>;7071 constructor(opts: AnthropicProviderOptions) {72 if (!opts.apiKey) throw new AiNotConfiguredError('ANTHROPIC_API_KEY missing');73 this.client = new Anthropic({ apiKey: opts.apiKey, timeout: opts.defaultTimeoutMs ?? 10 * 60_000, maxRetries: 2 });74 this.models = { ...ANTHROPIC_DEFAULT_MODELS, ...(opts.models ?? {}) };75 }7677 supports(role: Role): boolean {78 return role !== 'embed';79 }8081 modelFor(role: Role): string {82 return this.models[role] || ANTHROPIC_DEFAULT_MODELS[role];83 }8485 private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost']): Promise<number> {86 const usdEst = estimateUsd(model, usage);87 await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost });88 return usdEst;89 }9091 async complete(role: Role, req: CompletionRequest): Promise<Completion> {92 const model = this.modelFor(role);93 const system = req.json ? `${req.system ?? ''}\nRespond with a single JSON object and nothing else.`.trim() : req.system;94 const res = await this.client.messages.create({95 model,96 max_tokens: req.maxTokens ?? 4096,97 ...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}),98 messages: toMessages(req.messages),99 ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),100 ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),101 ...(req.stopSequences ? { stop_sequences: req.stopSequences } : {}),102 });103 const usage = usageOf(res.usage);104 const usdEst = await this.account(role, model, usage, req.cost);105 if (res.stop_reason === 'refusal') {106 const d = res.stop_details;107 return { text: '', model, provider: this.id, usage, usdEst, stopReason: 'refusal', refusal: { category: d?.category ?? null, explanation: d?.explanation ?? null } };108 }109 const text = res.content110 .filter((b): b is Anthropic.TextBlock => b.type === 'text')111 .map((b) => b.text)112 .join('');113 return { text, model, provider: this.id, usage, usdEst, stopReason: res.stop_reason, refusal: null };114 }115116 async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> {117 yield* this.runTools(role, { ...req, tools: [], execute: async () => null });118 }119120 async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> {121 const model = this.modelFor(role);122 const messages = toMessages(req.messages);123 const tools: Anthropic.Tool[] = req.tools.map((t) => ({124 name: t.name,125 description: t.description,126 input_schema: t.inputSchema as Anthropic.Tool.InputSchema,127 }));128 const maxIter = req.maxIterations ?? 8;129 let total: Usage = ZERO_USAGE;130 let iterations = 0;131 let stopReason: string | null = null;132 try {133 while (iterations < maxIter) {134 iterations++;135 const stream = this.client.messages.stream(136 {137 model,138 max_tokens: req.maxTokens ?? 8192,139 ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),140 messages,141 ...(tools.length ? { tools } : {}),142 ...(thinkingFor(model, req.effort) ? { thinking: { ...thinkingFor(model, req.effort)!, display: 'summarized' } as Anthropic.ThinkingConfigParam } : {}),143 ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),144 },145 { signal: req.signal },146 );147 const queue: StreamDelta[] = [];148 let resolveWake: (() => void) | null = null;149 const wake = () => {150 resolveWake?.();151 resolveWake = null;152 };153 stream.on('text', (delta) => {154 queue.push({ type: 'text', text: delta });155 wake();156 });157 stream.on('thinking', (delta) => {158 queue.push({ type: 'thinking', text: delta });159 wake();160 });161 let finished = false;162 let failure: unknown = null;163 const finalP = stream164 .finalMessage()165 .catch((err) => {166 failure = err;167 return null;168 })169 .finally(() => {170 finished = true;171 wake();172 });173 while (!finished || queue.length) {174 if (queue.length) {175 yield queue.shift()!;176 continue;177 }178 await new Promise<void>((r) => {179 resolveWake = r;180 });181 }182 const message = await finalP;183 if (failure || !message) throw failure ?? new Error('stream ended without a message');184 const u = usageOf(message.usage);185 total = addUsage(total, u);186 yield { type: 'usage', usage: u, usdEst: estimateUsd(model, u), model };187 stopReason = message.stop_reason;188 if (message.stop_reason === 'refusal') {189 const d = message.stop_details;190 throw new AiRefusalError(d?.category ?? null, d?.explanation ?? null);191 }192 if (message.stop_reason === 'pause_turn') {193 messages.push({ role: 'assistant', content: message.content });194 continue;195 }196 const toolUses = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');197 if (message.stop_reason !== 'tool_use' || toolUses.length === 0) break;198 messages.push({ role: 'assistant', content: message.content });199 const results: Anthropic.ToolResultBlockParam[] = [];200 for (const tu of toolUses) {201 yield { type: 'tool_call', id: tu.id, name: tu.name, input: tu.input };202 const started = Date.now();203 let output: unknown;204 let isError = false;205 try {206 output = await req.execute(tu.name, tu.input);207 } catch (err) {208 isError = true;209 output = { error: err instanceof Error ? err.message : String(err) };210 }211 yield { type: 'tool_result', id: tu.id, name: tu.name, output, isError, durationMs: Date.now() - started };212 results.push({ type: 'tool_result', tool_use_id: tu.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null), is_error: isError });213 }214 messages.push({ role: 'user', content: results });215 }216 } finally {217 await this.account(role, model, total, req.cost);218 }219 yield { type: 'done', stopReason };220 }221222 async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> {223 const model = this.modelFor(role);224 const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>;225 const format: Anthropic.JSONOutputFormat = { type: 'json_schema', schema: stripUnsupported(schema) };226 const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input];227 const res = await this.client.messages.create({228 model,229 max_tokens: req.maxTokens ?? 4096,230 ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),231 messages: toMessages([{ role: 'user', content }]),232 ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),233 output_config: outputConfig(model, req.effort, format)!,234 });235 const usage = usageOf(res.usage);236 const usdEst = await this.account(role, model, usage, req.cost);237 if (res.stop_reason === 'refusal') throw new AiRefusalError(res.stop_details?.category ?? null, res.stop_details?.explanation ?? null);238 const text = res.content239 .filter((b): b is Anthropic.TextBlock => b.type === 'text')240 .map((b) => b.text)241 .join('');242 const parsed = req.schema.safeParse(JSON.parse(text));243 if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);244 const data = parsed.data as T & { confidence?: number };245 const confidence = typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1;246 return { data: parsed.data, confidence, model, provider: this.id, usage, usdEst };247 }248249 vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> {250 return this.extract('vision', req);251 }252253 async embed(_req: EmbedRequest): Promise<EmbedResult> {254 throw new AiNotConfiguredError('Anthropic has no embeddings endpoint; configure OPENAI_API_KEY or a local embedding model');255 }256}257258/** Structured-output schemas reject a few JSON-schema keywords; strip them recursively. */259function stripUnsupported(schema: Record<string, unknown>): Record<string, unknown> {260 const drop = new Set(['$schema', 'default', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'pattern', 'format', 'minItems', 'maxItems', 'multipleOf']);261 const walk = (node: unknown): unknown => {262 if (Array.isArray(node)) return node.map(walk);263 if (node && typeof node === 'object') {264 const out: Record<string, unknown> = {};265 for (const [k, v] of Object.entries(node as Record<string, unknown>)) {266 if (drop.has(k)) continue;267 out[k] = walk(v);268 }269 if (out.type === 'object' && out.properties && out.additionalProperties === undefined) out.additionalProperties = false;270 return out;271 }272 return node;273 };274 return walk(schema) as Record<string, unknown>;275}276