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