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 TestBedrockBuildRequestMaxTokens(t *testing.T) {
167 client := &bedrockClient{region: "us-east-1"}
168
169 // A non-zero MaxTokens must flow through to InferenceConfig so the
170 // model gets its full output budget. This is the regression guard
171 // for long writes/edits being truncated at Bedrock's 4096 default.
172 req, err := client.buildRequest(Request{
173 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
174 MaxTokens: 64000,
175 Messages: []Message{
176 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
177 },
178 })
179 if err != nil {
180 t.Fatal(err)
181 }
182 if req.InferenceConfig.MaxTokens != 64000 {
183 t.Errorf("MaxTokens = %d, want 64000", req.InferenceConfig.MaxTokens)
184 }
185
186 // Zero still falls back to the conservative provider default so an
187 // unset budget never sends maxTokens:0 (which Bedrock rejects).
188 reqZero, err := client.buildRequest(Request{
189 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
190 Messages: []Message{
191 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
192 },
193 })
194 if err != nil {
195 t.Fatal(err)
196 }
197 if reqZero.InferenceConfig.MaxTokens != 4096 {
198 t.Errorf("zero MaxTokens default = %d, want 4096", reqZero.InferenceConfig.MaxTokens)
199 }
200}
201
202func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
203 // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
204 // in the system array and on the last user message.
205 client := &bedrockClient{region: "us-east-1"}
206 req, err := client.buildRequest(Request{
207 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
208 System: "You are a helpful assistant.",
209 Messages: []Message{
210 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
211 },
212 })
213 if err != nil {
214 t.Fatal(err)
215 }
216
217 // system should be: [{text: ...}, {cachePoint: {type: default}}]
218 if len(req.System) != 2 {
219 t.Fatalf("system len = %d, want 2 (text + cachePoint)", len(req.System))
220 }
221 if _, ok := req.System[0]["text"]; !ok {
222 t.Errorf("system[0] missing text key: %v", req.System[0])
223 }
224 if _, ok := req.System[1]["cachePoint"]; !ok {
225 t.Errorf("system[1] missing cachePoint key: %v", req.System[1])
226 }
227
228 // last user message should end with a cachePoint block
229 if len(req.Messages) == 0 {
230 t.Fatal("no messages")
231 }
232 lastMsg := req.Messages[len(req.Messages)-1]
233 if lastMsg.Role != "user" {
234 t.Fatalf("last message role = %q, want user", lastMsg.Role)
235 }
236 lastBlock := lastMsg.Content[len(lastMsg.Content)-1]
237 if _, ok := lastBlock["cachePoint"]; !ok {
238 t.Errorf("last user message final block missing cachePoint: %v", lastBlock)
239 }
240}
241
242func TestBedrockBuildRequestNoCachingNovaModel(t *testing.T) {
243 // A Nova model (PriceCacheWrite == 0) should NOT get cachePoint markers.
244 client := &bedrockClient{region: "us-east-1"}
245 req, err := client.buildRequest(Request{
246 Model: "amazon.nova-pro-v1:0",
247 System: "You are helpful.",
248 Messages: []Message{
249 {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
250 },
251 })
252 if err != nil {
253 t.Fatal(err)
254 }
255
256 // system should be plain: [{text: ...}] with no cachePoint
257 if len(req.System) != 1 {
258 t.Fatalf("system len = %d, want 1 (text only)", len(req.System))
259 }
260 if _, ok := req.System[0]["cachePoint"]; ok {
261 t.Errorf("Nova system unexpectedly contains cachePoint")
262 }
263
264 // last user message should NOT end with a cachePoint block
265 if len(req.Messages) == 0 {
266 t.Fatal("no messages")
267 }
268 lastMsg := req.Messages[len(req.Messages)-1]
269 for _, block := range lastMsg.Content {
270 if _, ok := block["cachePoint"]; ok {
271 t.Errorf("Nova user message unexpectedly contains cachePoint: %v", block)
272 }
273 }
274}
275
276func TestBedrockBuildRequestCachingMultiTurn(t *testing.T) {
277 // In a multi-turn conversation the cachePoint should be on the LAST
278 // user message only, not on earlier ones.
279 client := &bedrockClient{region: "us-east-1"}
280 req, err := client.buildRequest(Request{
281 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
282 Messages: []Message{
283 {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 1"}}},
284 {Role: RoleAssistant, Content: []Content{TextBlock{Text: "response 1"}}},
285 {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 2"}}},
286 },
287 })
288 if err != nil {
289 t.Fatal(err)
290 }
291
292 // Find all user messages and verify only the last has a cachePoint.
293 var userMsgs []bedrockMessage
294 for _, m := range req.Messages {
295 if m.Role == "user" {
296 userMsgs = append(userMsgs, m)
297 }
298 }
299 if len(userMsgs) != 2 {
300 t.Fatalf("expected 2 user messages, got %d", len(userMsgs))
301 }
302
303 // First user message: no cachePoint
304 for _, block := range userMsgs[0].Content {
305 if _, ok := block["cachePoint"]; ok {
306 t.Errorf("first user message should not have cachePoint: %v", block)
307 }
308 }
309
310 // Last user message: ends with cachePoint
311 lastContent := userMsgs[len(userMsgs)-1].Content
312 lastBlock := lastContent[len(lastContent)-1]
313 if _, ok := lastBlock["cachePoint"]; !ok {
314 t.Errorf("last user message should end with cachePoint, got: %v", lastBlock)
315 }
316}
317
318func TestBedrockBuildRequestCachingNoSystemNoCrash(t *testing.T) {
319 // No system prompt: system array should be nil, not a bare cachePoint.
320 client := &bedrockClient{region: "us-east-1"}
321 req, err := client.buildRequest(Request{
322 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
323 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
324 })
325 if err != nil {
326 t.Fatal(err)
327 }
328 if len(req.System) != 0 {
329 t.Errorf("empty system prompt should produce nil system, got: %v", req.System)
330 }
331 // Message cachePoint still present
332 lastBlock := req.Messages[0].Content[len(req.Messages[0].Content)-1]
333 if _, ok := lastBlock["cachePoint"]; !ok {
334 t.Errorf("user message should still have cachePoint when system is empty")
335 }
336}
337
338func TestBedrockTagLastUserCache(t *testing.T) {
339 msgs := []bedrockMessage{
340 {Role: "user", Content: []map[string]interface{}{{"text": "hi"}}},
341 {Role: "assistant", Content: []map[string]interface{}{{"text": "hello"}}},
342 {Role: "user", Content: []map[string]interface{}{{"text": "followup"}}},
343 }
344 bedrockTagLastUserCache(msgs)
345
346 // Only the last user message (index 2) should have a cachePoint appended.
347 last := msgs[2].Content
348 if _, ok := last[len(last)-1]["cachePoint"]; !ok {
349 t.Errorf("last user message should end with cachePoint")
350 }
351 // The first user message should be untouched.
352 if len(msgs[0].Content) != 1 {
353 t.Errorf("first user message content len = %d, want 1", len(msgs[0].Content))
354 }
355 if _, ok := msgs[0].Content[0]["cachePoint"]; ok {
356 t.Errorf("first user message should not have cachePoint")
357 }
358}
359
360func TestBedrockTagLastUserCacheEmpty(t *testing.T) {
361 // Should not panic on empty or assistant-only history.
362 bedrockTagLastUserCache(nil)
363 bedrockTagLastUserCache([]bedrockMessage{})
364 msgs := []bedrockMessage{
365 {Role: "assistant", Content: []map[string]interface{}{{"text": "hi"}}},
366 }
367 bedrockTagLastUserCache(msgs) // should not panic, no user message to tag
368}
369
370func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
371 // Verify the JSON shape Bedrock actually receives has the right keys.
372 client := &bedrockClient{region: "us-east-1"}
373 breq, err := client.buildRequest(Request{
374 Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
375 System: "be helpful",
376 Messages: []Message{
377 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
378 },
379 })
380 if err != nil {
381 t.Fatal(err)
382 }
383 b, err := json.Marshal(breq)
384 if err != nil {
385 t.Fatal(err)
386 }
387 s := string(b)
388 if !strings.Contains(s, `"cachePoint"`) {
389 t.Errorf("serialised request missing cachePoint key: %s", s)
390 }
391 if !strings.Contains(s, `"type":"default"`) {
392 t.Errorf("serialised request missing cachePoint type:default: %s", s)
393 }
394}
395
396// TestBedrockBuildRequestBtwToolHistory reproduces the /btw HTTP 400 bug:
397// the side-chat sends no tools but the frozen main transcript contains
398// toolUse / toolResult blocks. Bedrock rejects such a request unless
399// toolConfig is present. buildRequest must inject a stub toolConfig.
400func TestBedrockBuildRequestBtwToolHistory(t *testing.T) {
401 client := &bedrockClient{region: "us-east-1"}
402 req, err := client.buildRequest(Request{
403 Model: "amazon.nova-pro-v1:0",
404 // No tools — simulates /btw side-chat.
405 Messages: []Message{
406 // Frozen main transcript: one tool call + result already happened.
407 {Role: RoleAssistant, Content: []Content{
408 ToolCallBlock{ID: "tc-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)},
409 }},
410 {Role: RoleTool, Content: []Content{
411 ToolResultBlock{CallID: "tc-1", Content: []Content{TextBlock{Text: "file.go"}}},
412 }},
413 // Side-chat question appended by btwDialog.submit.
414 {Role: RoleUser, Content: []Content{TextBlock{Text: "what does that file do?"}}},
415 },
416 })
417 if err != nil {
418 t.Fatal(err)
419 }
420 if req.ToolConfig == nil {
421 t.Fatal("buildRequest should inject a stub toolConfig when history has tool blocks and req.Tools is empty, but ToolConfig is nil")
422 }
423 if len(req.ToolConfig.Tools) == 0 {
424 t.Fatal("stub toolConfig should have at least one tool")
425 }
426 // The stub must have a valid name and a non-nil schema so Bedrock won't
427 // reject it for a different reason.
428 stub := req.ToolConfig.Tools[0]
429 if stub.ToolSpec.Name == "" {
430 t.Error("stub tool name must not be empty")
431 }
432 if stub.ToolSpec.InputSchema.JSON == nil {
433 t.Error("stub tool schema must not be nil")
434 }
435 // Serialised wire payload must include toolConfig.
436 b, err := json.Marshal(req)
437 if err != nil {
438 t.Fatal(err)
439 }
440 if !strings.Contains(string(b), `"toolConfig"`) {
441 t.Errorf("serialised request missing toolConfig: %s", b)
442 }
443}
444
445// TestBedrockBuildRequestNoToolHistoryNoStub ensures that when there are
446// no tool blocks in the history and no tools in the request (ordinary
447// conversational call), we do NOT inject a toolConfig at all — the extra
448// field is unnecessary and some models may behave differently with it.
449func TestBedrockBuildRequestNoToolHistoryNoStub(t *testing.T) {
450 client := &bedrockClient{region: "us-east-1"}
451 req, err := client.buildRequest(Request{
452 Model: "amazon.nova-pro-v1:0",
453 Messages: []Message{
454 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
455 },
456 })
457 if err != nil {
458 t.Fatal(err)
459 }
460 if req.ToolConfig != nil {
461 t.Errorf("expected nil ToolConfig for plain conversation, got: %+v", req.ToolConfig)
462 }
463}
464
465func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
466 client := &bedrockClient{}
467 req, err := client.buildRequest(Request{Messages: []Message{
468 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
469 {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
470 }})
471 if err != nil {
472 t.Fatal(err)
473 }
474 if len(req.Messages) != 1 {
475 t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
476 }
477 if req.Messages[0].Role != "user" {
478 t.Fatalf("role = %s, want user", req.Messages[0].Role)
479 }
480}
481
482func TestBedrockEventPayloadHelpers(t *testing.T) {
483 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
484 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
485 t.Fatalf("event type = %q, want contentBlockDelta", got)
486 }
487 var delta struct {
488 ContentBlockIndex int `json:"contentBlockIndex"`
489 Delta struct {
490 Text string `json:"text"`
491 } `json:"delta"`
492 }
493 if err := unmarshalBedrockEventPayload(wrapped, "contentBlockDelta", &delta); err != nil {
494 t.Fatal(err)
495 }
496 if delta.ContentBlockIndex != 0 || delta.Delta.Text != "Hello" {
497 t.Fatalf("unexpected wrapped delta: %+v", delta)
498 }
499
500 direct := []byte(`{"contentBlockIndex":1,"delta":{"text":"world"}}`)
501 if err := unmarshalBedrockEventPayload(direct, "contentBlockDelta", &delta); err != nil {
502 t.Fatal(err)
503 }
504 if delta.ContentBlockIndex != 1 || delta.Delta.Text != "world" {
505 t.Fatalf("unexpected direct delta: %+v", delta)
506 }
507}
508
509// TestBedrockBuildRequestImageBlock is the regression test for the bug where
510// reading an image caused the Bedrock provider to return HTTP 500. The root
511// cause was a missing case ImageBlock in buildRequest's content-block switch,
512// which caused image content to be silently dropped and Bedrock to receive an
513// empty or malformed message.
514func TestBedrockBuildRequestImageBlock(t *testing.T) {
515 client := &bedrockClient{region: "us-east-1"}
516
517 // Minimal 1×1 red JPEG (smallest valid JPEG we can construct inline).
518 // This is 26 bytes: SOI + minimal APP0 marker + EOI.
519 // We use a real tiny JPEG to exercise the shrink path too.
520 jpegBytes := []byte{
521 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
522 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xD9,
523 }
524
525 req, err := client.buildRequest(Request{
526 Model: "amazon.nova-pro-v1:0",
527 Messages: []Message{
528 {
529 Role: RoleUser,
530 Content: []Content{
531 TextBlock{Text: "what's in this image?"},
532 ImageBlock{MimeType: "image/jpeg", Data: jpegBytes},
533 },
534 },
535 },
536 })
537 if err != nil {
538 t.Fatal(err)
539 }
540 if len(req.Messages) != 1 {
541 t.Fatalf("expected 1 message, got %d", len(req.Messages))
542 }
543 msg := req.Messages[0]
544 // Expect 2 content blocks: text + image (+ optional cachePoint for Nova which
545 // has no cachePoint, so exactly text + image).
546 var imageBlock map[string]interface{}
547 for _, block := range msg.Content {
548 if img, ok := block["image"]; ok {
549 imageBlock = img.(map[string]interface{})
550 }
551 }
552 if imageBlock == nil {
553 b, _ := json.Marshal(req)
554 t.Fatalf("image block missing from Bedrock request; full request: %s", b)
555 }
556 if imageBlock["format"] != "jpeg" {
557 t.Errorf("image format = %q, want \"jpeg\"", imageBlock["format"])
558 }
559 src, ok := imageBlock["source"].(map[string]interface{})
560 if !ok || src["bytes"] == "" {
561 t.Errorf("image source.bytes is missing or empty: %v", imageBlock["source"])
562 }
563}
564
565// TestBedrockBuildRequestImageInToolResult is the core regression for the
566// HTTP 500 crash: when the read tool returns an image it is wrapped inside a
567// ToolResultBlock. The previous serialiser only handled TextBlock inner
568// content, so the image was silently dropped, leaving Bedrock a toolResult
569// with empty content — which it rejects with HTTP 500.
570func TestBedrockBuildRequestImageInToolResult(t *testing.T) {
571 client := &bedrockClient{region: "us-east-1"}
572
573 pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic bytes
574
575 req, err := client.buildRequest(Request{
576 Model: "amazon.nova-pro-v1:0",
577 Messages: []Message{
578 {
579 Role: RoleAssistant,
580 Content: []Content{
581 ToolCallBlock{ID: "read-1", Name: "read", Arguments: json.RawMessage(`{"path":"/tmp/cat.jpg"}`)},
582 },
583 },
584 {
585 Role: RoleTool,
586 Content: []Content{
587 ToolResultBlock{
588 CallID: "read-1",
589 Content: []Content{
590 ImageBlock{MimeType: "image/png", Data: pngHeader},
591 },
592 },
593 },
594 },
595 {
596 Role: RoleUser,
597 Content: []Content{TextBlock{Text: "what's in the image?"}},
598 },
599 },
600 Tools: []Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}},
601 })
602 if err != nil {
603 t.Fatal(err)
604 }
605
606 // Find the toolResult content block in the serialised messages.
607 var toolResultContent []map[string]interface{}
608 for _, m := range req.Messages {
609 for _, block := range m.Content {
610 if tr, ok := block["toolResult"]; ok {
611 trd := tr.(map[string]interface{})
612 toolResultContent = trd["content"].([]map[string]interface{})
613 break
614 }
615 }
616 }
617 if toolResultContent == nil {
618 b, _ := json.Marshal(req)
619 t.Fatalf("toolResult block not found in request: %s", b)
620 }
621 if len(toolResultContent) == 0 {
622 t.Fatal("toolResult content is empty — image was dropped (reproduces HTTP 500 bug)")
623 }
624 block := toolResultContent[0]
625 img, ok := block["image"]
626 if !ok {
627 t.Fatalf("expected image block inside toolResult content, got: %v", block)
628 }
629 imgMap := img.(map[string]interface{})
630 if imgMap["format"] != "png" {
631 t.Errorf("image format = %q, want \"png\"", imgMap["format"])
632 }
633 src, ok := imgMap["source"].(map[string]interface{})
634 if !ok || src["bytes"] == "" {
635 t.Errorf("image source.bytes missing or empty: %v", imgMap["source"])
636 }
637}
638
639func TestResolveBedrockInferenceProfileID(t *testing.T) {
640 cases := []struct {
641 model string
642 region string
643 want string
644 }{
645 // Bare Anthropic foundation IDs get the region-matched prefix.
646 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"},
647 {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
648 {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"},
649 {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"},
650 {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"},
651 // Empty region defaults to us.
652 {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"},
653 // Already-prefixed IDs are left untouched.
654 {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
655 {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"},
656 {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"},
657 // ARNs are passed through verbatim.
658 {"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"},
659 // Families that don't need a profile are untouched.
660 {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"},
661 {"", "us-east-1", ""},
662 }
663 for _, c := range cases {
664 if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want {
665 t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want)
666 }
667 }
668}