|
| 1 | +/** |
| 2 | + * Tests for the Bedrock anthropic_beta body-vs-header workaround |
| 3 | + * (see src/services/api/bedrockClient.ts and anthropics/claude-code#49238). |
| 4 | + */ |
| 5 | +import { describe, expect, test } from 'bun:test' |
| 6 | +import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk' |
| 7 | +import { BedrockClient } from '../bedrockClient.js' |
| 8 | + |
| 9 | +type Captured = { |
| 10 | + url: string |
| 11 | + method: string |
| 12 | + headers: Record<string, string> |
| 13 | + body: string |
| 14 | +} |
| 15 | + |
| 16 | +function makeCaptureFetch(): { |
| 17 | + fetch: typeof fetch |
| 18 | + get(): Captured | null |
| 19 | +} { |
| 20 | + let captured: Captured | null = null |
| 21 | + const capture = async ( |
| 22 | + input: URL | RequestInfo, |
| 23 | + init?: RequestInit, |
| 24 | + ): Promise<Response> => { |
| 25 | + const req = new Request(input as RequestInfo, init) |
| 26 | + const body = await req.clone().text() |
| 27 | + const headers: Record<string, string> = {} |
| 28 | + req.headers.forEach((v, k) => { |
| 29 | + headers[k.toLowerCase()] = v |
| 30 | + }) |
| 31 | + captured = { url: req.url, method: req.method, headers, body } |
| 32 | + const streamBody = |
| 33 | + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"x","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n' |
| 34 | + return new Response(streamBody, { |
| 35 | + status: 200, |
| 36 | + headers: { 'content-type': 'text/event-stream' }, |
| 37 | + }) |
| 38 | + } |
| 39 | + // SDK only calls the fetch function form, never the static `preconnect` that |
| 40 | + // Bun/Node's `typeof fetch` declares. Cast is safe (mirrors openai/client.ts). |
| 41 | + return { fetch: capture as unknown as typeof fetch, get: () => captured } |
| 42 | +} |
| 43 | + |
| 44 | +const BEDROCK_ARGS = { |
| 45 | + awsRegion: 'us-east-1', |
| 46 | + awsAccessKey: 'AKIAIOSFODNN7EXAMPLE', |
| 47 | + awsSecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', |
| 48 | +} |
| 49 | +const REQUEST_PARAMS = { |
| 50 | + model: 'anthropic.claude-opus-4-7', |
| 51 | + max_tokens: 10, |
| 52 | + messages: [{ role: 'user' as const, content: 'hi' }], |
| 53 | + betas: ['interleaved-thinking-2025-05-14', 'effort-2025-11-24'], |
| 54 | + stream: true as const, |
| 55 | +} |
| 56 | + |
| 57 | +async function dispatch(client: AnthropicBedrock): Promise<void> { |
| 58 | + try { |
| 59 | + const stream = await client.beta.messages.create(REQUEST_PARAMS) |
| 60 | + for await (const _ of stream) { |
| 61 | + /* drain */ |
| 62 | + } |
| 63 | + } catch { |
| 64 | + /* ignore: only the captured request shape matters */ |
| 65 | + } |
| 66 | +} |
| 67 | + |
| 68 | +describe('BedrockClient.buildRequest body.anthropic_beta cleanup', () => { |
| 69 | + test('BUG REPRO: unmodified AnthropicBedrock puts anthropic_beta in body', async () => { |
| 70 | + const { fetch: captureFetch, get } = makeCaptureFetch() |
| 71 | + const client = new AnthropicBedrock({ |
| 72 | + ...BEDROCK_ARGS, |
| 73 | + fetch: captureFetch, |
| 74 | + }) |
| 75 | + await dispatch(client) |
| 76 | + const c = get() |
| 77 | + expect(c).not.toBeNull() |
| 78 | + const body = JSON.parse(c!.body) as Record<string, unknown> |
| 79 | + expect('anthropic_beta' in body).toBe(true) |
| 80 | + expect(body.anthropic_beta).toEqual([ |
| 81 | + 'interleaved-thinking-2025-05-14', |
| 82 | + 'effort-2025-11-24', |
| 83 | + ]) |
| 84 | + }) |
| 85 | + |
| 86 | + test('FIX: BedrockClient strips anthropic_beta from body', async () => { |
| 87 | + const { fetch: captureFetch, get } = makeCaptureFetch() |
| 88 | + const client = new BedrockClient({ ...BEDROCK_ARGS, fetch: captureFetch }) |
| 89 | + await dispatch(client) |
| 90 | + const c = get() |
| 91 | + expect(c).not.toBeNull() |
| 92 | + const body = JSON.parse(c!.body) as Record<string, unknown> |
| 93 | + expect('anthropic_beta' in body).toBe(false) |
| 94 | + }) |
| 95 | + |
| 96 | + test('FIX preserves anthropic-beta HTTP header with the original csv value', async () => { |
| 97 | + const { fetch: captureFetch, get } = makeCaptureFetch() |
| 98 | + const client = new BedrockClient({ ...BEDROCK_ARGS, fetch: captureFetch }) |
| 99 | + await dispatch(client) |
| 100 | + const c = get() |
| 101 | + expect(c).not.toBeNull() |
| 102 | + expect(c!.headers['anthropic-beta']).toBe( |
| 103 | + 'interleaved-thinking-2025-05-14,effort-2025-11-24', |
| 104 | + ) |
| 105 | + }) |
| 106 | + |
| 107 | + test('FIX keeps a valid AWS SigV4 authorization header (signing happens after cleanup)', async () => { |
| 108 | + const { fetch: captureFetch, get } = makeCaptureFetch() |
| 109 | + const client = new BedrockClient({ ...BEDROCK_ARGS, fetch: captureFetch }) |
| 110 | + await dispatch(client) |
| 111 | + const c = get() |
| 112 | + expect(c).not.toBeNull() |
| 113 | + expect(c!.headers.authorization).toBeDefined() |
| 114 | + expect(c!.headers.authorization.startsWith('AWS4-HMAC-SHA256')).toBe(true) |
| 115 | + }) |
| 116 | + |
| 117 | + test('FIX does not disturb requests that never had anthropic_beta', async () => { |
| 118 | + const { fetch: captureFetch, get } = makeCaptureFetch() |
| 119 | + const client = new BedrockClient({ ...BEDROCK_ARGS, fetch: captureFetch }) |
| 120 | + try { |
| 121 | + const stream = await client.beta.messages.create({ |
| 122 | + model: 'anthropic.claude-opus-4-7', |
| 123 | + max_tokens: 10, |
| 124 | + messages: [{ role: 'user', content: 'hi' }], |
| 125 | + stream: true, |
| 126 | + }) |
| 127 | + for await (const _ of stream) { |
| 128 | + /* drain */ |
| 129 | + } |
| 130 | + } catch { |
| 131 | + /* ignore */ |
| 132 | + } |
| 133 | + const c = get() |
| 134 | + expect(c).not.toBeNull() |
| 135 | + const body = JSON.parse(c!.body) as Record<string, unknown> |
| 136 | + expect('anthropic_beta' in body).toBe(false) |
| 137 | + expect(c!.headers['anthropic-beta']).toBeUndefined() |
| 138 | + }) |
| 139 | +}) |
0 commit comments