detached cc03a4c

provider/bedrock: add prompt caching via cachePoint markers

Place Bedrock Converse API cachePoint blocks at the system prompt
boundary and after the last user message on every request to Claude
models (those with PriceCacheWrite > 0 in the catalog).

This mirrors the existing Anthropic provider strategy (cache_control:
ephemeral on system, tools, and last user message) using Bedrock's
equivalent syntax: a {"cachePoint":{"type":"default"}} content block
appended to the relevant arrays.

Changes:
- bedrockRequest.System widened from []map[string]string to
  []map[string]interface{} to accommodate mixed text/cachePoint blocks
- bedrockCachePoint: shared sentinel content block var
- bedrockModelSupportsCaching: gates on PriceCacheWrite > 0; strips
  geo prefixes before catalog lookup; falls back to anthropic.claude-
  prefix check for unknown models (cachePoint is silently ignored by
  the API if unsupported)
- buildRequest: resolves model ID before caching check; injects
  cachePoint into system array and calls bedrockTagLastUserCache
- bedrockTagLastUserCache: appends cachePoint to last user message

Nova models (PriceCacheWrite == 0) are excluded — they use Bedrock's
automatic caching and don't need explicit markers.

Tests: 8 new cases covering model detection, Claude vs Nova presence/
absence, multi-turn last-message targeting, no-system safety,
nil/empty panic safety, and JSON wire shape.
commit
cc03a4c18a5849e6cd6602b581683abcffe71295
author
Raymond Gasper · · 2026-06-08 13:28 UTC
parent
a7ef8c2
signature
unsigned
2 files · +290 -3
M packages/provider/amazon_bedrock.go +64 -3
@@ -194,6 +194,13 @@ type bedrockMessage struct {
194194 Content []map[string]interface{} `json:"content"`
195195 }
196196
197+// bedrockCachePoint is the Converse API cache checkpoint content block.
198+// Appending this to a system or message content array tells Bedrock to
199+// create a cache boundary at that position in the prompt prefix.
200+var bedrockCachePoint = map[string]interface{}{
201+ "cachePoint": map[string]interface{}{"type": "default"},
202+}
203+
197204 type bedrockToolSpec struct {
198205 ToolSpec struct {
199206 Name string `json:"name"`
@@ -205,8 +212,8 @@ type bedrockToolSpec struct {
205212 }
206213
207214 type bedrockRequest struct {
208- Messages []bedrockMessage `json:"messages"`
209- System []map[string]string `json:"system,omitempty"`
215+ Messages []bedrockMessage `json:"messages"`
216+ System []map[string]interface{} `json:"system,omitempty"`
210217 InferenceConfig struct {
211218 MaxTokens int `json:"maxTokens,omitempty"`
212219 Temperature *float32 `json:"temperature,omitempty"`
@@ -271,10 +278,44 @@ func normalizeBedrockToolResults(msgs []Message) []Message {
271278 return out
272279 }
273280
281+// bedrockModelSupportsCaching reports whether the resolved model ID
282+// supports explicit prompt caching via cachePoint markers on Bedrock.
283+// We use PriceCacheWrite > 0 as a proxy: every Bedrock-hosted Claude
284+// model with a write price in the catalog supports cachePoint markers.
285+// Nova models use automatic caching and don't need explicit markers.
286+func bedrockModelSupportsCaching(modelID string) bool {
287+ // Strip geo prefix (us./eu./apac./au./global.) before catalog lookup.
288+ for _, p := range bedrockGeoPrefixes {
289+ if strings.HasPrefix(modelID, p+".") {
290+ modelID = modelID[len(p)+1:]
291+ break
292+ }
293+ }
294+ if m, err := FindModel("amazon-bedrock", modelID); err == nil {
295+ return m.PriceCacheWrite > 0
296+ }
297+ // Unknown model: enable for Anthropic Claude families — cachePoint is
298+ // silently ignored by the API if the model doesn't support it.
299+ return strings.HasPrefix(modelID, "anthropic.claude-")
300+}
301+
274302 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
275303 out := &bedrockRequest{}
304+
305+ // Resolve the model ID as it will appear on the wire so the caching
306+ // check operates on the same ID used for FindModel.
307+ resolvedModel := resolveBedrockInferenceProfileID(req.Model, c.region)
308+ caching := bedrockModelSupportsCaching(resolvedModel)
309+
276310 if req.System != "" {
277- out.System = []map[string]string{{"text": req.System}}
311+ sysBlock := map[string]interface{}{"text": req.System}
312+ if caching {
313+ // Append cachePoint after the system text so the stable system
314+ // prompt is cached as the first breakpoint.
315+ out.System = []map[string]interface{}{sysBlock, bedrockCachePoint}
316+ } else {
317+ out.System = []map[string]interface{}{sysBlock}
318+ }
278319 }
279320 out.InferenceConfig.Temperature = req.Temperature
280321 out.InferenceConfig.MaxTokens = req.MaxTokens
@@ -340,9 +381,29 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
340381 }
341382 out.ToolConfig = &tc
342383 }
384+
385+ if caching {
386+ // Tag the last user message with a cachePoint. This extends the
387+ // cached prefix to cover the full conversation history up to the
388+ // current turn, so the next turn reads that history cheaply.
389+ bedrockTagLastUserCache(out.Messages)
390+ }
391+
343392 return out, nil
344393 }
345394
395+// bedrockTagLastUserCache appends a cachePoint block to the last user
396+// message in the Bedrock message list. It is the Bedrock equivalent of
397+// Anthropic's cache_control:{type:"ephemeral"} on the last user message.
398+func bedrockTagLastUserCache(msgs []bedrockMessage) {
399+ for i := len(msgs) - 1; i >= 0; i-- {
400+ if msgs[i].Role == "user" {
401+ msgs[i].Content = append(msgs[i].Content, bedrockCachePoint)
402+ return
403+ }
404+ }
405+}
406+
346407 // resolveBedrockInferenceProfileID maps a bare foundation-model ID to
347408 // its region-matched cross-region inference-profile ID.
348409 //
@@ -194,6 +194,13 @@ type bedrockMessage struct {
194 Content []map[string]interface{} `json:"content"`194 Content []map[string]interface{} `json:"content"`
195 }195 }
196 196
197+// bedrockCachePoint is the Converse API cache checkpoint content block.
198+// Appending this to a system or message content array tells Bedrock to
199+// create a cache boundary at that position in the prompt prefix.
200+var bedrockCachePoint = map[string]interface{}{
201+ "cachePoint": map[string]interface{}{"type": "default"},
202+}
203+
197 type bedrockToolSpec struct {204 type bedrockToolSpec struct {
198 ToolSpec struct {205 ToolSpec struct {
199 Name string `json:"name"`206 Name string `json:"name"`
@@ -205,8 +212,8 @@ type bedrockToolSpec struct {
205 }212 }
206 213
207 type bedrockRequest struct {214 type bedrockRequest struct {
208- Messages []bedrockMessage `json:"messages"`215+ Messages []bedrockMessage `json:"messages"`
209- System []map[string]string `json:"system,omitempty"`216+ System []map[string]interface{} `json:"system,omitempty"`
210 InferenceConfig struct {217 InferenceConfig struct {
211 MaxTokens int `json:"maxTokens,omitempty"`218 MaxTokens int `json:"maxTokens,omitempty"`
212 Temperature *float32 `json:"temperature,omitempty"`219 Temperature *float32 `json:"temperature,omitempty"`
@@ -271,10 +278,44 @@ func normalizeBedrockToolResults(msgs []Message) []Message {
271 return out278 return out
272 }279 }
273 280
281+// bedrockModelSupportsCaching reports whether the resolved model ID
282+// supports explicit prompt caching via cachePoint markers on Bedrock.
283+// We use PriceCacheWrite > 0 as a proxy: every Bedrock-hosted Claude
284+// model with a write price in the catalog supports cachePoint markers.
285+// Nova models use automatic caching and don't need explicit markers.
286+func bedrockModelSupportsCaching(modelID string) bool {
287+ // Strip geo prefix (us./eu./apac./au./global.) before catalog lookup.
288+ for _, p := range bedrockGeoPrefixes {
289+ if strings.HasPrefix(modelID, p+".") {
290+ modelID = modelID[len(p)+1:]
291+ break
292+ }
293+ }
294+ if m, err := FindModel("amazon-bedrock", modelID); err == nil {
295+ return m.PriceCacheWrite > 0
296+ }
297+ // Unknown model: enable for Anthropic Claude families — cachePoint is
298+ // silently ignored by the API if the model doesn't support it.
299+ return strings.HasPrefix(modelID, "anthropic.claude-")
300+}
301+
274 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {302 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
275 out := &bedrockRequest{}303 out := &bedrockRequest{}
304+
305+ // Resolve the model ID as it will appear on the wire so the caching
306+ // check operates on the same ID used for FindModel.
307+ resolvedModel := resolveBedrockInferenceProfileID(req.Model, c.region)
308+ caching := bedrockModelSupportsCaching(resolvedModel)
309+
276 if req.System != "" {310 if req.System != "" {
277- out.System = []map[string]string{{"text": req.System}}311+ sysBlock := map[string]interface{}{"text": req.System}
312+ if caching {
313+ // Append cachePoint after the system text so the stable system
314+ // prompt is cached as the first breakpoint.
315+ out.System = []map[string]interface{}{sysBlock, bedrockCachePoint}
316+ } else {
317+ out.System = []map[string]interface{}{sysBlock}
318+ }
278 }319 }
279 out.InferenceConfig.Temperature = req.Temperature320 out.InferenceConfig.Temperature = req.Temperature
280 out.InferenceConfig.MaxTokens = req.MaxTokens321 out.InferenceConfig.MaxTokens = req.MaxTokens
@@ -340,9 +381,29 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
340 }381 }
341 out.ToolConfig = &tc382 out.ToolConfig = &tc
342 }383 }
384+
385+ if caching {
386+ // Tag the last user message with a cachePoint. This extends the
387+ // cached prefix to cover the full conversation history up to the
388+ // current turn, so the next turn reads that history cheaply.
389+ bedrockTagLastUserCache(out.Messages)
390+ }
391+
343 return out, nil392 return out, nil
344 }393 }
345 394
395+// bedrockTagLastUserCache appends a cachePoint block to the last user
396+// message in the Bedrock message list. It is the Bedrock equivalent of
397+// Anthropic's cache_control:{type:"ephemeral"} on the last user message.
398+func bedrockTagLastUserCache(msgs []bedrockMessage) {
399+ for i := len(msgs) - 1; i >= 0; i-- {
400+ if msgs[i].Role == "user" {
401+ msgs[i].Content = append(msgs[i].Content, bedrockCachePoint)
402+ return
403+ }
404+ }
405+}
406+
346 // resolveBedrockInferenceProfileID maps a bare foundation-model ID to407 // resolveBedrockInferenceProfileID maps a bare foundation-model ID to
347 // its region-matched cross-region inference-profile ID.408 // its region-matched cross-region inference-profile ID.
348 //409 //
M packages/provider/amazon_bedrock_test.go +226 -0
@@ -131,6 +131,232 @@ func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
131131 }
132132 }
133133
134+func 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+
166+func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
167+ // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
168+ // in the system array and on the last user message.
169+ client := &bedrockClient{region: "us-east-1"}
170+ req, err := client.buildRequest(Request{
171+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
172+ System: "You are a helpful assistant.",
173+ Messages: []Message{
174+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
175+ },
176+ })
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+
181+ // system should be: [{text: ...}, {cachePoint: {type: default}}]
182+ if len(req.System) != 2 {
183+ t.Fatalf("system len = %d, want 2 (text + cachePoint)", len(req.System))
184+ }
185+ if _, ok := req.System[0]["text"]; !ok {
186+ t.Errorf("system[0] missing text key: %v", req.System[0])
187+ }
188+ if _, ok := req.System[1]["cachePoint"]; !ok {
189+ t.Errorf("system[1] missing cachePoint key: %v", req.System[1])
190+ }
191+
192+ // last user message should end with a cachePoint block
193+ if len(req.Messages) == 0 {
194+ t.Fatal("no messages")
195+ }
196+ lastMsg := req.Messages[len(req.Messages)-1]
197+ if lastMsg.Role != "user" {
198+ t.Fatalf("last message role = %q, want user", lastMsg.Role)
199+ }
200+ lastBlock := lastMsg.Content[len(lastMsg.Content)-1]
201+ if _, ok := lastBlock["cachePoint"]; !ok {
202+ t.Errorf("last user message final block missing cachePoint: %v", lastBlock)
203+ }
204+}
205+
206+func TestBedrockBuildRequestNoCachingNovaModel(t *testing.T) {
207+ // A Nova model (PriceCacheWrite == 0) should NOT get cachePoint markers.
208+ client := &bedrockClient{region: "us-east-1"}
209+ req, err := client.buildRequest(Request{
210+ Model: "amazon.nova-pro-v1:0",
211+ System: "You are helpful.",
212+ Messages: []Message{
213+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
214+ },
215+ })
216+ if err != nil {
217+ t.Fatal(err)
218+ }
219+
220+ // system should be plain: [{text: ...}] with no cachePoint
221+ if len(req.System) != 1 {
222+ t.Fatalf("system len = %d, want 1 (text only)", len(req.System))
223+ }
224+ if _, ok := req.System[0]["cachePoint"]; ok {
225+ t.Errorf("Nova system unexpectedly contains cachePoint")
226+ }
227+
228+ // last user message should NOT 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+ for _, block := range lastMsg.Content {
234+ if _, ok := block["cachePoint"]; ok {
235+ t.Errorf("Nova user message unexpectedly contains cachePoint: %v", block)
236+ }
237+ }
238+}
239+
240+func TestBedrockBuildRequestCachingMultiTurn(t *testing.T) {
241+ // In a multi-turn conversation the cachePoint should be on the LAST
242+ // user message only, not on earlier ones.
243+ client := &bedrockClient{region: "us-east-1"}
244+ req, err := client.buildRequest(Request{
245+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
246+ Messages: []Message{
247+ {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 1"}}},
248+ {Role: RoleAssistant, Content: []Content{TextBlock{Text: "response 1"}}},
249+ {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 2"}}},
250+ },
251+ })
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+
256+ // Find all user messages and verify only the last has a cachePoint.
257+ var userMsgs []bedrockMessage
258+ for _, m := range req.Messages {
259+ if m.Role == "user" {
260+ userMsgs = append(userMsgs, m)
261+ }
262+ }
263+ if len(userMsgs) != 2 {
264+ t.Fatalf("expected 2 user messages, got %d", len(userMsgs))
265+ }
266+
267+ // First user message: no cachePoint
268+ for _, block := range userMsgs[0].Content {
269+ if _, ok := block["cachePoint"]; ok {
270+ t.Errorf("first user message should not have cachePoint: %v", block)
271+ }
272+ }
273+
274+ // Last user message: ends with cachePoint
275+ lastContent := userMsgs[len(userMsgs)-1].Content
276+ lastBlock := lastContent[len(lastContent)-1]
277+ if _, ok := lastBlock["cachePoint"]; !ok {
278+ t.Errorf("last user message should end with cachePoint, got: %v", lastBlock)
279+ }
280+}
281+
282+func TestBedrockBuildRequestCachingNoSystemNoCrash(t *testing.T) {
283+ // No system prompt: system array should be nil, not a bare cachePoint.
284+ client := &bedrockClient{region: "us-east-1"}
285+ req, err := client.buildRequest(Request{
286+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
287+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
288+ })
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if len(req.System) != 0 {
293+ t.Errorf("empty system prompt should produce nil system, got: %v", req.System)
294+ }
295+ // Message cachePoint still present
296+ lastBlock := req.Messages[0].Content[len(req.Messages[0].Content)-1]
297+ if _, ok := lastBlock["cachePoint"]; !ok {
298+ t.Errorf("user message should still have cachePoint when system is empty")
299+ }
300+}
301+
302+func TestBedrockTagLastUserCache(t *testing.T) {
303+ msgs := []bedrockMessage{
304+ {Role: "user", Content: []map[string]interface{}{{"text": "hi"}}},
305+ {Role: "assistant", Content: []map[string]interface{}{{"text": "hello"}}},
306+ {Role: "user", Content: []map[string]interface{}{{"text": "followup"}}},
307+ }
308+ bedrockTagLastUserCache(msgs)
309+
310+ // Only the last user message (index 2) should have a cachePoint appended.
311+ last := msgs[2].Content
312+ if _, ok := last[len(last)-1]["cachePoint"]; !ok {
313+ t.Errorf("last user message should end with cachePoint")
314+ }
315+ // The first user message should be untouched.
316+ if len(msgs[0].Content) != 1 {
317+ t.Errorf("first user message content len = %d, want 1", len(msgs[0].Content))
318+ }
319+ if _, ok := msgs[0].Content[0]["cachePoint"]; ok {
320+ t.Errorf("first user message should not have cachePoint")
321+ }
322+}
323+
324+func TestBedrockTagLastUserCacheEmpty(t *testing.T) {
325+ // Should not panic on empty or assistant-only history.
326+ bedrockTagLastUserCache(nil)
327+ bedrockTagLastUserCache([]bedrockMessage{})
328+ msgs := []bedrockMessage{
329+ {Role: "assistant", Content: []map[string]interface{}{{"text": "hi"}}},
330+ }
331+ bedrockTagLastUserCache(msgs) // should not panic, no user message to tag
332+}
333+
334+func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
335+ // Verify the JSON shape Bedrock actually receives has the right keys.
336+ client := &bedrockClient{region: "us-east-1"}
337+ breq, err := client.buildRequest(Request{
338+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
339+ System: "be helpful",
340+ Messages: []Message{
341+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
342+ },
343+ })
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ b, err := json.Marshal(breq)
348+ if err != nil {
349+ t.Fatal(err)
350+ }
351+ s := string(b)
352+ if !strings.Contains(s, `"cachePoint"`) {
353+ t.Errorf("serialised request missing cachePoint key: %s", s)
354+ }
355+ if !strings.Contains(s, `"type":"default"`) {
356+ t.Errorf("serialised request missing cachePoint type:default: %s", s)
357+ }
358+}
359+
134360 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135361 client := &bedrockClient{}
136362 req, err := client.buildRequest(Request{Messages: []Message{
@@ -131,6 +131,232 @@ func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
131 }131 }
132 }132 }
133 133
134+func 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+
166+func TestBedrockBuildRequestCachingClaudeModel(t *testing.T) {
167+ // A Claude model (PriceCacheWrite > 0) should get cachePoint markers
168+ // in the system array and on the last user message.
169+ client := &bedrockClient{region: "us-east-1"}
170+ req, err := client.buildRequest(Request{
171+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
172+ System: "You are a helpful assistant.",
173+ Messages: []Message{
174+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
175+ },
176+ })
177+ if err != nil {
178+ t.Fatal(err)
179+ }
180+
181+ // system should be: [{text: ...}, {cachePoint: {type: default}}]
182+ if len(req.System) != 2 {
183+ t.Fatalf("system len = %d, want 2 (text + cachePoint)", len(req.System))
184+ }
185+ if _, ok := req.System[0]["text"]; !ok {
186+ t.Errorf("system[0] missing text key: %v", req.System[0])
187+ }
188+ if _, ok := req.System[1]["cachePoint"]; !ok {
189+ t.Errorf("system[1] missing cachePoint key: %v", req.System[1])
190+ }
191+
192+ // last user message should end with a cachePoint block
193+ if len(req.Messages) == 0 {
194+ t.Fatal("no messages")
195+ }
196+ lastMsg := req.Messages[len(req.Messages)-1]
197+ if lastMsg.Role != "user" {
198+ t.Fatalf("last message role = %q, want user", lastMsg.Role)
199+ }
200+ lastBlock := lastMsg.Content[len(lastMsg.Content)-1]
201+ if _, ok := lastBlock["cachePoint"]; !ok {
202+ t.Errorf("last user message final block missing cachePoint: %v", lastBlock)
203+ }
204+}
205+
206+func TestBedrockBuildRequestNoCachingNovaModel(t *testing.T) {
207+ // A Nova model (PriceCacheWrite == 0) should NOT get cachePoint markers.
208+ client := &bedrockClient{region: "us-east-1"}
209+ req, err := client.buildRequest(Request{
210+ Model: "amazon.nova-pro-v1:0",
211+ System: "You are helpful.",
212+ Messages: []Message{
213+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
214+ },
215+ })
216+ if err != nil {
217+ t.Fatal(err)
218+ }
219+
220+ // system should be plain: [{text: ...}] with no cachePoint
221+ if len(req.System) != 1 {
222+ t.Fatalf("system len = %d, want 1 (text only)", len(req.System))
223+ }
224+ if _, ok := req.System[0]["cachePoint"]; ok {
225+ t.Errorf("Nova system unexpectedly contains cachePoint")
226+ }
227+
228+ // last user message should NOT 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+ for _, block := range lastMsg.Content {
234+ if _, ok := block["cachePoint"]; ok {
235+ t.Errorf("Nova user message unexpectedly contains cachePoint: %v", block)
236+ }
237+ }
238+}
239+
240+func TestBedrockBuildRequestCachingMultiTurn(t *testing.T) {
241+ // In a multi-turn conversation the cachePoint should be on the LAST
242+ // user message only, not on earlier ones.
243+ client := &bedrockClient{region: "us-east-1"}
244+ req, err := client.buildRequest(Request{
245+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
246+ Messages: []Message{
247+ {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 1"}}},
248+ {Role: RoleAssistant, Content: []Content{TextBlock{Text: "response 1"}}},
249+ {Role: RoleUser, Content: []Content{TextBlock{Text: "turn 2"}}},
250+ },
251+ })
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+
256+ // Find all user messages and verify only the last has a cachePoint.
257+ var userMsgs []bedrockMessage
258+ for _, m := range req.Messages {
259+ if m.Role == "user" {
260+ userMsgs = append(userMsgs, m)
261+ }
262+ }
263+ if len(userMsgs) != 2 {
264+ t.Fatalf("expected 2 user messages, got %d", len(userMsgs))
265+ }
266+
267+ // First user message: no cachePoint
268+ for _, block := range userMsgs[0].Content {
269+ if _, ok := block["cachePoint"]; ok {
270+ t.Errorf("first user message should not have cachePoint: %v", block)
271+ }
272+ }
273+
274+ // Last user message: ends with cachePoint
275+ lastContent := userMsgs[len(userMsgs)-1].Content
276+ lastBlock := lastContent[len(lastContent)-1]
277+ if _, ok := lastBlock["cachePoint"]; !ok {
278+ t.Errorf("last user message should end with cachePoint, got: %v", lastBlock)
279+ }
280+}
281+
282+func TestBedrockBuildRequestCachingNoSystemNoCrash(t *testing.T) {
283+ // No system prompt: system array should be nil, not a bare cachePoint.
284+ client := &bedrockClient{region: "us-east-1"}
285+ req, err := client.buildRequest(Request{
286+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
287+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
288+ })
289+ if err != nil {
290+ t.Fatal(err)
291+ }
292+ if len(req.System) != 0 {
293+ t.Errorf("empty system prompt should produce nil system, got: %v", req.System)
294+ }
295+ // Message cachePoint still present
296+ lastBlock := req.Messages[0].Content[len(req.Messages[0].Content)-1]
297+ if _, ok := lastBlock["cachePoint"]; !ok {
298+ t.Errorf("user message should still have cachePoint when system is empty")
299+ }
300+}
301+
302+func TestBedrockTagLastUserCache(t *testing.T) {
303+ msgs := []bedrockMessage{
304+ {Role: "user", Content: []map[string]interface{}{{"text": "hi"}}},
305+ {Role: "assistant", Content: []map[string]interface{}{{"text": "hello"}}},
306+ {Role: "user", Content: []map[string]interface{}{{"text": "followup"}}},
307+ }
308+ bedrockTagLastUserCache(msgs)
309+
310+ // Only the last user message (index 2) should have a cachePoint appended.
311+ last := msgs[2].Content
312+ if _, ok := last[len(last)-1]["cachePoint"]; !ok {
313+ t.Errorf("last user message should end with cachePoint")
314+ }
315+ // The first user message should be untouched.
316+ if len(msgs[0].Content) != 1 {
317+ t.Errorf("first user message content len = %d, want 1", len(msgs[0].Content))
318+ }
319+ if _, ok := msgs[0].Content[0]["cachePoint"]; ok {
320+ t.Errorf("first user message should not have cachePoint")
321+ }
322+}
323+
324+func TestBedrockTagLastUserCacheEmpty(t *testing.T) {
325+ // Should not panic on empty or assistant-only history.
326+ bedrockTagLastUserCache(nil)
327+ bedrockTagLastUserCache([]bedrockMessage{})
328+ msgs := []bedrockMessage{
329+ {Role: "assistant", Content: []map[string]interface{}{{"text": "hi"}}},
330+ }
331+ bedrockTagLastUserCache(msgs) // should not panic, no user message to tag
332+}
333+
334+func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
335+ // Verify the JSON shape Bedrock actually receives has the right keys.
336+ client := &bedrockClient{region: "us-east-1"}
337+ breq, err := client.buildRequest(Request{
338+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
339+ System: "be helpful",
340+ Messages: []Message{
341+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
342+ },
343+ })
344+ if err != nil {
345+ t.Fatal(err)
346+ }
347+ b, err := json.Marshal(breq)
348+ if err != nil {
349+ t.Fatal(err)
350+ }
351+ s := string(b)
352+ if !strings.Contains(s, `"cachePoint"`) {
353+ t.Errorf("serialised request missing cachePoint key: %s", s)
354+ }
355+ if !strings.Contains(s, `"type":"default"`) {
356+ t.Errorf("serialised request missing cachePoint type:default: %s", s)
357+ }
358+}
359+
134 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {360 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135 client := &bedrockClient{}361 client := &bedrockClient{}
136 req, err := client.buildRequest(Request{Messages: []Message{362 req, err := client.buildRequest(Request{Messages: []Message{

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