fix(qoder): detect billing blocks at stream start, return 403 for failover

Peek the first SSE frame in wrapQoderSSE; if statusCodeValue != 200 and the
body carries a billing signature (code 112/10605 or pricingUrl), return a
synthetic 403 so chatCore marks the connection unavailable and triggers
combo/account fallback instead of leaking the error text into chat.

wrapQoderSSE becomes async; consumed peek bytes are re-processed in the
stream start() seed loop so nothing is dropped.
This commit is contained in:
brimob-sowax
2026-08-13 11:42:39 +07:00
committed by decolua
parent 10a923da11
commit 80afb59907
3 changed files with 246 additions and 13 deletions

View File

@@ -215,6 +215,52 @@ 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.
*/
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");
}
/**
* Peek the first SSE frame to detect billing errors before piping.
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
* byte read so far (including the peeked line) so the caller can re-process
* it and nothing is dropped from the stream.
*/
async function peekFirstQoderFrame(reader, decoder) {
let consumed = "";
while (true) {
const { done, value } = await reader.read();
if (done) return { isBilling: false, consumed, upstreamDone: true };
consumed += decoder.decode(value, { stream: true });
const nl = consumed.indexOf("\n");
if (nl === -1) continue; // need a full line first
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trimStart();
if (data === "[DONE]") return { isBilling: false, consumed };
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 : "";
if (statusVal !== 200 && isBillingBlock(inner)) {
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
}
return { isBilling: false, consumed };
}
}
/**
* Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain
* OpenAI SSE chunks the rest of the chatCore pipeline understands.
@@ -229,16 +275,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
* response.text() which hangs until the socket closes — so on terminal
* events we cancel the upstream reader and close our stream immediately.
*
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
* If detected, return 403 response so chatCore marks connection unavailable
* and triggers combo fallback instead of leaking error text into chat.
*/
function wrapQoderSSE(response, model) {
async function wrapQoderSSE(response, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
let doneEmitted = false;
const reader = response.body.getReader();
// Peek first frame to detect billing block
const peek = await peekFirstQoderFrame(reader, decoder);
if (peek?.isBilling) {
// Billing block detected — return 403 so chatCore fails this connection
await reader.cancel().catch(() => {});
return new Response(
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
// Normal flow: re-process every byte the peek consumed, then continue.
let buffer = peek.consumed || "";
const upstreamDrained = peek.upstreamDone === true;
const encoder = new TextEncoder();
let doneEmitted = false;
// Process one already-extracted SSE line (no trailing newline).
const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim();
@@ -287,7 +351,28 @@ function wrapQoderSSE(response, model) {
// enqueueing would never be re-invoked, hanging consumers like .text().
async start(controller) {
try {
while (!doneEmitted) {
// Drain whatever the peek already pulled off the socket first.
let nlSeed;
while ((nlSeed = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nlSeed);
buffer = buffer.slice(nlSeed + 1);
processLine(line, controller);
if (doneEmitted) {
await reader.cancel().catch(() => {});
controller.close();
return;
}
}
if (upstreamDrained) {
// Peek hit end-of-stream: flush any trailing partial line.
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
}
while (!doneEmitted && !upstreamDrained) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
@@ -472,7 +557,7 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`);
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
return { response: wrapped, url, headers, transformedBody: payload };
}
@@ -496,4 +581,5 @@ export const __test__ = {
normalizeMessages,
wrapQoderSSE,
buildQoderRequestBody,
isBillingBlock,
};