Repository Public
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