SPB Git

Repository Public

anthropic.ts 25.4 KB 516 lines typescript

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
Files main
Menu

Theme