mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
fix(sse): surface ChatGPT-web image silent-drop as an accurate error (#6208)
When ChatGPT Web generates an image as an image_asset_pointer but the pointer fails to resolve to a downloadable URL (unknown asset scheme, download 403/ expired, oversize), resolveImagePointers returned [] — indistinguishable from 'no image produced' — so the image-generation handler reported the misleading 502 'completed without returning image markdown'. The image genuinely existed upstream; OmniRoute dropped it silently. Fix: the executor flags x_image_resolution_failed when a pointer existed but none resolved (and logs the unresolved asset scheme for follow-up), and the handler surfaces a truthful 'generated but not retrievable' 502 instead of 'no image markdown'. Adds executorFactory DI for unit testing. TDD: tests/unit/chatgpt-web-image-silentdrop.test.ts (red -> green), plus the existing chatgpt-web / image-generation-handler suites stay green. Reported via community triage (mesh escalated backlog).
This commit is contained in:
committed by
GitHub
parent
b3a2cfe0ea
commit
286fdf8794
@@ -1579,6 +1579,21 @@ type ImageResolver = (
|
||||
parentMessageId?: string | null
|
||||
) => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* True when ChatGPT emitted an image asset pointer (the image WAS generated
|
||||
* upstream) but none of the pointers could be resolved to a downloadable URL
|
||||
* — so the assistant text carries no image markdown. Lets callers surface an
|
||||
* accurate "generated but not retrievable" error instead of the misleading
|
||||
* "no image was produced". Escalated mesh report: image visible in the ChatGPT
|
||||
* chat but returned to OmniRoute as a bare "completed without image markdown".
|
||||
*/
|
||||
export function detectImageResolutionFailure(
|
||||
pointerCount: number,
|
||||
resolvedCount: number
|
||||
): boolean {
|
||||
return pointerCount > 0 && resolvedCount === 0;
|
||||
}
|
||||
|
||||
/** Build the final markdown block for a list of resolved image URLs. */
|
||||
function imageMarkdown(urls: string[]): string {
|
||||
if (urls.length === 0) return "";
|
||||
@@ -2017,6 +2032,23 @@ async function buildNonStreamingResponse(
|
||||
log,
|
||||
parentCandidateMessageId
|
||||
);
|
||||
// The image genuinely exists upstream but no pointer resolved to a URL
|
||||
// (unknown asset scheme, download 403/expired, oversize). Flag it so the
|
||||
// image-generation handler can report an accurate "generated but not
|
||||
// retrievable" error instead of the misleading "no image markdown" 502.
|
||||
const imageResolutionFailed = detectImageResolutionFailure(
|
||||
imagePointers?.length ?? 0,
|
||||
urls.length
|
||||
);
|
||||
if (imageResolutionFailed && log?.warn) {
|
||||
const schemes = (imagePointers ?? [])
|
||||
.map((p) => p.pointer.split("://")[0] || p.pointer.slice(0, 24))
|
||||
.join(", ");
|
||||
log.warn(
|
||||
"CGPT-WEB",
|
||||
`Image generated upstream but no asset pointer resolved (schemes: ${schemes}) — surfacing as unretrievable`
|
||||
);
|
||||
}
|
||||
fullAnswer += imageMarkdown(urls);
|
||||
const promptTokens = Math.ceil(currentMsg.length / 4);
|
||||
const completionTokens = Math.ceil(fullAnswer.length / 4);
|
||||
@@ -2028,6 +2060,7 @@ async function buildNonStreamingResponse(
|
||||
created,
|
||||
model,
|
||||
system_fingerprint: null,
|
||||
...(imageResolutionFailed ? { x_image_resolution_failed: true } : {}),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
|
||||
@@ -43,6 +43,9 @@ export async function handleChatGptWebImageGeneration({
|
||||
log,
|
||||
signal,
|
||||
clientHeaders,
|
||||
// Injectable so unit tests can drive the handler without a live ChatGPT
|
||||
// session; production uses the real executor.
|
||||
executorFactory = () => new ChatGptWebExecutor(),
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
@@ -98,7 +101,7 @@ export async function handleChatGptWebImageGeneration({
|
||||
};
|
||||
|
||||
for (let i = 0; i < requestedCount; i++) {
|
||||
const executor = new ChatGptWebExecutor();
|
||||
const executor = executorFactory();
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
body: {
|
||||
@@ -124,21 +127,30 @@ export async function handleChatGptWebImageGeneration({
|
||||
}
|
||||
|
||||
let content = "";
|
||||
let imageResolutionFailed = false;
|
||||
try {
|
||||
const json = JSON.parse(responseText);
|
||||
content = String(json?.choices?.[0]?.message?.content || "");
|
||||
imageResolutionFailed = json?.x_image_resolution_failed === true;
|
||||
} catch {
|
||||
content = responseText;
|
||||
}
|
||||
|
||||
const urls = extractMarkdownImageUrls(content);
|
||||
if (urls.length === 0) {
|
||||
// Distinguish "image was generated upstream but OmniRoute could not
|
||||
// retrieve it" (executor flagged the unresolved asset pointer) from
|
||||
// "no image was produced at all" — the former is our bug/limitation,
|
||||
// not a failed prompt, so the message must not read as "no image made".
|
||||
const error = imageResolutionFailed
|
||||
? `ChatGPT Web generated an image but OmniRoute could not retrieve it (the image asset could not be downloaded — the URL may have expired or ChatGPT changed its image delivery format). Please retry; if it persists, report it. Assistant text: ${content.slice(0, 200)}`
|
||||
: `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`;
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`,
|
||||
error,
|
||||
requestBody,
|
||||
});
|
||||
}
|
||||
|
||||
96
tests/unit/chatgpt-web-image-silentdrop.test.ts
Normal file
96
tests/unit/chatgpt-web-image-silentdrop.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Regression guard for the escalated mesh-bot report: a user generated an
|
||||
// image via the ChatGPT Web provider; the image WAS produced upstream but
|
||||
// OmniRoute returned `502 "ChatGPT Web completed without returning image
|
||||
// markdown"` — i.e. the silent-drop path where an image_asset_pointer existed
|
||||
// but resolution failed, and the handler reported it as "no image made".
|
||||
//
|
||||
// The fix distinguishes "image generated but not retrievable" (executor sets
|
||||
// x_image_resolution_failed) from "no image at all", so the 502 is accurate
|
||||
// and actionable instead of misleading.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-cgptweb-silentdrop-"));
|
||||
|
||||
const { detectImageResolutionFailure } = await import("../../open-sse/executors/chatgpt-web.ts");
|
||||
const { handleChatGptWebImageGeneration } = await import(
|
||||
"../../open-sse/handlers/imageGeneration/providers/chatgptWeb.ts"
|
||||
);
|
||||
|
||||
function fakeExecutor(jsonBody: object, status = 200) {
|
||||
return {
|
||||
execute: async () => ({
|
||||
response: new Response(JSON.stringify(jsonBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
model: "gpt-4o",
|
||||
provider: "chatgpt-web",
|
||||
body: { prompt: "a kitten" },
|
||||
credentials: { apiKey: "sess-cookie" },
|
||||
log: null,
|
||||
signal: null,
|
||||
clientHeaders: {},
|
||||
};
|
||||
|
||||
test("detectImageResolutionFailure: true only when a pointer existed but none resolved", () => {
|
||||
assert.equal(detectImageResolutionFailure(1, 0), true);
|
||||
assert.equal(detectImageResolutionFailure(2, 0), true);
|
||||
assert.equal(detectImageResolutionFailure(0, 0), false); // no image at all
|
||||
assert.equal(detectImageResolutionFailure(1, 1), false); // resolved fine
|
||||
});
|
||||
|
||||
test("handler surfaces a specific 502 when the image was generated but not retrievable", async () => {
|
||||
const res = await handleChatGptWebImageGeneration({
|
||||
...baseArgs,
|
||||
executorFactory: () =>
|
||||
fakeExecutor({
|
||||
choices: [{ message: { role: "assistant", content: "Here's your image:" } }],
|
||||
x_image_resolution_failed: true,
|
||||
}),
|
||||
});
|
||||
assert.equal(res.success, false);
|
||||
assert.equal(res.status, 502);
|
||||
// must NOT be the misleading "completed without returning image markdown"
|
||||
assert.ok(
|
||||
!/completed without returning image markdown/i.test(res.error),
|
||||
`expected specific retrieval error, got: ${res.error}`
|
||||
);
|
||||
// must clearly say the image was generated but could not be retrieved
|
||||
assert.match(res.error, /could not (be )?retriev|generated an image but/i);
|
||||
});
|
||||
|
||||
test("handler keeps the generic 502 when no image was generated at all", async () => {
|
||||
const res = await handleChatGptWebImageGeneration({
|
||||
...baseArgs,
|
||||
executorFactory: () =>
|
||||
fakeExecutor({
|
||||
choices: [{ message: { role: "assistant", content: "I can't create that." } }],
|
||||
}),
|
||||
});
|
||||
assert.equal(res.success, false);
|
||||
assert.equal(res.status, 502);
|
||||
assert.match(res.error, /completed without returning image markdown/i);
|
||||
});
|
||||
|
||||
test("handler returns success when the executor produced image markdown", async () => {
|
||||
const url = "/v1/chatgpt-web/image/abcdef0123456789";
|
||||
const res = await handleChatGptWebImageGeneration({
|
||||
...baseArgs,
|
||||
executorFactory: () =>
|
||||
fakeExecutor({
|
||||
choices: [{ message: { role: "assistant", content: `Here you go:\n\n` } }],
|
||||
}),
|
||||
});
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.data.data.length, 1);
|
||||
assert.equal(res.data.data[0].url, url);
|
||||
});
|
||||
Reference in New Issue
Block a user