Skip to content
31 changes: 31 additions & 0 deletions open-sse/config/errorConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@ export const DEFAULT_ERROR_MESSAGES = {
504: "Gateway timeout"
};

export const CODEX_REQUEST_SCHEMA_ERROR_CODES = new Set([
"unknown_parameter",
"unsupported_value",
]);

export const CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN = /\b(?:unknown[_ ]parameter|unsupported[_ ]value)\b/i;
export const CODEX_REQUEST_SCHEMA_PARAM_ROOTS = new Set([
"input",
"instructions",
"tools",
"tool_choice",
"parallel_tool_calls",
"stream",
"store",
"reasoning",
"service_tier",
"include",
"prompt_cache_key",
"client_metadata",
"text",
]);
export const CODEX_ITEM_ID_PARAM_PATTERN = /^input\[\d+\]\.id$/;
export const CODEX_ITEM_ID_MESSAGE_PATTERN = /expected an id that begins with ["'`]\w+["'`]/i;

export const REQUEST_SCHEMA_CLASSIFICATION = Object.freeze({
category: "request_schema",
accountFallback: false,
cooldownMs: 0,
comboScope: "provider",
});

// Exponential backoff config for rate limits
export const BACKOFF_CONFIG = {
base: 2000,
Expand Down
24 changes: 3 additions & 21 deletions open-sse/executors/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
refreshProviderCredentials,
shouldRefreshCredentials,
} from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { normalizeResponsesInput, normalizeStatelessResponseInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
Expand All @@ -24,9 +24,6 @@ const CODEX_SSE_USER_OUTPUT_PATTERNS = [
const CODEX_SSE_PEEK_BYTES = 256 * 1024;
const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model.";

// Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;

// Hosted tool types that Codex/OpenAI Responses executes server-side
const CODEX_HOSTED_TOOL_TYPES = new Set([
"image_generation", "web_search", "web_search_preview", "file_search",
Expand Down Expand Up @@ -54,21 +51,6 @@ function convertSystemToDeveloperRole(body) {
}
}

// Strip invalid or stored item IDs before sending a store=false request.
function stripStoredItemReferences(body) {
if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => {
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false;
if (item && typeof item === "object" && !Array.isArray(item)) {
if (item.type === "item_reference") return false;
// function_call.id is optional input metadata; call_id carries tool-result correlation.
if (item.type === "function_call") delete item.id;
if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id;
}
return true;
});
}

// Flatten Chat-Completions tool shape into Responses flat format + filter unsupported tools
function normalizeCodexTools(body) {
if (!Array.isArray(body.tools)) return;
Expand Down Expand Up @@ -403,8 +385,8 @@ export class CodexExecutor extends BaseExecutor {

// Keep system prompts in body.input as role=developer so they stay in the cacheable prefix
convertSystemToDeveloperRole(body);
// Strip invalid function-call IDs and stored references that Codex cannot resolve with store=false
stripStoredItemReferences(body);
// Strip optional call item IDs and stored references that store=false cannot resolve.
body.input = normalizeStatelessResponseInput(body.input);
// Flatten function tools + drop unsupported types
normalizeCodexTools(body);

Expand Down
8 changes: 6 additions & 2 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { cloneUpstreamErrorResponse, createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
Expand Down Expand Up @@ -367,6 +367,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Provider returned error
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false, true);
const upstreamResponse = provider === "codex" && providerResponse.status === HTTP_STATUS.BAD_REQUEST
? cloneUpstreamErrorResponse(providerResponse)
: null;
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
saveRequestDetail(buildRequestDetail({
Expand All @@ -386,7 +389,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`);
}
reqLogger.logError(new Error(message), finalBody || translatedBody);
return createErrorResult(statusCode, errMsg, resetsAtMs);
const errorResult = createErrorResult(statusCode, errMsg, resetsAtMs);
return upstreamResponse ? { ...errorResult, upstreamResponse } : errorResult;
}

const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log };
Expand Down
130 changes: 125 additions & 5 deletions open-sse/services/accountFallback.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,115 @@
import { ERROR_RULES, BACKOFF_CONFIG, TRANSIENT_COOLDOWN_MS } from "../config/errorConfig.js";
import {
ERROR_RULES,
BACKOFF_CONFIG,
TRANSIENT_COOLDOWN_MS,
CODEX_REQUEST_SCHEMA_ERROR_CODES,
CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN,
CODEX_REQUEST_SCHEMA_PARAM_ROOTS,
CODEX_ITEM_ID_PARAM_PATTERN,
CODEX_ITEM_ID_MESSAGE_PATTERN,
REQUEST_SCHEMA_CLASSIFICATION,
} from "../config/errorConfig.js";

function parseJsonErrorText(value) {
if (typeof value !== "string") return null;
const text = value.trim().replace(/^\[\d+\]:\s*/, "");
const candidates = [text];
const firstBrace = text.indexOf("{");
const lastBrace = text.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) candidates.push(text.slice(firstBrace, lastBrace + 1));
for (const candidate of candidates) {
try { return JSON.parse(candidate); } catch { /* try the next shape */ }
}
return null;
}

function hasErrorMetadata(value) {
return Boolean(value?.type || value?.code || value?.param);
}

function isGenericBadRequestWrapper(value) {
return String(value?.type || "").toLowerCase() === "invalid_request_error"
&& String(value?.code || "").toLowerCase() === "bad_request"
&& typeof value?.message === "string";
}

function normalizeErrorPayload(value, depth = 0) {
if (depth > 6) return { message: "" };
if (typeof value === "string") {
const parsed = parseJsonErrorText(value);
return parsed ? normalizeErrorPayload(parsed, depth + 1) : { message: value };
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { message: String(value || "") };
}
if (String(value.type || "").toLowerCase() === "error"
&& value.error && typeof value.error === "object" && !Array.isArray(value.error)) {
return normalizeErrorPayload(value.error, depth + 1);
}
if (hasErrorMetadata(value) && !isGenericBadRequestWrapper(value)) return value;
if (isGenericBadRequestWrapper(value)) {
const parsed = parseJsonErrorText(value.message);
return parsed ? normalizeErrorPayload(parsed, depth + 1) : { message: value.message };
}
if (value.error && typeof value.error === "object" && !Array.isArray(value.error)) {
return normalizeErrorPayload(value.error, depth + 1);
}
if (typeof value.error === "string") return normalizeErrorPayload(value.error, depth + 1);
if (typeof value.message === "string") {
const parsed = parseJsonErrorText(value.message);
if (parsed) return normalizeErrorPayload(parsed, depth + 1);
}
return value;
}

function getSchemaParamRoot(param, message) {
const direct = String(param || "").match(/^([a-z_]\w*)/i)?.[1];
if (direct) return direct.toLowerCase();
const embedded = String(message || "").match(
/\b(?:unknown[_ ]parameter\s*:\s*|unsupported[_ ]value\s+(?:for|at)\s+)["'`]?([a-z_]\w*)/i
)?.[1];
return embedded?.toLowerCase() || null;
}

export function isCodexRequestSchemaError(provider, status, errorValue = "") {
if (provider !== "codex" || Number(status) !== 400) return false;

const error = normalizeErrorPayload(errorValue);
const type = String(error?.type || "").toLowerCase();
const code = String(error?.code || "").toLowerCase();
const param = String(error?.param || "");
const message = String(error?.message || (typeof error?.error === "string" ? error.error : ""));

if (code === "invalid_prompt" || type === "invalid_prompt") return false;

const itemIdParam = CODEX_ITEM_ID_PARAM_PATTERN.test(param)
|| /input\[\d+\]\.id/i.test(message);
const itemIdMetadata = (!type || type === "invalid_request_error")
&& (!code || code === "invalid_value");
if (itemIdMetadata && itemIdParam && CODEX_ITEM_ID_MESSAGE_PATTERN.test(message)) return true;

const schemaCode = CODEX_REQUEST_SCHEMA_ERROR_CODES.has(code)
? code
: (CODEX_REQUEST_SCHEMA_ERROR_CODES.has(type) ? type : null);
const metadataAllowsMessageOnly = !code && (!type || type === "invalid_request_error");
const schemaField = CODEX_REQUEST_SCHEMA_PARAM_ROOTS.has(getSchemaParamRoot(param, message));
if (schemaCode === "unknown_parameter" || schemaCode === "unsupported_value") return schemaField;
return metadataAllowsMessageOnly && schemaField && CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message);
}

export function classifyProviderError(provider, status, errorText, backoffLevel = 0) {
if (isCodexRequestSchemaError(provider, status, errorText)) {
return { ...REQUEST_SCHEMA_CLASSIFICATION };
}
const { shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
return {
category: "provider_error",
accountFallback: shouldFallback,
cooldownMs,
comboScope: "model",
...(newBackoffLevel === undefined ? {} : { newBackoffLevel }),
};
}

/**
* Calculate exponential backoff cooldown for rate limits (429)
Expand Down Expand Up @@ -118,10 +229,19 @@ export function getModelLockKey(model) {
* Reads flat field `modelLock_${model}` (or `modelLock___all` when model=null).
*/
export function isModelLockActive(connection, model) {
const key = getModelLockKey(model);
const expiry = connection[key] || connection[MODEL_LOCK_ALL];
if (!expiry) return false;
return new Date(expiry).getTime() > Date.now();
return Boolean(getModelLockUntil(connection, model));
}

/** Return the latest active lock that applies to the requested model. */
export function getModelLockUntil(connection, model) {
if (!connection) return null;
const now = Date.now();
const expiries = [connection[getModelLockKey(model)], connection[MODEL_LOCK_ALL]]
.filter(Boolean)
.map(value => new Date(value).getTime())
.filter(value => Number.isFinite(value) && value > now);
if (expiries.length === 0) return null;
return new Date(Math.max(...expiries)).toISOString();
}

/**
Expand Down
Loading