1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13)
14
15func TestBedrockRetriesTransientRequestOpenFailures(t *testing.T) {
16 var attempts atomic.Int32
17 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18 if attempts.Add(1) < 5 {
19 http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
20 return
21 }
22 w.WriteHeader(http.StatusOK)
23 }))
24 t.Cleanup(server.Close)
25
26 client := &bedrockClient{
27 bearerToken: "synthetic-token",
28 region: "us-east-1",
29 baseURL: server.URL,
30 http: server.Client(),
31 }
32 events, err := client.Stream(context.Background(), Request{Model: "anthropic.claude-sonnet-4-5"})
33 if err != nil {
34 t.Fatal(err)
35 }
36 for range events {
37 }
38 if got := attempts.Load(); got != 5 {
39 t.Fatalf("request attempts = %d, want initial request plus four retries", got)
40 }
41}
42
43// TestSigV4Known checks our signer against AWS's published test vector
44// from the SigV4 documentation. The expected Authorization header below
45// is taken from "get-vanilla" in the suite (adapted: we use POST with
46// an empty body since Bedrock is always POST).
47//
48// We don't run the official suite (it lives in S3 as a tarball and the
49// signing rules vary slightly between examples); instead this test
50// pins a fixed input/output so future refactors can't silently break
51// the signer.
52func TestSigV4Deterministic(t *testing.T) {
53 creds := &bedrockSigV4Creds{
54 accessKeyID: "AKIDEXAMPLE",
55 secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
56 }
57 body := []byte(`{"hello":"world"}`)
58 req, err := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/foo/converse-stream", bytes.NewReader(body))
59 if err != nil {
60 t.Fatal(err)
61 }
62 req.Header.Set("content-type", "application/json")
63 when := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
64 if err := signSigV4(req, body, "bedrock", "us-east-1", creds, when); err != nil {
65 t.Fatal(err)
66 }
67 auth := req.Header.Get("Authorization")
68 if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 ") {
69 t.Fatalf("missing AWS4 prefix: %q", auth)
70 }
71 if !strings.Contains(auth, "Credential=AKIDEXAMPLE/20240115/us-east-1/bedrock/aws4_request") {
72 t.Errorf("wrong credential scope: %q", auth)
73 }
74 if !strings.Contains(auth, "SignedHeaders=") {
75 t.Errorf("missing SignedHeaders: %q", auth)
76 }
77 if !strings.Contains(auth, "Signature=") {
78 t.Errorf("missing Signature: %q", auth)
79 }
80 // Sign again with identical inputs -> identical signature (determinism).
81 req2, _ := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/model/foo/converse-stream", bytes.NewReader(body))
82 req2.Header.Set("content-type", "application/json")
83 if err := signSigV4(req2, body, "bedrock", "us-east-1", creds, when); err != nil {
84 t.Fatal(err)
85 }
86 if req2.Header.Get("Authorization") != auth {
87 t.Errorf("signature not deterministic:\n a=%q\n b=%q", auth, req2.Header.Get("Authorization"))
88 }
89}
90
91func TestSigV4PathEncoding(t *testing.T) {
92 // Bedrock model IDs include colons and dots. The colon must encode
93 // to %3A in the canonical path (per SigV4 rules).
94 cases := map[string]string{
95 "/model/anthropic.claude-sonnet-4-5-20250929-v1:0/converse-stream": "/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/converse-stream",
96 "/": "/",
97 "": "/",
98 "/a": "/a",
99 }
100 for in, want := range cases {
101 got := canonicalSigV4Path(in)
102 if got != want {
103 t.Errorf("canonicalSigV4Path(%q) = %q, want %q", in, got, want)
104 }
105 }
106}
107
108func TestReadAWSCredentialsFile(t *testing.T) {
109 // Smoke test: parses an INI-ish file when present. We don't require
110 // the file to exist on the test host.
111 if _, err := readAWSCredentialsFile("default"); err != nil {
112 // missing file or missing profile is fine — both are non-panics
113 t.Logf("no aws creds available (expected on CI): %v", err)
114 }
115}
116
117func TestNormalizeBedrockToolResultsMovesResultsAdjacentToToolUse(t *testing.T) {
118 msgs := []Message{
119 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{"path":"a"}`)}}},
120 {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
121 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "edited"}}}}},
122 }
123
124 out := normalizeBedrockToolResults(msgs)
125 if len(out) != 3 {
126 t.Fatalf("got %d messages, want 3: %+v", len(out), out)
127 }
128 if out[0].Role != RoleAssistant {
129 t.Fatalf("first role = %s, want assistant", out[0].Role)
130 }
131 if out[1].Role != RoleTool {
132 t.Fatalf("second role = %s, want tool", out[1].Role)
133 }
134 tr, ok := out[1].Content[0].(ToolResultBlock)
135 if !ok {
136 t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
137 }
138 if tr.CallID != "tool-1" || tr.IsError {
139 t.Fatalf("unexpected tool result: %+v", tr)
140 }
141 if out[2].Role != RoleUser {
142 t.Fatalf("third role = %s, want user", out[2].Role)
143 }
144}
145
146func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
147 msgs := []Message{
148 {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
149 {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
150 }
151
152 out := normalizeBedrockToolResults(msgs)
153 if len(out) != 3 {
154 t.Fatalf("got %d messages, want 3: %+v", len(out), out)
155 }
156 tr, ok := out[1].Content[0].(ToolResultBlock)
157 if !ok {
158 t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
159 }
160 if tr.CallID != "tool-1" || !tr.IsError {
161 t.Fatalf("unexpected synthetic result: %+v", tr)
162 }
163}
164
165func bedrockResultText(t *testing.T, m Message) string {
166 t.Helper()
167 if m.Role != RoleTool || len(m.Content) != 1 {
168 t.Fatalf("message = %+v, want one tool result", m)
169 }
170 tr, ok := m.Content[0].(ToolResultBlock)
171 if !ok || len(tr.Content) != 1 {
172 t.Fatalf("content = %#v, want ToolResultBlock", m.Content[0])
173 }
174 return tr.Content[0].(TextBlock).Text
175}
176
177func TestNormalizeBedrockToolResultsPairsReusedIDsInOrder(t *testing.T) {
178 call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}}
179 result := func(text string) Message {
180 return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}}
181 }
182 out := normalizeBedrockToolResults([]Message{call, result("first"), call, result("second")})
183 if len(out) != 4 {
184 t.Fatalf("got %d messages, want 4: %+v", len(out), out)
185 }
186 if got := bedrockResultText(t, out[1]); got != "first" {
187 t.Fatalf("first result = %q", got)
188 }
189 if got := bedrockResultText(t, out[3]); got != "second" {
190 t.Fatalf("second result = %q", got)
191 }
192}
193
194func TestNormalizeBedrockToolResultsDropsLeadingOrphanBeforeReusedID(t *testing.T) {
195 call := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "reused", Name: "read", Arguments: json.RawMessage(`{}`)}}}
196 result := func(text string) Message {
197 return Message{Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "reused", Content: []Content{TextBlock{Text: text}}}}}
198 }
199 // The leading and excess results have no matching call at their position;
200 // they must not be consumed by the later calls that reuse the ID.
201 out := normalizeBedrockToolResults([]Message{
202 result("orphan"), call, result("first"), result("excess"), call,
203 })
204 if len(out) != 4 {
205 t.Fatalf("got %d messages, want 4: %+v", len(out), out)
206 }
207 if got := bedrockResultText(t, out[1]); got != "first" {
208 t.Fatalf("first result = %q, want first", got)
209 }
210 tr := out[3].Content[0].(ToolResultBlock)
211 if out[3].Role != RoleTool || tr.CallID != "reused" || !tr.IsError {
212 t.Fatalf("second call must get a synthetic error result, got %+v", out[3])
213 }
214}
215
216func TestBedrockModelSupportsCaching(t *testing.T) {
217 cases := []struct {
218 model string
219 want bool
220 }{
221 // Bare Claude IDs (as they come from catalog_builtin)
222 {"anthropic.claude-sonnet-4-5-20250929-v1:0", true},
223 {"anthropic.claude-haiku-4-5-20251001-v1:0", true},
224 {"anthropic.claude-opus-4-5-20251101-v1:0", true},
225 // Geo-prefixed (resolved form that arrives at bedrockModelSupportsCaching)
226 {"us.anthropic.claude-sonnet-4-5-20250929-v1:0", true},
227 {"eu.anthropic.claude-haiku-4-5-20251001-v1:0", true},
228 {"global.anthropic.claude-opus-4-5-20251101-v1:0", true},
229 // Nova models have PriceCacheWrite==0 — they use automatic caching
230 {"amazon.nova-pro-v1:0", false},
231 {"amazon.nova-lite-v1:0", false},
232 {"amazon.nova-micro-v1:0", false},
233 // DeepSeek — no cache write price
234 {"deepseek.r1-v1:0", false},
235 // Unknown model with claude prefix
236 {"anthropic.claude-future-v99:0", true},
237 // Unknown non-claude model
238 {"some.unknown-model-v1:0", false},
239 }
240 for _, c := range cases {
241 got := bedrockModelSupportsCaching(c.model)
242 if got != c.want {
243 t.Errorf("bedrockModelSupportsCaching(%q) = %v, want %v", c.model, got, c.want)
244 }
245 }
246}
247
248func TestBedrockBuildRequestClaudeOpus5AdaptiveThinking(t *testing.T) {
249 client := &bedrockClient{region: "us-east-1"}
250 temperature := float32(0.7)
251 req, err := client.buildRequest(Request{
252 Model: "global.anthropic.claude-opus-5",
253 Reasoning: "max",
254 Temperature: &temperature,
255 Messages: []Message{
256 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
257 },
258 })
259 if err != nil {
260 t.Fatal(err)
261 }
262 if req.InferenceConfig.Temperature != nil {
263 t.Fatalf("adaptive thinking must omit temperature, got %v", *req.InferenceConfig.Temperature)
264 }
265 if req.AdditionalModelRequestFields == nil {
266 t.Fatal("missing additionalModelRequestFields")
267 }
268 thinking, ok := req.AdditionalModelRequestFields["thinking"].(map[string]interface{})
269 if !ok || thinking["type"] != "adaptive" {
270 t.Fatalf("unexpected thinking config: %#v", req.AdditionalModelRequestFields["thinking"])
271 }
272 output, ok := req.AdditionalModelRequestFields["output_config"].(map[string]interface{})
273 if !ok || output["effort"] != "max" {
274 t.Fatalf("unexpected output config: %#v", req.AdditionalModelRequestFields["output_config"])
275 }
276}
277
278func TestBedrockBuildRequestMaxTokens(t *testing.T) {
279 client := &bedrockClient{region: "us-east-1"}
280
281 // A non-zero MaxTokens must flow through to InferenceConfig so the
282 // model gets its full output budget. This is the regression guard
283 // for long writes/edits being truncated at Bedrock's 4096 default.
284 req, err := client.buildRequest(Request{
285 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
286 MaxTokens: 64000,
287 Messages: []Message{
288 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
289 },
290 })
291 if err != nil {
292 t.Fatal(err)
293 }
294 if req.InferenceConfig.MaxTokens != 64000 {
295 t.Errorf("MaxTokens = %d, want 64000", req.InferenceConfig.MaxTokens)
296 }
297
298 // Zero still falls back to the conservative provider default so an
299 // unset budget never sends maxTokens:0 (which Bedrock rejects).
300 reqZero, err := client.buildRequest(Request{
301 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
302 Messages: []Message{
303 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
304 },
305 })
306 if err != nil {
307 t.Fatal(err)
308 }
309 if reqZero.InferenceConfig.MaxTokens != 4096 {
310 t.Errorf("zero MaxTokens default = %d, want 4096", reqZero.InferenceConfig.MaxTokens)
311 }
312}
313
314func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
315 // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
316 // in the system array and on the last user message.
317 client := &bedrockClient{region: "us-east-1"}
318 req, err := client.buildRequest(Request{
319 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
320 System: "You are a helpful assistant.",
321 Messages: []Message{
322 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
323 },
324 })
325 if err != nil {
326 t.Fatal(err)
327 }
328
329 // system should be: [{text: ...}, {cachePoint: {type: default}}]
330 if len(req.System) != 2 {
331 t.Fatalf("system len = %d, want 2 (text + cachePoint)", len(req.System))
332 }
333 if _, ok := req.System[0]["text"]; !ok {
334 t.Errorf("system[0] missing text key: %v", req.System[0])
335 }
336 if _, ok := req.System[1]["cachePoint"]; !ok {
337 t.Errorf("system[1] missing cachePoint key: %v", req.System[1])
338 }
339
340 // last user message should end with a cachePoint block
341 if len(req.Messages) == 0 {
342 t.Fatal("no messages")
343 }
344 lastMsg := req.Messages[len(req.Messages)-1]
345 if lastMsg.Role != "user" {
346 t.Fatalf("last message role = %q, want user", lastMsg.Role)
347 }
348 lastBlock := lastMsg.Content[len(lastMsg.Content)-1]
349 if _, ok := lastBlock["cachePoint"]; !ok {
350 t.Errorf("last user message final block missing cachePoint: %v", lastBlock)
351 }
352}
353
354func TestBedrockBuildRequestNoCachingNovaModel(t *testing.T) {
355 // A Nova model (PriceCacheWrite == 0) should NOT get cachePoint markers.
356 client := &bedrockClient{region: "us-east-1"}
357 req, err := client.buildRequest(Request{
358 Model: "amazon.nova-pro-v1:0",
359 System: "You are helpful.",
360 Messages: []Message{
361 {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
362 },
363 })
364 if err != nil {
365 t.Fatal(err)
366 }
367
368 // system should be plain: [{text: ...}] with no cachePoint
369 if len(req.System) != 1 {
370 t.Fatalf("system len = %d, want 1 (text only)", len(req.System))
371 }
372 if _, ok := req.System[0]["cachePoint"]; ok {
373 t.Errorf("Nova system unexpectedly contains cachePoint")
374 }
375
376 // last user message should NOT end with a cachePoint block
377 if len(req.Messages) == 0 {
378 t.Fatal("no messages")
379 }
380 lastMsg := req.Messages[len(req.Messages)-1]
381 for _, block := range lastMsg.Content {
382 if _, ok := block["cachePoint"]; ok {
383 t.Errorf("Nova user message unexpectedly contains cachePoint: %v", block)
384 }
385 }
386}
387
388func TestBedrockBuildRequestCachingMultiTurn(t *testing.T) {
389 // In a multi-turn conversation the cachePoint should be on the LAST
390 // user message only, not on earlier ones.
391 client := &bedrockClient{region: "us-east-1"}
392 req, err := client.buildRequest(Request{
393 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
394 Messages: []Message{
395 {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 1"}}},
396 {Role: RoleAssistant, Content: []Content{TextBlock{Text: "response 1"}}},
397 {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 2"}}},
398 },
399 })
400 if err != nil {
401 t.Fatal(err)
402 }
403
404 // Find all user messages and verify only the last has a cachePoint.
405 var userMsgs []bedrockMessage
406 for _, m := range req.Messages {
407 if m.Role == "user" {
408 userMsgs = append(userMsgs, m)
409 }
410 }
411 if len(userMsgs) != 2 {
412 t.Fatalf("expected 2 user messages, got %d", len(userMsgs))
413 }
414
415 // First user message: no cachePoint
416 for _, block := range userMsgs[0].Content {
417 if _, ok := block["cachePoint"]; ok {
418 t.Errorf("first user message should not have cachePoint: %v", block)
419 }
420 }
421
422 // Last user message: ends with cachePoint
423 lastContent := userMsgs[len(userMsgs)-1].Content
424 lastBlock := lastContent[len(lastContent)-1]
425 if _, ok := lastBlock["cachePoint"]; !ok {
426 t.Errorf("last user message should end with cachePoint, got: %v", lastBlock)
427 }
428}
429
430func TestBedrockBuildRequestCachingNoSystemNoCrash(t *testing.T) {
431 // No system prompt: system array should be nil, not a bare cachePoint.
432 client := &bedrockClient{region: "us-east-1"}
433 req, err := client.buildRequest(Request{
434 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
435 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
436 })
437 if err != nil {
438 t.Fatal(err)
439 }
440 if len(req.System) != 0 {
441 t.Errorf("empty system prompt should produce nil system, got: %v", req.System)
442 }
443 // Message cachePoint still present
444 lastBlock := req.Messages[0].Content[len(req.Messages[0].Content)-1]
445 if _, ok := lastBlock["cachePoint"]; !ok {
446 t.Errorf("user message should still have cachePoint when system is empty")
447 }
448}
449
450func TestBedrockTagLastUserCache(t *testing.T) {
451 msgs := []bedrockMessage{
452 {Role: "user", Content: []map[string]interface{}{{"text": "hi"}}},
453 {Role: "assistant", Content: []map[string]interface{}{{"text": "hello"}}},
454 {Role: "user", Content: []map[string]interface{}{{"text": "followup"}}},
455 }
456 bedrockTagLastUserCache(msgs)
457
458 // Only the last user message (index 2) should have a cachePoint appended.
459 last := msgs[2].Content
460 if _, ok := last[len(last)-1]["cachePoint"]; !ok {
461 t.Errorf("last user message should end with cachePoint")
462 }
463 // The first user message should be untouched.
464 if len(msgs[0].Content) != 1 {
465 t.Errorf("first user message content len = %d, want 1", len(msgs[0].Content))
466 }
467 if _, ok := msgs[0].Content[0]["cachePoint"]; ok {
468 t.Errorf("first user message should not have cachePoint")
469 }
470}
471
472func TestBedrockTagLastUserCacheEmpty(t *testing.T) {
473 // Should not panic on empty or assistant-only history.
474 bedrockTagLastUserCache(nil)
475 bedrockTagLastUserCache([]bedrockMessage{})
476 msgs := []bedrockMessage{
477 {Role: "assistant", Content: []map[string]interface{}{{"text": "hi"}}},
478 }
479 bedrockTagLastUserCache(msgs) // should not panic, no user message to tag
480}
481
482func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
483 // Verify the JSON shape Bedrock actually receives has the right keys.
484 client := &bedrockClient{region: "us-east-1"}
485 breq, err := client.buildRequest(Request{
486 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
487 System: "be helpful",
488 Messages: []Message{
489 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
490 },
491 })
492 if err != nil {
493 t.Fatal(err)
494 }
495 b, err := json.Marshal(breq)
496 if err != nil {
497 t.Fatal(err)
498 }
499 s := string(b)
500 if !strings.Contains(s, `"cachePoint"`) {
501 t.Errorf("serialised request missing cachePoint key: %s", s)
502 }
503 if !strings.Contains(s, `"type":"default"`) {
504 t.Errorf("serialised request missing cachePoint type:default: %s", s)
505 }
506}
507
508// TestBedrockBuildRequestBtwToolHistory reproduces the /btw HTTP 400 bug:
509// the side-chat sends no tools but the frozen main transcript contains
510// toolUse / toolResult blocks. Bedrock rejects such a request unless
511// toolConfig is present. buildRequest must inject a stub toolConfig.
512func TestBedrockBuildRequestBtwToolHistory(t *testing.T) {
513 client := &bedrockClient{region: "us-east-1"}
514 req, err := client.buildRequest(Request{
515 Model: "amazon.nova-pro-v1:0",
516 // No tools — simulates /btw side-chat.
517 Messages: []Message{
518 // Frozen main transcript: one tool call + result already happened.
519 {Role: RoleAssistant, Content: []Content{
520 ToolCallBlock{ID: "tc-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)},
521 }},
522 {Role: RoleTool, Content: []Content{
523 ToolResultBlock{CallID: "tc-1", Content: []Content{TextBlock{Text: "file.go"}}},
524 }},
525 // Side-chat question appended by btwDialog.submit.
526 {Role: RoleUser, Content: []Content{TextBlock{Text: "what does that file do?"}}},
527 },
528 })
529 if err != nil {
530 t.Fatal(err)
531 }
532 if req.ToolConfig == nil {
533 t.Fatal("buildRequest should inject a stub toolConfig when history has tool blocks and req.Tools is empty, but ToolConfig is nil")
534 }
535 if len(req.ToolConfig.Tools) == 0 {
536 t.Fatal("stub toolConfig should have at least one tool")
537 }
538 // The stub must have a valid name and a non-nil schema so Bedrock won't
539 // reject it for a different reason.
540 stub := req.ToolConfig.Tools[0]
541 if stub.ToolSpec.Name == "" {
542 t.Error("stub tool name must not be empty")
543 }
544 if stub.ToolSpec.InputSchema.JSON == nil {
545 t.Error("stub tool schema must not be nil")
546 }
547 // Serialised wire payload must include toolConfig.
548 b, err := json.Marshal(req)
549 if err != nil {
550 t.Fatal(err)
551 }
552 if !strings.Contains(string(b), `"toolConfig"`) {
553 t.Errorf("serialised request missing toolConfig: %s", b)
554 }
555}
556
557// TestBedrockBuildRequestNoToolHistoryNoStub ensures that when there are
558// no tool blocks in the history and no tools in the request (ordinary
559// conversational call), we do NOT inject a toolConfig at all — the extra
560// field is unnecessary and some models may behave differently with it.
561func TestBedrockBuildRequestNoToolHistoryNoStub(t *testing.T) {
562 client := &bedrockClient{region: "us-east-1"}
563 req, err := client.buildRequest(Request{
564 Model: "amazon.nova-pro-v1:0",
565 Messages: []Message{
566 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
567 },
568 })
569 if err != nil {
570 t.Fatal(err)
571 }
572 if req.ToolConfig != nil {
573 t.Errorf("expected nil ToolConfig for plain conversation, got: %+v", req.ToolConfig)
574 }
575}
576
577func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
578 client := &bedrockClient{}
579 req, err := client.buildRequest(Request{Messages: []Message{
580 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
581 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
582 }})
583 if err != nil {
584 t.Fatal(err)
585 }
586 if len(req.Messages) != 1 {
587 t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
588 }
589 if req.Messages[0].Role != "user" {
590 t.Fatalf("role = %s, want user", req.Messages[0].Role)
591 }
592}
593
594func TestBedrockEventPayloadHelpers(t *testing.T) {
595 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
596 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
597 t.Fatalf("event type = %q, want contentBlockDelta", got)
598 }
599 var delta struct {
600 ContentBlockIndex int `json:"contentBlockIndex"`
601 Delta struct {
602 Text string `json:"text"`
603 } `json:"delta"`
604 }
605 if err := unmarshalBedrockEventPayload(wrapped, "contentBlockDelta", &delta); err != nil {
606 t.Fatal(err)
607 }
608 if delta.ContentBlockIndex != 0 || delta.Delta.Text != "Hello" {
609 t.Fatalf("unexpected wrapped delta: %+v", delta)
610 }
611
612 direct := []byte(`{"contentBlockIndex":1,"delta":{"text":"world"}}`)
613 if err := unmarshalBedrockEventPayload(direct, "contentBlockDelta", &delta); err != nil {
614 t.Fatal(err)
615 }
616 if delta.ContentBlockIndex != 1 || delta.Delta.Text != "world" {
617 t.Fatalf("unexpected direct delta: %+v", delta)
618 }
619}
620
621// TestBedrockBuildRequestImageBlock is the regression test for the bug where
622// reading an image caused the Bedrock provider to return HTTP 500. The root
623// cause was a missing case ImageBlock in buildRequest's content-block switch,
624// which caused image content to be silently dropped and Bedrock to receive an
625// empty or malformed message.
626func TestBedrockBuildRequestImageBlock(t *testing.T) {
627 client := &bedrockClient{region: "us-east-1"}
628
629 // Minimal 1×1 red JPEG (smallest valid JPEG we can construct inline).
630 // This is 26 bytes: SOI + minimal APP0 marker + EOI.
631 // We use a real tiny JPEG to exercise the shrink path too.
632 jpegBytes := []byte{
633 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
634 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
635 }
636
637 req, err := client.buildRequest(Request{
638 Model: "amazon.nova-pro-v1:0",
639 Messages: []Message{
640 {
641 Role: RoleUser,
642 Content: []Content{
643 TextBlock{Text: "what's in this image?"},
644 ImageBlock{MimeType: "image/jpeg", Data: jpegBytes},
645 },
646 },
647 },
648 })
649 if err != nil {
650 t.Fatal(err)
651 }
652 if len(req.Messages) != 1 {
653 t.Fatalf("expected 1 message, got %d", len(req.Messages))
654 }
655 msg := req.Messages[0]
656 // Expect 2 content blocks: text + image (+ optional cachePoint for Nova which
657 // has no cachePoint, so exactly text + image).
658 var imageBlock map[string]interface{}
659 for _, block := range msg.Content {
660 if img, ok := block["image"]; ok {
661 imageBlock = img.(map[string]interface{})
662 }
663 }
664 if imageBlock == nil {
665 b, _ := json.Marshal(req)
666 t.Fatalf("image block missing from Bedrock request; full request: %s", b)
667 }
668 if imageBlock["format"] != "jpeg" {
669 t.Errorf("image format = %q, want \"jpeg\"", imageBlock["format"])
670 }
671 src, ok := imageBlock["source"].(map[string]interface{})
672 if !ok || src["bytes"] == "" {
673 t.Errorf("image source.bytes is missing or empty: %v", imageBlock["source"])
674 }
675}
676
677// TestBedrockBuildRequestImageInToolResult is the core regression for the
678// HTTP 500 crash: when the read tool returns an image it is wrapped inside a
679// ToolResultBlock. The previous serialiser only handled TextBlock inner
680// content, so the image was silently dropped, leaving Bedrock a toolResult
681// with empty content — which it rejects with HTTP 500.
682func TestBedrockBuildRequestImageInToolResult(t *testing.T) {
683 client := &bedrockClient{region: "us-east-1"}
684
685 pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic bytes
686
687 req, err := client.buildRequest(Request{
688 Model: "amazon.nova-pro-v1:0",
689 Messages: []Message{
690 {
691 Role: RoleAssistant,
692 Content: []Content{
693 ToolCallBlock{ID: "read-1", Name: "read", Arguments: json.RawMessage(`{"path":"/tmp/cat.jpg"}`)},
694 },
695 },
696 {
697 Role: RoleTool,
698 Content: []Content{
699 ToolResultBlock{
700 CallID: "read-1",
701 Content: []Content{
702 ImageBlock{MimeType: "image/png", Data: pngHeader},
703 },
704 },
705 },
706 },
707 {
708 Role: RoleUser,
709 Content: []Content{TextBlock{Text: "what's in the image?"}},
710 },
711 },
712 Tools: []Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}},
713 })
714 if err != nil {
715 t.Fatal(err)
716 }
717
718 // Find the toolResult content block in the serialised messages.
719 var toolResultContent []map[string]interface{}
720 for _, m := range req.Messages {
721 for _, block := range m.Content {
722 if tr, ok := block["toolResult"]; ok {
723 trd := tr.(map[string]interface{})
724 toolResultContent = trd["content"].([]map[string]interface{})
725 break
726 }
727 }
728 }
729 if toolResultContent == nil {
730 b, _ := json.Marshal(req)
731 t.Fatalf("toolResult block not found in request: %s", b)
732 }
733 if len(toolResultContent) == 0 {
734 t.Fatal("toolResult content is empty — image was dropped (reproduces HTTP 500 bug)")
735 }
736 block := toolResultContent[0]
737 img, ok := block["image"]
738 if !ok {
739 t.Fatalf("expected image block inside toolResult content, got: %v", block)
740 }
741 imgMap := img.(map[string]interface{})
742 if imgMap["format"] != "png" {
743 t.Errorf("image format = %q, want \"png\"", imgMap["format"])
744 }
745 src, ok := imgMap["source"].(map[string]interface{})
746 if !ok || src["bytes"] == "" {
747 t.Errorf("image source.bytes missing or empty: %v", imgMap["source"])
748 }
749}
750
751func TestResolveBedrockInferenceProfileID(t *testing.T) {
752 cases := []struct {
753 model string
754 region string
755 want string
756 }{
757 // Opus 5 is available through its global inference profile only.
758 {"anthropic.claude-opus-5", "us-east-1", "global.anthropic.claude-opus-5"},
759 {"anthropic.claude-opus-5", "eu-central-1", "global.anthropic.claude-opus-5"},
760 // Other bare Anthropic foundation IDs get the region-matched prefix.
761 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"},
762 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
763 {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"},
764 {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
765 {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"},
766 // Empty region defaults to us.
767 {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"},
768 // Already-prefixed IDs are left untouched.
769 {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
770 {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"},
771 {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"},
772 // ARNs are passed through verbatim.
773 {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"},
774 // Families that don't need a profile are untouched.
775 {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"},
776 {"", "us-east-1", ""},
777 }
778 for _, c := range cases {
779 if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want {
780 t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want)
781 }
782 }
783}
784
785func TestClaudeOpus5BedrockCatalog(t *testing.T) {
786 m, err := FindModel("amazon-bedrock", "global.anthropic.claude-opus-5")
787 if err != nil {
788 t.Fatal(err)
789 }
790 if m.ContextWindow != 1000000 || m.MaxOutput != 128000 || !m.Reasoning || !m.AdaptiveThinking {
791 t.Fatalf("unexpected Bedrock Opus 5 model: %+v", m)
792 }
793 if _, err := FindModel("amazon-bedrock", "anthropic.claude-opus-5"); err == nil {
794 t.Fatal("bare Bedrock Opus 5 ID must not be exposed in the catalog")
795 }
796}