Files
OmniRoute/open-sse/utils/error.ts
Diego Rodrigues de Sa e Souza 5be61dbcd6 feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path (#8622)
* feat(sse): selective upstream 4xx error passthrough util

* feat(sse): relay upstream 4xx error bodies verbatim on the Anthropic request path

createErrorResult() gains an opt-in 7th param opts.passthrough; when set and
the upstream body is eligible (per shouldPassthroughUpstreamError), the
returned response body is the upstream 4xx body verbatim instead of the
sanitized wrapper. Internal classification fields (error/rawMessage/
errorType/errorCode) are never affected.

Wired into chatCore.ts's 7 upstream-error createErrorResult call sites
(model_unavailable / context_overflow / generic upstream-error branches),
gated on sourceFormat === FORMATS.CLAUDE so only /v1/messages requests get
the verbatim body — OpenAI-format paths are unchanged.

* test(quality): register upstream-error-passthrough in stryker tap.testFiles

`tests/unit/upstream-error-passthrough.test.ts` covers `open-sse/utils/error.ts`,
an instrumented module, so `check:mutation-test-coverage --strict` requires it in
`tap.testFiles` — the gate flagged the drift on this PR.
2026-07-26 16:30:52 -03:00

786 lines
28 KiB
TypeScript

import { CORS_HEADERS } from "./cors.ts";
import { unwrapClinepassEnvelope } from "./clinepassEnvelope.ts";
import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
import type { ModelCooldownErrorPayload } from "@/types";
import { buildPassthroughErrorResponse } from "./upstreamErrorPassthrough.ts";
/**
* Sanitize an error message to prevent stack trace exposure in API responses.
* Strips stack traces, file paths, and absolute Windows/POSIX paths from
* error messages before they reach the client.
*/
interface ErrorResponseBody {
error: {
message: string;
type?: string;
code?: string;
};
upstream_details?: Record<string, unknown> | null; // sanitized upstream provider body
}
// Length cap protects against pathological inputs even before tokenization.
const MAX_ERROR_LEN = 4096;
const SOURCE_EXT = ["ts", "tsx", "js", "jsx", "mjs", "cjs"] as const;
function looksLikeAbsolutePath(tok: string): boolean {
// POSIX: "/<...>.ts" (optionally followed by :line[:col]).
// Windows: "C:\<...>.ts" or "C:/<...>.ts".
if (tok.length < 4 || tok.length > 2048) return false;
const isPosix = tok.charCodeAt(0) === 0x2f; // '/'
const isWindows = tok.length > 2 && tok.charCodeAt(1) === 0x3a && /[A-Za-z]/.test(tok[0]);
if (!isPosix && !isWindows) return false;
const dot = tok.lastIndexOf(".");
if (dot <= 0 || dot === tok.length - 1) return false;
const ext = tok
.slice(dot + 1)
.split(":", 1)[0]
.toLowerCase();
return (SOURCE_EXT as readonly string[]).includes(ext);
}
function redactSensitiveErrorText(value: string): string {
return value
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*["'])[^"']*(["'])/gi,
"$1[REDACTED]$2"
)
.replace(
/(["']?(?:api[_-]?key|access[_-]?token|authorization|cookie|secret)["']?\s*[:=]\s*)[^"',\s}]+/gi,
"$1[REDACTED]"
);
}
/**
* Strip stack-trace tail and absolute source paths from error messages.
*
* Implemented via simple whitespace tokenization (linear time) instead of a
* single complex regex, so CodeQL `js/polynomial-redos` stays clean even when
* the runtime error message is attacker-controlled.
*/
export function sanitizeErrorMessage(message: unknown): string {
let str = typeof message === "string" ? message : String(message ?? "");
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
const nl = str.indexOf("\n");
const firstLine = nl >= 0 ? str.slice(0, nl) : str;
// Preserve original whitespace by splitting on captured separator.
const parts = firstLine.split(/(\s+)/);
for (let i = 0; i < parts.length; i++) {
if (looksLikeAbsolutePath(parts[i])) parts[i] = "<path>";
}
return redactSensitiveErrorText(parts.join(""));
}
const BLOCKED_KEYS =
/stack|trace|path|file|cwd|dir|password|secret|token|key|authorization|cookie/i;
const MAX_DEPTH = 4;
/**
* Recursively sanitize an arbitrary JSON value from an upstream provider body.
* - Strings: run through sanitizeErrorMessage (strips stacks + absolute paths).
* - Keys matching BLOCKED_KEYS are dropped (credential/path guards).
* - Depth capped at MAX_DEPTH to prevent pathological nesting.
* - Arrays capped at 32 elements.
* - Returns null for null/undefined/non-JSON-serializable values.
*/
export function sanitizeUpstreamDetails(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return "[truncated]";
if (value === null || value === undefined) return null;
if (typeof value === "string") return sanitizeErrorMessage(value);
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.slice(0, 32).map((v) => sanitizeUpstreamDetails(v, depth + 1));
}
if (typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (BLOCKED_KEYS.test(k)) continue;
out[k] = sanitizeUpstreamDetails(v, depth + 1);
}
return out;
}
return null;
}
/** Optional caller classification; when set, wins over status-derived defaults. */
export type ErrorBodyClassification = {
type?: string;
code?: string;
};
/**
* Build OpenAI-compatible error response body. Message is always sanitized
* so callers do not need to remember to strip stack traces themselves.
* Optional third argument `upstreamDetails` (raw parsed provider body) is
* sanitized by sanitizeUpstreamDetails before inclusion as `upstream_details`.
* Optional fourth argument `classification` preserves an explicit type/code
* instead of re-deriving both from the status-code table.
*/
export function buildErrorBody(
statusCode: number,
message: string,
upstreamDetails?: unknown,
classification?: ErrorBodyClassification
): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode);
const body: ErrorResponseBody = {
error: {
message: safeMessage,
type: classification?.type ?? errorInfo.type,
code: classification?.code ?? errorInfo.code,
},
};
if (upstreamDetails !== undefined && upstreamDetails !== null) {
const sanitized = sanitizeUpstreamDetails(upstreamDetails);
if (sanitized !== null && typeof sanitized === "object" && !Array.isArray(sanitized)) {
body.upstream_details = sanitized as Record<string, unknown>;
}
}
return body;
}
/**
* Sanitized auto-combo diagnostic trace surfaced on a combo terminal failure.
* Contains ONLY provider/model ids, enumerated reason codes, and counts — never
* keys, tokens, cookies, credentials, or upstream bodies. Fields are length- and
* count-capped so the projection is safe to place in HTTP headers too. (QA P0:
* "Add a sanitized combo diagnostic trace … candidate pool count, excluded
* provider/model reasons, selected attempt order, terminal failure summary.")
*/
export interface ComboExclusion {
provider: string;
model?: string;
reason: string;
}
/**
* Next-step suggestion surfaced when a combo cascade fails. Lets the client (e.g.
* the OpenCode plugin) auto-render an actionable hint in the TUI instead of an
* opaque "model stopped producing output" error — fixes the silent-stop pattern
* where the user has no way to recover a session without guessing. Whitelisted to
* a small set so the projection remains bounded.
*/
export type ComboRecoveryAction =
/** Cascade failed because every candidate is exhausted — try a different combo or `auto`. */
| "try-auto"
/** Upstream asks to retry after a cooldown window — wait, then retry the same combo. */
| "wait"
/** Transient failure (network, 5xx) — retry the same combo immediately. */
| "retry"
/** Cascade used every account of every provider — switch to a different combo entirely. */
| "switch-combo";
export interface ComboRecoveryHint {
/** Machine-readable action verb — consumed by clients to render a UI hint. */
action: ComboRecoveryAction;
/** Seconds the client should wait before retrying. Only meaningful when action="wait". */
retry_after_seconds?: number;
/** Human-readable next step — included verbatim in the error body for non-MCP clients. */
next_step: string;
}
export interface ComboExclusion {
provider: string;
model?: string;
reason: string;
}
export interface ComboDiagnostics {
poolSize: number;
attempted: number;
excluded: ComboExclusion[];
attemptOrder: Array<{ provider: string; model: string }>;
terminalReason: string;
/** Optional next-step hint — populated when the dispatcher can recommend a recovery action. */
recovery?: ComboRecoveryHint;
}
function clampDiagStr(v: unknown, max = 128): string {
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
}
/**
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
* (0-255) with "?" so header construction never throws. Only used for the
* literal header value; the JSON body keeps the original, unsanitized
* readable text via `sanitizeComboDiagnostics`.
*/
function toHeaderSafeAscii(v: string): string {
let out = "";
for (let i = 0; i < v.length; i++) {
const code = v.charCodeAt(i);
out += code > 255 ? "?" : v[i];
}
return out;
}
/**
* Whitelist sanitizer for the recovery hint. The `action` enum is a closed set;
* `retry_after_seconds` is clamped to a non-negative integer ≤ 3600; `next_step` is
* capped and stripped of CR/LF (would break header parsing). Returns undefined when
* no usable input was supplied so downstream code can branch cleanly on absence.
*/
const RECOVERY_ACTIONS = new Set<ComboRecoveryAction>([
"try-auto",
"wait",
"retry",
"switch-combo",
]);
export function sanitizeRecoveryHint(
r: ComboRecoveryHint | null | undefined
): ComboRecoveryHint | undefined {
if (!r || typeof r !== "object") return undefined;
const action = typeof r.action === "string" ? (r.action as ComboRecoveryAction) : null;
if (!action || !RECOVERY_ACTIONS.has(action)) return undefined;
// Reject empty OR whitespace-only next_step — the value must render usefully as a
// header and as a body field. A whitespace-only string would print as a blank hint.
const next_step = clampDiagStr(r.next_step, 200).trim();
if (!next_step) return undefined;
const hint: ComboRecoveryHint = { action, next_step };
if (typeof r.retry_after_seconds === "number" && Number.isFinite(r.retry_after_seconds)) {
hint.retry_after_seconds = Math.max(0, Math.min(3600, Math.floor(r.retry_after_seconds)));
}
return hint;
}
/**
* Whitelist projection — guarantees only id/reason string primitives + integer
* counts can escape, regardless of what the caller assembled. This is the secret
* containment boundary for the diagnostic trace.
*/
export function sanitizeComboDiagnostics(d: ComboDiagnostics): ComboDiagnostics {
const recovery = sanitizeRecoveryHint(d?.recovery);
const out: ComboDiagnostics = {
poolSize: Number.isFinite(d?.poolSize) ? d.poolSize : 0,
attempted: Number.isFinite(d?.attempted) ? d.attempted : 0,
excluded: (d?.excluded ?? []).slice(0, 64).map((e) => ({
provider: clampDiagStr(e?.provider, 64),
...(e?.model ? { model: clampDiagStr(e.model, 96) } : {}),
reason: clampDiagStr(e?.reason, 64),
})),
attemptOrder: (d?.attemptOrder ?? [])
.slice(0, 64)
.map((a) => ({ provider: clampDiagStr(a?.provider, 64), model: clampDiagStr(a?.model, 96) })),
terminalReason: clampDiagStr(d?.terminalReason, 200),
};
if (recovery) out.recovery = recovery;
return out;
}
/**
* errorResponse variant that attaches a sanitized combo diagnostic trace as BOTH
* `x-omniroute-combo-*` headers and a `diagnostics` field in the OpenAI-shaped
* error body (extra field — backward-compatible with standard error parsers).
* `opts.code`/`opts.type` override the status-derived defaults (e.g. to preserve
* the `ALL_ACCOUNTS_INACTIVE` code on the 503 terminal path). When the diagnostic
* carries a `recovery` hint it is mirrored as `x-omniroute-recovery-action` /
* `x-omniroute-recovery-next-step` / `x-omniroute-retry-after-seconds` headers and as a
* top-level `recovery_hint` field on the body so non-header-aware clients (curl,
* MCP tools, log scrapers) can also pick it up.
*/
export function errorResponseWithComboDiagnostics(
statusCode: number,
message: string,
diagnostics: ComboDiagnostics,
opts: { code?: string; type?: string } = {}
): Response {
const safe = sanitizeComboDiagnostics(diagnostics);
const body = buildErrorBody(statusCode, message) as ErrorResponseBody & {
diagnostics?: ComboDiagnostics;
recovery_hint?: ComboRecoveryHint;
};
if (opts.code) body.error.code = opts.code;
if (opts.type) body.error.type = opts.type;
body.diagnostics = safe;
if (safe.recovery) body.recovery_hint = safe.recovery;
const excludedHeader = toHeaderSafeAscii(
safe.excluded
.map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`)
.join(",")
.slice(0, 900)
);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-omniroute-combo-pool-size": String(safe.poolSize),
"x-omniroute-combo-attempted": String(safe.attempted),
"x-omniroute-combo-excluded": excludedHeader,
"x-omniroute-combo-terminal-reason": toHeaderSafeAscii(safe.terminalReason.slice(0, 200)),
};
if (safe.recovery) {
headers["x-omniroute-recovery-action"] = safe.recovery.action;
// Header limit of 128 chars — keep next_step compact for fast parsing.
// The body field carries the full 200-char value for richer display.
headers["x-omniroute-recovery-next-step"] = toHeaderSafeAscii(safe.recovery.next_step).slice(
0,
128
);
if (
typeof safe.recovery.retry_after_seconds === "number" &&
safe.recovery.retry_after_seconds > 0
) {
headers["x-omniroute-retry-after-seconds"] = String(safe.recovery.retry_after_seconds);
}
}
return new Response(JSON.stringify(body), {
status: statusCode,
headers,
});
}
/**
* Create error Response object (for non-streaming)
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @returns {Response} HTTP Response object
*/
export function errorResponse(statusCode: number, message: string): Response {
return new Response(JSON.stringify(buildErrorBody(statusCode, sanitizeErrorMessage(message))), {
status: statusCode,
headers: {
"Content-Type": "application/json",
},
});
}
/**
* Write error to SSE stream (for streaming)
* @param {WritableStreamDefaultWriter} writer - Stream writer
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
*/
export async function writeStreamError(
writer: WritableStreamDefaultWriter<Uint8Array>,
statusCode: number,
message: string
): Promise<void> {
const errorBody = buildErrorBody(statusCode, sanitizeErrorMessage(message));
const encoder = new TextEncoder();
await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`));
}
function normalizeRetryAfterSeconds(retryAfter?: string | number | Date | null): number {
if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) {
if (retryAfter > 0 && retryAfter < 1_000_000_000) {
return Math.max(Math.ceil(retryAfter), 1);
}
const retryTimeMs = new Date(retryAfter).getTime();
if (Number.isFinite(retryTimeMs)) {
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
}
}
if (retryAfter instanceof Date || typeof retryAfter === "string") {
const retryTimeMs = new Date(retryAfter).getTime();
if (Number.isFinite(retryTimeMs)) {
return Math.max(Math.ceil((retryTimeMs - Date.now()) / 1000), 1);
}
}
return 1;
}
/**
* Parse Antigravity error message to extract retry time
* Example: "You have exhausted your capacity on this model. Your quota will reset after 2h7m23s."
* @param {string} message - Error message
* @returns {number|null} Retry time in milliseconds, or null if not found
*/
export function parseAntigravityRetryTime(message: unknown): number | null {
if (typeof message !== "string") return null;
// Match patterns like: 2h7m23s, 5m30s, 45s, 1h20m, etc.
const match = message.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
if (!match) return null;
let totalMs = 0;
// Extract hours
if (match[1]) {
const hours = parseInt(match[1]);
totalMs += hours * 60 * 60 * 1000;
}
// Extract minutes
if (match[2]) {
const minutes = parseInt(match[2]);
totalMs += minutes * 60 * 1000;
}
// Extract seconds
if (match[3]) {
const seconds = parseInt(match[3]);
totalMs += seconds * 1000;
}
return totalMs > 0 ? totalMs : null;
}
/**
* Parse upstream provider error response
* @param {Response} response - Fetch response from provider
* @param {string} provider - Provider name (for Antigravity-specific parsing)
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
*/
export async function parseUpstreamError(response: Response, provider: string | null = null) {
let message: unknown = "";
let retryAfterMs: number | null = null;
let responseBody: unknown = null;
let errorCode: unknown = undefined;
let errorType: unknown = undefined;
try {
const text = await response.text();
responseBody = normalizePayloadForLog(text);
// Try parse as JSON
try {
const parsed = JSON.parse(text);
// Handle array responses (e.g., from some Gemini APIs)
const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {};
// ClinePass wraps upstream errors in a {success:false, error} envelope.
// Extract the upstream error string (an upstream JSON field, not a local
// stack) — still routed through sanitizeErrorMessage/buildErrorBody by
// every consumer below (Rule #12).
const { error: clinepassEnvError } = unwrapClinepassEnvelope(json, provider);
message = clinepassEnvError
? clinepassEnvError.message
: json.error?.message || json.message || json.error || text;
errorCode = json.error?.code || json.code;
errorType = json.error?.type || json.type;
} catch {
message = text;
}
} catch {
message = `Upstream error: ${response.status}`;
responseBody = { _rawText: message };
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const retryAfterHeader = response.headers?.get?.("retry-after");
if (retryAfterHeader && !retryAfterMs) {
const retryAfterSec = Number.parseInt(retryAfterHeader, 10);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = retryAfterSec * 1000;
} else {
const retryAfterDate = new Date(retryAfterHeader).getTime();
if (Number.isFinite(retryAfterDate) && retryAfterDate > Date.now()) {
retryAfterMs = retryAfterDate - Date.now();
}
}
}
// Parse Antigravity-specific retry time from error message
if (provider === "antigravity" && response.status === 429) {
retryAfterMs = parseAntigravityRetryTime(messageStr);
}
// Also parse retry time for other providers (Qwen, etc.) with "quota will reset after XhYmZs" format
if (response.status === 429 && !retryAfterMs) {
retryAfterMs = parseAntigravityRetryTime(messageStr);
}
// Generic providers: "Please retry after 20s"
if (response.status === 429 && !retryAfterMs) {
const retryMatch = messageStr.match(/retry\s+after\s+(\d+)\s*s/i);
if (retryMatch) {
retryAfterMs = Number.parseInt(retryMatch[1], 10) * 1000;
}
}
// Cap maximum retry time at 24 hours to prevent infinite wait
const MAX_RETRY_MS = 24 * 60 * 60 * 1000;
if (retryAfterMs && retryAfterMs > MAX_RETRY_MS) {
retryAfterMs = MAX_RETRY_MS;
}
const responseHeaders: Record<string, string> | null = response.headers
? Object.fromEntries(response.headers.entries())
: null;
return {
statusCode: response.status,
message: messageStr,
errorCode,
errorType,
retryAfterMs,
responseBody,
responseHeaders,
};
}
/**
* Create error result for chatCore handler
* @param {number} statusCode - HTTP status code
* @param {string} message - Error message
* @param {number|null} retryAfterMs - Optional retry-after time in milliseconds
* @returns {{ success: false, status: number, error: string, response: Response, retryAfterMs?: number }}
*/
export function createErrorResult(
statusCode: number,
message: string,
retryAfterMs: number | null = null,
errorCode?: string,
errorType?: string,
upstreamDetails?: unknown,
opts?: { passthrough?: boolean }
) {
const body = buildErrorBody(statusCode, message, upstreamDetails);
if (errorCode) {
body.error.code = errorCode;
}
if (errorType) {
body.error.type = errorType;
}
const result: {
success: false;
status: number;
error: string;
/**
* #7360: the FULL, un-sanitized upstream message — `error` above is
* truncated to its first line by sanitizeErrorMessage() (correctly, for
* the client-facing response body). Server-side classification
* (checkFallbackError / Gemini TPM-vs-RPD metric detection) needs the
* complete multi-line text — e.g. Google's metric name and retry hint
* live on lines 2-3, after the generic "quota exceeded" preamble on
* line 1. This field NEVER reaches the HTTP response body (`response`
* below is already built from the sanitized `body`); it exists purely
* for internal callers that inspect the returned object.
*/
rawMessage: string;
errorType?: string;
errorCode?: string;
response: Response;
retryAfterMs?: number;
} = {
success: false,
status: statusCode,
error: body.error.message,
rawMessage: message,
errorType,
errorCode,
response: new Response(JSON.stringify(body), {
status: statusCode,
headers: { "Content-Type": "application/json" },
}),
};
// Add retryAfterMs if available (for Antigravity quota errors)
if (retryAfterMs) {
result.retryAfterMs = retryAfterMs;
}
// Opt-in relay of the verbatim upstream error body (Claude Code auto-recover
// contract — see upstreamErrorPassthrough.ts). Only swaps `result.response`;
// `result.error`/`rawMessage`/`errorType`/`errorCode` stay untouched so
// server-side classification (checkFallbackError, combo retry logic, etc.)
// never sees a different value depending on this flag.
if (opts?.passthrough) {
const passthroughResponse = buildPassthroughErrorResponse(
statusCode,
upstreamDetails,
retryAfterMs ? { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) } : undefined
);
if (passthroughResponse) {
result.response = passthroughResponse;
}
}
return result;
}
/**
* Create unavailable response when all accounts are rate limited
* @param {number} statusCode - Original error status code
* @param {string} message - Error message (without retry info)
* @param {string} retryAfter - ISO timestamp when earliest account becomes available
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
* @returns {Response}
*/
export function unavailableResponse(
statusCode: number,
message: string,
retryAfter?: string | number | Date | null,
retryAfterHuman?: string
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message;
return new Response(JSON.stringify({ error: { message: msg } }), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
},
});
}
export function providerCircuitOpenResponse(
provider: string,
retryAfter?: string | number | Date | null
) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
return new Response(
JSON.stringify({
error: {
message: `Provider ${provider} circuit breaker is open`,
type: "server_error",
code: "provider_circuit_open",
provider,
retry_after: retryAfterSec,
},
}),
{
status: 503,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
"X-OmniRoute-Provider-Breaker": "open",
},
}
);
}
export function buildModelCooldownBody({
model,
retryAfterSec,
retryAfterAt,
credentialsCoolingCount,
}: {
model?: string | null;
retryAfterSec: number;
retryAfterAt?: string | null;
credentialsCoolingCount?: number | null;
}): ModelCooldownErrorPayload {
const resolvedModel = typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
const resolvedRetryAfterAt =
typeof retryAfterAt === "string" && retryAfterAt.length > 0 ? retryAfterAt : null;
const resolvedCoolingCount =
typeof credentialsCoolingCount === "number" &&
Number.isFinite(credentialsCoolingCount) &&
credentialsCoolingCount > 0
? Math.floor(credentialsCoolingCount)
: null;
return {
error: {
message: resolvedModel
? `All credentials for model ${resolvedModel} are cooling down`
: "All credentials for the requested model are cooling down",
type: "rate_limit_error",
code: "model_cooldown",
...(resolvedModel ? { model: resolvedModel } : {}),
reset_seconds: Math.max(Math.ceil(retryAfterSec), 1),
...(resolvedRetryAfterAt ? { retry_after: resolvedRetryAfterAt } : {}),
...(resolvedCoolingCount ? { credentials_cooling: resolvedCoolingCount } : {}),
},
};
}
export function modelCooldownResponse({
model,
retryAfter,
retryAfterAt,
credentialsCoolingCount,
}: {
model?: string | null;
retryAfter?: string | number | Date | null;
retryAfterAt?: string | null;
credentialsCoolingCount?: number | null;
}) {
const retryAfterSec = normalizeRetryAfterSeconds(retryAfter);
const resolvedRetryAfterAt =
typeof retryAfterAt === "string" && retryAfterAt.length > 0
? retryAfterAt
: typeof retryAfter === "string" && retryAfter.length > 0
? retryAfter
: null;
return new Response(
JSON.stringify(
buildModelCooldownBody({
model,
retryAfterSec,
retryAfterAt: resolvedRetryAfterAt,
credentialsCoolingCount,
})
),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec),
},
}
);
}
/**
* Build an executor-style error result (response + url + headers + transformedBody).
* Shared by web-cookie executors that return the `{ response, url, headers, transformedBody }` shape.
*/
export function makeExecutorErrorResult(
status: number,
message: string,
body: unknown,
url: string
) {
return {
response: new Response(
JSON.stringify({
error: {
message: sanitizeErrorMessage(message),
type: "upstream_error",
code: `HTTP_${status}`,
},
}),
{ status, headers: { "Content-Type": "application/json" } }
),
url,
headers: {} as Record<string, string>,
transformedBody: body,
};
}
/**
* Normalize a cookie string: strip a leading "Cookie:" prefix if present.
*/
export function normalizeCookie(raw: string): string {
return raw?.startsWith("Cookie:") ? raw.slice(7).trim() : raw || "";
}
/**
* Format provider error with context
* @param {Error} error - Original error
* @param {string} provider - Provider name
* @param {string} model - Model name
* @param {number|string} statusCode - HTTP status code or error code
* @returns {string} Formatted error message
*/
export function formatProviderError(
error: { code?: string | number; message?: string; cause?: unknown } | Error,
provider: string,
model: string,
statusCode?: string | number | null
): string {
const providerCode = "code" in error ? error.code : undefined;
const code = statusCode || providerCode || "FETCH_FAILED";
const message = error.message || "Unknown error";
// Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures
const cause = (error as { cause?: unknown }).cause;
const causeObj =
cause && typeof cause === "object" ? (cause as Record<string, unknown>) : undefined;
const causeCode = typeof causeObj?.code === "string" ? causeObj.code : undefined;
const causeMsg = typeof causeObj?.message === "string" ? causeObj.message : undefined;
const causeStr =
causeCode || causeMsg ? ` (cause: ${[causeCode, causeMsg].filter(Boolean).join(": ")})` : "";
return `[${code}]: ${message}${causeStr}`;
}