fix(qoder): handle code 110 billing blocks and preserve SSE error status
- Match code 110 (billing daily count exceeded) alongside 112/10605/pricingUrl in isBillingBlock, parsing JSON safely and accepting numeric/string codes - Accept numeric strings for statusCodeValue and object bodies in envelope peek - Emit structured 403 quota error chunk instead of synthetic assistant text when a billing envelope appears mid-stream - Preserve upstream HTTP status in handleForcedSSEToJson when error chunk carries a valid 400-599 status - Add unit tests for code-110 detection, mid-stream billing envelopes, and false-positive guard
This commit is contained in:
@@ -336,13 +336,21 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a qoder error message indicates a billing/quota block.
|
* Check if a qoder error message indicates a billing/quota block.
|
||||||
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
|
* Signatures: code 110 (billing daily count exceeded), code 112 (quota
|
||||||
|
* exhausted), code 10605 (queue throttle), pricingUrl field.
|
||||||
*/
|
*/
|
||||||
function isBillingBlock(inner) {
|
function isBillingBlock(inner) {
|
||||||
if (!inner || typeof inner !== "string") return false;
|
if (!inner || typeof inner !== "string") return false;
|
||||||
const lowerMsg = inner.toLowerCase();
|
const lowerMsg = inner.toLowerCase();
|
||||||
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
|
if (lowerMsg.includes("pricingurl")) return true;
|
||||||
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
|
// Parsed code preferred over regex: matches numeric or string "110"/"112"/"10605".
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inner);
|
||||||
|
const code = String(parsed?.code ?? "");
|
||||||
|
if (code === "110" || code === "112" || code === "10605") return true;
|
||||||
|
} catch { /* not JSON — fall through to legacy shape match */ }
|
||||||
|
// Match legacy exact shapes: {"code":"112",...}, {"code":"10605",...}.
|
||||||
|
return /"code"\s*:\s*"(112|10605)"/.test(inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,10 +377,11 @@ async function peekFirstQoderFrame(reader, decoder) {
|
|||||||
|
|
||||||
let envelope;
|
let envelope;
|
||||||
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
|
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
|
||||||
|
// statusCodeValue is documented numeric, but accept numeric strings defensively.
|
||||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
const statusVal = Number(envelope.statusCodeValue) || 200;
|
||||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
const inner = typeof envelope.body === "string"
|
||||||
|
? envelope.body
|
||||||
|
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
||||||
if (statusVal !== 200 && isBillingBlock(inner)) {
|
if (statusVal !== 200 && isBillingBlock(inner)) {
|
||||||
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
||||||
}
|
}
|
||||||
@@ -404,7 +413,7 @@ async function peekFirstQoderFrame(reader, decoder) {
|
|||||||
* If detected, return 403 response so chatCore marks connection unavailable
|
* If detected, return 403 response so chatCore marks connection unavailable
|
||||||
* and triggers combo fallback instead of leaking error text into chat.
|
* and triggers combo fallback instead of leaking error text into chat.
|
||||||
*/
|
*/
|
||||||
async function wrapQoderSSE(response, model) {
|
async function wrapQoderSSE(response, model, log = null) {
|
||||||
if (!response.ok || !response.body) return response;
|
if (!response.ok || !response.body) return response;
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -448,11 +457,35 @@ async function wrapQoderSSE(response, model) {
|
|||||||
|
|
||||||
let envelope;
|
let envelope;
|
||||||
try { envelope = JSON.parse(data); } catch { return; }
|
try { envelope = JSON.parse(data); } catch { return; }
|
||||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
const statusVal = Number(envelope.statusCodeValue) || 200;
|
||||||
const inner = typeof envelope.body === "string"
|
const inner = typeof envelope.body === "string"
|
||||||
? envelope.body
|
? envelope.body
|
||||||
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
||||||
if (statusVal !== 200) {
|
if (statusVal !== 200) {
|
||||||
|
// Always visible: error envelopes are rare and worth one stderr line at
|
||||||
|
// any log level (response bodies carry no credentials).
|
||||||
|
try {
|
||||||
|
console.error(`[QODER] error envelope status=${statusVal} statusType=${typeof envelope.statusCodeValue} bodyType=${typeof envelope.body} body=${truncate(inner, 300)}`);
|
||||||
|
} catch { /* logging must not break the stream */ }
|
||||||
|
if (isBillingBlock(inner)) {
|
||||||
|
// Billing/quota envelope at any stream position (peek only covers the
|
||||||
|
// first frame): emit a structured error chunk, not fake assistant text.
|
||||||
|
// parseSSEToOpenAIResponse understands chunk.error and turns it into a
|
||||||
|
// non-200 result so chat.js locks the model and falls back. Streaming
|
||||||
|
// clients receive a real SSE error instead of "[qoder error ...]" text.
|
||||||
|
const errObj = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: inner || `qoder billing block (${statusVal})`,
|
||||||
|
code: "qoder_billing_block",
|
||||||
|
status: 403,
|
||||||
|
type: "quota_error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
controller.enqueue(encoder.encode(`data: ${errObj}\n\n`));
|
||||||
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
|
doneEmitted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = inner || `upstream status ${statusVal}`;
|
const msg = inner || `upstream status ${statusVal}`;
|
||||||
const errChunk = JSON.stringify({
|
const errChunk = JSON.stringify({
|
||||||
id: `qoder-error-${Date.now()}`,
|
id: `qoder-error-${Date.now()}`,
|
||||||
@@ -676,7 +709,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
return { response, url, headers, transformedBody: payload };
|
return { response, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
|
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, log);
|
||||||
return { response: wrapped, url, headers, transformedBody: payload };
|
return { response: wrapped, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -295,8 +295,16 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
|
|||||||
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||||
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
|
// Structured error chunks may carry the real upstream status (e.g. the
|
||||||
|
// Qoder executor emits status 403 for billing envelopes). Preserve it so
|
||||||
|
// the account loop locks/falls back on the right status instead of a
|
||||||
|
// generic 502. Anything outside 400-599 still maps to 502.
|
||||||
|
const upstreamStatus = Number(parsed.error.status);
|
||||||
|
const status = Number.isInteger(upstreamStatus) && upstreamStatus >= 400 && upstreamStatus <= 599
|
||||||
|
? upstreamStatus
|
||||||
|
: HTTP_STATUS.BAD_GATEWAY;
|
||||||
return createErrorResult(
|
return createErrorResult(
|
||||||
HTTP_STATUS.BAD_GATEWAY,
|
status,
|
||||||
parsed.error.message || "Upstream SSE stream failed"
|
parsed.error.message || "Upstream SSE stream failed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,148 @@ describe("wrapQoderSSE billing detection", () => {
|
|||||||
expect(wrapped.ok).toBe(false);
|
expect(wrapped.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 403 response when first frame is billing block (code 110 string)", async () => {
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 403,
|
||||||
|
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||||
|
});
|
||||||
|
const upstream = `data: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
expect(wrapped.status).toBe(403);
|
||||||
|
expect(wrapped.ok).toBe(false);
|
||||||
|
const json = await wrapped.json();
|
||||||
|
expect(json.error.message).toContain("Billing daily count exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 response when first frame is billing block (code 110 numeric)", async () => {
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 403,
|
||||||
|
body: '{"code":110,"message":"Billing daily count exceeded"}',
|
||||||
|
});
|
||||||
|
const upstream = `data: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
expect(wrapped.status).toBe(403);
|
||||||
|
expect(wrapped.ok).toBe(false);
|
||||||
|
});
|
||||||
|
it("returns 403 response when statusCodeValue is string \"403\" (code 110)", async () => {
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: "403",
|
||||||
|
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||||
|
});
|
||||||
|
const upstream = `data: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
expect(wrapped.status).toBe(403);
|
||||||
|
expect(wrapped.ok).toBe(false);
|
||||||
|
const json = await wrapped.json();
|
||||||
|
expect(json.error.message).toContain("Billing daily count exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured 403 error chunk for object-body billing after a data frame (peek miss)", async () => {
|
||||||
|
const okEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 200,
|
||||||
|
body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }),
|
||||||
|
});
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 403,
|
||||||
|
body: { code: "110", message: "Billing daily count exceeded" },
|
||||||
|
});
|
||||||
|
const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
const reader = wrapped.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
buf += decoder.decode();
|
||||||
|
|
||||||
|
expect(buf).not.toContain("[qoder error");
|
||||||
|
const errLine = buf.split("\n").find((l) => l.includes('"error"'));
|
||||||
|
expect(errLine).toBeDefined();
|
||||||
|
const errChunk = JSON.parse(errLine.slice(5).trim());
|
||||||
|
expect(errChunk.error.status).toBe(403);
|
||||||
|
expect(errChunk.error.message).toContain("Billing daily count exceeded");
|
||||||
|
expect(errChunk.choices).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("does not treat legitimate assistant text mentioning code 110 as billing", async () => {
|
||||||
|
const inner = JSON.stringify({
|
||||||
|
choices: [{ delta: { content: "error 110 means billing daily count exceeded in docs" } }],
|
||||||
|
});
|
||||||
|
const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner });
|
||||||
|
const upstream = `data: ${successEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
expect(wrapped.status).toBe(200);
|
||||||
|
const reader = wrapped.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
buf += decoder.decode();
|
||||||
|
|
||||||
|
expect(buf).toContain("billing daily count exceeded");
|
||||||
|
expect(buf).not.toContain("[qoder error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured 403 error chunk for billing envelope after a data frame (peek miss)", async () => {
|
||||||
|
const okEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 200,
|
||||||
|
body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }),
|
||||||
|
});
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 403,
|
||||||
|
body: '{"code":"110","message":"Billing daily count exceeded"}',
|
||||||
|
});
|
||||||
|
const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
const reader = wrapped.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
buf += decoder.decode();
|
||||||
|
|
||||||
|
expect(buf).not.toContain("[qoder error");
|
||||||
|
const errLine = buf.split("\n").find((l) => l.includes('"error"'));
|
||||||
|
expect(errLine).toBeDefined();
|
||||||
|
const errChunk = JSON.parse(errLine.slice(5).trim());
|
||||||
|
expect(errChunk.error.status).toBe(403);
|
||||||
|
expect(errChunk.error.message).toContain("Billing daily count exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured 403 error chunk for object-body billing envelope (peek miss)", async () => {
|
||||||
|
const billingEnv = JSON.stringify({
|
||||||
|
statusCodeValue: 403,
|
||||||
|
body: { code: "110", message: "Billing daily count exceeded" },
|
||||||
|
});
|
||||||
|
const upstream = `data: ${billingEnv}\n\n`;
|
||||||
|
|
||||||
|
const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel");
|
||||||
|
|
||||||
|
expect(wrapped.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 403 response when first frame has pricingUrl", async () => {
|
it("returns 403 response when first frame has pricingUrl", async () => {
|
||||||
const billingEnv = JSON.stringify({
|
const billingEnv = JSON.stringify({
|
||||||
statusCodeValue: 402,
|
statusCodeValue: 402,
|
||||||
|
|||||||
Reference in New Issue
Block a user