diff --git a/pkg/backend/anthropic_stream_test.go b/pkg/backend/anthropic_stream_test.go index 6b7c6be..b61e5cb 100644 --- a/pkg/backend/anthropic_stream_test.go +++ b/pkg/backend/anthropic_stream_test.go @@ -441,3 +441,73 @@ func TestToolResultCaching_LastLargeResult(t *testing.T) { t.Errorf("last large result missing cache_control:ephemeral; got %+v", last.Content[1].CacheControl) } } + +// --- image ContentBlocks --- + +func TestBuildAnthropicMessages_UserImageBlocks(t *testing.T) { + _, out := buildAnthropicMessages([]Message{{ + Role: "user", + ContentBlocks: []ContentBlock{ + {Type: "text", Text: "what is this?"}, + {Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/png", Data: "iVBOR"}}, + }, + }}) + if len(out) != 1 { + t.Fatalf("len = %d; want 1", len(out)) + } + if len(out[0].Content) != 2 { + t.Fatalf("content blocks = %d; want 2", len(out[0].Content)) + } + if out[0].Content[0].Type != "text" || out[0].Content[0].Text != "what is this?" { + t.Errorf("block[0] = %+v", out[0].Content[0]) + } + if out[0].Content[1].Type != "image" || out[0].Content[1].Source == nil { + t.Fatalf("block[1] = %+v", out[0].Content[1]) + } + if out[0].Content[1].Source.Type != "base64" || out[0].Content[1].Source.MediaType != "image/png" || out[0].Content[1].Source.Data != "iVBOR" { + t.Errorf("source = %+v", out[0].Content[1].Source) + } +} + +func TestBuildAnthropicMessages_ToolImageBlocks(t *testing.T) { + _, out := buildAnthropicMessages([]Message{ + {Role: "user", Content: "read image"}, + {Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Name: "file_read", Arguments: json.RawMessage(`{}`)}}}, + {Role: "tool", ToolCallID: "c1", Content: "[image]", ContentBlocks: []ContentBlock{ + {Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/jpeg", Data: "AAAA"}}, + {Type: "text", Text: "[image: image/jpeg]"}, + }}, + }) + // Last message is the batched tool result (user role). + last := out[len(out)-1] + if last.Role != "user" { + t.Fatalf("role = %q; want user", last.Role) + } + // Should have image block + tool_result block. + var hasImage, hasToolResult bool + for _, b := range last.Content { + if b.Type == "image" && b.Source != nil && b.Source.MediaType == "image/jpeg" { + hasImage = true + } + if b.Type == "tool_result" && b.ToolUseID == "c1" { + hasToolResult = true + } + } + if !hasImage { + t.Error("missing image block in tool result") + } + if !hasToolResult { + t.Error("missing tool_result block") + } +} + +func TestBuildAnthropicMessages_UserPlainTextFallback(t *testing.T) { + // When ContentBlocks is nil, should fall back to Content string. + _, out := buildAnthropicMessages([]Message{{Role: "user", Content: "hello"}}) + if len(out) != 1 || len(out[0].Content) != 1 { + t.Fatalf("unexpected: %+v", out) + } + if out[0].Content[0].Type != "text" || out[0].Content[0].Text != "hello" { + t.Errorf("block = %+v", out[0].Content[0]) + } +} diff --git a/pkg/backend/openai_encode_test.go b/pkg/backend/openai_encode_test.go index 5db196a..79fc5a8 100644 --- a/pkg/backend/openai_encode_test.go +++ b/pkg/backend/openai_encode_test.go @@ -362,6 +362,57 @@ func TestOpenAI_NoCacheControl(t *testing.T) { } } +func TestEncodeMessages_UserWithImageContentBlocks(t *testing.T) { + wire := encodeOpenAIMessages([]Message{{ + Role: "user", + ContentBlocks: []ContentBlock{ + {Type: "text", Text: "what is this?"}, + {Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/png", Data: "iVBOR"}}, + }, + }}) + if len(wire) != 1 { + t.Fatalf("len = %d; want 1", len(wire)) + } + if wire[0].Content != nil { + t.Error("Content should be nil when ContentBlocks is used") + } + if len(wire[0].ContentBlocks) != 2 { + t.Fatalf("ContentBlocks len = %d; want 2", len(wire[0].ContentBlocks)) + } + if wire[0].ContentBlocks[0].Type != "text" || wire[0].ContentBlocks[0].Text != "what is this?" { + t.Errorf("block[0] = %+v", wire[0].ContentBlocks[0]) + } + if wire[0].ContentBlocks[1].Type != "image_url" || wire[0].ContentBlocks[1].ImageURL == nil { + t.Fatalf("block[1] = %+v", wire[0].ContentBlocks[1]) + } + if wire[0].ContentBlocks[1].ImageURL.URL != "data:image/png;base64,iVBOR" { + t.Errorf("image url = %q", wire[0].ContentBlocks[1].ImageURL.URL) + } +} + +func TestEncodeMessages_UserWithImageContentBlocks_JSON(t *testing.T) { + wire := encodeOpenAIMessages([]Message{{ + Role: "user", + ContentBlocks: []ContentBlock{ + {Type: "text", Text: "look"}, + {Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/jpeg", Data: "AAAA"}}, + }, + }}) + data, _ := json.Marshal(wire[0]) + var raw map[string]json.RawMessage + json.Unmarshal(data, &raw) + // content must be an array + if raw["content"][0] != '[' { + t.Errorf("content should be array; got %s", raw["content"]) + } + if !strings.Contains(string(raw["content"]), "image_url") { + t.Errorf("content missing image_url: %s", raw["content"]) + } + if !strings.Contains(string(raw["content"]), "data:image/jpeg;base64,AAAA") { + t.Errorf("content missing data URI: %s", raw["content"]) + } +} + func TestEncodeRequest_ToolsOmittedWhenEmpty(t *testing.T) { req := openAIChatRequest{ Model: "m",