detached d5d0754

Merge pull request #40 from pr-40

commit
d5d0754b134b101e2d07d4268e36ca6b919f65d8
author
patriceckhart · · 2026-06-17 14:25 UTC
merge of
1d7dc39 + a75054c · diff against the first parent
signature
unsigned
2 files · +157 -2
M packages/provider/amazon_bedrock.go +27 -2
@@ -41,6 +41,7 @@ import (
4141 "context"
4242 "crypto/hmac"
4343 "crypto/sha256"
44+ "encoding/base64"
4445 "encoding/binary"
4546 "encoding/hex"
4647 "encoding/json"
@@ -361,11 +362,35 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
361362 "toolUseId": v.ID, "name": v.Name, "input": input,
362363 },
363364 })
365+ case ImageBlock:
366+ data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
367+ // Bedrock Converse uses the bare format name (jpeg/png/gif/webp).
368+ imgFmt := strings.TrimPrefix(mime, "image/")
369+ bm.Content = append(bm.Content, map[string]interface{}{
370+ "image": map[string]interface{}{
371+ "format": imgFmt,
372+ "source": map[string]interface{}{
373+ "bytes": base64.StdEncoding.EncodeToString(data),
374+ },
375+ },
376+ })
364377 case ToolResultBlock:
365378 var resultContent []map[string]interface{}
366379 for _, inner := range v.Content {
367- if tb, ok := inner.(TextBlock); ok {
368- resultContent = append(resultContent, map[string]interface{}{"text": tb.Text})
380+ switch iv := inner.(type) {
381+ case TextBlock:
382+ resultContent = append(resultContent, map[string]interface{}{"text": iv.Text})
383+ case ImageBlock:
384+ data, mime := anthShrinkImageBytesIfTooBig(iv.Data, iv.MimeType)
385+ imgFmt := strings.TrimPrefix(mime, "image/")
386+ resultContent = append(resultContent, map[string]interface{}{
387+ "image": map[string]interface{}{
388+ "format": imgFmt,
389+ "source": map[string]interface{}{
390+ "bytes": base64.StdEncoding.EncodeToString(data),
391+ },
392+ },
393+ })
369394 }
370395 }
371396 status := "success"
@@ -41,6 +41,7 @@ import (
41 "context"41 "context"
42 "crypto/hmac"42 "crypto/hmac"
43 "crypto/sha256"43 "crypto/sha256"
44+ "encoding/base64"
44 "encoding/binary"45 "encoding/binary"
45 "encoding/hex"46 "encoding/hex"
46 "encoding/json"47 "encoding/json"
@@ -361,11 +362,35 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
361 "toolUseId": v.ID, "name": v.Name, "input": input,362 "toolUseId": v.ID, "name": v.Name, "input": input,
362 },363 },
363 })364 })
365+ case ImageBlock:
366+ data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
367+ // Bedrock Converse uses the bare format name (jpeg/png/gif/webp).
368+ imgFmt := strings.TrimPrefix(mime, "image/")
369+ bm.Content = append(bm.Content, map[string]interface{}{
370+ "image": map[string]interface{}{
371+ "format": imgFmt,
372+ "source": map[string]interface{}{
373+ "bytes": base64.StdEncoding.EncodeToString(data),
374+ },
375+ },
376+ })
364 case ToolResultBlock:377 case ToolResultBlock:
365 var resultContent []map[string]interface{}378 var resultContent []map[string]interface{}
366 for _, inner := range v.Content {379 for _, inner := range v.Content {
367- if tb, ok := inner.(TextBlock); ok {380+ switch iv := inner.(type) {
368- resultContent = append(resultContent, map[string]interface{}{"text": tb.Text})381+ case TextBlock:
382+ resultContent = append(resultContent, map[string]interface{}{"text": iv.Text})
383+ case ImageBlock:
384+ data, mime := anthShrinkImageBytesIfTooBig(iv.Data, iv.MimeType)
385+ imgFmt := strings.TrimPrefix(mime, "image/")
386+ resultContent = append(resultContent, map[string]interface{}{
387+ "image": map[string]interface{}{
388+ "format": imgFmt,
389+ "source": map[string]interface{}{
390+ "bytes": base64.StdEncoding.EncodeToString(data),
391+ },
392+ },
393+ })
369 }394 }
370 }395 }
371 status := "success"396 status := "success"
M packages/provider/amazon_bedrock_test.go +130 -0
@@ -506,6 +506,136 @@ func TestBedrockEventPayloadHelpers(t *testing.T) {
506506 }
507507 }
508508
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.
514+func 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.
570+func 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+
509639 func TestResolveBedrockInferenceProfileID(t *testing.T) {
510640 cases := []struct {
511641 model string
@@ -506,6 +506,136 @@ func TestBedrockEventPayloadHelpers(t *testing.T) {
506 }506 }
507 }507 }
508 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.
514+func 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.
570+func 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+
509 func TestResolveBedrockInferenceProfileID(t *testing.T) {639 func TestResolveBedrockInferenceProfileID(t *testing.T) {
510 cases := []struct {640 cases := []struct {
511 model string641 model string

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