Skip to content

Commit be97a0b

Browse files
unraidclaude
andcommitted
feat: 添加 Bedrock API 客户端及 API 层增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 59f8675 commit be97a0b

15 files changed

Lines changed: 1366 additions & 201 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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

Comments
 (0)