| 1 | package anthropic |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/provider/openai" |
| 12 | ) |
| 13 | |
| 14 | func TestBuildRequestEmbedsImageBlockForVisionModel(t *testing.T) { |
| 15 | c := &client{model: "claude-opus-4-8", vision: true} |
| 16 | req := c.buildRequest(context.Background(), provider.Request{ |
| 17 | Messages: []provider.Message{ |
| 18 | {Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/jpeg;base64,ZZZZ"}}, |
| 19 | }, |
| 20 | }) |
| 21 | blocks := req.Messages[0].Content |
| 22 | if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" { |
| 23 | t.Fatalf("blocks = %+v, want [text, image]", blocks) |
| 24 | } |
| 25 | src := blocks[1].Source |
| 26 | if src == nil || src.Type != "base64" || src.MediaType != "image/jpeg" || src.Data != "ZZZZ" { |
| 27 | t.Fatalf("image source = %+v, want base64 / image/jpeg / ZZZZ", src) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func TestModelInfoEnablesImageWireSerialization(t *testing.T) { |
| 32 | p, err := New(provider.Config{ |
| 33 | Name: "catalog", BaseURL: "https://example.test", Model: "vision", |
| 34 | ModelInfo: &provider.ModelInfo{ID: "vision", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}, |
| 35 | }) |
| 36 | if err != nil { |
| 37 | t.Fatalf("New: %v", err) |
| 38 | } |
| 39 | c := p.(*client) |
| 40 | if !c.vision { |
| 41 | t.Fatal("model metadata should enable image wire serialization") |
| 42 | } |
| 43 | req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}}) |
| 44 | if len(req.Messages[0].Content) != 2 || req.Messages[0].Content[1].Type != "image" { |
| 45 | t.Fatalf("content = %#v, want text + image blocks", req.Messages[0].Content) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestBuildRequestSkipsImageBlockWithoutVision(t *testing.T) { |
| 50 | c := &client{model: "claude-opus-4-8"} // vision unset |
| 51 | req := c.buildRequest(context.Background(), provider.Request{ |
| 52 | Messages: []provider.Message{ |
| 53 | {Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/jpeg;base64,ZZZZ"}}, |
| 54 | }, |
| 55 | }) |
| 56 | blocks := req.Messages[0].Content |
| 57 | if len(blocks) != 1 || blocks[0].Type != "text" { |
| 58 | t.Fatalf("blocks = %+v, want [text] only when vision is off", blocks) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestOfficialDeepSeekVisionSKUEmbedsUserImages(t *testing.T) { |
| 63 | p, err := New(provider.Config{ |
| 64 | Name: "deepseek-anthropic", |
| 65 | BaseURL: "https://api.deepseek.com/anthropic", |
| 66 | Model: openai.OfficialDeepSeekVisionModel, |
| 67 | }) |
| 68 | if err != nil { |
| 69 | t.Fatalf("New: %v", err) |
| 70 | } |
| 71 | c := p.(*client) |
| 72 | if !c.vision { |
| 73 | t.Fatal("pinned official DeepSeek vision SKU must enable user image serialization") |
| 74 | } |
| 75 | req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{ |
| 76 | Role: provider.RoleUser, Content: "describe", |
| 77 | Images: []string{"data:image/jpeg;base64,ZZZZ"}, |
| 78 | }}}) |
| 79 | blocks := req.Messages[0].Content |
| 80 | if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" { |
| 81 | t.Fatalf("blocks = %+v, want [text, image]", blocks) |
| 82 | } |
| 83 | src := blocks[1].Source |
| 84 | if src == nil || src.Type != "base64" || src.MediaType != "image/jpeg" || src.Data != "ZZZZ" { |
| 85 | t.Fatalf("image source = %+v", src) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestOfficialRequestURLImageHardLimit(t *testing.T) { |
| 90 | p, err := New(provider.Config{BaseURL: "https://relay.test", Model: "deepseek-v4-pro", Extra: map[string]any{"request_url": "https://api.deepseek.com/anthropic/v1/messages", "vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}}) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | body, err := json.Marshal(p.(*client).buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})) |
| 95 | if err != nil || strings.Contains(string(body), "AAAA") { |
| 96 | t.Fatalf("official request URL leaked image: %s %v", body, err) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestOfficialVisionExplicitOffRespectsResolvedMetadata(t *testing.T) { |
| 101 | p, err := New(provider.Config{BaseURL: "https://api.deepseek.com/anthropic", Model: openai.OfficialDeepSeekVisionModel, Extra: map[string]any{"vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}}) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | body, err := json.Marshal(p.(*client).buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})) |
| 106 | if err != nil || strings.Contains(string(body), "AAAA") { |
| 107 | t.Fatalf("explicit off leaked image: %s %v", body, err) |
| 108 | } |
| 109 | if p.(provider.ModelInfoProvider).ModelInfo().SupportsInput(provider.ModalityImage) { |
| 110 | t.Fatal("metadata disagrees with serializer") |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestOfficialDeepSeekVisionSKUEmbedsURLAndFileID(t *testing.T) { |
| 115 | p, err := New(provider.Config{ |
| 116 | Name: "deepseek-anthropic", |
| 117 | BaseURL: "https://api.deepseek.com/anthropic", |
| 118 | Model: openai.OfficialDeepSeekVisionModel, |
| 119 | }) |
| 120 | if err != nil { |
| 121 | t.Fatalf("New: %v", err) |
| 122 | } |
| 123 | c := p.(*client) |
| 124 | req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{ |
| 125 | Role: provider.RoleUser, |
| 126 | Content: "describe", |
| 127 | Images: []string{ |
| 128 | "https://cdn.example.com/cat.png", |
| 129 | "file-api-0a1b2c3d4e5f6071", |
| 130 | }, |
| 131 | }}}) |
| 132 | blocks := req.Messages[0].Content |
| 133 | if len(blocks) != 3 || blocks[1].Type != "image" || blocks[2].Type != "image" { |
| 134 | t.Fatalf("blocks = %+v", blocks) |
| 135 | } |
| 136 | if blocks[1].Source == nil || blocks[1].Source.Type != "url" || blocks[1].Source.URL != "https://cdn.example.com/cat.png" { |
| 137 | t.Fatalf("url source = %+v", blocks[1].Source) |
| 138 | } |
| 139 | if blocks[2].Source == nil || blocks[2].Source.Type != "file" || blocks[2].Source.FileID != "file-api-0a1b2c3d4e5f6071" { |
| 140 | t.Fatalf("file source = %+v", blocks[2].Source) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestOfficialDeepSeekVisionSKUEmbedsToolImages(t *testing.T) { |
| 145 | p, err := New(provider.Config{ |
| 146 | Name: "deepseek-anthropic", |
| 147 | BaseURL: "https://api.deepseek.com/anthropic", |
| 148 | Model: openai.OfficialDeepSeekVisionModel, |
| 149 | Extra: map[string]any{"vision": true}, |
| 150 | }) |
| 151 | if err != nil { |
| 152 | t.Fatalf("New: %v", err) |
| 153 | } |
| 154 | c := p.(*client) |
| 155 | messages := toolMessages([]string{"data:image/png;base64,QUFB"}) |
| 156 | messages[1].ReasoningContent = "Inspect the requested image." |
| 157 | req := c.buildRequest(context.Background(), provider.Request{Messages: messages}) |
| 158 | body, err := json.Marshal(req) |
| 159 | if err != nil { |
| 160 | t.Fatalf("marshal: %v", err) |
| 161 | } |
| 162 | if !strings.Contains(string(body), `"type":"image"`) || !strings.Contains(string(body), "QUFB") { |
| 163 | t.Fatalf("official DeepSeek vision SKU omitted tool image payload: %s", body) |
| 164 | } |
| 165 | last := req.Messages[len(req.Messages)-1] |
| 166 | if len(last.Content) != 2 || last.Content[0].Type != "tool_result" || last.Content[1].Type != "image" { |
| 167 | t.Fatalf("expected tool result then top-level image: %+v", last) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestOfficialDeepSeekIgnoresVisionMetadata(t *testing.T) { |
| 172 | p, err := New(provider.Config{ |
| 173 | Name: "deepseek-anthropic", |
| 174 | BaseURL: "https://api.deepseek.com/anthropic", |
| 175 | Model: "deepseek-v4-pro", |
| 176 | Extra: map[string]any{"vision": true}, |
| 177 | }) |
| 178 | if err != nil { |
| 179 | t.Fatalf("New: %v", err) |
| 180 | } |
| 181 | c := p.(*client) |
| 182 | if c.vision { |
| 183 | t.Fatal("official DeepSeek Anthropic endpoint must ignore vision metadata") |
| 184 | } |
| 185 | req := c.buildRequest(context.Background(), provider.Request{Messages: append( |
| 186 | []provider.Message{{ |
| 187 | Role: provider.RoleUser, Content: "describe", |
| 188 | Images: []string{"data:image/jpeg;base64,ZZZZ"}, |
| 189 | }}, |
| 190 | toolMessages([]string{"data:image/png;base64,QUFB"})..., |
| 191 | )}) |
| 192 | body, err := json.Marshal(req) |
| 193 | if err != nil { |
| 194 | t.Fatalf("marshal request: %v", err) |
| 195 | } |
| 196 | if strings.Contains(string(body), `"type":"image"`) || strings.Contains(string(body), "ZZZZ") || strings.Contains(string(body), "QUFB") { |
| 197 | t.Fatalf("official DeepSeek Anthropic request leaked image payload: %s", body) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestOfficialDeepSeekImageMetadataMatchesTextOnlyWireBytes(t *testing.T) { |
| 202 | p, err := New(provider.Config{ |
| 203 | Name: "deepseek-anthropic", |
| 204 | BaseURL: "https://api.deepseek.com/anthropic", |
| 205 | Model: "deepseek-v4-pro", |
| 206 | Extra: map[string]any{"vision": true}, |
| 207 | }) |
| 208 | if err != nil { |
| 209 | t.Fatalf("New: %v", err) |
| 210 | } |
| 211 | c := p.(*client) |
| 212 | plain := []provider.Message{ |
| 213 | {Role: provider.RoleUser, Content: "inspect"}, |
| 214 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}}, |
| 215 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"}, |
| 216 | } |
| 217 | withImages := append([]provider.Message(nil), plain...) |
| 218 | withImages[0].Images = []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)} |
| 219 | withImages[2].Images = []string{"data:image/png;base64,VE9PTA=="} |
| 220 | |
| 221 | plainBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: plain})) |
| 222 | if err != nil { |
| 223 | t.Fatalf("marshal plain request: %v", err) |
| 224 | } |
| 225 | imageBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: withImages})) |
| 226 | if err != nil { |
| 227 | t.Fatalf("marshal image request: %v", err) |
| 228 | } |
| 229 | if !bytes.Equal(imageBody, plainBody) { |
| 230 | t.Fatalf("official DeepSeek Anthropic image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, imageBody) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // toolMessages is a paired history whose tool result carries an image: the |
| 235 | // shape parseToolResult produces for an MCP screenshot tool. |
| 236 | func toolMessages(images []string) []provider.Message { |
| 237 | return []provider.Message{ |
| 238 | {Role: provider.RoleUser, Content: "screenshot please"}, |
| 239 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}}, |
| 240 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: images}, |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestBuildRequestEmbedsToolResultImagesForVisionModel(t *testing.T) { |
| 245 | c := &client{model: "claude-opus-4-8", vision: true} |
| 246 | req := c.buildRequest(context.Background(), provider.Request{Messages: toolMessages([]string{"data:image/png;base64,QUFB"})}) |
| 247 | last := req.Messages[len(req.Messages)-1] |
| 248 | if last.Role != "user" || len(last.Content) != 1 || last.Content[0].Type != "tool_result" { |
| 249 | t.Fatalf("last message = %+v, want a single tool_result block", last) |
| 250 | } |
| 251 | blocks, ok := last.Content[0].Content.([]contentBlock) |
| 252 | if !ok { |
| 253 | t.Fatalf("tool_result content = %T, want []contentBlock", last.Content[0].Content) |
| 254 | } |
| 255 | if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" { |
| 256 | t.Fatalf("tool_result blocks = %+v, want [text, image]", blocks) |
| 257 | } |
| 258 | if blocks[0].Text != "[image: image/png]" { |
| 259 | t.Fatalf("text block = %q, want the placeholder text", blocks[0].Text) |
| 260 | } |
| 261 | src := blocks[1].Source |
| 262 | if src == nil || src.Type != "base64" || src.MediaType != "image/png" || src.Data != "QUFB" { |
| 263 | t.Fatalf("image source = %+v, want base64 / image/png / QUFB", src) |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | func TestBuildRequestDropsToolResultImagesWithoutVision(t *testing.T) { |
| 268 | c := &client{model: "claude-opus-4-8"} // vision unset |
| 269 | req := c.buildRequest(context.Background(), provider.Request{Messages: toolMessages([]string{"data:image/png;base64,QUFB"})}) |
| 270 | last := req.Messages[len(req.Messages)-1] |
| 271 | if s, ok := last.Content[0].Content.(string); !ok || s != "[image: image/png]" { |
| 272 | t.Fatalf("non-vision tool_result content = %#v, want the plain placeholder string", last.Content[0].Content) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // A text-only tool result must keep serializing exactly as before the image |
| 277 | // channel existed: plain string content, no array — the prompt-cache prefix of |
| 278 | // existing sessions depends on those bytes. |
| 279 | func TestBuildRequestToolResultTextOnlyKeepsStringContent(t *testing.T) { |
| 280 | c := &client{model: "claude-opus-4-8", vision: true} |
| 281 | msgs := toolMessages(nil) |
| 282 | msgs[2].Content = "plain output" |
| 283 | // Trailing user turn merges after the tool_result in the same user message |
| 284 | // and takes the cache breakpoint, so the tool_result block keeps its |
| 285 | // pre-image-channel bytes. |
| 286 | msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "next"}) |
| 287 | req := c.buildRequest(context.Background(), provider.Request{Messages: msgs}) |
| 288 | last := req.Messages[len(req.Messages)-1] |
| 289 | if len(last.Content) != 2 || last.Content[0].Type != "tool_result" { |
| 290 | t.Fatalf("last message blocks = %+v, want [tool_result, text]", last.Content) |
| 291 | } |
| 292 | if s, ok := last.Content[0].Content.(string); !ok || s != "plain output" { |
| 293 | t.Fatalf("tool_result content = %#v, want plain string", last.Content[0].Content) |
| 294 | } |
| 295 | body, err := json.Marshal(last.Content[0]) |
| 296 | if err != nil { |
| 297 | t.Fatalf("marshal: %v", err) |
| 298 | } |
| 299 | want := `{"type":"tool_result","tool_use_id":"c1","content":"plain output"}` |
| 300 | if string(body) != want { |
| 301 | t.Fatalf("serialized tool_result = %s, want %s", body, want) |
| 302 | } |
| 303 | } |
| 304 |