v0.11.1 ab30a57

amazon_bedrock_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close