Merge pull request #40 from pr-40
2 files · +157 -2
M packages/provider/amazon_bedrock.go +27 -2
| @@ -41,6 +41,7 @@ import ( | |||
| 41 | 41 | "context" | |
| 42 | 42 | "crypto/hmac" | |
| 43 | 43 | "crypto/sha256" | |
| 44 | + | "encoding/base64" | |
| 44 | 45 | "encoding/binary" | |
| 45 | 46 | "encoding/hex" | |
| 46 | 47 | "encoding/json" | |
| @@ -361,11 +362,35 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 361 | 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 | 377 | case ToolResultBlock: | |
| 365 | 378 | var resultContent []map[string]interface{} | |
| 366 | 379 | 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 | + | }) | |
| 369 | 394 | } | |
| 370 | 395 | } | |
| 371 | 396 | 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) { | |||
| 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 | 639 | func TestResolveBedrockInferenceProfileID(t *testing.T) { | |
| 510 | 640 | cases := []struct { | |
| 511 | 641 | 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 string | 641 | model string | ||