mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
1 Commits
fix/9971-e
...
fix/9981-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea614ab12 |
@@ -1 +0,0 @@
|
||||
- fix(chat): don't misclassify content-less thinking/redacted Claude bodies as empty_choices (#9971)
|
||||
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)
|
||||
@@ -523,19 +523,6 @@ export function translateNonStreamingResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// #9971: a content-less-but-valid Claude body (thinking / redacted_thinking
|
||||
// / tool_use-only, or a truncated extended-thinking-only stream) has blocks
|
||||
// but no final text. Surfacing it here helps correlate a live VPS capture
|
||||
// with detectMalformedNonStream's clause; the content itself is valid output
|
||||
// (see detectMalformedNonStream), so this is observation, not a decision.
|
||||
if (textContent.length === 0 && process.env.DEBUG_CLAUDE_NONSTREAM === "true") {
|
||||
console.log(
|
||||
`[ClaudeNonStream] ${contentBlocks.length} content block(s), empty textContent ` +
|
||||
`(thinking=${thinkingContent.length}, toolCalls=${toolCalls.length}); ` +
|
||||
`content-less-but-valid body preserved (not empty_choices)`
|
||||
);
|
||||
}
|
||||
|
||||
const message: JsonRecord = { role: "assistant" };
|
||||
if (textContent) {
|
||||
message.content = textContent;
|
||||
|
||||
@@ -211,8 +211,7 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
// `choices`. Without this branch every non-streaming Claude response (incl. plain text)
|
||||
// falls through to `empty_choices` → a false 502 (#5108, regression from #4942).
|
||||
if (body.type === "message" && Array.isArray(body.content)) {
|
||||
const content = body.content as unknown[];
|
||||
const hasOutput = content.some((block) => {
|
||||
const hasOutput = (body.content as unknown[]).some((block) => {
|
||||
// A malformed/partial provider response could carry a null (or non-object)
|
||||
// entry in `content`; guard before type-asserting so the detector never
|
||||
// throws on `null.type` (that would crash the whole non-stream classifier).
|
||||
@@ -230,18 +229,16 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Extended-thinking block: valid structural output whenever the model
|
||||
// entered the thinking phase, even with no visible thinking text and no
|
||||
// `signature`. #9971: the Claude Code OAuth upstream can truncate long
|
||||
// large-input+large-output generations around the ~3-min turn boundary,
|
||||
// leaving a content-less thinking-only body whose final text (and, when
|
||||
// cut mid-think, its signature) never arrived. The block's very presence
|
||||
// is proof the turn produced output upstream, so it is a valid
|
||||
// in-progress completion, NOT a genuinely empty terminal response.
|
||||
// (Previously only a non-empty `thinking` text OR `signature` counted —
|
||||
// #5108 — which misclassified these content-less bodies as empty_choices
|
||||
// → 502.)
|
||||
if (b.type === "thinking") return true;
|
||||
// Extended-thinking block: valid when it carries visible thinking text OR a
|
||||
// non-empty `signature` (cryptographic proof the thinking step ran, so it is a
|
||||
// valid completion even when the thinking text is "").
|
||||
if (
|
||||
b.type === "thinking" &&
|
||||
((typeof b.thinking === "string" && (b.thinking as string).length > 0) ||
|
||||
(typeof b.signature === "string" && (b.signature as string).length > 0))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Redacted thinking and tool_use are valid structural output.
|
||||
if (b.type === "redacted_thinking") return true;
|
||||
if (b.type === "tool_use" && typeof b.id === "string" && (b.id as string).length > 0) {
|
||||
@@ -249,27 +246,7 @@ export function detectMalformedNonStream(resp: unknown): MalformedReason | null
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasOutput) return null;
|
||||
|
||||
// No per-block output. Two distinct situations remain:
|
||||
// 1) A block IS present but invalid (e.g. text:"", a lone "(empty response)"
|
||||
// sentinel, or only null entries) — the model genuinely produced no
|
||||
// usable output. That is a MALFORMED-200 empty_choices regardless of
|
||||
// stop_reason (parity with the OpenAI content:"" path).
|
||||
// 2) `content: []` — no block at all. Only a genuinely *terminal* response
|
||||
// (a final stop_reason with no output) is empty_choices. #9971: a
|
||||
// truncated / non-terminal body — the Claude Code OAuth upstream cutting
|
||||
// a long generation mid-turn, or a content-less thinking-only stream
|
||||
// that never emitted a terminal event — carries content:[] with no
|
||||
// reachable end, so flagging it would turn an upstream truncation into a
|
||||
// false 502. Require a terminal stop_reason before calling a block-less
|
||||
// response genuinely empty.
|
||||
if (content.length === 0) {
|
||||
const stopReason = typeof body.stop_reason === "string" ? body.stop_reason : "";
|
||||
const isTerminal = stopReason.length > 0;
|
||||
return isTerminal ? "empty_choices" : null;
|
||||
}
|
||||
return "empty_choices";
|
||||
return hasOutput ? null : "empty_choices";
|
||||
}
|
||||
|
||||
// ── Chat Completions shape ──
|
||||
|
||||
@@ -308,10 +308,11 @@ async function postHandler(request, context) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
|
||||
@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,8 @@
|
||||
* the thinking step actually ran, so this is a valid completion, not an empty one.
|
||||
*
|
||||
* The detector must understand the Claude shape: text blocks with text, thinking blocks
|
||||
* (with or without a signature), redacted_thinking, and tool_use blocks count as output;
|
||||
* a genuinely empty terminal `content:[]` (or a terminal `(empty response)` text sentinel)
|
||||
* is still flagged. #9971 refined #5108's rule: an empty thinking block is also valid
|
||||
* structural output (the upstream can truncate a thinking-only generation before any
|
||||
* text/signature lands), so it is no longer flagged — only content-less *terminal* bodies are.
|
||||
* with a signature, and tool_use blocks count as output; a genuinely empty `content:[]`
|
||||
* (or thinking with neither text nor signature) is still flagged.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -51,12 +48,9 @@ test("#5108 genuinely empty Claude content:[] is still flagged malformed", () =>
|
||||
assert.equal(detectMalformedNonStream(claudeMsg([])), "empty_choices");
|
||||
});
|
||||
|
||||
test("#5108/#9971 Claude thinking block with neither text nor signature is valid output (was empty_choices)", () => {
|
||||
test("#5108 Claude thinking block with neither text nor signature is still flagged", () => {
|
||||
const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }]);
|
||||
// #9971: an empty thinking block is valid structural output — the upstream can
|
||||
// truncate a thinking-only generation before any text/signature lands, and a
|
||||
// content-less thinking-only body must not turn into an empty_choices 502.
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
// Existing OpenAI / Responses behavior must be unchanged.
|
||||
|
||||
@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
|
||||
]);
|
||||
});
|
||||
|
||||
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "single-expired-image-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer single-expired-image-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await imageRoute.POST(
|
||||
new Request("http://localhost/api/v1/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired access token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer provider-single-expired-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await providerImageRoute.POST(
|
||||
new Request("http://localhost/api/v1/providers/openai/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "openai" }) }
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired provider token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
|
||||
await seedConnection("antigravity", {
|
||||
authType: "oauth",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* #9971 — Non-stream MALFORMED-200/empty_choices false positive on `cc/` routes.
|
||||
*
|
||||
* A content-less-but-valid Claude body — thinking-only (with no visible text AND
|
||||
* no signature), redacted_thinking-only, or a truncated extended-thinking-only
|
||||
* stream cut before any text/signature landed — must be treated as VALID output,
|
||||
* not flagged as `empty_choices` (which became a false 502 / BAD_GATEWAY).
|
||||
*
|
||||
* Root cause (plan-file): the Claude Code OAuth subscription upstream can truncate
|
||||
* long large-input+large-output generations around the ~3-min turn boundary; the
|
||||
* non-stream path's `detectMalformedNonStream` then misclassified the resulting
|
||||
* content-less/thinking-only Claude body as `empty_choices`. The guard may only
|
||||
* fire for a genuinely malformed upstream response — a non-200 or a truly empty
|
||||
* *terminal* completion (terminal stop_reason with no usable output).
|
||||
*
|
||||
* Live note: the exact large-recvBytes+empty signature (33–65KB) needs a live VPS
|
||||
* capture to confirm the upstream truncation; this test encodes the
|
||||
* offline-reproducible mechanism (content-less thinking/redacted bodies), which
|
||||
* failed to `empty_choices` on the unfixed code and must pass after the fix.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts";
|
||||
|
||||
const claudeMsg = (content: unknown[], stopReason = "end_turn") => ({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg_x",
|
||||
model: "claude-sonnet-4-5",
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
usage: { input_tokens: 30000, output_tokens: 120 },
|
||||
});
|
||||
|
||||
// ── Content-less-but-valid Claude bodies must NOT be empty_choices ───────────
|
||||
|
||||
test("#9971 content-less thinking-only body (no text, no signature) is valid output", () => {
|
||||
// Truncated extended-thinking stream: a thinking block arrived but the model
|
||||
// never emitted its final text (and was cut before producing a signature).
|
||||
const body = claudeMsg([{ type: "thinking", thinking: "", signature: "" }], "");
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("#9971 redacted_thinking-only body is valid output", () => {
|
||||
// OAuth-style redacted footprint: the control plane suppresses the raw thinking
|
||||
// text, leaving only a redacted_thinking marker — still a valid completion.
|
||||
const body = claudeMsg([{ type: "redacted_thinking", data: "" }]);
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("#9971 thinking-only body with visible thinking text is valid output", () => {
|
||||
const body = claudeMsg([
|
||||
{ type: "thinking", thinking: "working through the request", signature: "" },
|
||||
]);
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("#9971 functional/structural tool_use body is valid output", () => {
|
||||
const body = claudeMsg([
|
||||
{ type: "tool_use", id: "toolu_1", name: "bash", input: { command: "ls" } },
|
||||
]);
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
// ── Genuinely malformed / truly-empty terminal bodies must STILL be flagged ──
|
||||
|
||||
test("#9971 genuinely empty terminal content:[] is still flagged", () => {
|
||||
// Terminal stop_reason + no blocks at all = a truly empty completion.
|
||||
assert.equal(detectMalformedNonStream(claudeMsg([], "end_turn")), "empty_choices");
|
||||
});
|
||||
|
||||
test("#9971 terminal '(empty response)' text sentinel is still flagged", () => {
|
||||
// The OpenAI->Claude converter's sentinel for an upstream that produced no
|
||||
// content: a terminal body carrying only that sentinel is genuinely empty.
|
||||
const body = claudeMsg([{ type: "text", text: "(empty response)" }], "end_turn");
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
test("#9971 truncated non-terminal empty body is valid (no false 502)", () => {
|
||||
// Upstream cut mid-turn before a stop_reason landed: not a terminal completion,
|
||||
// so the guard must not fire even though there is no output block yet.
|
||||
const body = claudeMsg([], "");
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
Reference in New Issue
Block a user