SPB Git

Repository Public

anthropic.test.ts 32.4 KB 554 lines typescript

1import type { ToolResult } from '@aos/shared';2import { describe, expect, it } from 'vitest';3import { ANTHROPIC_STRICT_UNION_LIMIT, anthropicEntries, anthropicTools, countUnionParams, createAnthropicAdapter, renderAnthropicMessages, sanitizeFallbackTurn } from './anthropic.js';4import { ProviderError } from './errors.js';5import { invalidToolInputMessage, parseInvalidToolInput } from './schema.js';6import {7  anthropicError, anthropicEvents, anthropicStream, fakeFetch, hangUntilAborted, jsonResponse, noSleep, sseResponse,8  type Responder,9} from './test/fixtures.js';10import type { ModelDelta, ModelRequest, ToolDef } from './types.js';1112const READ: ToolDef = {13  name: 'read_file', description: 'Read a file',14  inputSchema: { type: 'object', properties: { path: { type: 'string' }, limit: { type: 'integer', minimum: 1 } }, required: ['path'] },15};16const LIST: ToolDef = {17  name: 'list_dir', description: 'List a directory',18  inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },19};2021function setup(responders: Responder[], opts: { maxAttempts?: number } = {}) {22  const { fetch, requests } = fakeFetch(responders);23  const { sleep, waits } = noSleep();24  const adapter = createAnthropicAdapter({ apiKey: 'test-key-not-real', fetch, sleep, random: () => 0.5, ...opts });25  return { adapter, requests, waits };26}27const req = (adapter: ReturnType<typeof createAnthropicAdapter>, over: Partial<ModelRequest> = {}): ModelRequest => ({28  model: 'claude-haiku-5-5', effort: 'low', system: 'You are a test agent.', tools: [READ, LIST],29  transcript: adapter.initTranscript('hello'), ...over,30});31const result = (callId: string, ok: boolean, content: string): ToolResult => ({ callId, name: 'read_file', ok, content, artifact: null, trust: 'tool' });3233describe('anthropic request shape', () => {34  it('sends the documented parameters and nothing forbidden (Haiku: no fallback beta)', async () => {35    const { adapter, requests } = setup([anthropicStream({ content: [{ type: 'text', text: 'hi' }], stop_reason: 'end_turn' })]);36    await adapter.call(req(adapter));37    const r = requests[0]!;38    expect(new URL(r.url).pathname).toBe('/v1/messages');39    expect(new URL(r.url).search).toBe('');40    expect(r.headers.get('anthropic-beta')).toBeNull();41    expect(`${r.headers.get('x-api-key')} ${r.headers.get('authorization')}`).toContain('test-key-not-real');42    const b = r.body!;43    expect(b.model).toBe('claude-haiku-5-5');44    expect(b.stream).toBe(true);45    expect(b.max_tokens).toBe(64_000);46    expect(b.thinking).toEqual({ type: 'adaptive', display: 'summarized' });47    expect(b.output_config).toEqual({ effort: 'low' });48    expect(b.cache_control).toEqual({ type: 'ephemeral' });49    expect(b.system).toEqual([{ type: 'text', text: 'You are a test agent.', cache_control: { type: 'ephemeral' } }]);50    for (const forbidden of ['tool_choice', 'temperature', 'top_p', 'top_k', 'fallbacks', 'betas', 'stop_sequences']) expect(b).not.toHaveProperty(forbidden);51    expect(JSON.stringify(b)).not.toContain('budget_tokens');52    expect(JSON.stringify(b)).not.toContain('"disabled"');53    // Tools: deterministic (sorted) order, strict schemas, eager input streaming.54    const tools = b.tools as Record<string, unknown>[];55    expect(tools.map((t) => t.name)).toEqual(['list_dir', 'read_file']);56    for (const t of tools) {57      expect(t.strict).toBe(true);58      expect(t.eager_input_streaming).toBe(true);59    }60    expect(tools[1]!.input_schema).toEqual({61      type: 'object',62      properties: { path: { type: 'string' }, limit: { anyOf: [{ type: 'integer' }, { type: 'null' }] } },63      required: ['path', 'limit'], additionalProperties: false,64    });65    expect(b.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]);66  });6768  it('produces a byte-identical prefix for the same inputs whatever the tool order given', async () => {69    const { adapter, requests } = setup([70      anthropicStream({ content: [{ type: 'text', text: 'a' }], stop_reason: 'end_turn' }),71      anthropicStream({ content: [{ type: 'text', text: 'b' }], stop_reason: 'end_turn' }),72    ]);73    await adapter.call(req(adapter, { tools: [READ, LIST] }));74    await adapter.call(req(adapter, { tools: [LIST, READ] }));75    expect(JSON.stringify(requests[0]!.body)).toBe(JSON.stringify(requests[1]!.body));76  });7778  it.each(['claude-fable-5-2', 'claude-opus-5-5', 'claude-sonnet-5-5'])('enables server-side fallbacks on %s through the beta endpoint', async (model) => {79    const { adapter, requests } = setup([anthropicStream({ model, content: [{ type: 'text', text: 'hi' }], stop_reason: 'end_turn' })]);80    await adapter.call(req(adapter, { model, effort: undefined }));81    const r = requests[0]!;82    expect(new URL(r.url).pathname).toBe('/v1/messages');83    expect(r.headers.get('anthropic-beta')).toContain('server-side-fallback-2026-07-01');84    expect(r.body!.fallbacks).toBe('default');85    expect(r.body).not.toHaveProperty('betas'); // moved to the header by the SDK86    // Effort is always explicit: the model's own default when the request has none.87    expect(r.body!.output_config).toEqual({ effort: model === 'claude-opus-5-5' ? 'medium' : 'high' });88    expect(r.body).not.toHaveProperty('tool_choice');89  });9091  it('keeps strict on as many tools as the API union-parameter limit allows, deterministically', () => {92    // 6 tools × 4 optional (nullable) properties = 24 union parameters; the API accepts at most 16 on strict tools.93    const nullable = (n: number): Record<string, unknown> => ({94      type: 'object', additionalProperties: false, required: ['path'],95      properties: { path: { type: 'string' }, ...Object.fromEntries(Array.from({ length: n }, (_, i) => [`opt${i}`, { type: 'integer' }])) },96    });97    const defs: ToolDef[] = ['f', 'b', 'e', 'a', 'd', 'c'].map((name) => ({ name, description: name, inputSchema: nullable(4) }));98    defs.push({ name: 'plain', description: 'no optional property', inputSchema: nullable(0) });99    const built = anthropicTools(defs);100    expect(built.tools.map((t) => t.name)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'plain']);101    const strict = built.tools.filter((t) => t.strict === true);102    expect(strict.map((t) => t.name)).toEqual(['a', 'b', 'c', 'd', 'plain']); // fewest unions first, then by name103    expect(strict.reduce((n, t) => n + countUnionParams(t.input_schema), 0)).toBeLessThanOrEqual(ANTHROPIC_STRICT_UNION_LIMIT);104    for (const t of built.tools) {105      expect(t.eager_input_streaming).toBe(true);106      expect(t.input_schema).toEqual(built.strictSchemas.get(t.name as string)); // same schema either way: inputs are validated client-side107      if (t.strict !== true) expect(t).not.toHaveProperty('strict');108    }109    expect(anthropicTools([...defs].reverse()).tools).toEqual(built.tools); // input order does not matter110    // within the limit nothing changes: every tool is strict111    expect(anthropicTools(defs.slice(0, 4)).tools.every((t) => t.strict === true)).toBe(true);112  });113114  it('caps max_tokens at the model limit and omits empty system/tools', async () => {115    const { adapter, requests } = setup([anthropicStream({ content: [{ type: 'text', text: 'hi' }], stop_reason: 'end_turn' })]);116    await adapter.call(req(adapter, { system: '', tools: [], maxOutputTokens: 500_000 }));117    expect(requests[0]!.body!.max_tokens).toBe(128_000);118    expect(requests[0]!.body).not.toHaveProperty('system');119    expect(requests[0]!.body).not.toHaveProperty('tools');120  });121});122123describe('anthropic streaming and normalisation', () => {124  it('streams text and reasoning-summary deltas and normalises blocks, stop and usage', async () => {125    const { adapter } = setup([anthropicStream({126      content: [127        { type: 'thinking', thinking: 'The user greets me.', signature: 'sig-fixture-A', chunks: ['The user ', 'greets me.'] },128        { type: 'text', text: 'Hello there', chunks: ['Hello ', 'there'] },129      ],130      stop_reason: 'end_turn',131      usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 2000, cache_creation_input_tokens: 300 },132    })]);133    const deltas: ModelDelta[] = [];134    const res = await adapter.call(req(adapter, { onDelta: (d) => deltas.push(d) }));135    expect(deltas).toEqual([136      { type: 'reasoning', text: 'The user ' }, { type: 'reasoning', text: 'greets me.' },137      { type: 'text', text: 'Hello ' }, { type: 'text', text: 'there' },138    ]);139    expect(res.blocks).toEqual([{ type: 'reasoning', summary: 'The user greets me.' }, { type: 'text', text: 'Hello there' }]);140    expect(res.stop).toBe('end_turn');141    expect(res.nativeStop).toBe('end_turn');142    expect(res.model).toBe('claude-haiku-5-5');143    expect(res.attempts).toBe(1);144    // Haiku 5.5: 0.10 in, 0.50 out, 0.01 cache read, 0.125 cache write per MTok.145    expect(res.usage).toEqual({146      usd: (100 * 0.1 + 50 * 0.5 + 2000 * 0.01 + 300 * 0.125) / 1e6,147      inputTokens: 100, outputTokens: 50, cacheReadTokens: 2000, cacheWriteTokens: 300, turns: 1, toolCalls: 0,148    });149    expect(adapter.price('claude-haiku-5-5', res.usage)).toBe(res.usage.usd);150    // Native: every block verbatim, thinking + signature included.151    expect(res.native).toHaveLength(1);152    const assistant = res.native[0] as { role: string; content: Record<string, unknown>[] };153    expect(assistant.role).toBe('assistant');154    expect(assistant.content[0]).toMatchObject({ type: 'thinking', thinking: 'The user greets me.', signature: 'sig-fixture-A' });155    expect(assistant.content[1]).toMatchObject({ type: 'text', text: 'Hello there' });156  });157158  it('separates consecutive blocks of the same kind in the delta stream and survives a throwing listener', async () => {159    const { adapter } = setup([anthropicStream({160      content: [161        { type: 'thinking', thinking: 'one', signature: 's1', chunks: ['one'] },162        { type: 'tool_use', id: 'toolu_1', name: 'list_dir', json: '{"path":"."}' },163        { type: 'thinking', thinking: 'two', signature: 's2', chunks: ['two'] },164        { type: 'tool_use', id: 'toolu_2', name: 'list_dir', json: '{"path":"src"}' },165      ],166      stop_reason: 'tool_use',167    })]);168    const seen: string[] = [];169    const res = await adapter.call(req(adapter, { onDelta: (d) => { seen.push(d.text); throw new Error('listener bug'); } }));170    expect(seen).toEqual(['one', '\n\n', 'two']);171    expect(res.blocks.map((b) => b.type)).toEqual(['reasoning', 'tool_call', 'reasoning', 'tool_call']);172  });173174  it('parses parallel tool calls and keeps the transcript continuous across the tool round-trip', async () => {175    const { adapter, requests } = setup([176      anthropicStream({177        content: [178          { type: 'thinking', thinking: 'Read both.', signature: 'sig-fixture-B' },179          { type: 'tool_use', id: 'toolu_a', name: 'read_file', json: '{"path":"a.txt","limit":null}' },180          { type: 'tool_use', id: 'toolu_b', name: 'read_file', json: '{"path":"b.txt","limit":20}' },181        ],182        stop_reason: 'tool_use',183      }),184      anthropicStream({ content: [{ type: 'text', text: 'done' }], stop_reason: 'end_turn' }),185    ]);186    const t0 = adapter.initTranscript('read a.txt and b.txt');187    const first = await adapter.call(req(adapter, { transcript: t0 }));188    expect(first.stop).toBe('tool_use');189    expect(first.blocks.filter((b) => b.type === 'tool_call')).toEqual([190      // `limit` is optional in the tool's own schema: the strict-mode null is removed again.191      { type: 'tool_call', id: 'toolu_a', name: 'read_file', input: { path: 'a.txt' } },192      { type: 'tool_call', id: 'toolu_b', name: 'read_file', input: { path: 'b.txt', limit: 20 } },193    ]);194195    const frozen = JSON.stringify(t0);196    const t1 = adapter.appendAssistant(t0, first.native);197    const t2 = adapter.appendToolResults(t1, [result('toolu_a', true, 'AAA'), result('toolu_b', false, 'ENOENT: no such file')]);198    expect(JSON.stringify(t0)).toBe(frozen); // append-only: earlier arrays are never mutated199    expect(t2).toHaveLength(3);200    // ALL results of the turn in ONE user message; the failure is flagged.201    expect(t2[2]).toEqual({202      role: 'user',203      content: [204        { type: 'tool_result', tool_use_id: 'toolu_a', content: 'AAA' },205        { type: 'tool_result', tool_use_id: 'toolu_b', content: 'ENOENT: no such file', is_error: true },206      ],207    });208209    const second = await adapter.call(req(adapter, { transcript: t2 }));210    expect(second.stop).toBe('end_turn');211    const sent = requests[1]!.body!.messages as unknown[];212    // The second request replays the first prefix byte for byte, then the assistant turn verbatim (the raw213    // model input, null included, and the thinking block with its signature), then the results.214    expect(JSON.stringify(sent.slice(0, 1))).toBe(JSON.stringify(requests[0]!.body!.messages));215    expect(JSON.stringify(sent[1])).toBe(JSON.stringify(first.native[0]));216    expect((sent[1] as { content: Record<string, unknown>[] }).content[0]).toMatchObject({ type: 'thinking', signature: 'sig-fixture-B' });217    expect((sent[1] as { content: Record<string, unknown>[] }).content[1]).toMatchObject({ type: 'tool_use', id: 'toolu_a', input: { path: 'a.txt', limit: null } });218    expect(sent[2]).toEqual(t2[2]);219    // Same request whether the engine spreads `native` or pushes it as one element.220    expect(JSON.stringify(anthropicEntries([...t0, first.native, t2[2]]))).toBe(JSON.stringify(anthropicEntries(t2)));221  });222223  it('surfaces a tool input cut off by max_tokens instead of letting it run', async () => {224    const { adapter } = setup([anthropicStream({225      content: [226        { type: 'tool_use', id: 'toolu_ok', name: 'list_dir', json: '{"path":"."}' },227        { type: 'tool_use', id: 'toolu_cut', name: 'read_file', json: '{"path":"a-very-long-pa', chunks: ['{"path":"a-very', '-long-pa'] },228      ],229      stop_reason: 'max_tokens',230    })]);231    const res = await adapter.call(req(adapter));232    // Tool calls are pending, so the engine must answer them: stop is tool_use, the raw reason is kept.233    expect(res.stop).toBe('tool_use');234    expect(res.nativeStop).toBe('max_tokens');235    const [ok, cut] = res.blocks.filter((b) => b.type === 'tool_call');236    expect(ok!.input).toEqual({ path: '.' });237    expect(typeof cut!.input).toBe('string');238    expect(parseInvalidToolInput(cut!.input)).toMatchObject({ reason: 'truncated' });239    expect(invalidToolInputMessage(cut!.input)).toContain('NOT run');240    expect(invalidToolInputMessage(ok!.input)).toBeNull();241  });242243  it('flags parsed tool input that does not match the schema (eager streaming is not validated by the API)', async () => {244    const { adapter } = setup([anthropicStream({245      content: [246        { type: 'tool_use', id: 'toolu_1', name: 'read_file', json: '{"limit":5}' },247        { type: 'tool_use', id: 'toolu_2', name: 'read_file', json: '{"path":42,"limit":null}' },248      ],249      stop_reason: 'tool_use',250    })]);251    const res = await adapter.call(req(adapter));252    const calls = res.blocks.filter((b) => b.type === 'tool_call');253    expect(parseInvalidToolInput(calls[0]!.input)).toMatchObject({ reason: 'schema', detail: 'path: missing', raw: '{"limit":5}' });254    expect(parseInvalidToolInput(calls[1]!.input)).toMatchObject({ reason: 'schema' });255    expect(invalidToolInputMessage(calls[0]!.input)).toContain('"INVALID_JSON"');256  });257258  it('re-issues the turn when the SDK cannot parse a tool input at all, then gives up with a typed error', async () => {259    const broken = (): Responder => anthropicStream({260      content: [{ type: 'tool_use', id: 'toolu_x', name: 'read_file', json: 'not json at all', chunks: ['not json at all'] }], stop_reason: 'tool_use',261    });262    const good = anthropicStream({ content: [{ type: 'tool_use', id: 'toolu_y', name: 'read_file', json: '{"path":"x","limit":null}' }], stop_reason: 'tool_use' });263    const a = setup([broken(), good]);264    const res = await a.adapter.call(req(a.adapter));265    expect(a.requests).toHaveLength(2);266    expect(res.attempts).toBe(2);267    expect(res.blocks).toEqual([{ type: 'tool_call', id: 'toolu_y', name: 'read_file', input: { path: 'x' } }]);268269    const b = setup([broken(), broken(), broken()]);270    const err = await b.adapter.call(req(b.adapter)).catch((e: unknown) => e);271    expect(err).toBeInstanceOf(ProviderError);272    expect(err).toMatchObject({ code: 'invalid_stream', retryable: false, provider: 'anthropic' });273    expect(b.requests).toHaveLength(3);274  });275276  it('maps a refusal: category surfaced, partial tool calls never surfaced, only text kept', async () => {277    const { adapter } = setup([anthropicStream({278      content: [279        { type: 'thinking', thinking: 'hmm', signature: 'sig' },280        { type: 'text', text: 'I started to' },281        { type: 'tool_use', id: 'toolu_r', name: 'read_file', json: '{"path":"x"', chunks: ['{"path":"x"'] },282      ],283      stop_reason: 'refusal', stop_details: { type: 'refusal', category: 'cyber', explanation: 'Declined by a safety classifier.' },284    })]);285    const res = await adapter.call(req(adapter));286    expect(res.stop).toBe('refusal');287    expect(res.refusal).toEqual({ category: 'cyber', explanation: 'Declined by a safety classifier.' });288    expect(res.blocks).toEqual([{ type: 'text', text: 'I started to' }]);289    expect(res.native).toHaveLength(1);290    expect((res.native[0] as { content: { type: string }[] }).content.map((b) => b.type)).toEqual(['text']);291  });292293  it('handles a refusal before any output (empty content, null details)', async () => {294    const { adapter } = setup([anthropicStream({ content: [], stop_reason: 'refusal', stop_details: null })]);295    const res = await adapter.call(req(adapter));296    expect(res.stop).toBe('refusal');297    expect(res.refusal).toEqual({ category: null, explanation: null });298    expect(res.blocks).toEqual([]);299    expect(res.native).toEqual([]); // nothing to append: an empty assistant message would be rejected300  });301302  it('follows the echo rule and prices each attempt after a server-side fallback', async () => {303    const { adapter } = setup([anthropicStream({304      model: 'claude-opus-4-8',305      content: [306        { type: 'thinking', thinking: 'declined model reasoning', signature: 'sig-declined' },307        { type: 'text', text: 'Partial. ' },308        { type: 'tool_use', id: 'toolu_declined', name: 'list_dir', json: '{"path":"."}' },309        { type: 'raw', start: { type: 'fallback', from: { model: 'claude-opus-5-5' }, to: { model: 'claude-opus-4-8' }, trigger: { type: 'refusal' } } },310        { type: 'text', text: 'Continued by the fallback.' },311        { type: 'tool_use', id: 'toolu_kept', name: 'list_dir', json: '{"path":"src"}' },312      ],313      stop_reason: 'tool_use',314      usage: {315        input_tokens: 10, output_tokens: 30,316        iterations: [317          { type: 'message', model: 'claude-opus-5-5', input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },318          { type: 'fallback_message', model: 'claude-opus-4-8', input_tokens: 1000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },319        ],320      },321    })]);322    const res = await adapter.call(req(adapter, { model: 'claude-opus-5-5' }));323    expect(res.model).toBe('claude-opus-4-8');324    expect(res.stop).toBe('tool_use');325    // Blocks of the declined model before the fallback boundary: text only; the boundary block stays in place.326    expect((res.native[0] as { content: { type: string }[] }).content.map((b) => b.type)).toEqual(['text', 'fallback', 'text', 'tool_use']);327    expect(res.blocks).toEqual([328      { type: 'text', text: 'Partial. ' },329      { type: 'text', text: 'Continued by the fallback.' },330      { type: 'tool_call', id: 'toolu_kept', name: 'list_dir', input: { path: 'src' } },331    ]);332    // usage.iterations is the per-attempt source of truth: Opus 5.5 at 4/20, Opus 4.8 at 5/25.333    expect(res.usage.inputTokens).toBe(2000);334    expect(res.usage.outputTokens).toBe(300);335    expect(res.usage.usd).toBeCloseTo((1000 * 4 + 100 * 20 + 1000 * 5 + 200 * 25) / 1e6, 12);336  });337338  it('resumes pause_turn by sending the assistant turn back unchanged, without an extra user message', async () => {339    const paused = { type: 'raw' as const, start: { type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: {} } };340    const { adapter, requests } = setup([341      anthropicStream({ content: [{ type: 'text', text: 'Searching. ' }, paused], stop_reason: 'pause_turn', usage: { input_tokens: 10, output_tokens: 5 } }),342      anthropicStream({ content: [{ type: 'text', text: 'Found it.' }], stop_reason: 'end_turn', usage: { input_tokens: 20, output_tokens: 7 } }),343    ]);344    const res = await adapter.call(req(adapter));345    expect(res.stop).toBe('end_turn');346    expect(res.attempts).toBe(2);347    expect(res.native).toHaveLength(2);348    expect(res.blocks).toEqual([{ type: 'text', text: 'Searching. ' }, { type: 'text', text: 'Found it.' }]);349    expect(res.usage.inputTokens).toBe(30);350    expect(res.usage.outputTokens).toBe(12);351    const second = requests[1]!.body!.messages as { role: string }[];352    expect(second.map((m) => m.role)).toEqual(['user', 'assistant']);353    expect(JSON.stringify(second[1])).toBe(JSON.stringify(res.native[0]));354  });355356  it('returns stop "pause" when the turn is still paused after the continuation limit', async () => {357    const paused = (): Responder => anthropicStream({ content: [{ type: 'raw', start: { type: 'server_tool_use', id: 'srvtoolu_1', name: 'web_search', input: {} } }], stop_reason: 'pause_turn' });358    const { adapter, requests } = setup(Array.from({ length: 6 }, paused));359    const res = await adapter.call(req(adapter));360    expect(res.stop).toBe('pause');361    expect(requests).toHaveLength(6);362  });363364  it.each([365    ['stop_sequence', 'end_turn'], ['model_context_window_exceeded', 'error'], ['max_tokens', 'max_tokens'],366  ] as const)('maps stop_reason %s to %s', async (native, stop) => {367    const { adapter } = setup([anthropicStream({ content: [{ type: 'text', text: 'x' }], stop_reason: native })]);368    const res = await adapter.call(req(adapter));369    expect(res.stop).toBe(stop);370    expect(res.nativeStop).toBe(native);371  });372});373374describe('anthropic operator notes and user messages', () => {375  const user = (text: string) => ({ role: 'user' as const, content: [{ type: 'text', text }] });376  const assistant = (text: string) => ({ role: 'assistant' as const, content: [{ type: 'text', text }] });377  const note = (text: string) => ({ role: 'system' as const, content: text });378  const reminder = (text: string) => ({ role: 'user', content: [{ type: 'text', text: `<system-reminder>\n${text}\n</system-reminder>` }] });379380  it('appends notes and user messages without mutating the transcript', () => {381    const { adapter } = setup([]);382    const t0 = adapter.initTranscript('go');383    const t1 = adapter.appendOperatorNote(t0, 'wrap up');384    const t2 = adapter.appendUser(t1, 'steer');385    expect(t0).toHaveLength(1);386    expect(t1[1]).toEqual({ role: 'system', content: 'wrap up' });387    expect(t2[2]).toEqual(user('steer'));388    expect(adapter.appendToolResults(t0, [])).toEqual(t0);389  });390391  it('renders a note after a user turn as a mid-conversation system message (last, or followed by assistant)', () => {392    expect(renderAnthropicMessages([user('a'), note('n1')], true)).toEqual([user('a'), note('n1')]);393    expect(renderAnthropicMessages([user('a'), note('n1'), assistant('b'), user('c')], true)).toEqual([user('a'), note('n1'), assistant('b'), user('c')]);394    // Consecutive notes are one system message.395    expect(renderAnthropicMessages([user('a'), note('n1'), note('n2')], true)).toEqual([user('a'), note('n1\n\nn2')]);396  });397398  it('falls back to a delimited user-turn note when the placement rules would be violated', () => {399    // After an assistant turn (the nudge case): a system message may not follow an assistant message.400    expect(renderAnthropicMessages([user('a'), assistant('b'), note('n')], true)).toEqual([user('a'), assistant('b'), reminder('n')]);401    // Followed by a user message (steering arrives after the note): must be last or precede an assistant turn.402    expect(renderAnthropicMessages([user('a'), note('n'), user('steer')], true)).toEqual([user('a'), reminder('n'), user('steer')]);403    // Model without mid-conversation system messages.404    expect(renderAnthropicMessages([user('a'), note('n')], false)).toEqual([user('a'), reminder('n')]);405  });406407  it('never changes the rendering of a prefix that was already sent', () => {408    const sent = [user('a'), note('n1')];409    const later = [...sent, assistant('b'), user('tool results'), note('n2'), assistant('c'), note('n3')];410    const before = renderAnthropicMessages(sent, true);411    expect(renderAnthropicMessages(later, true).slice(0, before.length)).toEqual(before);412  });413414  it('sends role:system on a supporting model and a user-turn note on another', async () => {415    const ok = (): Responder => anthropicStream({ content: [{ type: 'text', text: 'k' }], stop_reason: 'end_turn' });416    const { adapter, requests } = setup([ok(), ok()]);417    const t = adapter.appendOperatorNote(adapter.initTranscript('go'), 'be brief');418    await adapter.call(req(adapter, { transcript: t }));419    expect((requests[0]!.body!.messages as unknown[])[1]).toEqual({ role: 'system', content: 'be brief' });420    await adapter.call(req(adapter, { transcript: t, model: 'claude-sonnet-5' }));421    expect((requests[1]!.body!.messages as unknown[])[1]).toEqual(reminder('be brief'));422  });423424  it('rejects a corrupted transcript with a typed error before any request', async () => {425    const { adapter, requests } = setup([]);426    await expect(adapter.call(req(adapter, { transcript: [{ role: 'tool', content: 'x' }] }))).rejects.toMatchObject({ code: 'invalid_transcript', retryable: false });427    expect(requests).toHaveLength(0);428  });429430  it('sanitizeFallbackTurn is the identity without a fallback block', () => {431    const content = [{ type: 'thinking' }, { type: 'tool_use' }];432    expect(sanitizeFallbackTurn(content)).toBe(content);433  });434});435436describe('anthropic errors, retries and abort', () => {437  const ok = (): Responder => anthropicStream({ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' });438439  it('honours retry-after on 429 and reports the attempt count', async () => {440    const { adapter, requests, waits } = setup([anthropicError(429, 'rate_limit_error', 'slow down', { 'retry-after': '2' }), ok()]);441    const res = await adapter.call(req(adapter));442    expect(res.attempts).toBe(2);443    expect(waits).toEqual([2000]);444    expect(requests).toHaveLength(2);445    expect(requests[0]!.headers.get('x-stainless-retry-count')).not.toBe('1'); // the SDK's own retries are off446  });447448  it('backs off exponentially on 529 overloaded and 500', async () => {449    const { adapter, waits } = setup([anthropicError(529, 'overloaded_error', 'Overloaded'), anthropicError(500, 'api_error', 'boom'), ok()]);450    const res = await adapter.call(req(adapter));451    expect(res.attempts).toBe(3);452    expect(waits).toEqual([750, 1500]); // 1 s and 2 s ceilings with random() = 0.5453  });454455  it('gives up after maxAttempts with a retryable ProviderError', async () => {456    const { adapter, requests, waits } = setup(Array.from({ length: 6 }, () => anthropicError(529, 'overloaded_error', 'Overloaded')));457    const err = await adapter.call(req(adapter)).catch((e: unknown) => e);458    expect(err).toBeInstanceOf(ProviderError);459    expect(err).toMatchObject({ provider: 'anthropic', status: 529, code: 'overloaded_error', retryable: true, attempts: 6, requestId: 'req_fixture_err' });460    expect(requests).toHaveLength(6);461    expect(waits).toEqual([750, 1500, 3000, 6000, 12000]);462  });463464  it.each([465    [400, 'invalid_request_error'], [401, 'authentication_error'], [403, 'permission_error'], [404, 'not_found_error'], [413, 'request_too_large'],466  ])('does not retry %i %s', async (status, type) => {467    const { adapter, requests, waits } = setup([anthropicError(status, type, 'nope')]);468    const err = await adapter.call(req(adapter)).catch((e: unknown) => e);469    expect(err).toMatchObject({ status, code: type, retryable: false, attempts: 1, message: 'nope' });470    expect(requests).toHaveLength(1);471    expect(waits).toEqual([]);472  });473474  it('retries connection errors', async () => {475    const { adapter, requests } = setup([() => { throw new TypeError('fetch failed'); }, ok()]);476    const res = await adapter.call(req(adapter));477    expect(res.attempts).toBe(2);478    expect(requests).toHaveLength(2);479  });480481  it('retries an overloaded error delivered inside an open stream', async () => {482    const midStream: Responder = () => sseResponse([483      ...anthropicEvents({ content: [{ type: 'text', text: 'partial' }], stop_reason: 'end_turn' }).slice(0, 3),484      { event: 'error', data: { type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } } },485    ]);486    const { adapter, requests } = setup([midStream, ok()]);487    const res = await adapter.call(req(adapter));488    expect(requests).toHaveLength(2);489    expect(res.blocks).toEqual([{ type: 'text', text: 'ok' }]);490  });491492  it('aborts through AbortSignal with a non-retryable typed error', async () => {493    const { adapter, requests } = setup([hangUntilAborted, ok()]);494    const controller = new AbortController();495    const pending = adapter.call(req(adapter, { signal: controller.signal }));496    setTimeout(() => controller.abort(), 20);497    await expect(pending).rejects.toMatchObject({ name: 'ProviderError', code: 'aborted', retryable: false });498    expect(requests).toHaveLength(1);499  });500501  it('stops waiting between retries when aborted', async () => {502    const { fetch } = fakeFetch([anthropicError(529, 'overloaded_error', 'Overloaded'), ok()]);503    const controller = new AbortController();504    const adapter = createAnthropicAdapter({505      apiKey: 'test-key-not-real', fetch, random: () => 0.5,506      sleep: (_ms, signal) => new Promise((_r, reject) => { signal?.addEventListener('abort', () => reject(new Error('aborted'))); controller.abort(); }),507    });508    await expect(adapter.call(req(adapter, { signal: controller.signal }))).rejects.toMatchObject({ code: 'aborted', retryable: false });509  });510511  it('fails with a typed error when the API key is missing, and never puts the key in an error', async () => {512    const saved = process.env.ANTHROPIC_API_KEY;513    delete process.env.ANTHROPIC_API_KEY;514    try {515      const adapter = createAnthropicAdapter({ fetch: fakeFetch([]).fetch });516      await expect(adapter.call(req(adapter))).rejects.toMatchObject({ code: 'missing_api_key', retryable: false });517      expect(await adapter.listModels()).toSatisfy((ms: { available: boolean }[]) => ms.length === 4 && ms.every((m) => !m.available));518    } finally {519      if (saved !== undefined) process.env.ANTHROPIC_API_KEY = saved;520    }521    const { adapter } = setup([anthropicError(401, 'authentication_error', 'invalid x-api-key')]);522    const err = (await adapter.call(req(adapter)).catch((e: unknown) => e)) as ProviderError;523    expect(JSON.stringify(err)).not.toContain('test-key-not-real');524    expect(err.message).not.toContain('test-key-not-real');525  });526});527528describe('anthropic listModels', () => {529  it('merges the live Models API with the catalogue; available only when the live list has the ID', async () => {530    const { adapter, requests } = setup([() => jsonResponse(200, {531      data: [532        { type: 'model', id: 'claude-haiku-5-5', display_name: 'Claude Haiku 5.5', created_at: '2026-10-07T18:00:00Z', max_input_tokens: 1_000_000, max_tokens: 128_000 },533        { type: 'model', id: 'claude-opus-5-5', display_name: 'Claude Opus 5.5', created_at: '2026-09-01T00:00:00Z', max_input_tokens: 900_000, max_tokens: null },534        { type: 'model', id: 'claude-opus-4-8', display_name: 'Claude Opus 4.8', created_at: '2026-05-01T00:00:00Z', max_input_tokens: 1_000_000, max_tokens: 128_000 },535      ],536      has_more: false, first_id: 'claude-haiku-5-5', last_id: 'claude-opus-4-8',537    })]);538    const models = await adapter.listModels();539    expect(new URL(requests[0]!.url).pathname).toBe('/v1/models');540    expect(models.map((m) => [m.id, m.tier, m.available])).toEqual([541      ['claude-fable-5-2', 'frontier', false], ['claude-opus-5-5', 'main', true], ['claude-sonnet-5-5', 'worker', false], ['claude-haiku-5-5', 'volume', true],542    ]);543    // Live limits win when reported; the catalogue value stays when the API reports null.544    expect(models[1]).toMatchObject({ contextTokens: 900_000, maxOutputTokens: 128_000, inputUsd: 4, outputUsd: 20 });545  });546547  it('marks everything unavailable when the Models API fails', async () => {548    const { adapter } = setup([anthropicError(500, 'api_error', 'boom')]);549    expect((await adapter.listModels()).every((m) => !m.available)).toBe(true);550    const again = setup([anthropicError(401, 'authentication_error', 'bad key')]);551    await expect(again.adapter.listLiveModelIds()).rejects.toMatchObject({ status: 401, retryable: false });552  });553});554
Files main
Menu

Theme