v0.50.1 a637010

amazon_bedrock_test.go RawHistoryFolder

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}

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