Skip to content

Commit 4e5854a

Browse files
Marvaecaozhiyuan
authored andcommitted
fix(responses): preserve image inputs with placeholder
1 parent 183128d commit 4e5854a

3 files changed

Lines changed: 83 additions & 52 deletions

File tree

src/routes/responses/utils.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ export const hasVisionInput = (payload: ResponsesPayload): boolean => {
8080
const DATA_URL_PREFIX = "data:"
8181
const BASE64_MARKER = ";base64,"
8282
const IMAGE_MEDIA_TYPE_PATTERN = /^image\/[a-zA-Z0-9.+-]+$/
83+
// Static 96x32 PNG reading "Image too large / Redacted".
84+
const REDACTED_IMAGE_PLACEHOLDER_DATA_URL =
85+
"data:image/png;base64,"
86+
+ [
87+
"iVBORw0KGgoAAAANSUhEUgAAAGAAAAAgCAMAAADaHo1mAAADAFBMVEX///8fKTfR1dsAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
88+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
89+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
90+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
91+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
92+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
93+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
94+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
95+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
96+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
97+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
98+
"AAAAAAAAAAAAAAAAAAAAAACae8QWAAAAvElEQVR42u1WixKAIAhj/f9Hdz2BXJiVed3pVSYtpgwsGSo3GaRq6wSd4F8EyIJx",
99+
"ydSUAMB8il51sHT2fiVQu8czguQwXWAyFvswIJhmoS9gmzYlcFiHj1aAgzcJVgCyguYhAhNZmMhYQZs1EJnnIAqKiuHjSrZT",
100+
"ucSQ4s8JkKDDIYr3IuR8vEWgqroKP9b1bYKk2wfgeVmqATQLXdXamsXdEKkz3QXEEeTTuWWImMhW6qci94/+hwSVf99HqVoD",
101+
"OAuj2SEAAAAASUVORK5CYII=",
102+
].join("")
83103

84104
export const sanitizeOversizedInputImages = (
85105
payload: ResponsesPayload,
@@ -97,7 +117,7 @@ export const sanitizeOversizedInputImages = (
97117
let count = 0
98118
for (const image of collectInputImageDataUrls(payload.input)) {
99119
if (limit !== undefined && image.decodedBytes > limit) {
100-
replaceInputImageWithText(image, `max ${limit} bytes`)
120+
replaceInputImageWithPlaceholder(image)
101121
count += 1
102122
}
103123
}
@@ -112,7 +132,7 @@ export const sanitizeAllInputImages = (payload: ResponsesPayload): number => {
112132

113133
let count = 0
114134
for (const image of collectInputImageDataUrls(payload.input)) {
115-
replaceInputImageWithText(image, "upstream rejected payload as too large")
135+
replaceInputImageWithPlaceholder(image)
116136
count += 1
117137
}
118138
return count
@@ -204,15 +224,12 @@ const getBase64DecodedByteLength = (
204224
return Math.max(0, Math.floor((base64Length * 3) / 4) - padding)
205225
}
206226

207-
const replaceInputImageWithText = (
208-
image: InputImageDataUrl,
209-
reason: string,
210-
): void => {
211-
image.record.type = "input_text"
212-
image.record.text = `[omitted input image: ${image.mediaType}, ${image.decodedBytes} bytes, ${reason}]`
213-
delete image.record.image_url
227+
const replaceInputImageWithPlaceholder = (image: InputImageDataUrl): void => {
228+
image.record.type = "input_image"
229+
image.record.image_url = REDACTED_IMAGE_PLACEHOLDER_DATA_URL
230+
image.record.detail = "low"
231+
delete image.record.text
214232
delete image.record.file_id
215-
delete image.record.detail
216233
}
217234

218235
export const resolveResponsesCompactThreshold = (

tests/responses-handler.test.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -313,18 +313,20 @@ describe("responses handler token usage", () => {
313313

314314
expect(response.status).toBe(200)
315315
expect(createResponses).toHaveBeenCalledTimes(1)
316-
expect(createResponses.mock.calls[0][0].input).toEqual([
317-
{
318-
content: [
319-
{ text: "look", type: "input_text" },
320-
{
321-
text: "[omitted input image: image/png, 12 bytes, max 8 bytes]",
322-
type: "input_text",
323-
},
324-
],
325-
role: "user",
326-
},
327-
])
316+
const image = (
317+
createResponses.mock.calls[0][0].input as Array<{
318+
content: Array<{
319+
detail?: string
320+
image_url?: string
321+
text?: string
322+
type: string
323+
}>
324+
}>
325+
)[0].content[1]
326+
expect(image.type).toBe("input_image")
327+
expect(image.detail).toBe("low")
328+
expect(image.image_url?.startsWith("data:image/png;base64,")).toBe(true)
329+
expect(image.text).toBeUndefined()
328330
})
329331

330332
test("preserves multiple input images before forwarding to Copilot Responses", async () => {
@@ -446,19 +448,24 @@ describe("responses handler token usage", () => {
446448

447449
expect(response.status).toBe(200)
448450
expect(createResponses).toHaveBeenCalledTimes(2)
449-
expect(createResponses.mock.calls[1][0].input).toEqual([
450-
{
451-
content: [
452-
{ text: "look", type: "input_text" },
453-
{
454-
text: "[omitted input image: image/png, 6 bytes, upstream rejected payload as too large]",
455-
type: "input_text",
456-
},
457-
],
458-
role: "user",
459-
},
460-
])
461-
expect(createResponses.mock.calls[1][1]?.vision).toBe(false)
451+
const retryImage = (
452+
createResponses.mock.calls[1][0].input as Array<{
453+
content: Array<{
454+
detail?: string
455+
image_url?: string
456+
text?: string
457+
type: string
458+
}>
459+
}>
460+
)[0].content[1]
461+
expect(retryImage.type).toBe("input_image")
462+
expect(retryImage.detail).toBe("low")
463+
expect(retryImage.image_url?.startsWith("data:image/png;base64,")).toBe(
464+
true,
465+
)
466+
expect(retryImage.image_url).not.toBe(imageUrl)
467+
expect(retryImage.text).toBeUndefined()
468+
expect(createResponses.mock.calls[1][1]?.vision).toBe(true)
462469
})
463470

464471
test("records usage from failed streaming responses and falls back to interaction id", async () => {

tests/responses-image-sanitizer.test.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,27 @@ const makePayload = (imageUrl: string): ResponsesPayload =>
2828
}) as unknown as ResponsesPayload
2929

3030
describe("sanitizeOversizedInputImages", () => {
31-
test("replaces oversized input images with text markers", () => {
31+
test("replaces oversized input images with placeholder images", () => {
3232
const payload = makePayload(tinyPngDataUrl)
3333

3434
const sanitized = sanitizeOversizedInputImages(payload, 67)
3535

3636
expect(sanitized).toBe(1)
37-
expect(payload.input).toEqual([
38-
{
39-
content: [
40-
{ text: "look", type: "input_text" },
41-
{
42-
text: "[omitted input image: image/png, 68 bytes, max 67 bytes]",
43-
type: "input_text",
44-
},
45-
],
46-
role: "user",
47-
},
48-
])
37+
const image = (
38+
payload.input as Array<{
39+
content: Array<{
40+
detail?: string
41+
image_url?: string
42+
text?: string
43+
type: string
44+
}>
45+
}>
46+
)[0].content[1]
47+
expect(image.type).toBe("input_image")
48+
expect(image.detail).toBe("low")
49+
expect(image.image_url?.startsWith("data:image/png;base64,")).toBe(true)
50+
expect(image.image_url).not.toBe(tinyPngDataUrl)
51+
expect(image.text).toBeUndefined()
4952
})
5053

5154
test("keeps input images within the model size limit", () => {
@@ -64,20 +67,22 @@ describe("sanitizeOversizedInputImages", () => {
6467
).toEqual({ detail: "low", image_url: imageUrl, type: "input_image" })
6568
})
6669

67-
test("removes all input images for a retry after payload rejection", () => {
70+
test("replaces all input images for a retry after payload rejection", () => {
71+
const firstImageUrl = imageDataUrl(1024)
72+
const secondImageUrl = imageDataUrl(1024)
6873
const payload = {
6974
input: [
7075
{
7176
content: [
7277
{ text: "look", type: "input_text" },
7378
{
7479
detail: "low",
75-
image_url: imageDataUrl(1024),
80+
image_url: firstImageUrl,
7681
type: "input_image",
7782
},
7883
{
7984
detail: "low",
80-
image_url: imageDataUrl(1024),
85+
image_url: secondImageUrl,
8186
type: "input_image",
8287
},
8388
],
@@ -90,6 +95,8 @@ describe("sanitizeOversizedInputImages", () => {
9095
const sanitized = sanitizeAllInputImages(payload)
9196

9297
expect(sanitized).toBe(2)
93-
expect(JSON.stringify(payload)).not.toContain("data:image/png;base64")
98+
expect(JSON.stringify(payload)).not.toContain(firstImageUrl)
99+
expect(JSON.stringify(payload)).not.toContain(secondImageUrl)
100+
expect(JSON.stringify(payload)).toContain("data:image/png;base64")
94101
})
95102
})

0 commit comments

Comments
 (0)