Skip to content

Commit 941a785

Browse files
chenxiaoyu209claude
andcommitted
test(agents): add end-to-end Anthropic media_type proof for #102323
Drives the real shared image sanitizer into the real Anthropic Messages transport and captures the outgoing request body, proving an image/heic-labeled within-limit image now leaves as a supported media_type instead of being forwarded verbatim (which Anthropic rejects with a 400). Co-Authored-By: Claude <[email protected]>
1 parent 830bd60 commit 941a785

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* End-to-end runtime proof for issue #102323.
3+
*
4+
* Drives the real shared image sanitizer into the real Anthropic Messages
5+
* transport and captures the outgoing request body, proving that an
6+
* unsupported-but-decodable image mime (e.g. image/heic) no longer reaches the
7+
* Anthropic API as a verbatim media_type that the API rejects with a 400.
8+
*/
9+
import type { Model } from "openclaw/plugin-sdk/llm";
10+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js";
12+
import { createImageProcessor } from "../media/image-ops.js";
13+
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
14+
import { sanitizeImageBlocks } from "./tool-images.js";
15+
16+
const { buildGuardedModelFetchMock, guardedFetchMock } = vi.hoisted(() => ({
17+
buildGuardedModelFetchMock: vi.fn(),
18+
guardedFetchMock: vi.fn(),
19+
}));
20+
21+
vi.mock("./provider-transport-fetch.js", () => ({
22+
buildGuardedModelFetch: buildGuardedModelFetchMock,
23+
}));
24+
25+
let createAnthropicMessagesTransportStreamFn: typeof import("./anthropic-transport-stream.js").createAnthropicMessagesTransportStreamFn;
26+
27+
type AnthropicMessagesModel = Model<"anthropic-messages">;
28+
29+
const SUPPORTED_INLINE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
30+
31+
function createSseResponse(): Response {
32+
return new Response(
33+
'data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":1,"output_tokens":0}}}\n\n' +
34+
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n' +
35+
'data: {"type":"message_stop"}\n\n',
36+
{ status: 200, headers: { "content-type": "text/event-stream" } },
37+
);
38+
}
39+
40+
function makeImageModel(): AnthropicMessagesModel {
41+
return attachModelProviderRequestTransport(
42+
{
43+
id: "claude-sonnet-4-6",
44+
name: "Claude Sonnet 4.6",
45+
api: "anthropic-messages",
46+
provider: "anthropic",
47+
baseUrl: "https://api.anthropic.com",
48+
reasoning: true,
49+
input: ["text", "image"],
50+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
51+
contextWindow: 200000,
52+
maxTokens: 8192,
53+
} satisfies AnthropicMessagesModel,
54+
{ proxy: { mode: "env-proxy" } },
55+
);
56+
}
57+
58+
function outgoingImageMediaTypes(): string[] {
59+
const [, init] = guardedFetchMock.mock.calls.at(-1) ?? [];
60+
const body = init?.body;
61+
const payload = typeof body === "string" ? (JSON.parse(body) as Record<string, unknown>) : {};
62+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
63+
const mediaTypes: string[] = [];
64+
for (const message of messages) {
65+
const content = (message as { content?: unknown }).content;
66+
if (!Array.isArray(content)) {
67+
continue;
68+
}
69+
for (const block of content) {
70+
const source = (block as { type?: unknown; source?: unknown }).source;
71+
if ((block as { type?: unknown }).type === "image" && source && typeof source === "object") {
72+
const mediaType = (source as { media_type?: unknown }).media_type;
73+
if (typeof mediaType === "string") {
74+
mediaTypes.push(mediaType);
75+
}
76+
}
77+
}
78+
}
79+
return mediaTypes;
80+
}
81+
82+
describe("anthropic image media_type end-to-end (issue #102323)", () => {
83+
beforeAll(async () => {
84+
({ createAnthropicMessagesTransportStreamFn } = await import(
85+
"./anthropic-transport-stream.js"
86+
));
87+
});
88+
89+
beforeEach(() => {
90+
vi.unstubAllEnvs();
91+
buildGuardedModelFetchMock.mockReset();
92+
guardedFetchMock.mockReset();
93+
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
94+
guardedFetchMock.mockResolvedValue(createSseResponse());
95+
});
96+
97+
afterEach(() => {
98+
vi.useRealTimers();
99+
});
100+
101+
it("sends a supported media_type after sanitizing an image/heic-labeled image", async () => {
102+
// Real, decodable within-limit bytes. WebP is not covered by the sanitizer's
103+
// base64 sniff, so the declared (unsupported) mime drives the transcode path.
104+
const png = createSolidPngBuffer(48, 48, { r: 30, g: 160, b: 210 });
105+
const webp = (await createImageProcessor().encode(png, { format: "webp" })).data;
106+
107+
const { images: sanitized, dropped } = await sanitizeImageBlocks(
108+
[{ type: "image", data: webp.toString("base64"), mimeType: "image/heic" }],
109+
"issue-102323",
110+
);
111+
expect(dropped).toBe(0);
112+
expect(sanitized).toHaveLength(1);
113+
114+
const streamFn = createAnthropicMessagesTransportStreamFn();
115+
const stream = await Promise.resolve(
116+
streamFn(
117+
makeImageModel(),
118+
{
119+
messages: [
120+
{
121+
role: "user",
122+
content: [{ type: "text", text: "what is this?" }, ...sanitized],
123+
timestamp: 1,
124+
},
125+
],
126+
} as Parameters<typeof streamFn>[1],
127+
{ apiKey: "sk-ant-api03-test" } as Parameters<typeof streamFn>[2],
128+
),
129+
);
130+
await stream.result();
131+
132+
const mediaTypes = outgoingImageMediaTypes();
133+
expect(mediaTypes).toHaveLength(1);
134+
// The bug: image/heic passed through verbatim and Anthropic 400'd the turn.
135+
expect(mediaTypes[0]).not.toBe("image/heic");
136+
expect(SUPPORTED_INLINE_MIME.has(mediaTypes[0] ?? "")).toBe(true);
137+
}, 20_000);
138+
});

0 commit comments

Comments
 (0)