Repository Public
1/**2 * Anthropic adapter — Messages API through the official SDK, written against the bundled Claude API reference3 * (typescript/claude-api/{README,tool-use,streaming}.md, shared/{tool-use-concepts,prompt-caching,model-migration}.md).4 *5 * Request shape (every call):6 * client.messages.stream({ model, max_tokens: 64000, system:[{text, cache_control}], tools:[{…, strict, eager_input_streaming}],7 * messages, thinking:{type:'adaptive', display:'summarized'}, output_config:{effort}, cache_control:{type:'ephemeral'} })8 * + on Fable 5.2 / Opus 5.5 / Sonnet 5.5: client.beta.messages.stream with betas:['server-side-fallback-2026-07-01'], fallbacks:'default'.9 * Never sent: `budget_tokens`, `thinking:{type:'disabled'}`, `tool_choice`, sampling parameters, assistant prefill.10 *11 * Native transcript entries are Messages API message params, kept verbatim and append-only:12 * {role:'user', content:[…]} · {role:'assistant', content:[every block of the response, thinking included]} ·13 * {role:'system', content:'…'} (operator note; rendered per the placement rules at call time).14 */15import Anthropic from '@anthropic-ai/sdk';16import type { ContentBlock, Effort, ModelInfo, StopReason, ToolResult, Usage } from '@aos/shared';17import { z } from 'zod';18import { CATALOGUE, catalogueEntry, priceRow, priceUsage, type TokenUsage } from './catalogue.js';19import { ProviderError, parseRetryAfter } from './errors.js';20import { DEFAULT_MAX_ATTEMPTS, abortableSleep, withRetries } from './retry.js';21import { markInvalidToolInput, stripNullOptionals, toStrictSchema, validateToolInput } from './schema.js';22import type { AdapterOptions, ModelRequest, ProviderAdapter, ProviderResponse, ToolDef } from './types.js';2324const PROVIDER = 'anthropic' as const;25export const ANTHROPIC_DEFAULT_MAX_TOKENS = 64_000;26export const ANTHROPIC_FALLBACK_BETA = 'server-side-fallback-2026-07-01';27const MAX_PAUSE_CONTINUATIONS = 5;28const MAX_TOOL_JSON_REISSUES = 2;2930/** `fallbacks:'default'` is documented for these; Haiku 5.5 has no server-side fallback (model-migration.md). */31const SERVER_FALLBACK_MODELS = new Set(['claude-fable-5-2', 'claude-opus-5-5', 'claude-sonnet-5-5']);3233/** Models documented to accept mid-conversation `{role:'system'}` messages without a beta header (prompt-caching.md). */34const MID_CONVERSATION_SYSTEM_MODELS = new Set([35 'claude-opus-5', 'claude-opus-5-5', 'claude-opus-4-8',36 'claude-fable-5', 'claude-fable-5-1', 'claude-fable-5-2',37 'claude-mythos-5', 'claude-mythos-5-1', 'claude-mythos-5-2',38 'claude-sonnet-5-5', 'claude-haiku-5-5',39]);4041/** API default effort per model. We always send effort explicitly; this is used only when the request has none. */42const DEFAULT_EFFORT: Record<string, Effort> = {43 'claude-fable-5-2': 'high', 'claude-opus-5-5': 'medium', 'claude-sonnet-5-5': 'high', 'claude-haiku-5-5': 'medium',44};4546type Block = { type: string } & Record<string, unknown>;47export type AnthropicEntry =48 | { role: 'user'; content: string | Block[] }49 | { role: 'assistant'; content: string | Block[] }50 | { role: 'system'; content: string };5152const BlockSchema = z.looseObject({ type: z.string() });53const EntrySchema = z.union([54 z.object({ role: z.enum(['user', 'assistant']), content: z.union([z.string(), z.array(BlockSchema)]) }),55 z.object({ role: z.literal('system'), content: z.string() }),56]);5758/** Accepts `[...t, ...native]` and `[...t, native]`: nested arrays are flattened one level, then validated. */59export function anthropicEntries(transcript: unknown[]): AnthropicEntry[] {60 const out: AnthropicEntry[] = [];61 for (const item of transcript) {62 for (const entry of Array.isArray(item) ? item : [item]) {63 const parsed = EntrySchema.safeParse(entry);64 if (!parsed.success) {65 throw new ProviderError('Invalid Anthropic transcript entry', { provider: PROVIDER, retryable: false, code: 'invalid_transcript' });66 }67 out.push(parsed.data as AnthropicEntry);68 }69 }70 return out;71}7273export function operatorNoteAsUserText(text: string): string {74 return `<system-reminder>\n${text}\n</system-reminder>`;75}7677/**78 * Renders the native transcript into `messages`. A run of consecutive operator notes becomes ONE79 * `{role:'system'}` message when the model supports it AND the placement rules hold (it follows a user80 * message and is last or followed by an assistant turn); otherwise each note is a delimited user-turn text81 * block. The rendering of everything that was already sent never changes when entries are appended.82 */83export function renderAnthropicMessages(entries: AnthropicEntry[], supportsSystem: boolean): AnthropicEntry[] {84 const out: AnthropicEntry[] = [];85 for (let i = 0; i < entries.length;) {86 const e = entries[i] as AnthropicEntry;87 if (e.role !== 'system') {88 out.push(e);89 i++;90 continue;91 }92 let j = i;93 while (j < entries.length && entries[j]?.role === 'system') j++;94 const run = entries.slice(i, j) as { role: 'system'; content: string }[];95 const followsUser = out[out.length - 1]?.role === 'user';96 const endsWell = j === entries.length || entries[j]?.role === 'assistant';97 if (supportsSystem && followsUser && endsWell) {98 out.push({ role: 'system', content: run.map((r) => r.content).join('\n\n') });99 } else {100 for (const r of run) out.push({ role: 'user', content: [{ type: 'text', text: operatorNoteAsUserText(r.content) }] });101 }102 i = j;103 }104 return out;105}106107/**108 * The Messages API refuses a request whose STRICT tool schemas hold more than 16 union-typed parameters in109 * total ("Schemas contains too many parameters with union types … limit: 16", HTTP 400 — observed live on110 * 2026-11-10 with the full Agent OS tool set, where every optional property is an `anyOf [T, null]`).111 */112export const ANTHROPIC_STRICT_UNION_LIMIT = 16;113114/** Number of union-typed nodes (`anyOf` / `oneOf` / type arrays) in a schema. */115export function countUnionParams(node: unknown): number {116 if (Array.isArray(node)) return node.reduce((n: number, x) => n + countUnionParams(x), 0);117 if (!node || typeof node !== 'object') return 0;118 const o = node as Record<string, unknown>;119 let n = Array.isArray(o.anyOf) || Array.isArray(o.oneOf) || Array.isArray(o.type) ? 1 : 0;120 for (const v of Object.values(o)) n += countUnionParams(v);121 return n;122}123124/**125 * Tools as sent to the API. Every tool gets `strict: true` while the request stays within the union limit;126 * when the tool set exceeds it, the tools with the fewest unions keep `strict` (deterministically: fewest127 * first, then by name) and the others are sent with the same schema but without it. Nothing is lost in128 * safety: with eager input streaming the adapter validates EVERY tool input against the schema it sent129 * (`validateToolInput`), strict or not, and an invalid input becomes an error result for the model.130 */131export function anthropicTools(tools: ToolDef[]): { tools: Record<string, unknown>[]; strictSchemas: Map<string, Record<string, unknown>>; originals: Map<string, Record<string, unknown>> } {132 const strictSchemas = new Map<string, Record<string, unknown>>();133 const originals = new Map<string, Record<string, unknown>>();134 // Deterministic order: tools render first in the cached prefix, so a reordered list would miss the cache.135 const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));136 const unions = new Map<string, number>();137 for (const t of sorted) {138 const strict = toStrictSchema(t.inputSchema);139 strictSchemas.set(t.name, strict);140 originals.set(t.name, t.inputSchema);141 unions.set(t.name, countUnionParams(strict));142 }143 const strictNames = new Set<string>();144 let used = 0;145 for (const t of [...sorted].sort((a, b) => (unions.get(a.name) as number) - (unions.get(b.name) as number) || (a.name < b.name ? -1 : 1))) {146 const n = unions.get(t.name) as number;147 if (used + n > ANTHROPIC_STRICT_UNION_LIMIT) continue;148 used += n;149 strictNames.add(t.name);150 }151 const out = sorted.map((t) => ({152 name: t.name, description: t.description, input_schema: strictSchemas.get(t.name),153 ...(strictNames.has(t.name) ? { strict: true } : {}),154 eager_input_streaming: true,155 }));156 return { tools: out, strictSchemas, originals };157}158159export interface AnthropicRequest {160 beta: boolean;161 params: Record<string, unknown>;162 strictSchemas: Map<string, Record<string, unknown>>;163 originals: Map<string, Record<string, unknown>>;164}165166/** Pure request builder (exported for tests and for auditing the exact payload). */167export function buildAnthropicRequest(req: ModelRequest, entries: AnthropicEntry[] = anthropicEntries(req.transcript)): AnthropicRequest {168 const info = catalogueEntry(PROVIDER, req.model);169 const { tools, strictSchemas, originals } = anthropicTools(req.tools);170 const cap = info?.maxOutputTokens ?? Number.POSITIVE_INFINITY;171 const params: Record<string, unknown> = {172 model: req.model,173 max_tokens: Math.min(req.maxOutputTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, cap),174 messages: renderAnthropicMessages(entries, MID_CONVERSATION_SYSTEM_MODELS.has(req.model)),175 // Adaptive thinking only; `summarized` returns reasoning summaries and progress updates as thinking text.176 thinking: { type: 'adaptive', display: 'summarized' },177 output_config: { effort: req.effort ?? DEFAULT_EFFORT[req.model] ?? 'high' },178 // Automatic caching of the growing conversation tail (5-minute TTL).179 cache_control: { type: 'ephemeral' },180 };181 // Explicit breakpoint on the static prefix: render order is tools → system, so this caches both.182 if (req.system.length > 0) params.system = [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }];183 if (tools.length > 0) params.tools = tools;184 const beta = SERVER_FALLBACK_MODELS.has(req.model);185 if (beta) {186 params.betas = [ANTHROPIC_FALLBACK_BETA];187 params.fallbacks = 'default';188 }189 return { beta, params, strictSchemas, originals };190}191192/**193 * Echo rule after a mid-output server-side fallback: blocks produced by the declined model BEFORE the final194 * `fallback` block are omitted except text; the `fallback` block stays exactly where it was.195 */196export function sanitizeFallbackTurn(content: Block[]): Block[] {197 let last = -1;198 content.forEach((b, i) => { if (b.type === 'fallback') last = i; });199 if (last < 0) return content;200 return content.filter((b, i) => i >= last || b.type === 'text');201}202203function num(v: unknown): number {204 return typeof v === 'number' && Number.isFinite(v) ? v : 0;205}206207/** Normalised usage for one API response. With fallbacks, `usage.iterations` is the per-attempt source of truth. */208export function anthropicUsage(message: { model?: unknown; usage?: unknown }, requestedModel: string): { tokens: TokenUsage; usd: number } {209 const usage = (message.usage ?? {}) as Record<string, unknown>;210 const servedBy = typeof message.model === 'string' ? message.model : requestedModel;211 const pick = (u: Record<string, unknown>): TokenUsage => ({212 inputTokens: num(u.input_tokens), outputTokens: num(u.output_tokens),213 cacheReadTokens: num(u.cache_read_input_tokens), cacheWriteTokens: num(u.cache_creation_input_tokens),214 });215 const priced = (model: string, t: TokenUsage): number =>216 priceUsage(PROVIDER, priceRow(PROVIDER, model) ? model : requestedModel, t);217 const iterations = Array.isArray(usage.iterations) ? (usage.iterations as Record<string, unknown>[]) : [];218 const attempts = iterations.filter((it) => it && (it.type === 'message' || it.type === 'fallback_message'));219 if (attempts.length === 0) {220 const tokens = pick(usage);221 return { tokens, usd: priced(servedBy, tokens) };222 }223 const total: TokenUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };224 let usd = 0;225 for (const it of attempts) {226 const t = pick(it);227 total.inputTokens += t.inputTokens;228 total.outputTokens += t.outputTokens;229 total.cacheReadTokens += t.cacheReadTokens;230 total.cacheWriteTokens += t.cacheWriteTokens;231 // A `message` entry is an attempt on the requested model unless it names another one.232 usd += priced(typeof it.model === 'string' ? it.model : it.type === 'message' ? requestedModel : servedBy, t);233 }234 return { tokens: total, usd };235}236237function mapStop(stopReason: unknown): StopReason {238 switch (stopReason) {239 case 'end_turn': case 'stop_sequence': return 'end_turn';240 case 'tool_use': return 'tool_use';241 case 'max_tokens': return 'max_tokens';242 case 'pause_turn': return 'pause';243 case 'refusal': return 'refusal';244 default: return 'error'; // model_context_window_exceeded, null, anything new245 }246}247248interface Normalised {249 blocks: ContentBlock[];250 native: AnthropicEntry[];251 stop: StopReason;252 refusal?: { category: string | null; explanation: string | null };253}254255/** Normalises one final Message. Exported for fixture tests. */256export function normaliseAnthropicMessage(257 message: { content?: unknown; stop_reason?: unknown; stop_details?: unknown },258 strictSchemas: Map<string, Record<string, unknown>>,259 originals: Map<string, Record<string, unknown>>,260): Normalised {261 const raw = Array.isArray(message.content) ? (message.content as Block[]) : [];262 let stop = mapStop(message.stop_reason);263264 if (stop === 'refusal') {265 // Partial output of a declined turn is discarded, and a refusal can cut a tool_use off mid-input: never266 // surface that turn's tool calls. Only already-streamed text is kept in the transcript.267 const text = raw.filter((b) => b.type === 'text' && typeof b.text === 'string' && b.text.length > 0);268 const details = (message.stop_details ?? null) as Record<string, unknown> | null;269 return {270 blocks: text.map((b) => ({ type: 'text' as const, text: b.text as string })),271 native: text.length > 0 ? [{ role: 'assistant', content: text }] : [],272 stop,273 refusal: {274 category: typeof details?.category === 'string' ? details.category : null,275 explanation: typeof details?.explanation === 'string' ? details.explanation : null,276 },277 };278 }279280 const content = sanitizeFallbackTurn(raw);281 const blocks: ContentBlock[] = [];282 const lastIndex = content.length - 1;283 content.forEach((b, i) => {284 if (b.type === 'text' && typeof b.text === 'string') {285 if (b.text.length > 0) blocks.push({ type: 'text', text: b.text });286 } else if (b.type === 'thinking') {287 blocks.push({ type: 'reasoning', summary: typeof b.thinking === 'string' ? b.thinking : '' });288 } else if (b.type === 'redacted_thinking') {289 blocks.push({ type: 'reasoning', summary: '' });290 } else if (b.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string') {291 const rawText = (): string => { try { return JSON.stringify(b.input) ?? ''; } catch { return ''; } };292 const strict = strictSchemas.get(b.name);293 let input: unknown = b.input;294 if (message.stop_reason === 'max_tokens' && i === lastIndex) {295 // A cut-off input usually parses as a valid partial object; the stop reason is what catches it.296 input = markInvalidToolInput({ reason: 'truncated', detail: 'output-token limit reached inside the tool input', raw: rawText() });297 } else if (strict) {298 // Eager input streaming: the API did not validate this input, so check it against the schema we sent.299 const problem = validateToolInput(strict, b.input);300 input = problem301 ? markInvalidToolInput({ reason: 'schema', detail: problem, raw: rawText() })302 : stripNullOptionals(originals.get(b.name), b.input);303 }304 blocks.push({ type: 'tool_call', id: b.id, name: b.name, input });305 }306 // `fallback`, server-tool and unknown blocks have no normalised view; they stay in `native`.307 });308 // Every tool_use block in the transcript needs a tool_result, whatever ended the turn.309 if (stop !== 'pause' && blocks.some((b) => b.type === 'tool_call')) stop = 'tool_use';310 return { blocks, native: content.length > 0 ? [{ role: 'assistant', content }] : [], stop };311}312313/** Typed error chain, most specific first (shared/error-codes.md). Anything that is not an SDK error is returned as null. */314export function mapAnthropicError(err: unknown): ProviderError | null {315 if (err instanceof ProviderError) return err;316 if (err instanceof Anthropic.APIUserAbortError) {317 return new ProviderError('Request aborted', { provider: PROVIDER, retryable: false, code: 'aborted', cause: err });318 }319 if (err instanceof Anthropic.APIConnectionTimeoutError) {320 return new ProviderError('Anthropic request timed out', { provider: PROVIDER, retryable: true, code: 'timeout', cause: err });321 }322 if (err instanceof Anthropic.APIConnectionError) {323 return new ProviderError('Could not reach the Anthropic API', { provider: PROVIDER, retryable: true, code: 'connection_error', cause: err });324 }325 if (err instanceof Anthropic.APIError) {326 const status = typeof err.status === 'number' ? err.status : null;327 const type = err.type ?? null;328 const retryable = err instanceof Anthropic.RateLimitError || err instanceof Anthropic.InternalServerError329 || status === 408 || status === 409 || (status !== null && status >= 500)330 // Errors delivered inside an open stream carry no HTTP status; classify them by the typed `.type`.331 || type === 'overloaded_error' || type === 'api_error' || type === 'rate_limit_error' || type === 'timeout_error';332 return new ProviderError(err.apiMessage ?? err.message, {333 provider: PROVIDER, retryable, status, code: type ?? (status === null ? 'api_error' : `http_${status}`),334 requestId: err.requestID ?? null, retryAfterMs: parseRetryAfter(err.headers), cause: err,335 });336 }337 return null;338}339340export function createAnthropicAdapter(options: AdapterOptions = {}): ProviderAdapter {341 let client: Anthropic | null = null;342 const getClient = (): Anthropic => {343 if (client) return client;344 const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;345 if (!apiKey) {346 throw new ProviderError('ANTHROPIC_API_KEY is not set', { provider: PROVIDER, retryable: false, code: 'missing_api_key' });347 }348 client = new Anthropic({349 apiKey,350 maxRetries: 0, // retries are handled here so that attempts, backoff and retry-after are observable351 timeout: options.timeoutMs ?? 600_000,352 ...(options.baseURL ? { baseURL: options.baseURL } : {}),353 ...(options.fetch ? { fetch: options.fetch } : {}),354 });355 return client;356 };357 const retry = {358 maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,359 sleep: options.sleep ?? abortableSleep,360 random: options.random ?? Math.random,361 };362 const aborted = (cause: unknown, attempts: number): ProviderError =>363 new ProviderError('Request aborted', { provider: PROVIDER, retryable: false, code: 'aborted', attempts, cause });364365 /** One streamed request → final Message. Deltas are forwarded as they arrive. */366 async function streamOnce(built: AnthropicRequest, req: ModelRequest): Promise<Record<string, unknown>> {367 const c = getClient();368 const opts = req.signal ? { signal: req.signal } : {};369 // The params object is assembled as plain JSON (it carries fields newer than some SDK typings, and the370 // native transcript is opaque JSON by contract), so it is cast once at the SDK boundary.371 const stream = built.beta372 ? c.beta.messages.stream(built.params as unknown as Parameters<Anthropic['beta']['messages']['stream']>[0], opts)373 : c.messages.stream(built.params as unknown as Parameters<Anthropic['messages']['stream']>[0], opts);374 const emitted = { text: false, reasoning: false };375 const emit = (type: 'text' | 'reasoning', text: string): void => {376 if (!req.onDelta || text.length === 0) return;377 try { req.onDelta({ type, text }); } catch { /* a console listener must never break the model call */ }378 };379 for await (const event of stream) {380 if (event.type === 'content_block_start') {381 // Separate consecutive blocks of the same kind (e.g. progress updates between tool calls).382 const kind = event.content_block.type === 'text' ? 'text' : event.content_block.type === 'thinking' ? 'reasoning' : null;383 if (kind && emitted[kind]) emit(kind, '\n\n');384 } else if (event.type === 'content_block_delta') {385 if (event.delta.type === 'text_delta') {386 emit('text', event.delta.text);387 emitted.text = true;388 } else if (event.delta.type === 'thinking_delta') {389 emit('reasoning', event.delta.thinking);390 emitted.reasoning = true;391 }392 }393 }394 return (await stream.finalMessage()) as unknown as Record<string, unknown>;395 }396397 async function requestWithRetries(built: AnthropicRequest, req: ModelRequest): Promise<{ message: Record<string, unknown>; attempts: number }> {398 let jsonReissues = 0;399 const { value, attempts } = await withRetries(async () => {400 for (;;) {401 try {402 return await streamOnce(built, req);403 } catch (err) {404 const mapped = mapAnthropicError(err);405 if (mapped) throw mapped;406 if (req.signal?.aborted) throw aborted(err, 1);407 // Not an API error: with eager input streaming the SDK rejects when a tool input is not parseable408 // at all. The tool_use block never completed, so there is no id to answer — re-issue the turn.409 if (jsonReissues++ >= MAX_TOOL_JSON_REISSUES) {410 throw new ProviderError('The response stream could not be read on consecutive attempts (unparseable tool input or malformed stream)', {411 provider: PROVIDER, retryable: false, code: 'invalid_stream', cause: err,412 });413 }414 }415 }416 }, { ...retry, signal: req.signal }, aborted);417 return { message: value, attempts: attempts + jsonReissues };418 }419420 const adapter: ProviderAdapter = {421 id: PROVIDER,422423 initTranscript(userText) {424 return [{ role: 'user', content: [{ type: 'text', text: userText }] }];425 },426 appendAssistant(transcript, native) {427 return [...transcript, ...(Array.isArray(native) ? native : [native])];428 },429 appendToolResults(transcript, results: ToolResult[]) {430 if (results.length === 0) return [...transcript];431 // ALL results of the turn in ONE user message; failures carry is_error so the model can adapt.432 const content = results.map((r) => ({433 type: 'tool_result', tool_use_id: r.callId, content: r.content, ...(r.ok ? {} : { is_error: true }),434 }));435 return [...transcript, { role: 'user', content }];436 },437 appendUser(transcript, text) {438 return [...transcript, { role: 'user', content: [{ type: 'text', text }] }];439 },440 appendOperatorNote(transcript, text) {441 return [...transcript, { role: 'system', content: text }];442 },443444 async call(req) {445 let entries = anthropicEntries(req.transcript);446 const native: AnthropicEntry[] = [];447 const blocks: ContentBlock[] = [];448 const usage: Usage = { usd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 1, toolCalls: 0 };449 let attempts = 0;450 for (let continuation = 0; ; continuation++) {451 const built = buildAnthropicRequest(req, entries);452 const { message, attempts: n } = await requestWithRetries(built, req);453 attempts += n;454 const u = anthropicUsage(message, req.model);455 usage.usd = Math.round((usage.usd + u.usd) * 1e9) / 1e9;456 usage.inputTokens += u.tokens.inputTokens;457 usage.outputTokens += u.tokens.outputTokens;458 usage.cacheReadTokens += u.tokens.cacheReadTokens;459 usage.cacheWriteTokens += u.tokens.cacheWriteTokens;460 const norm = normaliseAnthropicMessage(message, built.strictSchemas, built.originals);461 native.push(...norm.native);462 blocks.push(...norm.blocks);463 // pause_turn (server-side sampling loop hit its limit): send the assistant turn back unchanged, no464 // extra user message, and let the server resume. Bounded.465 if (norm.stop === 'pause' && norm.native.length > 0 && continuation < MAX_PAUSE_CONTINUATIONS) {466 entries = [...entries, ...norm.native];467 continue;468 }469 return {470 blocks, stop: norm.stop, usage,471 model: typeof message.model === 'string' ? message.model : req.model,472 native,473 ...(norm.refusal ? { refusal: norm.refusal } : {}),474 nativeStop: typeof message.stop_reason === 'string' ? message.stop_reason : null,475 attempts,476 } satisfies ProviderResponse;477 }478 },479480 async listLiveModelIds() {481 try {482 const ids: string[] = [];483 for await (const m of getClient().models.list({ limit: 1000 })) ids.push(m.id);484 return ids;485 } catch (err) {486 throw mapAnthropicError(err) ?? new ProviderError('Could not list Anthropic models', { provider: PROVIDER, retryable: false, code: 'list_models_failed', cause: err });487 }488 },489490 async listModels(): Promise<ModelInfo[]> {491 const mine = CATALOGUE.filter((m) => m.provider === PROVIDER);492 const live = new Map<string, { max_input_tokens?: number | null; max_tokens?: number | null }>();493 try {494 for await (const m of getClient().models.list({ limit: 1000 })) live.set(m.id, m);495 } catch {496 return mine.map((m) => ({ ...m, available: false }));497 }498 return mine.map((m) => {499 const l = live.get(m.id);500 return {501 ...m,502 available: l !== undefined,503 // The Models API is authoritative for limits when it reports them.504 contextTokens: typeof l?.max_input_tokens === 'number' ? l.max_input_tokens : m.contextTokens,505 maxOutputTokens: typeof l?.max_tokens === 'number' ? l.max_tokens : m.maxOutputTokens,506 };507 });508 },509510 price(model, usage) {511 return priceUsage(PROVIDER, model, usage);512 },513 };514 return adapter;515}516