Skip to content
18 changes: 16 additions & 2 deletions open-sse/config/errorConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,38 @@ const COOLDOWN = {
/**
* Unified error classification rules.
* Checked top-to-bottom: text rules first (by order), then status rules.
* Each rule: { text?, status?, cooldownMs?, backoff? }
* Each rule: { text?, status?, cooldownMs?, backoff?, pass? }
* - text: substring match (case-insensitive) on error message
* - status: HTTP status code match
* - cooldownMs: fixed cooldown duration
* - backoff: true = use exponential backoff (rate limit)
* - pass: true = client-side error, do NOT lock the account (shouldFallback: false)
*/
export const ERROR_RULES = [
// --- Text-based rules (checked first, order = priority) ---
{ text: "no credentials", cooldownMs: COOLDOWN.long },
{ text: "request not allowed", cooldownMs: COOLDOWN.short },
{ text: "improperly formed request", cooldownMs: COOLDOWN.long },
// Client-side request errors: request is invalid / malformed β€” no account can fix this, don't lock
{ text: "invalid_request_error", pass: true },
{ text: "invalid_request", pass: true },
{ text: "improperly formed request", pass: true },
{ text: "bad request", pass: true },
{ text: "unsupported parameter", pass: true },
{ text: "unsupported_parameter", pass: true },
{ text: "invalid parameter", pass: true },
{ text: "invalid_parameter", pass: true },
{ text: "maximum context length", pass: true },
{ text: "context_length_exceeded", pass: true },
{ text: "prompt is too long", pass: true },
{ text: "exceeds the limit", pass: true },
{ text: "rate limit", backoff: true },
{ text: "too many requests", backoff: true },
{ text: "quota exceeded", backoff: true },
{ text: "capacity", backoff: true },
{ text: "overloaded", backoff: true },

// --- Status-based rules (fallback when text doesn't match) ---
{ status: 400, pass: true },
{ status: 401, cooldownMs: COOLDOWN.long },
{ status: 402, cooldownMs: COOLDOWN.long },
{ status: 403, cooldownMs: COOLDOWN.long },
Expand Down
1 change: 1 addition & 0 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "",
strictProxy: credentials?.providerSpecificData?.strictProxy === true,
};

if (proxyOptions.vercelRelayUrl) {
Expand Down
152 changes: 141 additions & 11 deletions open-sse/handlers/chatCore/streamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,21 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
* Handle streaming response β€” pipe provider SSE through transform stream to client.
*/
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}

// When upstream returns HTML/text instead of SSE (e.g. Cloudflare 5xx error
// page), piping it through the SSE transform stream causes Next.js
// "failed to pipe response" and crashes the chat router. Read the body,
// pull a short human-readable message from the <title>, sanitize it, and
// return a clean JSON error instead. The message is stripped of HTML tags
// and clamped so untrusted upstream text never reaches the client verbatim
// (the UI may render error.message as HTML).
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
const upstreamContentType = (providerResponse.headers?.get?.('content-type') || '').toLowerCase();
if (
upstreamContentType &&
!upstreamContentType.includes('text/event-stream') &&
!upstreamContentType.includes('application/json') &&
!upstreamContentType.includes('application/x-ndjson') &&
!upstreamContentType.includes('application/stream+json')
) {
const bodyText = await providerResponse.text().catch(() => '');
const titleMatch = bodyText.match(/<title>([^<]+)<\/title>/i);
const sanitizedTitle = (titleMatch?.[1] || '').replace(/<[^>]*>/g, '').replace(/[\r\n]+/g, ' ').trim().slice(0, 160);
Expand All @@ -72,20 +70,152 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`));
return {
success: false,
status,
error: shortMsg,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}

// First-valid-event gate: buffer the first chunk from upstream before confirming success.
// This prevents empty streams (0 bytes) or immediate error objects disguised as 200 OK
// from falsely clearing account errors or committing an unusable stream to the client.
if (!providerResponse.body) {
const status = 502;
const shortMsg = "Upstream returned no response body";
if (log?.errorLine) log.errorLine(reqTag, "βœ—", `BLOCKED ${status} Β· ${provider}/${model} Β· ${shortMsg}`);
streamController?.handleError?.(new Error(shortMsg));
return {
success: false,
status,
error: shortMsg,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}

let reader = null;
let firstChunk = null;
try {
reader = providerResponse.body.getReader();
const { done, value } = await reader.read();
if (done || !value || value.length === 0) {
try { reader.releaseLock?.(); } catch {}
const status = 502;
const shortMsg = "Upstream stream ended before a valid event (empty stream)";
if (log?.errorLine) log.errorLine(reqTag, "βœ—", `BLOCKED ${status} Β· ${provider}/${model} Β· ${shortMsg}`);
streamController?.handleError?.(new Error(shortMsg));
return {
success: false,
status,
error: shortMsg,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}
firstChunk = value;
} catch (readErr) {
const status = 502;
const shortMsg = `Upstream stream read error: ${readErr?.message || readErr}`;
if (log?.errorLine) log.errorLine(reqTag, "βœ—", `BLOCKED ${status} Β· ${provider}/${model} Β· ${shortMsg}`);
streamController?.handleError?.(readErr);
return {
success: false,
status,
error: shortMsg,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${shortMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}

// Check if first chunk contains a structured JSON error object returned as 200 OK
if (firstChunk) {
const chunkStr = new TextDecoder().decode(firstChunk);
const trimmed = chunkStr.trim();
if (trimmed.startsWith("{") && (trimmed.includes('"error"') || trimmed.includes('"error_code"') || trimmed.includes('"detail"'))) {
try {
const parsed = JSON.parse(trimmed);
if (parsed.error || parsed.error_code || (parsed.detail && !parsed.choices && !parsed.delta)) {
const errMsg = typeof parsed.error === "string"
? parsed.error
: parsed.error?.message || parsed.error_msg || parsed.detail || JSON.stringify(parsed);
const rawStatus = parsed.error?.status || parsed.status || 502;
const status = typeof rawStatus === "number" && rawStatus >= 400 && rawStatus < 600 ? rawStatus : 502;
if (log?.errorLine) log.errorLine(reqTag, "βœ—", `ERROR ${status} Β· ${provider}/${model} Β· ${errMsg}`);
streamController?.handleError?.(new Error(errMsg));
return {
success: false,
status,
error: errMsg,
response: new Response(JSON.stringify({ error: { message: `[${status}]: ${errMsg}` } }), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
}),
};
}
} catch {
// Not a pure JSON error object, treat as valid streaming content
}
}
}

// First valid event/chunk confirmed β€” notify request success callback
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}

// Reconstruct ReadableStream with the buffered firstChunk prepended
let responseBodyStream = providerResponse.body;
if (reader && firstChunk) {
let yieldedFirst = false;
responseBodyStream = new ReadableStream({
async pull(controller) {
if (!yieldedFirst) {
yieldedFirst = true;
controller.enqueue(firstChunk);
return;
}
try {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
} catch (err) {
controller.error(err);
}
},
cancel(reason) {
return reader.cancel(reason);
}
});
}

const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey });

// Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS;
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);
const wrappedResponse = {
...providerResponse,
body: responseBodyStream,
headers: providerResponse.headers,
};
const transformedBody = pipeWithDisconnect(wrappedResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs);

saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
Expand Down
2 changes: 2 additions & 0 deletions open-sse/providers/registry/nvidia.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default {
transport: {
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
validateUrl: "https://integrate.api.nvidia.com/v1/models",
stallTimeoutMs: 600000, // 10 min β€” reasoning models (step-3.7-flash, nemotron-ultra) can have long silent thinking phases; observed >5 min on step-3.7-flash
},
models: [
{ id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" },
Expand All @@ -28,6 +29,7 @@ export default {
{ id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" },
{ id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" },
{ id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra" },
{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", kind: "embedding" },
{ id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B", params: ["language"], kind: "stt" },
Expand Down
4 changes: 4 additions & 0 deletions open-sse/services/accountFallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
for (const rule of ERROR_RULES) {
// Text-based rule: match substring in error message
if (rule.text && lowerError && lowerError.includes(rule.text)) {
// pass: true = client-side error, do not lock the account
if (rule.pass) return { shouldFallback: false, cooldownMs: 0 };
if (rule.backoff) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
Expand All @@ -37,6 +39,8 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {

// Status-based rule: match HTTP status code
if (rule.status && rule.status === status) {
// pass: true = client-side error, do not lock the account
if (rule.pass) return { shouldFallback: false, cooldownMs: 0 };
if (rule.backoff) {
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel };
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
"description": "9Router web dashboard",
"private": true,
"scripts": {
"dev": "next dev --port 20127",
"dev:webpack": "next dev --webpack --port 20127",
"dev": "next dev --port 20128",
"dev:webpack": "next dev --webpack --port 20128",
"build": "next build --webpack",
"postbuild": "node scripts/copy-standalone-assets.mjs",
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
"start": "node custom-server.js --port 20127",
"dev:bun": "bun --bun next dev --webpack --port 20127",
"start": "node custom-server.js --port 20128",
"dev:bun": "bun --bun next dev --webpack --port 20128",
"build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/custom-server.js",
"cli:pack": "npm --prefix cli run pack:cli",
Expand Down
2 changes: 2 additions & 0 deletions src/sse/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
connectionNoProxy: resolvedProxy.connectionNoProxy,
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
strictProxy: resolvedProxy.strictProxy === true,
},
};
}
Expand Down Expand Up @@ -193,6 +194,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
connectionNoProxy: resolvedProxy.connectionNoProxy,
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
strictProxy: resolvedProxy.strictProxy === true,
},
connectionId: connection.id,
// Include current status for optimization check
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/account-fallback-rules.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { checkFallbackError, getQuotaCooldown } from "open-sse/services/accountFallback.js";
import { TRANSIENT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";

describe("accountFallback: checkFallbackError rules", () => {
describe("pass: true client-side errors (context length exceeded)", () => {
it("does not fallback or lock account when message contains 'maximum context length'", () => {
const res = checkFallbackError(400, "This model's maximum context length is 128000 tokens. However, your messages resulted in 130000 tokens.");
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account when message contains 'context_length_exceeded'", () => {
const res = checkFallbackError(400, JSON.stringify({ error: { code: "context_length_exceeded", message: "Too many tokens" } }));
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account when message contains 'prompt is too long'", () => {
const res = checkFallbackError(400, "The prompt is too long for the requested model.");
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account on status 400 (Bad Request)", () => {
const res = checkFallbackError(400, "Bad Request");
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account when message contains 'invalid_request_error'", () => {
const res = checkFallbackError(400, JSON.stringify({ error: { type: "invalid_request_error", message: "tools[0].function.parameters is invalid" } }));
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account when message contains 'improperly formed request'", () => {
const res = checkFallbackError(400, "Improperly formed request");
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});

it("does not fallback or lock account when message contains 'unsupported parameter'", () => {
const res = checkFallbackError(400, "Unsupported parameter: reasoning_effort");
expect(res).toEqual({ shouldFallback: false, cooldownMs: 0 });
});
});

describe("standard rate-limit and auth error rules", () => {
it("handles 401 unauthorized with fixed long cooldown", () => {
const res = checkFallbackError(401, "Unauthorized");
expect(res.shouldFallback).toBe(true);
expect(res.cooldownMs).toBeGreaterThan(0);
});

it("handles rate limit (429) with exponential backoff", () => {
const res1 = checkFallbackError(429, "Too many requests", 0);
expect(res1.shouldFallback).toBe(true);
expect(res1.newBackoffLevel).toBe(1);
expect(res1.cooldownMs).toBe(getQuotaCooldown(1));

const res2 = checkFallbackError(429, "rate limit", 1);
expect(res2.shouldFallback).toBe(true);
expect(res2.newBackoffLevel).toBe(2);
expect(res2.cooldownMs).toBe(getQuotaCooldown(2));
});

it("returns transient cooldown for unknown errors", () => {
const res = checkFallbackError(500, "Internal server glitch");
expect(res).toEqual({ shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS });
});
});
});
Loading