diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 8ee21ac5..d1b0dbae 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -336,13 +336,21 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio /** * 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) { if (!inner || typeof inner !== "string") return false; const lowerMsg = inner.toLowerCase(); - // Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field - return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl"); + if (lowerMsg.includes("pricingurl")) return true; + // 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; try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; } - - const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; - const inner = typeof envelope.body === "string" ? envelope.body : ""; - + // statusCodeValue is documented numeric, but accept numeric strings defensively. + const statusVal = Number(envelope.statusCodeValue) || 200; + const inner = typeof envelope.body === "string" + ? envelope.body + : envelope.body != null ? JSON.stringify(envelope.body) : ""; if (statusVal !== 200 && isBillingBlock(inner)) { 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 * 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; const decoder = new TextDecoder(); @@ -448,11 +457,35 @@ async function wrapQoderSSE(response, model) { let envelope; 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" ? envelope.body : envelope.body != null ? JSON.stringify(envelope.body) : ""; 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 errChunk = JSON.stringify({ id: `qoder-error-${Date.now()}`, @@ -676,7 +709,7 @@ export class QoderExecutor extends BaseExecutor { 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 }; } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index cc7c9aac..800c92a7 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -295,8 +295,16 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta const parsed = parseSSEToOpenAIResponse(sseText, model); if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); 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( - HTTP_STATUS.BAD_GATEWAY, + status, parsed.error.message || "Upstream SSE stream failed" ); } diff --git a/tests/unit/qoder-billing.test.js b/tests/unit/qoder-billing.test.js index f97a1293..d7c5482e 100644 --- a/tests/unit/qoder-billing.test.js +++ b/tests/unit/qoder-billing.test.js @@ -82,6 +82,148 @@ describe("wrapQoderSSE billing detection", () => { 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 () => { const billingEnv = JSON.stringify({ statusCodeValue: 402,