From 5ade434ef7bee861a92ee32bfcbd4529f520320a Mon Sep 17 00:00:00 2001 From: Jia Date: Fri, 31 Jul 2026 11:20:02 +0800 Subject: [PATCH 1/4] fix(codex): isolate stateless schema failures --- open-sse/config/errorConfig.js | 17 ++ open-sse/executors/codex.js | 47 ++-- open-sse/services/accountFallback.js | 132 ++++++++- open-sse/services/combo.js | 64 ++++- src/sse/handlers/chat.js | 17 +- src/sse/services/auth.js | 22 +- tests/unit/auth-model-lock-isolation.test.js | 156 +++++++++++ .../unit/chat-codex-schema-isolation.test.js | 169 ++++++++++++ .../unit/codex-function-call-item-id.test.js | 186 ++++++++----- tests/unit/codex-schema-fallback.test.js | 254 ++++++++++++++++++ 10 files changed, 954 insertions(+), 110 deletions(-) create mode 100644 tests/unit/auth-model-lock-isolation.test.js create mode 100644 tests/unit/chat-codex-schema-isolation.test.js create mode 100644 tests/unit/codex-schema-fallback.test.js diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js index 71491a4d2c..f3a59533d2 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.js @@ -28,6 +28,23 @@ 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_MESSAGE_ONLY_PATTERN = /(?:\bunknown[_ ]parameter\s*:\s*["'`]?[a-z_]\w*(?:\[\d+\])?(?:\.\w+)+|\bunsupported[_ ]value\s+(?:for|at)\s+["'`]?[a-z_]\w*(?:\[\d+\])?(?:\.\w+)+)/i; +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, diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index 43c36f5350..cf979e8aba 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -24,8 +24,14 @@ 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)_/; +// Bare server-side references cannot be resolved when Codex requests use store=false. +const STORED_ITEM_REFERENCE_PATTERN = /^(rs|fc|ctc|resp|msg)_/; +const STATELESS_ITEM_ID_TYPES = new Set([ + "function_call", + "function_call_output", + "custom_tool_call", + "custom_tool_call_output", +]); // Hosted tool types that Codex/OpenAI Responses executes server-side const CODEX_HOSTED_TOOL_TYPES = new Set([ @@ -54,19 +60,24 @@ 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; +// Normalize stateless input without mutating replay items shared with another combo provider. +function normalizeStatelessInput(body) { + const strippedIds = {}; + if (!Array.isArray(body.input)) return strippedIds; + + body.input = body.input.flatMap((item) => { + if (typeof item === "string" && STORED_ITEM_REFERENCE_PATTERN.test(item)) return []; + if (!item || typeof item !== "object" || Array.isArray(item)) return [item]; + if (item.type === "item_reference") return []; + if (!STATELESS_ITEM_ID_TYPES.has(item.type) || !Object.hasOwn(item, "id")) return [item]; + + const normalizedItem = { ...item }; + delete normalizedItem.id; + strippedIds[item.type] = (strippedIds[item.type] || 0) + 1; + return [normalizedItem]; }); + + return strippedIds; } // Flatten Chat-Completions tool shape into Responses flat format + filter unsupported tools @@ -403,8 +414,12 @@ 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 tool item IDs and stored references that store=false cannot resolve. + const strippedIds = normalizeStatelessInput(body); + if (Object.keys(strippedIds).length > 0) { + const counts = Object.entries(strippedIds).map(([type, count]) => `${type}=${count}`).join(" "); + dbg("CODEX", `normalized stateless item ids | ${counts}`); + } // Flatten function tools + drop unsupported types normalizeCodexTools(body); diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index 8d280da412..b76b86c38b 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -1,4 +1,110 @@ -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_MESSAGE_ONLY_PATTERN, + 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 (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; +} + +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 = /^(?:input|tools)(?:\[\d+\])?(?:\.|\[)|^(?:tool_choice|reasoning|text|include|instructions)\./i.test(param) + || CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_PATTERN.test(message); + if (schemaCode === "unknown_parameter") return CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); + if (schemaCode === "unsupported_value") return schemaField && CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); + return metadataAllowsMessageOnly && CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_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) @@ -118,10 +224,26 @@ 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)); +} + +/** + * Get when a connection becomes usable for the requested model. + * The account is blocked until both its model-specific and account-wide locks expire. + */ +export function getModelLockUntil(connection, model) { + if (!connection) return null; + const keys = model + ? [...new Set([getModelLockKey(model), MODEL_LOCK_ALL])] + : [MODEL_LOCK_ALL]; + const now = Date.now(); + let unlockAt = null; + for (const key of keys) { + const time = new Date(connection[key]).getTime(); + if (!Number.isFinite(time) || time <= now) continue; + if (!unlockAt || time > unlockAt) unlockAt = time; + } + return unlockAt ? new Date(unlockAt).toISOString() : null; } /** diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 9216ab2fcf..7d639a21a0 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -2,7 +2,8 @@ * Shared combo (model combo) handling with fallback support */ -import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; +import { classifyProviderError, formatRetryAfter } from "./accountFallback.js"; +import { parseModel } from "./model.js"; import { unavailableResponse } from "../utils/error.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { extractTextContent } from "../translator/formats/gemini.js"; @@ -214,6 +215,30 @@ export function getComboModelsFromData(modelStr, combosData) { return null; } +async function resolveComboProvider(modelStr, resolveModelProvider) { + if (typeof modelStr !== "string") return null; + if (resolveModelProvider) { + try { + const resolved = await resolveModelProvider(modelStr); + if (resolved) return resolved; + } catch { + // Fall back to the registry parser when an external alias resolver fails. + } + } + return parseModel(modelStr).provider; +} + +function retryAfterFromResponse(response) { + const value = response?.headers?.get?.("Retry-After"); + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds > 0) { + return new Date(Date.now() + seconds * 1000).toISOString(); + } + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) && timestamp > Date.now() ? new Date(timestamp).toISOString() : null; +} + /** * Handle combo chat with fallback * @param {Object} options @@ -224,9 +249,10 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {string} [options.comboName] - Name of the combo (for round-robin tracking) * @param {string} [options.comboStrategy] - Strategy: "fallback" or "round-robin" * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching + * @param {Function} [options.resolveModelProvider] - Resolve aliases to canonical provider IDs * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { +export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true, resolveModelProvider = null }) { // Apply rotation strategy if enabled let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); @@ -245,12 +271,19 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co let lastError = null; let earliestRetryAfter = null; let lastStatus = null; + let requestSchemaResponse = null; + const blockedProviders = new Set(); for (let i = 0; i < rotatedModels.length; i++) { const modelStr = rotatedModels[i]; - log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); try { + const provider = await resolveComboProvider(modelStr, resolveModelProvider); + if (provider && blockedProviders.has(provider)) { + log.info("COMBO", `Skipping model ${modelStr}: provider ${provider} rejected the request schema`); + continue; + } + log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); const result = await handleSingleModel(body, modelStr); // Success (2xx) - return response @@ -261,11 +294,13 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co // Extract error info from response let errorText = result.statusText || ""; - let retryAfter = null; + let retryAfter = retryAfterFromResponse(result); + let errorPayload = null; try { const errorBody = await result.clone().json(); + errorPayload = errorBody; errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; + retryAfter = errorBody?.retryAfter || retryAfter; } catch { // Ignore JSON parse errors } @@ -280,10 +315,15 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } } - // Check if should fallback to next model - const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); + const classification = classifyProviderError(provider, result.status, errorPayload || errorText); + if (classification.comboScope === "provider") { + if (!requestSchemaResponse) requestSchemaResponse = result; + if (provider) blockedProviders.add(provider); + log.warn("COMBO", `Provider ${provider || "unknown"} rejected the request schema`, { status: result.status }); + continue; + } - if (!shouldFallback) { + if (!classification.accountFallback) { log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); return result; } @@ -291,10 +331,10 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co // For transient errors (503/502/504), wait for cooldown before falling through // so a briefly-overloaded provider gets a chance to recover rather than being // skipped immediately (fixes: combo falls through on transient 503) - if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && + if (classification.cooldownMs && classification.cooldownMs > 0 && classification.cooldownMs <= 5000 && (result.status === 503 || result.status === 502 || result.status === 504)) { - log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); - await new Promise(r => setTimeout(r, cooldownMs)); + log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${classification.cooldownMs}ms before next`); + await new Promise(r => setTimeout(r, classification.cooldownMs)); } // Fallback to next model @@ -309,6 +349,8 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co } } + if (requestSchemaResponse) return requestSchemaResponse; + // All models failed // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies // the request itself is invalid, but here the providers are simply unavailable diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index af2914a451..9efc2de948 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -1,4 +1,5 @@ import "open-sse/index.js"; +import { classifyProviderError } from "open-sse/services/accountFallback.js"; import { getProviderCredentials, @@ -123,7 +124,8 @@ export async function handleChat(request, clientRawRequest = null) { log, comboName: modelStr, comboStrategy, - comboStickyLimit + comboStickyLimit, + resolveModelProvider: async (comboModel) => (await getModelInfo(comboModel)).provider, }); } @@ -176,7 +178,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re log, comboName: modelStr, comboStrategy, - comboStickyLimit + comboStickyLimit, + resolveModelProvider: async (comboModel) => (await getModelInfo(comboModel)).provider, }); } log.warn("CHAT", "Invalid model format", { model: modelStr }); @@ -201,8 +204,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // All accounts unavailable if (!credentials || credentials.allRateLimited) { if (credentials?.allRateLimited) { - const errorMsg = lastError || credentials.lastError || "Unavailable"; - const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + const errorMsg = lastError || "Temporarily unavailable"; + const status = lastError ? (lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE) : HTTP_STATUS.SERVICE_UNAVAILABLE; log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); } @@ -271,6 +274,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re if (result.success) return result.response; + const classification = classifyProviderError(provider, result.status, result.error); + if (classification.category === "request_schema") { + log.warn("REQUEST", `Non-retryable Codex request schema error (${result.status})`); + return result.response; + } + // Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs) const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 36fd6c4962..ef38978413 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,6 +1,6 @@ import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; -import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; +import { formatRetryAfter, classifyProviderError, isModelLockActive, buildModelLockUpdate, getModelLockUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js"; import * as log from "../utils/logger.js"; @@ -79,25 +79,24 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu const excluded = excludeSet.has(c.id); const locked = isModelLockActive(c, model); if (excluded || locked) { - const lockUntil = getEarliestModelLockUntil(c); + const lockUntil = getModelLockUntil(c, model); log.debug("AUTH", ` → ${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${locked ? `modelLocked(${model}) until ${lockUntil}` : ""}`); } }); if (availableConnections.length === 0) { // Find earliest lock expiry across all connections for retry timing - const lockedConns = connections.filter(c => isModelLockActive(c, model)); - const expiries = lockedConns.map(c => getEarliestModelLockUntil(c)).filter(Boolean); - const earliest = expiries.sort()[0] || null; + const lockedConns = connections + .map((connection) => ({ connection, retryAfter: getModelLockUntil(connection, model) })) + .filter(({ retryAfter }) => retryAfter) + .sort((a, b) => new Date(a.retryAfter) - new Date(b.retryAfter)); + const earliest = lockedConns[0]?.retryAfter || null; if (earliest) { - const earliestConn = lockedConns[0]; - log.warn("AUTH", `${provider} | all ${connections.length} accounts locked for ${model || "all"} (${formatRetryAfter(earliest)}) | lastError=${earliestConn?.lastError?.slice(0, 50)}`); + log.warn("AUTH", `${provider} | all ${connections.length} accounts locked for ${model || "all"} (${formatRetryAfter(earliest)})`); return { allRateLimited: true, retryAfter: earliest, retryAfterHuman: formatRetryAfter(earliest), - lastError: earliestConn?.lastError || null, - lastErrorCode: earliestConn?.errorCode || null }; } log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); @@ -220,7 +219,10 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); newBackoffLevel = 0; } else { - ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); + const classification = classifyProviderError(provider, status, errorText, backoffLevel); + shouldFallback = classification.accountFallback; + cooldownMs = classification.cooldownMs; + newBackoffLevel = classification.newBackoffLevel; } if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; diff --git a/tests/unit/auth-model-lock-isolation.test.js b/tests/unit/auth-model-lock-isolation.test.js new file mode 100644 index 0000000000..733de86534 --- /dev/null +++ b/tests/unit/auth-model-lock-isolation.test.js @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), + getSettings: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: mocks.getProviderConnections, + updateProviderConnection: mocks.updateProviderConnection, + getSettings: mocks.getSettings, + getProxyPools: vi.fn().mockResolvedValue([]), + validateApiKey: vi.fn(), +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: vi.fn().mockResolvedValue({ + connectionProxyEnabled: false, + connectionProxyUrl: "", + connectionNoProxy: "", + proxyPoolId: null, + vercelRelayUrl: "", + }), + pickProxyPoolId: vi.fn(), +})); + +vi.mock("@/shared/constants/providers.js", () => ({ + resolveProviderId: vi.fn((provider) => provider), + FREE_PROVIDERS: {}, +})); + +vi.mock("@/sse/utils/logger.js", () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), +})); + +import { getProviderCredentials, markAccountUnavailable } from "../../src/sse/services/auth.js"; +import { getModelLockUntil, isModelLockActive } from "../../open-sse/services/accountFallback.js"; + +const NOW = new Date("2026-07-31T03:00:00.000Z"); +const at = (seconds) => new Date(NOW.getTime() + seconds * 1000).toISOString(); + +function connection(id, fields = {}) { + return { + id, + provider: "codex", + isActive: true, + accessToken: `TOKEN_${id}`, + displayName: id, + providerSpecificData: {}, + ...fields, + }; +} + +describe("model lock isolation", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ fallbackStrategy: "fill-first" }); + mocks.updateProviderConnection.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("uses the later relevant lock per connection and the earliest connection unlock", async () => { + const connections = [ + connection("account-a", { + modelLock_gpt: at(60), + modelLock___all: at(120), + modelLock_other: at(10), + lastError: "old item_probe_a", + errorCode: 400, + }), + connection("account-b", { + modelLock_gpt: at(90), + modelLock_other: at(300), + lastError: "old item_probe_b", + errorCode: 429, + }), + ]; + mocks.getProviderConnections.mockResolvedValue(connections); + + expect(getModelLockUntil(connections[0], "gpt")).toBe(at(120)); + expect(getModelLockUntil(connections[1], "gpt")).toBe(at(90)); + + const result = await getProviderCredentials("codex", null, "gpt"); + + expect(result).toEqual({ + allRateLimited: true, + retryAfter: at(90), + retryAfterHuman: "reset after 1m 30s", + }); + expect(result).not.toHaveProperty("lastError"); + expect(result).not.toHaveProperty("lastErrorCode"); + }); + + it("keeps an account locked when an expired model lock is shadowed by an active global lock", () => { + const conn = connection("account-a", { + modelLock_gpt: at(-10), + modelLock___all: at(30), + }); + + expect(isModelLockActive(conn, "gpt")).toBe(true); + expect(getModelLockUntil(conn, "gpt")).toBe(at(30)); + }); + + it("ignores locks for other models when selecting the current model", async () => { + mocks.getProviderConnections.mockResolvedValue([ + connection("account-a", { + modelLock_other: at(300), + lastError: "old item_probe_other", + }), + ]); + + const result = await getProviderCredentials("codex", null, "gpt"); + + expect(result.connectionId).toBe("account-a"); + expect(result.accessToken).toBe("TOKEN_account-a"); + }); + + it("does not write account state when the defensive marker receives a schema 400", async () => { + mocks.getProviderConnections.mockResolvedValue([connection("account-a")]); + const error = `[400]: ${JSON.stringify({ + error: { + type: "invalid_request_error", + code: "invalid_value", + param: "input[434].id", + message: "Expected an ID that begins with 'ctc' for input[434].id", + }, + })}`; + + const result = await markAccountUnavailable("account-a", 400, error, "codex", "gpt"); + + expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("keeps rate-limit locking and fallback behavior unchanged", async () => { + mocks.getProviderConnections.mockResolvedValue([connection("account-a", { backoffLevel: 0 })]); + + const result = await markAccountUnavailable("account-a", 429, "rate limit", "codex", "gpt"); + + expect(result).toEqual({ shouldFallback: true, cooldownMs: 2000 }); + expect(mocks.updateProviderConnection).toHaveBeenCalledWith("account-a", expect.objectContaining({ + modelLock_gpt: at(2), + testStatus: "unavailable", + errorCode: 429, + backoffLevel: 1, + })); + }); +}); diff --git a/tests/unit/chat-codex-schema-isolation.test.js b/tests/unit/chat-codex-schema-isolation.test.js new file mode 100644 index 0000000000..ac58d20dec --- /dev/null +++ b/tests/unit/chat-codex-schema-isolation.test.js @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + getSettings: vi.fn(), + getModelInfo: vi.fn(), + getComboModels: vi.fn(), + handleChatCore: vi.fn(), + checkAndRefreshToken: vi.fn(), +})); + +vi.mock("open-sse/index.js", () => ({})); + +vi.mock("@/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.getProviderCredentials, + markAccountUnavailable: mocks.markAccountUnavailable, + clearAccountError: mocks.clearAccountError, + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ getSettings: mocks.getSettings })); + +vi.mock("@/sse/services/model.js", () => ({ + getModelInfo: mocks.getModelInfo, + getComboModels: mocks.getComboModels, +})); + +vi.mock("open-sse/handlers/chatCore.js", () => ({ handleChatCore: mocks.handleChatCore })); + +vi.mock("@/sse/services/tokenRefresh.js", () => ({ + checkAndRefreshToken: mocks.checkAndRefreshToken, + updateProviderCredentials: vi.fn(), +})); + +vi.mock("@/lib/headroom/detect", () => ({ DEFAULT_HEADROOM_URL: "http://localhost:8787" })); +vi.mock("@/lib/pxpipe/loader.js", () => ({ getTransform: vi.fn(() => null) })); +vi.mock("@/lib/pxpipe/events.js", () => ({ appendPxpipeEvent: vi.fn() })); +vi.mock("open-sse/utils/bypassHandler.js", () => ({ handleBypassRequest: vi.fn(() => null) })); +vi.mock("open-sse/services/projectId.js", () => ({ getProjectIdForConnection: vi.fn() })); +vi.mock("@/sse/utils/logger.js", () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + maskKey: vi.fn(() => "masked"), +})); + +import { handleChat } from "../../src/sse/handlers/chat.js"; + +const PROBE_ID = "item_probe_cross_request"; +const SCHEMA_ERROR = `[400]: ${JSON.stringify({ + error: { + type: "invalid_request_error", + code: "invalid_value", + param: "input[434].id", + message: `Invalid 'input[434].id': '${PROBE_ID}'. Expected an ID that begins with 'ctc'.`, + }, +})}`; + +function account(id) { + return { + connectionId: id, + connectionName: id, + accessToken: "TOKEN", + providerSpecificData: {}, + _connection: { id }, + }; +} + +function request(body = {}) { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "cx/gpt-5.6-sol", + input: [{ type: "message", role: "user", content: "hello" }], + ...body, + }), + }); +} + +describe("Codex schema 400 account isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireApiKey: false }); + mocks.getModelInfo.mockResolvedValue({ provider: "codex", model: "gpt-5.6-sol" }); + mocks.getComboModels.mockResolvedValue(null); + mocks.getProviderCredentials.mockResolvedValue(account("codex-account-1")); + mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials); + mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: true, cooldownMs: 30000 }); + }); + + it("returns the original schema response without account writes or rotation", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("codex-account-1")) + .mockResolvedValueOnce(account("codex-account-2")); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: SCHEMA_ERROR, + response: originalResponse, + }); + + const response = await handleChat(request()); + + expect(response).toBe(originalResponse); + expect(mocks.getProviderCredentials).toHaveBeenCalledTimes(1); + expect(mocks.handleChatCore).toHaveBeenCalledTimes(1); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + expect(mocks.clearAccountError).not.toHaveBeenCalled(); + expect(response.headers.get("Retry-After")).toBeNull(); + }); + + it("does not carry a failed request's probe ID into the next valid request", async () => { + const failedResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { status: 400 }); + const successResponse = new Response(JSON.stringify({ id: "resp_ok", output: [] }), { status: 200 }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("codex-account-1")) + .mockResolvedValueOnce(account("codex-account-1")); + mocks.handleChatCore + .mockReset() + .mockResolvedValueOnce({ success: false, status: 400, error: SCHEMA_ERROR, response: failedResponse }) + .mockImplementationOnce(async (options) => { + await options.onRequestSuccess(); + return { success: true, response: successResponse }; + }); + + const first = await handleChat(request({ input: [{ type: "custom_tool_call", id: PROBE_ID, call_id: "call_1", name: "tool", input: "x" }] })); + const second = await handleChat(request({ input: [{ type: "message", role: "user", content: "valid" }] })); + + expect(first.status).toBe(400); + expect(second).toBe(successResponse); + expect(await second.clone().text()).not.toContain(PROBE_ID); + expect(JSON.stringify(mocks.handleChatCore.mock.calls[1][0].body)).not.toContain(PROBE_ID); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + expect(mocks.clearAccountError).toHaveBeenCalledTimes(1); + }); + + it("uses a generic retryable response when every account was already locked", async () => { + const retryAfter = new Date(Date.now() + 45000).toISOString(); + mocks.getProviderCredentials.mockReset().mockResolvedValue({ + allRateLimited: true, + retryAfter, + retryAfterHuman: "reset after 45s", + lastError: `old ${PROBE_ID}`, + lastErrorCode: 400, + }); + + const response = await handleChat(request()); + const text = await response.text(); + + expect(response.status).toBe(503); + expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0); + expect(text).toContain("Temporarily unavailable"); + expect(text).not.toContain(PROBE_ID); + expect(text).not.toContain("old"); + expect(mocks.handleChatCore).not.toHaveBeenCalled(); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/codex-function-call-item-id.test.js b/tests/unit/codex-function-call-item-id.test.js index a3df61dd0a..e4c06d1c43 100644 --- a/tests/unit/codex-function-call-item-id.test.js +++ b/tests/unit/codex-function-call-item-id.test.js @@ -11,96 +11,154 @@ function transformInput(input) { }; executor.transformRequest("gpt-5.6-sol", body, true, { - connectionId: "test-codex-function-call-item-id", + connectionId: "test-codex-stateless-item-id", providerSpecificData: {}, }); return body.input; } -describe("CodexExecutor function-call item ids", () => { - it("strips replayed item ids without changing the tool correlation id", () => { +const TARGET_ITEMS = { + function_call: { + legalId: "fc_valid_1", + payload: { call_id: "call_function", name: "shell", arguments: "{\"cmd\":\"pwd\"}", status: "completed" }, + }, + function_call_output: { + legalId: "fco_valid_1", + payload: { call_id: "call_function", output: "done", status: "completed" }, + }, + custom_tool_call: { + legalId: "ctc_valid_1", + payload: { call_id: "call_custom", name: "codex_app", input: "PAYLOAD", status: "completed" }, + }, + custom_tool_call_output: { + legalId: "ctco_valid_1", + payload: { call_id: "call_custom", output: "RESULT", status: "completed" }, + }, +}; + +describe("CodexExecutor stateless item IDs", () => { + it.each(Object.entries(TARGET_ITEMS))("removes every optional %s id and preserves its payload", (type, fixture) => { + const ids = ["item_replayed_1", fixture.legalId, 42, null]; + const source = [ + ...ids.map((id, index) => ({ type, id, ...fixture.payload, sequence: index })), + { type, ...fixture.payload, sequence: ids.length }, + ]; + + const input = transformInput(source); + + expect(input).toHaveLength(source.length); + input.forEach((item, index) => { + expect(item).toEqual({ type, ...fixture.payload, sequence: index }); + }); + expect(source.slice(0, ids.length).map((item) => item.id)).toEqual(ids); + }); + + it("preserves typed message and reasoning IDs plus encrypted reasoning content", () => { const input = transformInput([ { - type: "function_call", - id: "item_a80a215e158de93e3e66cd2c", - call_id: "call_shell_1", - name: "shell", - arguments: "{}", + type: "message", + id: "msg_history_1", + role: "assistant", + content: [{ type: "output_text", text: "continue" }], }, { - type: "function_call_output", - id: "item_output_1", - call_id: "call_shell_1", - output: "done", + type: "reasoning", + id: "rs_history_1", + encrypted_content: "ENCRYPTED_REASONING", + summary: [{ type: "summary_text", text: "summary" }], }, ]); - expect(input[0]).toEqual({ - type: "function_call", - call_id: "call_shell_1", - name: "shell", - arguments: "{}", + expect(input[0].id).toBe("msg_history_1"); + expect(input[1]).toEqual({ + type: "reasoning", + id: "rs_history_1", + encrypted_content: "ENCRYPTED_REASONING", + summary: [{ type: "summary_text", text: "summary" }], }); - expect(input[1].id).toBe("item_output_1"); - expect(input[1].call_id).toBe("call_shell_1"); }); - it("cleans an invalid function-call id at the reported history index", () => { - const history = Array.from({ length: 59 }, (_, index) => ({ - type: "message", - id: `item_message_${index}`, - role: "user", - content: [{ type: "input_text", text: `step ${index}` }], - })); - history.push({ - type: "function_call", - id: "item_6a5f72cd0d444378d96b2841", - call_id: "call_reported_59", - name: "local_tool", - arguments: "{}", - }); - - const input = transformInput(history); + it("leaves non-target hosted, shell, patch, and compaction items unchanged", () => { + const source = [ + { type: "computer_call", id: "cmp_1", call_id: "call_computer", action: { type: "screenshot" } }, + { type: "local_shell_call", id: "shell_1", call_id: "call_shell", action: { command: ["pwd"] } }, + { type: "apply_patch_call", id: "patch_1", call_id: "call_patch", operation: { type: "update_file" } }, + { type: "compaction", id: "comp_1", encrypted_content: "COMPACTED" }, + ]; - expect(input[59].id).toBeUndefined(); - expect(input[59].call_id).toBe("call_reported_59"); + expect(transformInput(source)).toEqual(source); }); - it("removes every function-call item id when store is disabled", () => { + it("removes bare stored references and item_reference objects only", () => { const input = transformInput([ - { - type: "function_call", - id: "fc_valid_1", - call_id: "call_valid_1", - name: "shell", - arguments: "{}", - }, - { - type: "function_call", - id: 42, - call_id: "call_numeric_1", - name: "shell", - arguments: "{}", - }, + "rs_stored", + "fc_stored", + "ctc_stored", + "resp_stored", + "msg_stored", + { type: "item_reference", id: "item_stored" }, + "ordinary text", + { type: "message", id: "msg_kept", role: "user", content: "continue" }, ]); - expect(input[0].id).toBeUndefined(); - expect(input[0].call_id).toBe("call_valid_1"); - expect(input[1].id).toBeUndefined(); - expect(input[1].call_id).toBe("call_numeric_1"); + expect(input).toEqual([ + "ordinary text", + { type: "message", id: "msg_kept", role: "user", content: "continue" }, + ]); }); - it("does not strip item ids from non-function-call input items", () => { + it("normalizes function and custom call/output pairs without changing call_id", () => { const input = transformInput([ - { - type: "message", - id: "item_message_1", - role: "user", - content: [{ type: "input_text", text: "continue" }], - }, + { type: "function_call", id: "item_fc", call_id: "call_function", name: "shell", arguments: "{}" }, + { type: "function_call_output", id: "item_fco", call_id: "call_function", output: "done" }, + { type: "custom_tool_call", id: "item_ctc", call_id: "call_custom", name: "codex_app", input: "PAYLOAD" }, + { type: "custom_tool_call_output", id: "item_ctco", call_id: "call_custom", output: "RESULT" }, + ]); + + expect(input.map((item) => item.call_id)).toEqual([ + "call_function", + "call_function", + "call_custom", + "call_custom", ]); + expect(input.every((item) => !Object.hasOwn(item, "id"))).toBe(true); + }); - expect(input[0].id).toBe("item_message_1"); + it("cleans a custom tool call at input[434] in a long replay history", () => { + const history = Array.from({ length: 434 }, (_, index) => ({ + type: "message", + id: `msg_history_${index}`, + role: "user", + content: [{ type: "input_text", text: `step ${index}` }], + })); + history.push({ + type: "custom_tool_call", + id: "item_probe_434", + call_id: "call_reported_434", + name: "codex_app", + input: "PAYLOAD", + }); + history.push({ + type: "custom_tool_call_output", + id: "item_probe_output_434", + call_id: "call_reported_434", + output: "RESULT", + }); + + const input = transformInput(history); + + expect(input[433].id).toBe("msg_history_433"); + expect(input[434]).toEqual({ + type: "custom_tool_call", + call_id: "call_reported_434", + name: "codex_app", + input: "PAYLOAD", + }); + expect(input[435]).toEqual({ + type: "custom_tool_call_output", + call_id: "call_reported_434", + output: "RESULT", + }); }); }); diff --git a/tests/unit/codex-schema-fallback.test.js b/tests/unit/codex-schema-fallback.test.js new file mode 100644 index 0000000000..3053c1701b --- /dev/null +++ b/tests/unit/codex-schema-fallback.test.js @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { classifyProviderError, isCodexRequestSchemaError } from "../../open-sse/services/accountFallback.js"; +import { handleComboChat } from "../../open-sse/services/combo.js"; + +const ITEM_ID_ERROR = { + type: "invalid_request_error", + code: "invalid_value", + param: "input[434].id", + message: "Invalid 'input[434].id': 'item_probe_434'. Expected an ID that begins with 'ctc'.", +}; + +const log = { info: vi.fn(), warn: vi.fn() }; + +function jsonErrorResponse(status, error) { + return new Response(JSON.stringify({ error }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function wrappedSchemaResponse() { + return jsonErrorResponse(400, { + message: `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}`, + type: "invalid_request_error", + code: "bad_request", + }); +} + +function wrappedMessageOnlySchemaResponse() { + return jsonErrorResponse(400, { + message: "[400]: Unknown parameter: input[150].namespace", + type: "invalid_request_error", + code: "bad_request", + }); +} + +describe("Codex request schema classification", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["structured error", { error: ITEM_ID_ERROR }], + ["raw JSON", JSON.stringify({ error: ITEM_ID_ERROR })], + ["status wrapped JSON", `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}`], + ["outer response wrapper", { error: { message: `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}`, type: "invalid_request_error", code: "bad_request" } }], + ["message-only item error", `[400]: ${ITEM_ID_ERROR.message}`], + ["unknown_parameter", { error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter: 'input[2].namespace'." } }], + ["top-level unknown_parameter", { error: { type: "invalid_request_error", code: "unknown_parameter", param: "parallel_tool_calls", message: "Unknown parameter: 'parallel_tool_calls'." } }], + ["unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", message: "Unsupported value for 'input[2].type'." } }], + ["message-only unknown_parameter", "[400]: Unknown parameter: 'input[150].namespace'."], + ["message-only unsupported_value", "[400]: Unsupported value for 'input[2].type'."], + ])("classifies %s as a provider-scoped request schema error", (_name, value) => { + expect(classifyProviderError("codex", 400, value)).toEqual({ + category: "request_schema", + accountFallback: false, + cooldownMs: 0, + comboScope: "provider", + }); + }); + + it.each([ + ["other provider", "openai", 400, { error: ITEM_ID_ERROR }], + ["unauthorized", "codex", 401, { error: ITEM_ID_ERROR }], + ["forbidden", "codex", 403, { error: ITEM_ID_ERROR }], + ["rate limit", "codex", 429, "rate limit"], + ["capacity", "codex", 400, "Selected model is at capacity"], + ["invalid_prompt", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_prompt", message: "Unknown parameter in the prompt" } }], + ["invalid_prompt wrapping schema JSON", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_prompt", message: `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}` } }], + ["generic wrapper around invalid_prompt", "codex", 400, { error: { type: "invalid_request_error", code: "bad_request", message: `[400]: ${JSON.stringify({ error: { type: "invalid_request_error", code: "invalid_prompt", message: "Unknown parameter in prompt" } })}` } }], + ["top-level invalid_prompt with error string", "codex", 400, { type: "invalid_request_error", code: "invalid_prompt", error: "Unknown parameter: 'input[2].namespace'." }], + ["top-level invalid_prompt with item param", "codex", 400, { type: "invalid_request_error", code: "invalid_prompt", param: "input[4].id", error: "Expected an ID that begins with 'ctc'" }], + ["message-only invalid_prompt wording", "codex", 400, "Unknown parameter in the prompt"], + ["message-only unsupported account value", "codex", 400, "Unsupported value for the current account"], + ["unsupported account model", "codex", 400, "The model is not supported when using Codex with a ChatGPT account."], + ["structured unsupported account model", "codex", 400, { error: { type: "invalid_request_error", code: "unsupported_value", param: "model", message: "Unsupported value for model: this model is not supported when using Codex with a ChatGPT account." } }], + ["unrelated invalid_value", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_value", param: "reasoning.effort", message: "Invalid value: xhigh" } }], + ["non-ID prefix message", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_value", param: "input[4].id", message: "Invalid item ID" } }], + ])("does not classify %s", (_name, provider, status, value) => { + expect(isCodexRequestSchemaError(provider, status, value)).toBe(false); + }); + + it("blocks the remaining Codex provider models but continues a heterogeneous combo", async () => { + const calls = []; + const handleSingleModel = vi.fn(async (_body, model) => { + calls.push(model); + if (model === "openai/gpt-5.5") return new Response("ok", { status: 200 }); + return wrappedSchemaResponse(); + }); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "codex/gpt-5.5", "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(calls).toEqual(["cx/gpt-5.6-sol", "openai/gpt-5.5"]); + }); + + it("returns the first original 400 after an all-Codex combo makes one upstream call", async () => { + const firstResponse = wrappedSchemaResponse(); + const handleSingleModel = vi.fn().mockResolvedValue(firstResponse); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "codex/gpt-5.5", "cx/gpt-5.4"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResponse); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + }); + + it("keeps message-only schema errors provider-scoped after chat wrapping", async () => { + const firstResponse = wrappedMessageOnlySchemaResponse(); + const handleSingleModel = vi.fn().mockResolvedValue(firstResponse); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "codex/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResponse); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + }); + + it("uses canonical alias resolution for provider-scoped Combo blocking", async () => { + const calls = []; + const providers = { + "codex-primary": "codex", + "codex-secondary": "codex", + "openai-backup": "openai", + }; + const handleSingleModel = vi.fn(async (_body, model) => { + calls.push(model); + return model === "openai-backup" ? new Response("ok", { status: 200 }) : wrappedSchemaResponse(); + }); + + const response = await handleComboChat({ + body: {}, + models: ["codex-primary", "codex-secondary", "openai-backup"], + handleSingleModel, + resolveModelProvider: async (model) => providers[model], + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(calls).toEqual(["codex-primary", "openai-backup"]); + }); + + it("returns the first Codex schema error when other providers are also unavailable", async () => { + const firstResponse = wrappedSchemaResponse(); + const handleSingleModel = vi.fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(jsonErrorResponse(429, { message: "rate limit" })); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResponse); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); + + it.each([ + [429, { message: "rate limit" }], + [502, { message: "bad gateway" }], + [503, { message: "service unavailable" }], + [504, { message: "gateway timeout" }], + [400, { type: "invalid_request_error", code: "invalid_prompt", message: "Prompt is too long" }], + ])("keeps existing Codex model fallback for status %s", async (status, error) => { + const handleSingleModel = vi.fn() + .mockResolvedValueOnce(jsonErrorResponse(status, error)) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "codex/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); + + it("keeps the first failure status when every normal fallback attempt fails", async () => { + const handleSingleModel = vi.fn() + .mockResolvedValueOnce(jsonErrorResponse(429, { message: "rate limit" })) + .mockResolvedValueOnce(jsonErrorResponse(503, { message: "service unavailable" })); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(429); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); + + it("preserves Retry-After when a combo model was already account-locked", async () => { + const locked = new Response(JSON.stringify({ error: { message: "Temporarily unavailable" } }), { + status: 503, + headers: { "Content-Type": "application/json", "Retry-After": "60" }, + }); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol"], + handleSingleModel: vi.fn().mockResolvedValue(locked), + log, + autoSwitch: false, + }); + + expect(response.status).toBe(503); + expect(Number(response.headers.get("Retry-After"))).toBeGreaterThanOrEqual(59); + expect(Number(response.headers.get("Retry-After"))).toBeLessThanOrEqual(60); + }); + + it("contains malformed combo entries inside normal fallback handling", async () => { + const handleSingleModel = vi.fn() + .mockRejectedValueOnce(new Error("invalid combo model")) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + + const response = await handleComboChat({ + body: {}, + models: [{ provider: "codex" }, "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); +}); From 9c9ab9c4a8d685f5a0c07fb0b64fca9d83915f9b Mon Sep 17 00:00:00 2001 From: Jia Date: Fri, 31 Jul 2026 12:08:11 +0800 Subject: [PATCH 2/4] fix(codex): harden stateless response replay --- open-sse/config/errorConfig.js | 16 +- open-sse/executors/codex.js | 70 +++--- open-sse/executors/default.js | 12 +- open-sse/services/accountFallback.js | 20 +- open-sse/translator/formats/responsesApi.js | 68 +++++ open-sse/translator/schema/blocks.js | 14 +- src/sse/handlers/chat.js | 3 +- tests/unit/auth-model-lock-isolation.test.js | 24 +- .../unit/chat-codex-schema-isolation.test.js | 124 ++++++++- .../unit/codex-function-call-item-id.test.js | 237 ++++++++++++++++-- tests/unit/codex-schema-fallback.test.js | 59 +++++ tests/unit/codex-tool-normalization.test.js | 12 +- 12 files changed, 575 insertions(+), 84 deletions(-) diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js index f3a59533d2..f03477b943 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.js @@ -34,7 +34,21 @@ export const CODEX_REQUEST_SCHEMA_ERROR_CODES = new Set([ ]); export const CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN = /\b(?:unknown[_ ]parameter|unsupported[_ ]value)\b/i; -export const CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_PATTERN = /(?:\bunknown[_ ]parameter\s*:\s*["'`]?[a-z_]\w*(?:\[\d+\])?(?:\.\w+)+|\bunsupported[_ ]value\s+(?:for|at)\s+["'`]?[a-z_]\w*(?:\[\d+\])?(?:\.\w+)+)/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; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index cf979e8aba..eb2cd9d5d8 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -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"; @@ -24,15 +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."; -// Bare server-side references cannot be resolved when Codex requests use store=false. -const STORED_ITEM_REFERENCE_PATTERN = /^(rs|fc|ctc|resp|msg)_/; -const STATELESS_ITEM_ID_TYPES = new Set([ - "function_call", - "function_call_output", - "custom_tool_call", - "custom_tool_call_output", -]); - // 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", @@ -60,24 +51,31 @@ function convertSystemToDeveloperRole(body) { } } -// Normalize stateless input without mutating replay items shared with another combo provider. -function normalizeStatelessInput(body) { - const strippedIds = {}; - if (!Array.isArray(body.input)) return strippedIds; - - body.input = body.input.flatMap((item) => { - if (typeof item === "string" && STORED_ITEM_REFERENCE_PATTERN.test(item)) return []; - if (!item || typeof item !== "object" || Array.isArray(item)) return [item]; - if (item.type === "item_reference") return []; - if (!STATELESS_ITEM_ID_TYPES.has(item.type) || !Object.hasOwn(item, "id")) return [item]; - - const normalizedItem = { ...item }; - delete normalizedItem.id; - strippedIds[item.type] = (strippedIds[item.type] || 0) + 1; - return [normalizedItem]; - }); - - return strippedIds; +function cloneCodexRequestBody(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const cloned = { ...body }; + if (Array.isArray(body.input)) { + cloned.input = body.input.map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + return { + ...item, + ...(Array.isArray(item.content) + ? { content: item.content.map((part) => ( + part && typeof part === "object" && !Array.isArray(part) ? { ...part } : part + )) } + : {}), + }; + }); + } + if (Array.isArray(body.tools)) { + cloned.tools = body.tools.map((tool) => ( + tool && typeof tool === "object" && !Array.isArray(tool) ? { ...tool } : tool + )); + } + if (body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)) { + cloned.reasoning = { ...body.reasoning }; + } + return cloned; } // Flatten Chat-Completions tool shape into Responses flat format + filter unsupported tools @@ -264,15 +262,16 @@ export class CodexExecutor extends BaseExecutor { } async execute(args) { - const imgCount = Array.isArray(args.body?.input) ? args.body.input.reduce((n, it) => n + (Array.isArray(it.content) ? it.content.filter(c => c.type === "image_url").length : 0), 0) : 0; - const inputLen = Array.isArray(args.body?.input) ? args.body.input.length : 0; + const requestArgs = { ...args, body: cloneCodexRequestBody(args.body) }; + const imgCount = Array.isArray(requestArgs.body?.input) ? requestArgs.body.input.reduce((n, it) => n + (Array.isArray(it.content) ? it.content.filter(c => c.type === "image_url").length : 0), 0) : 0; + const inputLen = Array.isArray(requestArgs.body?.input) ? requestArgs.body.input.length : 0; dbg("CODEX", `execute start | inputItems=${inputLen} | images=${imgCount} | sessionId=${this._currentSessionId || "pending"}`); if (imgCount > 0) { const t0 = Date.now(); - await this.prefetchImages(args.body); + await this.prefetchImages(requestArgs.body); dbg("CODEX", `prefetchImages done | ${Date.now() - t0}ms`); } else { - await this.prefetchImages(args.body); + await this.prefetchImages(requestArgs.body); } // Retry loop for SSE-level overloaded errors (200 OK body contains event: error) @@ -281,7 +280,7 @@ export class CodexExecutor extends BaseExecutor { const { attempts, delayMs } = resolveRetryEntry(retryConfig[503]); let attempt = 0; while (true) { - const result = await super.execute(args); + const result = await super.execute(requestArgs); const peek = await this._peekSseTransientError(result.response); if (!peek.matched) { // Replace body with re-assembled stream (prefix bytes already read + rest) @@ -399,6 +398,7 @@ export class CodexExecutor extends BaseExecutor { * Image fetching is handled separately in prefetchImages() so this stays sync. */ transformRequest(model, body, stream, credentials) { + body = cloneCodexRequestBody(body); this._isCompact = !!body._compact; delete body._compact; // Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine) @@ -415,7 +415,9 @@ export class CodexExecutor extends BaseExecutor { // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix convertSystemToDeveloperRole(body); // Strip optional tool item IDs and stored references that store=false cannot resolve. - const strippedIds = normalizeStatelessInput(body); + const normalizedInput = normalizeStatelessResponseInput(body.input, { stripUnknownIds: true }); + body.input = normalizedInput.input; + const strippedIds = normalizedInput.strippedIds; if (Object.keys(strippedIds).length > 0) { const counts = Object.entries(strippedIds).map(([type, count]) => `${type}=${count}`).join(" "); dbg("CODEX", `normalized stateless item ids | ${counts}`); diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 48be83505e..00c763f1d8 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -7,6 +7,7 @@ import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; +import { normalizeStatelessResponseInput } from "../translator/formats/responsesApi.js"; // Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults. const BEARER = { combined: true, header: "Authorization", scheme: "bearer" }; @@ -83,7 +84,16 @@ export class DefaultExecutor extends BaseExecutor { } transformRequest(model, body) { - const transformed = this.applyJsonSchemaFallback(body); + let transformed = this.applyJsonSchemaFallback(body); + + const usesResponsesApi = this.config?.format === "openai-responses" + || (this.provider?.startsWith?.("openai-compatible-") && this.provider.includes("responses")); + if (usesResponsesApi + && transformed?.store === false + && Array.isArray(transformed.input)) { + const normalized = normalizeStatelessResponseInput(transformed.input, { stripUnknownIds: true }); + transformed = { ...transformed, input: normalized.input }; + } if (transformed && typeof transformed === "object") { // quirk: some openai-compatible providers reject Anthropic's client_metadata field diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index b76b86c38b..d5c0d18623 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -4,7 +4,7 @@ import { TRANSIENT_COOLDOWN_MS, CODEX_REQUEST_SCHEMA_ERROR_CODES, CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN, - CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_PATTERN, + CODEX_REQUEST_SCHEMA_PARAM_ROOTS, CODEX_ITEM_ID_PARAM_PATTERN, CODEX_ITEM_ID_MESSAGE_PATTERN, REQUEST_SCHEMA_CLASSIFICATION, @@ -63,6 +63,15 @@ function normalizeErrorPayload(value, depth = 0) { 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; @@ -84,11 +93,10 @@ export function isCodexRequestSchemaError(provider, status, errorValue = "") { ? code : (CODEX_REQUEST_SCHEMA_ERROR_CODES.has(type) ? type : null); const metadataAllowsMessageOnly = !code && (!type || type === "invalid_request_error"); - const schemaField = /^(?:input|tools)(?:\[\d+\])?(?:\.|\[)|^(?:tool_choice|reasoning|text|include|instructions)\./i.test(param) - || CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_PATTERN.test(message); - if (schemaCode === "unknown_parameter") return CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); - if (schemaCode === "unsupported_value") return schemaField && CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); - return metadataAllowsMessageOnly && CODEX_REQUEST_SCHEMA_MESSAGE_ONLY_PATTERN.test(message); + const schemaField = CODEX_REQUEST_SCHEMA_PARAM_ROOTS.has(getSchemaParamRoot(param, message)); + if (schemaCode === "unknown_parameter") return schemaField; + if (schemaCode === "unsupported_value") return schemaField; + return metadataAllowsMessageOnly && schemaField && CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); } export function classifyProviderError(provider, status, errorText, backoffLevel = 0) { diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js index c41ee470db..6778c70dc8 100644 --- a/open-sse/translator/formats/responsesApi.js +++ b/open-sse/translator/formats/responsesApi.js @@ -1,5 +1,73 @@ import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js"; +const STORED_ITEM_REFERENCE_PATTERN = /^(?:at|msg|amsg|rs|lsh|fc|tsc|fco|ctc|ctco|tso|ws|ig|cmp|resp)_/; +const UNTRUSTED_STATELESS_ID_TYPES = new Set([ + RESPONSES_ITEM.FUNCTION_CALL, + RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, + RESPONSES_ITEM.CUSTOM_TOOL_CALL, + RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT, +]); +const RESPONSE_ITEM_ID_PREFIXES = new Map([ + [RESPONSES_ITEM.ADDITIONAL_TOOLS, "at"], + [RESPONSES_ITEM.MESSAGE, "msg"], + [RESPONSES_ITEM.AGENT_MESSAGE, "amsg"], + [RESPONSES_ITEM.REASONING, "rs"], + [RESPONSES_ITEM.LOCAL_SHELL_CALL, "lsh"], + [RESPONSES_ITEM.FUNCTION_CALL, "fc"], + [RESPONSES_ITEM.TOOL_SEARCH_CALL, "tsc"], + [RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, "fco"], + [RESPONSES_ITEM.CUSTOM_TOOL_CALL, "ctc"], + [RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT, "ctco"], + [RESPONSES_ITEM.TOOL_SEARCH_OUTPUT, "tso"], + [RESPONSES_ITEM.WEB_SEARCH_CALL, "ws"], + [RESPONSES_ITEM.IMAGE_GENERATION_CALL, "ig"], + [RESPONSES_ITEM.COMPACTION, "cmp"], + [RESPONSES_ITEM.CONTEXT_COMPACTION, "cmp"], +]); + +function getResponseItemType(item) { + return item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null); +} + +/** + * Remove stored references and untrusted item IDs from a stateless Responses replay. + * Tool call/output IDs are always omitted because call_id is the correlation key. + * Other known IDs are retained only when their type-specific prefix is valid. + * Unknown types retain plausible typed IDs but drop generic item_* replay IDs. + */ +export function normalizeStatelessResponseInput(input, { stripUnknownIds = false } = {}) { + const strippedIds = {}; + if (!Array.isArray(input)) return { input, strippedIds }; + + const normalizedInput = input.flatMap((item) => { + if (typeof item === "string" && STORED_ITEM_REFERENCE_PATTERN.test(item)) return []; + if (!item || typeof item !== "object" || Array.isArray(item)) return [item]; + if (item.type === RESPONSES_ITEM.ITEM_REFERENCE) return []; + if (!Object.hasOwn(item, "id")) return [item]; + + const type = getResponseItemType(item); + const expectedPrefix = RESPONSE_ITEM_ID_PREFIXES.get(type); + const hasExpectedId = typeof item.id === "string" + && expectedPrefix + && item.id.startsWith(`${expectedPrefix}_`) + && item.id.length > expectedPrefix.length + 1; + const hasPlausibleUnknownId = typeof item.id === "string" + && item.id.length > 0 + && !item.id.startsWith("item_"); + const shouldStrip = UNTRUSTED_STATELESS_ID_TYPES.has(type) + || (expectedPrefix ? !hasExpectedId : (stripUnknownIds && !hasPlausibleUnknownId)); + + if (!shouldStrip) return [item]; + const normalizedItem = { ...item }; + delete normalizedItem.id; + const countKey = type || "unknown"; + strippedIds[countKey] = (strippedIds[countKey] || 0) + 1; + return [normalizedItem]; + }); + + return { input: normalizedInput, strippedIds }; +} + /** * Normalize Responses API input to array format. * Accepts string or array, returns array of message items. diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js index 61c2564593..2b9e345c5a 100644 --- a/open-sse/translator/schema/blocks.js +++ b/open-sse/translator/schema/blocks.js @@ -24,10 +24,22 @@ export const CLAUDE_BLOCK = { // OpenAI Responses API item types. export const RESPONSES_ITEM = { + ADDITIONAL_TOOLS: "additional_tools", MESSAGE: "message", + AGENT_MESSAGE: "agent_message", + REASONING: "reasoning", + LOCAL_SHELL_CALL: "local_shell_call", FUNCTION_CALL: "function_call", + TOOL_SEARCH_CALL: "tool_search_call", FUNCTION_CALL_OUTPUT: "function_call_output", - REASONING: "reasoning", + CUSTOM_TOOL_CALL: "custom_tool_call", + CUSTOM_TOOL_CALL_OUTPUT: "custom_tool_call_output", + TOOL_SEARCH_OUTPUT: "tool_search_output", + WEB_SEARCH_CALL: "web_search_call", + IMAGE_GENERATION_CALL: "image_generation_call", + COMPACTION: "compaction", + CONTEXT_COMPACTION: "context_compaction", + ITEM_REFERENCE: "item_reference", OUTPUT_TEXT: "output_text", INPUT_TEXT: "input_text", INPUT_IMAGE: "input_image", diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 9efc2de948..e1525e36d1 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -233,8 +233,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Use shared chatCore const chatSettings = await getSettings(); const providerThinking = (chatSettings.providerThinking || {})[provider] || null; + const attemptBody = structuredClone(body); const result = await handleChatCore({ - body: { ...body, model: `${provider}/${model}` }, + body: { ...attemptBody, model: `${provider}/${model}` }, modelInfo: { provider, model }, credentials: refreshedCredentials, log, diff --git a/tests/unit/auth-model-lock-isolation.test.js b/tests/unit/auth-model-lock-isolation.test.js index 733de86534..b860f28688 100644 --- a/tests/unit/auth-model-lock-isolation.test.js +++ b/tests/unit/auth-model-lock-isolation.test.js @@ -123,16 +123,22 @@ describe("model lock isolation", () => { expect(result.accessToken).toBe("TOKEN_account-a"); }); - it("does not write account state when the defensive marker receives a schema 400", async () => { + it.each([ + ["item ID", { + type: "invalid_request_error", + code: "invalid_value", + param: "input[58].id", + message: "Expected an ID that begins with 'ctc' for input[58].id", + }], + ["top-level unsupported value", { + type: "invalid_request_error", + code: "unsupported_value", + param: "tool_choice", + message: "Unsupported value for 'tool_choice': 'BAD'.", + }], + ])("does not write account state when the defensive marker receives a %s schema 400", async (_name, payload) => { mocks.getProviderConnections.mockResolvedValue([connection("account-a")]); - const error = `[400]: ${JSON.stringify({ - error: { - type: "invalid_request_error", - code: "invalid_value", - param: "input[434].id", - message: "Expected an ID that begins with 'ctc' for input[434].id", - }, - })}`; + const error = `[400]: ${JSON.stringify({ error: payload })}`; const result = await markAccountUnavailable("account-a", 400, error, "codex", "gpt"); diff --git a/tests/unit/chat-codex-schema-isolation.test.js b/tests/unit/chat-codex-schema-isolation.test.js index ac58d20dec..2d35005252 100644 --- a/tests/unit/chat-codex-schema-isolation.test.js +++ b/tests/unit/chat-codex-schema-isolation.test.js @@ -50,13 +50,21 @@ vi.mock("@/sse/utils/logger.js", () => ({ import { handleChat } from "../../src/sse/handlers/chat.js"; -const PROBE_ID = "item_probe_cross_request"; -const SCHEMA_ERROR = `[400]: ${JSON.stringify({ +const PROBE_ID = "item_8e297850f5942c40d91db6c2"; +const SCHEMA_ERROR = `[codex/gpt-5.6-sol] [400]: ${JSON.stringify({ error: { type: "invalid_request_error", code: "invalid_value", - param: "input[434].id", - message: `Invalid 'input[434].id': '${PROBE_ID}'. Expected an ID that begins with 'ctc'.`, + param: "input[58].id", + message: `Invalid 'input[58].id': '${PROBE_ID}'. Expected an ID that begins with 'ctc'.`, + }, +})} (reset after 19s)`; +const TOP_LEVEL_SCHEMA_ERROR = `[400]: ${JSON.stringify({ + error: { + type: "invalid_request_error", + code: "unsupported_value", + param: "tool_choice", + message: "Unsupported value for 'tool_choice': 'BAD'.", }, })}`; @@ -119,6 +127,114 @@ describe("Codex schema 400 account isolation", () => { expect(response.headers.get("Retry-After")).toBeNull(); }); + it("does not rotate accounts or add cooldown for a top-level unsupported_value", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: TOP_LEVEL_SCHEMA_ERROR } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("codex-account-1")) + .mockResolvedValueOnce(account("codex-account-2")); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: TOP_LEVEL_SCHEMA_ERROR, + response: originalResponse, + }); + + const response = await handleChat(request({ tool_choice: "BAD" })); + + expect(response).toBe(originalResponse); + expect(mocks.getProviderCredentials).toHaveBeenCalledTimes(1); + expect(mocks.handleChatCore).toHaveBeenCalledTimes(1); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + expect(mocks.clearAccountError).not.toHaveBeenCalled(); + expect(response.headers.get("Retry-After")).toBeNull(); + }); + + it("gives each account retry an independent request body", async () => { + const successResponse = new Response(JSON.stringify({ id: "resp_ok", output: [] }), { status: 200 }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("codex-account-1")) + .mockResolvedValueOnce(account("codex-account-2")); + mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: true, cooldownMs: 2000 }); + mocks.handleChatCore + .mockReset() + .mockImplementationOnce(async ({ body }) => { + body.input[0].role = "mutated"; + body.tools[0].function.name = "mutated"; + body.injected = true; + return { + success: false, + status: 429, + error: "rate limit", + response: new Response("rate limit", { status: 429 }), + }; + }) + .mockImplementationOnce(async (options) => { + expect(options.body.input[0].role).toBe("system"); + expect(options.body.tools[0].function.name).toBe("shell"); + expect(options.body).not.toHaveProperty("injected"); + await options.onRequestSuccess(); + return { success: true, response: successResponse }; + }); + + const response = await handleChat(request({ + input: [{ type: "message", role: "system", content: "system prompt" }], + tools: [{ type: "function", function: { name: "shell", parameters: { type: "object" } } }], + })); + + expect(response).toBe(successResponse); + expect(mocks.handleChatCore).toHaveBeenCalledTimes(2); + expect(mocks.markAccountUnavailable).toHaveBeenCalledTimes(1); + expect(mocks.clearAccountError).toHaveBeenCalledTimes(1); + }); + + it("gives each heterogeneous Combo provider an independent request body", async () => { + const successResponse = new Response(JSON.stringify({ id: "resp_ok", output: [] }), { status: 200 }); + mocks.getComboModels.mockResolvedValue(["cx/gpt-5.6-sol", "openai/gpt-5.5"]); + mocks.getModelInfo.mockImplementation(async (model) => ({ + provider: model.startsWith("openai/") ? "openai" : "codex", + model: model.split("/")[1] || model, + })); + mocks.getProviderCredentials.mockImplementation(async (provider) => account(`${provider}-account-1`)); + mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: false, cooldownMs: 2000 }); + mocks.handleChatCore + .mockReset() + .mockImplementationOnce(async ({ body }) => { + body.input[0].content = "mutated"; + body.tools[0].function.name = "mutated"; + body.injected = true; + return { + success: false, + status: 429, + error: "rate limit", + response: new Response("rate limit", { status: 429 }), + }; + }) + .mockImplementationOnce(async (options) => { + expect(options.modelInfo.provider).toBe("openai"); + expect(options.body.input[0].content).toBe("hello"); + expect(options.body.tools[0].function.name).toBe("shell"); + expect(options.body).not.toHaveProperty("injected"); + await options.onRequestSuccess(); + return { success: true, response: successResponse }; + }); + + const response = await handleChat(request({ + model: "company-combo", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "shell", parameters: { type: "object" } } }], + })); + + expect(response).toBe(successResponse); + expect(mocks.handleChatCore).toHaveBeenCalledTimes(2); + expect(mocks.markAccountUnavailable).toHaveBeenCalledTimes(1); + expect(mocks.clearAccountError).toHaveBeenCalledTimes(1); + }); + it("does not carry a failed request's probe ID into the next valid request", async () => { const failedResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { status: 400 }); const successResponse = new Response(JSON.stringify({ id: "resp_ok", output: [] }), { status: 200 }); diff --git a/tests/unit/codex-function-call-item-id.test.js b/tests/unit/codex-function-call-item-id.test.js index e4c06d1c43..b1c2143dfc 100644 --- a/tests/unit/codex-function-call-item-id.test.js +++ b/tests/unit/codex-function-call-item-id.test.js @@ -1,21 +1,27 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CodexExecutor } from "../../open-sse/executors/codex.js"; +import { DefaultExecutor } from "../../open-sse/executors/default.js"; -function transformInput(input) { +function transformBody(input, overrides = {}) { const executor = new CodexExecutor(); const body = { model: "gpt-5.6-sol", input, stream: true, + ...overrides, }; - executor.transformRequest("gpt-5.6-sol", body, true, { + const transformed = executor.transformRequest("gpt-5.6-sol", body, true, { connectionId: "test-codex-stateless-item-id", providerSpecificData: {}, }); - return body.input; + return { source: body, transformed }; +} + +function transformInput(input) { + return transformBody(input).transformed.input; } const TARGET_ITEMS = { @@ -44,6 +50,7 @@ describe("CodexExecutor stateless item IDs", () => { ...ids.map((id, index) => ({ type, id, ...fixture.payload, sequence: index })), { type, ...fixture.payload, sequence: ids.length }, ]; + const snapshot = structuredClone(source); const input = transformInput(source); @@ -51,6 +58,7 @@ describe("CodexExecutor stateless item IDs", () => { input.forEach((item, index) => { expect(item).toEqual({ type, ...fixture.payload, sequence: index }); }); + expect(source).toEqual(snapshot); expect(source.slice(0, ids.length).map((item) => item.id)).toEqual(ids); }); @@ -79,24 +87,86 @@ describe("CodexExecutor stateless item IDs", () => { }); }); - it("leaves non-target hosted, shell, patch, and compaction items unchanged", () => { + it("preserves valid IDs for every known non-tool item type and removes invalid prefixes", () => { + const fixtures = [ + ["additional_tools", "at", { tools: [] }], + ["message", "msg", { role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + ["agent_message", "amsg", { content: "ok" }], + ["reasoning", "rs", { encrypted_content: "ENCRYPTED_REASONING" }], + ["local_shell_call", "lsh", { call_id: "call_shell", action: { command: ["pwd"] } }], + ["tool_search_call", "tsc", { call_id: "call_search", arguments: "{}" }], + ["tool_search_output", "tso", { call_id: "call_search", output: "RESULT" }], + ["web_search_call", "ws", { call_id: "call_web", action: { type: "search" } }], + ["image_generation_call", "ig", { call_id: "call_image", result: "IMAGE" }], + ["compaction", "cmp", { encrypted_content: "COMPACTED" }], + ["context_compaction", "cmp", { encrypted_content: "CONTEXT_COMPACTED" }], + ]; + const source = fixtures.flatMap(([type, prefix, payload]) => [ + { type, id: `${prefix}_valid_1`, ...payload }, + { type, id: "item_replayed_1", ...payload }, + { type, id: "wrong_prefix_1", ...payload }, + { type, id: 42, ...payload }, + { type, id: null, ...payload }, + ]); + const snapshot = structuredClone(source); + + const input = transformInput(source); + + fixtures.forEach(([, prefix, payload], fixtureIndex) => { + const items = input.slice(fixtureIndex * 5, fixtureIndex * 5 + 5); + expect(items[0]).toEqual(expect.objectContaining({ id: `${prefix}_valid_1`, ...payload })); + for (const item of items.slice(1)) { + expect(item).toEqual(expect.objectContaining(payload)); + expect(item).not.toHaveProperty("id"); + } + }); + expect(source).toEqual(snapshot); + }); + + it("validates implicit message IDs when the item only has a role", () => { + const input = transformInput([ + { id: "msg_valid_1", role: "user", content: "hello" }, + { id: "item_replayed_1", role: "user", content: "again" }, + ]); + + expect(input[0]).toEqual({ id: "msg_valid_1", role: "user", content: "hello" }); + expect(input[1]).toEqual({ role: "user", content: "again" }); + }); + + it("removes generic replay IDs from unknown canonical item types but preserves plausible typed IDs", () => { const source = [ - { type: "computer_call", id: "cmp_1", call_id: "call_computer", action: { type: "screenshot" } }, - { type: "local_shell_call", id: "shell_1", call_id: "call_shell", action: { command: ["pwd"] } }, + { type: "computer_call", id: "item_computer", call_id: "call_computer", action: { type: "screenshot" } }, { type: "apply_patch_call", id: "patch_1", call_id: "call_patch", operation: { type: "update_file" } }, - { type: "compaction", id: "comp_1", encrypted_content: "COMPACTED" }, + { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, ]; - expect(transformInput(source)).toEqual(source); + expect(transformInput(source)).toEqual([ + { type: "computer_call", call_id: "call_computer", action: { type: "screenshot" } }, + { type: "apply_patch_call", id: "patch_1", call_id: "call_patch", operation: { type: "update_file" } }, + { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, + ]); }); it("removes bare stored references and item_reference objects only", () => { - const input = transformInput([ + const storedReferences = [ + "at_stored", + "msg_stored", + "amsg_stored", "rs_stored", + "lsh_stored", "fc_stored", + "tsc_stored", + "fco_stored", "ctc_stored", + "ctco_stored", + "tso_stored", + "ws_stored", + "ig_stored", + "cmp_stored", "resp_stored", - "msg_stored", + ]; + const input = transformInput([ + ...storedReferences, { type: "item_reference", id: "item_stored" }, "ordinary text", { type: "message", id: "msg_kept", role: "user", content: "continue" }, @@ -125,8 +195,8 @@ describe("CodexExecutor stateless item IDs", () => { expect(input.every((item) => !Object.hasOwn(item, "id"))).toBe(true); }); - it("cleans a custom tool call at input[434] in a long replay history", () => { - const history = Array.from({ length: 434 }, (_, index) => ({ + it.each([58, 434])("cleans a custom tool call at input[%i] in a long replay history", (targetIndex) => { + const history = Array.from({ length: targetIndex }, (_, index) => ({ type: "message", id: `msg_history_${index}`, role: "user", @@ -134,31 +204,154 @@ describe("CodexExecutor stateless item IDs", () => { })); history.push({ type: "custom_tool_call", - id: "item_probe_434", - call_id: "call_reported_434", + id: `item_probe_${targetIndex}`, + call_id: `call_reported_${targetIndex}`, name: "codex_app", input: "PAYLOAD", }); history.push({ type: "custom_tool_call_output", - id: "item_probe_output_434", - call_id: "call_reported_434", + id: `item_probe_output_${targetIndex}`, + call_id: `call_reported_${targetIndex}`, output: "RESULT", }); const input = transformInput(history); - expect(input[433].id).toBe("msg_history_433"); - expect(input[434]).toEqual({ + expect(input[targetIndex - 1].id).toBe(`msg_history_${targetIndex - 1}`); + expect(input[targetIndex]).toEqual({ type: "custom_tool_call", - call_id: "call_reported_434", + call_id: `call_reported_${targetIndex}`, name: "codex_app", input: "PAYLOAD", }); - expect(input[435]).toEqual({ + expect(input[targetIndex + 1]).toEqual({ type: "custom_tool_call_output", - call_id: "call_reported_434", + call_id: `call_reported_${targetIndex}`, output: "RESULT", }); }); + + it("does not mutate any nested source fields during Codex transformation", () => { + const input = [{ + type: "message", + id: "item_system", + role: "system", + content: [{ type: "input_text", text: "system prompt", metadata: { keep: true } }], + }]; + const overrides = { + tools: [{ + type: "function", + function: { + name: "shell", + description: "run command", + parameters: { type: "object", properties: { cmd: { type: "string" } } }, + }, + }], + reasoning: { effort: "max" }, + tool_choice: { type: "function", name: "shell" }, + }; + const sourceSnapshot = structuredClone({ model: "gpt-5.6-sol", input, stream: true, ...overrides }); + + const { source, transformed } = transformBody(input, overrides); + + expect(source).toEqual(sourceSnapshot); + expect(transformed).not.toBe(source); + expect(transformed.input[0]).toEqual({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: "system prompt", metadata: { keep: true } }], + }); + expect(transformed.tools[0]).toEqual({ + type: "function", + name: "shell", + description: "run command", + parameters: { type: "object", properties: { cmd: { type: "string" } } }, + }); + expect(transformed.reasoning).toEqual({ effort: "xhigh", summary: "auto" }); + }); + + it("logs only per-type counts and never the stripped item ID", () => { + const probeId = "item_private_probe_58"; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + transformInput([{ + type: "custom_tool_call", + id: probeId, + call_id: "call_private", + name: "tool", + input: "PAYLOAD", + }]); + + const output = logSpy.mock.calls.flat().join(" "); + expect(output).toContain("custom_tool_call=1"); + expect(output).not.toContain(probeId); + } finally { + logSpy.mockRestore(); + } + }); +}); + +describe("OpenAI-compatible Responses stateless item IDs", () => { + it("applies the same prefix validation without mutating the source request", () => { + const executor = new DefaultExecutor("openai-compatible-responses-local"); + const body = { + model: "TARGET", + store: false, + input: [ + { type: "custom_tool_call", id: "item_probe_58", call_id: "call_58", name: "tool", input: "PAYLOAD" }, + { type: "message", id: "item_message", role: "assistant", content: "hello" }, + { type: "reasoning", id: "rs_valid_1", encrypted_content: "ENCRYPTED" }, + { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, + ], + }; + const snapshot = structuredClone(body); + + const transformed = executor.transformRequest("TARGET", body); + + expect(body).toEqual(snapshot); + expect(transformed.input).toEqual([ + { type: "custom_tool_call", call_id: "call_58", name: "tool", input: "PAYLOAD" }, + { type: "message", role: "assistant", content: "hello" }, + { type: "reasoning", id: "rs_valid_1", encrypted_content: "ENCRYPTED" }, + { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, + ]); + }); + + it("normalizes stateless IDs for static Responses providers using DefaultExecutor", () => { + const executor = new DefaultExecutor("perplexity-agent"); + const body = { + model: "openai/gpt-5.5", + store: false, + input: [{ + type: "custom_tool_call", + id: "item_probe_58", + call_id: "call_58", + name: "tool", + input: "PAYLOAD", + }], + }; + + expect(executor.transformRequest("openai/gpt-5.5", body).input).toEqual([{ + type: "custom_tool_call", + call_id: "call_58", + name: "tool", + input: "PAYLOAD", + }]); + }); + + it("does not sanitize stateful or Chat Completions-compatible requests", () => { + const input = [{ + type: "custom_tool_call", + id: "item_stateful", + call_id: "call_stateful", + name: "tool", + input: "PAYLOAD", + }]; + const responsesExecutor = new DefaultExecutor("openai-compatible-responses-local"); + const chatExecutor = new DefaultExecutor("openai-compatible-local"); + + expect(responsesExecutor.transformRequest("TARGET", { store: true, input }).input).toEqual(input); + expect(chatExecutor.transformRequest("TARGET", { store: false, input }).input).toEqual(input); + }); }); diff --git a/tests/unit/codex-schema-fallback.test.js b/tests/unit/codex-schema-fallback.test.js index 3053c1701b..a7f71f7e3a 100644 --- a/tests/unit/codex-schema-fallback.test.js +++ b/tests/unit/codex-schema-fallback.test.js @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { classifyProviderError, isCodexRequestSchemaError } from "../../open-sse/services/accountFallback.js"; import { handleComboChat } from "../../open-sse/services/combo.js"; +import { createErrorResult } from "../../open-sse/utils/error.js"; const ITEM_ID_ERROR = { type: "invalid_request_error", @@ -10,6 +11,16 @@ const ITEM_ID_ERROR = { message: "Invalid 'input[434].id': 'item_probe_434'. Expected an ID that begins with 'ctc'.", }; +const SCREENSHOT_ITEM_ID_ERROR = { + type: "invalid_request_error", + code: "invalid_value", + param: "input[58].id", + message: "Invalid 'input[58].id': 'item_8e297850f5942c40d91db6c2'. Expected an ID that begins with 'ctc'.", +}; +const SCREENSHOT_WRAPPED_ERROR = `[codex/gpt-5.6-sol] [400]: ${JSON.stringify({ + error: SCREENSHOT_ITEM_ID_ERROR, +})} (reset after 19s)`; + const log = { info: vi.fn(), warn: vi.fn() }; function jsonErrorResponse(status, error) { @@ -42,15 +53,24 @@ describe("Codex request schema classification", () => { it.each([ ["structured error", { error: ITEM_ID_ERROR }], + ["screenshot input[58] error", { error: SCREENSHOT_ITEM_ID_ERROR }], + ["full screenshot wrapper with stale reset suffix", SCREENSHOT_WRAPPED_ERROR], ["raw JSON", JSON.stringify({ error: ITEM_ID_ERROR })], ["status wrapped JSON", `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}`], ["outer response wrapper", { error: { message: `[400]: ${JSON.stringify({ error: ITEM_ID_ERROR })}`, type: "invalid_request_error", code: "bad_request" } }], ["message-only item error", `[400]: ${ITEM_ID_ERROR.message}`], ["unknown_parameter", { error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter: 'input[2].namespace'." } }], ["top-level unknown_parameter", { error: { type: "invalid_request_error", code: "unknown_parameter", param: "parallel_tool_calls", message: "Unknown parameter: 'parallel_tool_calls'." } }], + ["structured unknown_parameter alternate wording", { error: { type: "invalid_request_error", code: "unknown_parameter", param: "stream", message: "This request option is not recognized." } }], ["unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", message: "Unsupported value for 'input[2].type'." } }], + ["top-level tool_choice unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", param: "tool_choice", message: "Unsupported value for 'tool_choice': 'BAD'." } }], + ["top-level service_tier unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", param: "service_tier", message: "Unsupported value for 'service_tier': 'BAD'." } }], + ["top-level prompt_cache_key unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", param: "prompt_cache_key", message: "Unsupported value for 'prompt_cache_key': 'BAD'." } }], + ["top-level client_metadata unsupported_value", { error: { type: "invalid_request_error", code: "unsupported_value", param: "client_metadata", message: "Unsupported value for 'client_metadata': 'BAD'." } }], + ["structured unsupported_value alternate wording", { error: { type: "invalid_request_error", code: "unsupported_value", param: "client_metadata", message: "This request option is invalid." } }], ["message-only unknown_parameter", "[400]: Unknown parameter: 'input[150].namespace'."], ["message-only unsupported_value", "[400]: Unsupported value for 'input[2].type'."], + ["message-only top-level unsupported_value", "[400]: Unsupported value for 'tool_choice': 'BAD'."], ])("classifies %s as a provider-scoped request schema error", (_name, value) => { expect(classifyProviderError("codex", 400, value)).toEqual({ category: "request_schema", @@ -60,6 +80,22 @@ describe("Codex request schema classification", () => { }); }); + it("classifies the real createErrorResult wrapper used by chatCore", () => { + const result = createErrorResult(400, `[400]: ${JSON.stringify({ error: { + type: "invalid_request_error", + code: "unsupported_value", + param: "tool_choice", + message: "Unsupported value for 'tool_choice': 'BAD'.", + } })}`); + + expect(classifyProviderError("codex", result.status, result.error)).toEqual({ + category: "request_schema", + accountFallback: false, + cooldownMs: 0, + comboScope: "provider", + }); + }); + it.each([ ["other provider", "openai", 400, { error: ITEM_ID_ERROR }], ["unauthorized", "codex", 401, { error: ITEM_ID_ERROR }], @@ -75,6 +111,8 @@ describe("Codex request schema classification", () => { ["message-only unsupported account value", "codex", 400, "Unsupported value for the current account"], ["unsupported account model", "codex", 400, "The model is not supported when using Codex with a ChatGPT account."], ["structured unsupported account model", "codex", 400, { error: { type: "invalid_request_error", code: "unsupported_value", param: "model", message: "Unsupported value for model: this model is not supported when using Codex with a ChatGPT account." } }], + ["generic model unsupported_value", "codex", 400, { error: { type: "invalid_request_error", code: "unsupported_value", param: "model", message: "Unsupported value for 'model': 'BAD'." } }], + ["account model unknown_parameter", "codex", 400, { error: { type: "invalid_request_error", code: "unknown_parameter", param: "model", message: "Unknown parameter: 'model'. This model is not supported for the current account." } }], ["unrelated invalid_value", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_value", param: "reasoning.effort", message: "Invalid value: xhigh" } }], ["non-ID prefix message", "codex", 400, { error: { type: "invalid_request_error", code: "invalid_value", param: "input[4].id", message: "Invalid item ID" } }], ])("does not classify %s", (_name, provider, status, value) => { @@ -133,6 +171,27 @@ describe("Codex request schema classification", () => { expect(handleSingleModel).toHaveBeenCalledTimes(1); }); + it("keeps createErrorResult schema errors provider-scoped in an all-Codex combo", async () => { + const firstResult = createErrorResult(400, `[400]: ${JSON.stringify({ error: { + type: "invalid_request_error", + code: "unsupported_value", + param: "service_tier", + message: "Unsupported value for 'service_tier': 'BAD'.", + } })}`); + const handleSingleModel = vi.fn().mockResolvedValue(firstResult.response); + + const response = await handleComboChat({ + body: {}, + models: ["cx/gpt-5.6-sol", "codex/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResult.response); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + }); + it("uses canonical alias resolution for provider-scoped Combo blocking", async () => { const calls = []; const providers = { diff --git a/tests/unit/codex-tool-normalization.test.js b/tests/unit/codex-tool-normalization.test.js index d1b5901e73..759b550a73 100644 --- a/tests/unit/codex-tool-normalization.test.js +++ b/tests/unit/codex-tool-normalization.test.js @@ -11,12 +11,12 @@ function normalizeTools(tools) { stream: true, }; - executor.transformRequest("gpt-5.5", body, true, { + const transformed = executor.transformRequest("gpt-5.5", body, true, { connectionId: "test-codex-tools", providerSpecificData: {}, }); - return body.tools; + return transformed.tools; } describe("CodexExecutor tool normalization", () => { @@ -45,12 +45,13 @@ describe("CodexExecutor tool normalization", () => { }, }; - executor.transformRequest("gpt-5.4-mini", body, true, { + const sourceSnapshot = structuredClone(body); + const transformed = executor.transformRequest("gpt-5.4-mini", body, true, { connectionId: "test-codex-structured-output", providerSpecificData: {}, }); - expect(body.text).toEqual({ + expect(transformed.text).toEqual({ format: { type: "json_schema", name: "codex_output_schema", @@ -58,7 +59,8 @@ describe("CodexExecutor tool normalization", () => { schema, }, }); - expect(body.metadata).toBeUndefined(); + expect(transformed.metadata).toBeUndefined(); + expect(body).toEqual(sourceSnapshot); }); it("preserves Responses-native tool_search tools", () => { From c80102d02dd3b54a6ca10e58495f69e0e3c5edc9 Mon Sep 17 00:00:00 2001 From: Jia Date: Fri, 31 Jul 2026 12:45:26 +0800 Subject: [PATCH 3/4] fix(claude): normalize adaptive model variants --- open-sse/providers/capabilities.js | 72 +++++++++++--------- tests/translator/thinking-unified.test.js | 13 ++++ tests/unit/capabilities-opus-context.test.js | 31 ++++++++- tests/unit/capabilities.test.js | 57 +++++++++------- 4 files changed, 113 insertions(+), 60 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 0fb20a6c9b..cc27717393 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -2,9 +2,10 @@ // // Fallback order (first match wins), result merged over DEFAULT_CAPABILITIES: // 1. PROVIDER_CAPABILITIES[provider][model] — provider-specific override -// 2. MODEL_CAPABILITIES[model] — canonical exact id (handles exceptions) -// 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic -// 4. DEFAULT_CAPABILITIES — safe floor (always returned) +// 2. Dynamic family resolvers — versioned families and aliases +// 3. MODEL_CAPABILITIES[model] — canonical exact id (handles exceptions) +// 4. PATTERN_CAPABILITIES — glob match, ordered specific -> generic +// 5. DEFAULT_CAPABILITIES — safe floor (always returned) // // ── HOW TO ADD / UPDATE A MODEL ────────────────────────────────────── // Authoritative data source: https://models.dev/api.json (145 providers, 4000+ @@ -71,26 +72,6 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) - "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4.8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4-8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4.8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - "claude-sonnet-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, - // Gemini image-gen / OpenAI image / xai image variants "gpt-image-1": { imageOutput: true, tools: false }, @@ -110,6 +91,32 @@ export const MODEL_CAPABILITIES = { "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, }; +const CLAUDE_ADAPTIVE_1M_CAPABILITIES = { + vision: true, + reasoning: true, + search: true, + thinkingFormat: "claude-adaptive", + contextWindow: 1000000, + maxOutput: 128000, +}; + +// Keep the version boundary strict so future families such as Opus 5.1 do not +// inherit 5.0 capabilities before their limits are verified. Aliases may append +// named transport variants or YYYY[-MM[-DD]] release dates. +const CLAUDE_ALIAS_SUFFIX = String.raw`(?=$|[-_.:@](?:[a-z][a-z0-9]*|\d{4}(?:[-_.]?\d{2}){0,2})(?:$|[-_.:@]))`; +const CLAUDE_ADAPTIVE_FAMILY_PATTERNS = [ + new RegExp(String.raw`(?:^|[^a-z0-9])claude[-_.]+opus[-_.]+(?:4[.-](?:6|7|8)|5)${CLAUDE_ALIAS_SUFFIX}`, "i"), + new RegExp(String.raw`(?:^|[^a-z0-9])claude[-_.]+sonnet[-_.]+(?:4[.-](?:6|7)|5)${CLAUDE_ALIAS_SUFFIX}`, "i"), + new RegExp(String.raw`(?:^|[^a-z0-9])claude[-_.]+fable[-_.]+5${CLAUDE_ALIAS_SUFFIX}`, "i"), +]; + +function resolveClaudeAdaptiveFamily(model) { + if (typeof model !== "string") return null; + return CLAUDE_ADAPTIVE_FAMILY_PATTERNS.some((pattern) => pattern.test(model)) + ? CLAUDE_ADAPTIVE_1M_CAPABILITIES + : null; +} + const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; // Codex OAuth (ChatGPT backend) — per-model context window reported by upstream @@ -189,16 +196,9 @@ export const PROVIDER_CAPABILITIES = { */ export const PATTERN_CAPABILITIES = [ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── - { pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, - { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, - { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, - { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, - { pattern: "*claude*sonnet-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, - { pattern: "*claude*sonnet-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, { pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, { pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, - { pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude-3*", caps: { vision: true } }, { pattern: "*claude*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } }, @@ -317,7 +317,7 @@ export const PATTERN_CAPABILITIES = [ ]; /** - * Resolve capabilities for a model using the 4-step fallback chain, + * Resolve capabilities for a model using the 5-step fallback chain, * merged over DEFAULT_CAPABILITIES so the result is always complete. * * @param {string} provider @@ -337,17 +337,21 @@ export function getCapabilitiesForModel(provider, model) { if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] }; } - // 2. Canonical exact + // 2. Versioned model family + const familyCaps = resolveClaudeAdaptiveFamily(model) || resolveClaudeAdaptiveFamily(baseModel); + if (familyCaps) return { ...DEFAULT_CAPABILITIES, ...familyCaps }; + + // 3. Canonical exact if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] }; if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] }; - // 3. Pattern match (first match wins) + // 4. Pattern match (first match wins) for (const { pattern, caps } of PATTERN_CAPABILITIES) { if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { return { ...DEFAULT_CAPABILITIES, ...caps }; } } - // 4. Floor + // 5. Floor return { ...DEFAULT_CAPABILITIES }; } diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index 56bc649d50..aedca54a7a 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -72,6 +72,19 @@ describe("applyThinking per provider format", () => { expect(out.output_config).toEqual({ effort: "high" }); expect(out.thinking).toEqual({ type: "adaptive", display: "summarized" }); }); + it("claude 4.6+ aliases produce adaptive wire payloads without budget_tokens", () => { + for (const model of [ + "anthropic/claude-opus-4-6-thinking", + "claude-opus-4.8-fast", + "vendor/claude-sonnet-4-7-agentic", + "claude-sonnet-5-20260731", + ]) { + const out = apply("claude", model, { reasoning_effort: "high" }, "claude"); + expect(out.output_config).toEqual({ effort: "high" }); + expect(out.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(out.thinking).not.toHaveProperty("budget_tokens"); + } + }); it("claude fable-5 high → adaptive summarized thinking without budget_tokens", () => { const out = apply("claude", "claude-fable-5", { reasoning_effort: "high" }, "github"); expect(out.output_config).toEqual({ effort: "high" }); diff --git a/tests/unit/capabilities-opus-context.test.js b/tests/unit/capabilities-opus-context.test.js index 2ef9f95688..9a96f266af 100644 --- a/tests/unit/capabilities-opus-context.test.js +++ b/tests/unit/capabilities-opus-context.test.js @@ -26,6 +26,8 @@ describe("Claude Opus 1M context capabilities", () => { "claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6", + "anthropic/claude-opus-4.6-fast", + "bedrock/us.anthropic.claude-opus-4-8-20260731-v1:0", ]) { it(`resolves ${model} to a 1M context window`, () => { expect(getCapabilitiesForModel("cc", model)).toMatchObject(expected); @@ -33,6 +35,33 @@ describe("Claude Opus 1M context capabilities", () => { } it("keeps the older Opus 4.5 at the standard 200k context", () => { - expect(getCapabilitiesForModel("cc", "claude-opus-4-5-20251101").contextWindow).toBe(200000); + expect(getCapabilitiesForModel("cc", "claude-opus-4-5-20251101")).toMatchObject({ + contextWindow: 200000, + maxOutput: 64000, + thinkingFormat: "claude-budget", + }); + }); + + it("does not infer unverified Claude families from nearby version numbers", () => { + for (const model of ["claude-opus-5.1-fast", "claude-opus-5-1-fast", "claude-sonnet-4.8-thinking", "claude-fable-4.9"]) { + expect(getCapabilitiesForModel("cc", model)).toMatchObject({ + contextWindow: 200000, + maxOutput: 64000, + thinkingFormat: "claude-budget", + }); + } + }); + + it("keeps Haiku and Mythos on their existing budget-thinking capabilities", () => { + expect(getCapabilitiesForModel("cc", "claude-haiku-4.5-thinking")).toMatchObject({ + contextWindow: 200000, + maxOutput: 64000, + thinkingFormat: "claude-budget", + }); + expect(getCapabilitiesForModel("cc", "claude-mythos-5-agentic")).toMatchObject({ + contextWindow: 1000000, + maxOutput: 128000, + thinkingFormat: "claude-budget", + }); }); }); diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 367f00cd39..24dee432ca 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; -import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; +import { + getCapabilitiesForModel, + PROVIDER_CAPABILITIES, +} from "../../open-sse/providers/capabilities.js"; describe("getCapabilitiesForModel", () => { - const claudeSonnet5Expected = { + const claudeAdaptive1mExpected = { contextWindow: 1000000, maxOutput: 128000, thinkingFormat: "claude-adaptive", @@ -20,37 +23,41 @@ describe("getCapabilitiesForModel", () => { search: true, }; - it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => { + it("resolves Claude 4.6+ families and aliases to shared 1M adaptive capabilities", () => { for (const model of [ + "claude-opus-4.6", + "claude-opus-4-6-thinking", + "anthropic/claude-opus-4.7-fast", + "us.anthropic.claude-opus-4-8-agentic", + "vendor/anthropic/claude-opus-4.8-20260731-thinking", + "vendor/claude-opus-4-8-preview-20260731", "claude-opus-5", - "anthropic/claude-opus-5", - "claude-opus-5-thinking", - "claude-opus-5-agentic", "claude-opus-5-thinking-agentic", + "claude-sonnet-4.6", + "claude-sonnet-4-6-thinking", + "anthropic/claude-sonnet-4.7-fast-agentic", + "vendor/claude-sonnet-5-2026-07-31", + "claude-fable-5", + "anthropic/claude-fable-5-fast", ]) { - expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected); + expect(getCapabilitiesForModel("github", model)).toMatchObject(claudeAdaptive1mExpected); } }); - it("reports Kiro Claude Opus 4.8 as a 1M context model", () => { - expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000); - expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000); - expect(getCapabilitiesForModel("kiro", "claude-opus-4-8").contextWindow).toBe(1000000); - expect(getCapabilitiesForModel("kiro", "claude-opus-4.8-thinking").contextWindow).toBe(1000000); - expect(getCapabilitiesForModel("kiro", "claude-opus-4-8-thinking").contextWindow).toBe(1000000); - }); - - it("reports Kiro Claude Sonnet 5 as a 1M adaptive-thinking model", () => { - expect(getCapabilitiesForModel("kiro", "claude-sonnet-5")).toMatchObject(claudeSonnet5Expected); - expect(getCapabilitiesForModel("kiro", "anthropic/claude-sonnet-5")).toMatchObject(claudeSonnet5Expected); - expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking")).toMatchObject(claudeSonnet5Expected); - expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected); - expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected); - }); + it("keeps provider-specific overrides ahead of the Claude family resolver", () => { + const provider = "capability-test-provider"; + PROVIDER_CAPABILITIES[provider] = { + "claude-opus-4-8-fast": { thinkingFormat: "openai", contextWindow: 300000, maxOutput: 32000 }, + }; - it("reports GitHub Claude Fable 5 as a 1M adaptive-thinking model", () => { - for (const model of ["claude-fable-5", "anthropic/claude-fable-5"]) { - expect(getCapabilitiesForModel("github", model)).toMatchObject(claudeSonnet5Expected); + try { + expect(getCapabilitiesForModel(provider, "vendor/claude-opus-4-8-fast")).toMatchObject({ + thinkingFormat: "openai", + contextWindow: 300000, + maxOutput: 32000, + }); + } finally { + delete PROVIDER_CAPABILITIES[provider]; } }); From 1135900ff7b308c506a651da1f650ba6019c3aaa Mon Sep 17 00:00:00 2001 From: Jia Date: Fri, 31 Jul 2026 14:23:11 +0800 Subject: [PATCH 4/4] fix(claude): align beta fields and isolate schema errors --- open-sse/config/anthropicBeta.js | 27 ++ open-sse/config/errorConfig.js | 5 + open-sse/executors/base.js | 49 ++- open-sse/executors/codex.js | 2 +- open-sse/executors/default.js | 49 --- open-sse/executors/github.js | 39 +- open-sse/executors/opencode-go.js | 13 +- open-sse/executors/opencode.js | 11 +- open-sse/handlers/chatCore.js | 20 +- open-sse/providers/capabilities.js | 5 +- open-sse/services/accountFallback.js | 56 ++- open-sse/services/combo.js | 35 +- open-sse/services/provider.js | 17 + open-sse/translator/formats/responsesApi.js | 44 +-- open-sse/utils/anthropicOutbound.js | 132 +++++++ open-sse/utils/claudeHeaderCache.js | 2 - src/sse/handlers/chat.js | 54 ++- src/sse/services/auth.js | 24 +- tests/unit/auth-model-lock-isolation.test.js | 10 +- tests/unit/base-finalize-outbound.test.js | 129 ++++++ tests/unit/capabilities.test.js | 3 + .../unit/chat-claude-schema-isolation.test.js | 230 +++++++++++ tests/unit/chat-core-request-route.test.js | 81 ++++ .../claude-adaptive-family-executor.test.js | 50 +++ tests/unit/claude-header-forwarding.test.js | 347 +++++++++++++--- tests/unit/claude-schema-fallback.test.js | 373 ++++++++++++++++++ .../unit/codex-function-call-item-id.test.js | 89 +---- tests/unit/request-route-resolution.test.js | 144 +++++++ 28 files changed, 1776 insertions(+), 264 deletions(-) create mode 100644 open-sse/config/anthropicBeta.js create mode 100644 open-sse/utils/anthropicOutbound.js create mode 100644 tests/unit/base-finalize-outbound.test.js create mode 100644 tests/unit/chat-claude-schema-isolation.test.js create mode 100644 tests/unit/chat-core-request-route.test.js create mode 100644 tests/unit/claude-adaptive-family-executor.test.js create mode 100644 tests/unit/claude-schema-fallback.test.js create mode 100644 tests/unit/request-route-resolution.test.js diff --git a/open-sse/config/anthropicBeta.js b/open-sse/config/anthropicBeta.js new file mode 100644 index 0000000000..eb5494fb0f --- /dev/null +++ b/open-sse/config/anthropicBeta.js @@ -0,0 +1,27 @@ +export const ANTHROPIC_BETA_HEADER = "anthropic-beta"; +export const CLAUDE_SESSION_HEADER = "x-claude-code-session-id"; +export const CONTEXT_MANAGEMENT_BETA = "context-management-2025-06-27"; +export const CLAUDE_CODE_BETA = "claude-code-20250219"; +export const VALID_ANTHROPIC_BETA = /^[a-z0-9][a-z0-9-]{0,127}$/i; +export const MAX_CLAUDE_SESSION_ID_LENGTH = 256; + +// Keep body-field-to-beta coupling in one table so future features need one mapping. +export const ANTHROPIC_BETA_FEATURES = Object.freeze([ + { + flag: CONTEXT_MANAGEMENT_BETA, + present: body => Object.prototype.hasOwnProperty.call(body || {}, "context_management"), + }, + { + flag: "effort-2025-11-24", + present: body => Object.prototype.hasOwnProperty.call(body?.output_config || {}, "effort"), + }, + { + flag: "advanced-tool-use-2025-11-20", + present: body => Array.isArray(body?.tools) + && body.tools.some(tool => Object.prototype.hasOwnProperty.call(tool || {}, "input_examples")), + }, + { + flag: "structured-outputs-2025-12-15", + present: body => Object.prototype.hasOwnProperty.call(body?.output_config || {}, "format"), + }, +]); diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js index f03477b943..258ca8d443 100644 --- a/open-sse/config/errorConfig.js +++ b/open-sse/config/errorConfig.js @@ -52,6 +52,11 @@ export const CODEX_REQUEST_SCHEMA_PARAM_ROOTS = new Set([ 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 CLAUDE_SCHEMA_FIELD_MESSAGE_PATTERN = /(?:^|[\s"'`])(?:[a-z_]\w*(?:\.\w+|\[\d+\])*)["'`]?\s*:\s*extra inputs are not permitted\b/i; +export const CLAUDE_BETA_HEADER_MESSAGE_PATTERN = /(?:\banthropic[-_ ]beta\b(?:\s+header)?\s*(?::|contains?|has|includes?)\s*(?:an?\s+)?(?:invalid|unsupported|unknown|unrecognized)\s+(?:beta\s+)?(?:value|flag|token|feature|version|name)\b|\b(?:invalid|unsupported|unknown|unrecognized)\s+(?:(?:value|flag|token|feature|version|name)\s+(?:for|in)\s+)?(?:the\s+)?anthropic[-_ ]beta(?:\s+header)?(?:\s+(?:value|flag|token|feature|version|name))?\b)/i; +export const CLAUDE_INVALID_PROMPT_MESSAGE_PATTERN = /\binvalid[_ ]prompt\b/i; +export const CLAUDE_PERMISSION_MESSAGE_PATTERN = /(?:\b(?:unauthorized|unauthorised|forbidden|permission|entitlement)\b|\b(?:account|org(?:anization|anisation)?|workspace|subscription|plan|model)\b.{0,80}\b(?:access|permission|entitlement|unsupported|does\s+not\s+have|not\s+(?:allowed|available|enabled|entitled|supported))\b|\b(?:access|permission|entitlement|unsupported|does\s+not\s+have|not\s+(?:allowed|available|enabled|entitled|supported))\b.{0,80}\b(?:account|org(?:anization|anisation)?|workspace|subscription|plan|model)\b)/i; + export const REQUEST_SCHEMA_CLASSIFICATION = Object.freeze({ category: "request_schema", accountFallback: false, diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 9bf5bedc3f..460931303e 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -3,6 +3,8 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js" import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { dbg } from "../utils/debugLog.js"; import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; +import { FORMATS } from "../translator/formats.js"; +import { finalizeAnthropicOutboundRequest } from "../utils/anthropicOutbound.js"; function removeBetaFlag(headers, flag) { for (const key of ["anthropic-beta", "Anthropic-Beta"]) { @@ -93,6 +95,30 @@ export class BaseExecutor { return body; } + getOutboundFormat(model, credentials) { + if (credentials?.requestTargetFormat) return credentials.requestTargetFormat; + if (credentials?.runtimeTransport?.format) return credentials.runtimeTransport.format; + if (this.provider?.startsWith?.("anthropic-compatible-")) return FORMATS.CLAUDE; + return this.config?.format || FORMATS.OPENAI; + } + + // Final URL, body, headers, and current request headers are normalized together. + finalizeOutboundRequest({ url, headers, transformedBody, credentials, model, targetFormat }) { + const runtimeTransport = credentials?.runtimeTransport; + const featurePolicy = runtimeTransport != null + ? runtimeTransport.quirks?.anthropicBetaFeatures + : this.config?.quirks?.anthropicBetaFeatures; + return finalizeAnthropicOutboundRequest({ + url, + headers, + transformedBody, + credentials, + provider: this.provider, + targetFormat: targetFormat || this.getOutboundFormat(model, credentials), + featurePolicy, + }); + } + shouldRetry(status, urlIndex) { return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount(); } @@ -138,12 +164,27 @@ export class BaseExecutor { }; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex, credentials); - const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream, url); + let url = this.buildUrl(model, stream, urlIndex, credentials); + let transformedBody = this.transformRequest(model, body, stream, credentials); + let headers = this.buildHeaders(credentials, stream, url); + const requestFormat = this.getOutboundFormat(model, credentials); if (transformedBody?.thinking?.display === "summarized") { removeBetaFlag(headers, "redact-thinking-2026-02-12"); } + const finalized = this.finalizeOutboundRequest({ + url, + headers, + transformedBody, + credentials, + model, + stream, + targetFormat: requestFormat, + }); + if (finalized) { + url = finalized.url ?? url; + headers = finalized.headers ?? headers; + transformedBody = finalized.transformedBody ?? transformedBody; + } if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; @@ -176,7 +217,7 @@ export class BaseExecutor { continue; } - return { response, url, headers, transformedBody }; + return { response, url, headers, transformedBody, requestFormat }; } catch (error) { clearTimeout(connectTimer); lastError = error; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index eb2cd9d5d8..f42a9ab064 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -415,7 +415,7 @@ export class CodexExecutor extends BaseExecutor { // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix convertSystemToDeveloperRole(body); // Strip optional tool item IDs and stored references that store=false cannot resolve. - const normalizedInput = normalizeStatelessResponseInput(body.input, { stripUnknownIds: true }); + const normalizedInput = normalizeStatelessResponseInput(body.input); body.input = normalizedInput.input; const strippedIds = normalizedInput.strippedIds; if (Object.keys(strippedIds).length > 0) { diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 00c763f1d8..c96f09c74d 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -7,7 +7,6 @@ import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; -import { normalizeStatelessResponseInput } from "../translator/formats/responsesApi.js"; // Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults. const BEARER = { combined: true, header: "Authorization", scheme: "bearer" }; @@ -48,12 +47,6 @@ const HEADER_HOOKS = { if (!cached) return; for (const lcKey of Object.keys(cached)) { const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase()); - if (lcKey === "anthropic-beta") { - const staticBetaStr = h[titleKey] || h[lcKey] || ""; - const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean)); - for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f); - cached[lcKey] = Array.from(flags).join(","); - } if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey]; } Object.assign(h, cached); @@ -86,15 +79,6 @@ export class DefaultExecutor extends BaseExecutor { transformRequest(model, body) { let transformed = this.applyJsonSchemaFallback(body); - const usesResponsesApi = this.config?.format === "openai-responses" - || (this.provider?.startsWith?.("openai-compatible-") && this.provider.includes("responses")); - if (usesResponsesApi - && transformed?.store === false - && Array.isArray(transformed.input)) { - const normalized = normalizeStatelessResponseInput(transformed.input, { stripUnknownIds: true }); - transformed = { ...transformed, input: normalized.input }; - } - if (transformed && typeof transformed === "object") { // quirk: some openai-compatible providers reject Anthropic's client_metadata field if (this.config.quirks?.dropClientMetadata) { @@ -179,39 +163,6 @@ export class DefaultExecutor extends BaseExecutor { for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials); applyAuth(headers, desc, credentials); - // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams - if (this.provider?.startsWith?.("anthropic-compatible-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; - const isOfficialAnthropic = baseUrl === "" || baseUrl.includes("api.anthropic.com"); - if (!isOfficialAnthropic) { - // Some third-party Anthropic-compatible gateways require Bearer auth in - // addition to x-api-key. Send both (x-api-key already set above) so - // gateways that read either header succeed. - if (credentials.apiKey && !headers["Authorization"]) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; - } - delete headers["anthropic-dangerous-direct-browser-access"]; - delete headers["Anthropic-Dangerous-Direct-Browser-Access"]; - delete headers["x-app"]; - delete headers["X-App"]; - // Strip claude-code-20250219 from Anthropic-Beta / anthropic-beta - for (const betaKey of ["anthropic-beta", "Anthropic-Beta"]) { - if (headers[betaKey]) { - const filtered = headers[betaKey] - .split(",") - .map(s => s.trim()) - .filter(f => f && f !== "claude-code-20250219") - .join(","); - if (filtered) { - headers[betaKey] = filtered; - } else { - delete headers[betaKey]; - } - } - } - } - } - if (stream) headers["Accept"] = "text/event-stream"; return headers; } diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index 208ff8f2c9..21259dc7af 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -29,6 +29,12 @@ export class GithubExecutor extends BaseExecutor { return /claude/i.test(model || ""); } + getOutboundFormat(model, credentials) { + if (this.isClaudeModel(model)) return FORMATS.CLAUDE; + if (this.knownCodexModels.has(model)) return FORMATS.OPENAI_RESPONSES; + return super.getOutboundFormat(model, credentials); + } + buildUrl(model, stream, urlIndex = 0) { return this.config.baseUrl; } @@ -180,7 +186,7 @@ export class GithubExecutor extends BaseExecutor { }, proxyOptions); if (!response.ok) { - return { response, url, headers, transformedBody }; + return { response, url, headers, transformedBody, requestFormat: FORMATS.OPENAI_RESPONSES }; } const state = initState("openai-responses"); @@ -229,7 +235,7 @@ export class GithubExecutor extends BaseExecutor { }); if (!response.body) { - return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody }; + return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody, requestFormat: FORMATS.OPENAI_RESPONSES }; } const convertedStream = response.body.pipeThrough(transformStream); @@ -241,7 +247,8 @@ export class GithubExecutor extends BaseExecutor { }), url, headers, - transformedBody + transformedBody, + requestFormat: FORMATS.OPENAI_RESPONSES, }; } @@ -250,14 +257,14 @@ export class GithubExecutor extends BaseExecutor { // This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject // cache_control — /chat/completions never gets there, so it never sees cache tokens. async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) { - const url = this.config.messagesUrl; - const headers = this.buildHeaders(credentials, stream); + let url = this.config.messagesUrl; + let headers = this.buildHeaders(credentials, stream); // Force stream:true upstream regardless of client preference, same as // executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already // knows how to buffer an SSE response into a single JSON reply when the client // asked for stream:false. - const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github"); + let transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github"); // _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js // normally strips it before dispatch and threads it into the response state to // restore original tool names; we must do the same here, or Anthropic's strict @@ -265,6 +272,19 @@ export class GithubExecutor extends BaseExecutor { const toolNameMap = transformedBody._toolNameMap; delete transformedBody._toolNameMap; + const finalized = this.finalizeOutboundRequest({ + url, + headers, + transformedBody, + credentials, + model, + stream, + targetFormat: FORMATS.CLAUDE, + }); + url = finalized.url ?? url; + headers = finalized.headers ?? headers; + transformedBody = finalized.transformedBody ?? transformedBody; + log?.debug("GITHUB", "Sending translated request to /v1/messages"); const response = await proxyAwareFetch(url, { @@ -275,7 +295,7 @@ export class GithubExecutor extends BaseExecutor { }, proxyOptions); if (!response.ok) { - return { response, url, headers, transformedBody }; + return { response, url, headers, transformedBody, requestFormat: FORMATS.CLAUDE }; } const state = initState(FORMATS.CLAUDE); @@ -324,7 +344,7 @@ export class GithubExecutor extends BaseExecutor { }); if (!response.body) { - return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody }; + return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody, requestFormat: FORMATS.CLAUDE }; } const convertedStream = response.body.pipeThrough(transformStream); @@ -336,7 +356,8 @@ export class GithubExecutor extends BaseExecutor { }), url, headers, - transformedBody + transformedBody, + requestFormat: FORMATS.CLAUDE, }; } diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index 7bf47edb7b..c3302c95d4 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -2,6 +2,7 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; +import { FORMATS } from "../translator/formats.js"; // Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) const MESSAGES_FORMAT_MODELS = new Set([ @@ -20,10 +21,18 @@ export class OpenCodeGoExecutor extends BaseExecutor { super("opencode-go", PROVIDERS["opencode-go"]); } + usesMessagesEndpoint(model) { + return MESSAGES_FORMAT_MODELS.has(model); + } + + getOutboundFormat(model, credentials) { + return this.usesMessagesEndpoint(model) ? FORMATS.CLAUDE : super.getOutboundFormat(model, credentials); + } + // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here buildUrl(model) { this._lastModel = model; - return MESSAGES_FORMAT_MODELS.has(model) + return this.usesMessagesEndpoint(model) ? `${BASE}/messages` : `${BASE}/chat/completions`; } @@ -32,7 +41,7 @@ export class OpenCodeGoExecutor extends BaseExecutor { const key = credentials?.apiKey || credentials?.accessToken; const headers = { "Content-Type": "application/json" }; - if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) { + if (this.usesMessagesEndpoint(this._lastModel)) { headers["x-api-key"] = key; headers["anthropic-version"] = ANTHROPIC_API_VERSION; } else { diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index f7aee211cf..f684bddf50 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,6 +1,7 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; +import { FORMATS } from "../translator/formats.js"; // Models that use /zen/v1/messages (claude format) const MESSAGES_MODELS = new Set(); @@ -10,13 +11,21 @@ export class OpenCodeExecutor extends BaseExecutor { super("opencode", PROVIDERS.opencode); } + usesMessagesEndpoint(model) { + return MESSAGES_MODELS.has(model); + } + + getOutboundFormat(model, credentials) { + return this.usesMessagesEndpoint(model) ? FORMATS.CLAUDE : super.getOutboundFormat(model, credentials); + } + transformRequest(model, body) { return injectReasoningContent({ provider: this.provider, model, body }); } buildUrl(model) { const base = this.config.baseUrl; - return MESSAGES_MODELS.has(model) + return this.usesMessagesEndpoint(model) ? `${base}/zen/v1/messages` : `${base}/zen/v1/chat/completions`; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4f91e020a0..c1bc62088b 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,4 +1,4 @@ -import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; +import { detectFormat, resolveRequestRoute } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; @@ -6,7 +6,7 @@ import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; import { createStreamController } from "../utils/streamHandler.js"; 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 { 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 { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; @@ -58,11 +58,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (bypassResponse) return bypassResponse; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; - const modelTargetFormat = getModelTargetFormat(alias, model); - // Multi-endpoint providers: pick transport matching sourceFormat → zero translation - const runtimeTransport = resolveTransport(provider, sourceFormat); - const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider); - if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport; + const { targetFormat, runtimeTransport } = resolveRequestRoute(provider, model, sourceFormat); + if (credentials) { + if (runtimeTransport) credentials.runtimeTransport = runtimeTransport; + else delete credentials.runtimeTransport; + delete credentials.requestTargetFormat; + } const stripList = getModelStrip(alias, model); const upstreamModel = getModelUpstreamId(alias, model); @@ -162,6 +163,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Token savers: applied at the final body just before dispatch // Covers both passthrough (source shape) and translated (target shape) flows const finalFormat = passthrough ? sourceFormat : targetFormat; + if (credentials) credentials.requestTargetFormat = finalFormat; // Request line: one correlated summary (fmt + thinking + counts + account) if (log?.line) { @@ -294,12 +296,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Most executors return their registry format. Cursor AgentService is an // exception: it is decoded by the executor into OpenAI-compatible output. let providerResponseFormat = targetFormat; + let providerRequestFormat = executor.getOutboundFormat?.(model, credentials) || finalFormat; try { const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; finalBody = result.transformedBody; + providerRequestFormat = result.requestFormat || providerRequestFormat; providerResponseFormat = result.responseFormat || targetFormat; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { @@ -386,7 +390,7 @@ 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); + return { ...createErrorResult(statusCode, errMsg, resetsAtMs), targetFormat: providerRequestFormat }; } const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log }; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index cc27717393..1dd7ee29ac 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -102,8 +102,9 @@ const CLAUDE_ADAPTIVE_1M_CAPABILITIES = { // Keep the version boundary strict so future families such as Opus 5.1 do not // inherit 5.0 capabilities before their limits are verified. Aliases may append -// named transport variants or YYYY[-MM[-DD]] release dates. -const CLAUDE_ALIAS_SUFFIX = String.raw`(?=$|[-_.:@](?:[a-z][a-z0-9]*|\d{4}(?:[-_.]?\d{2}){0,2})(?:$|[-_.:@]))`; +// named transport variants, numeric-unit labels (for example 1m), or +// YYYY[-MM[-DD]] release dates. +const CLAUDE_ALIAS_SUFFIX = String.raw`(?=$|[-_.:@](?:[a-z][a-z0-9]*|\d+[a-z][a-z0-9]*|\d{4}(?:[-_.]?\d{2}){0,2})(?:$|[-_.:@]))`; const CLAUDE_ADAPTIVE_FAMILY_PATTERNS = [ new RegExp(String.raw`(?:^|[^a-z0-9])claude[-_.]+opus[-_.]+(?:4[.-](?:6|7|8)|5)${CLAUDE_ALIAS_SUFFIX}`, "i"), new RegExp(String.raw`(?:^|[^a-z0-9])claude[-_.]+sonnet[-_.]+(?:4[.-](?:6|7)|5)${CLAUDE_ALIAS_SUFFIX}`, "i"), diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js index d5c0d18623..c555df88ce 100644 --- a/open-sse/services/accountFallback.js +++ b/open-sse/services/accountFallback.js @@ -7,8 +7,16 @@ import { CODEX_REQUEST_SCHEMA_PARAM_ROOTS, CODEX_ITEM_ID_PARAM_PATTERN, CODEX_ITEM_ID_MESSAGE_PATTERN, + CLAUDE_SCHEMA_FIELD_MESSAGE_PATTERN, + CLAUDE_BETA_HEADER_MESSAGE_PATTERN, + CLAUDE_INVALID_PROMPT_MESSAGE_PATTERN, + CLAUDE_PERMISSION_MESSAGE_PATTERN, REQUEST_SCHEMA_CLASSIFICATION, } from "../config/errorConfig.js"; +import { getTargetFormat } from "./provider.js"; +import { FORMATS } from "../translator/formats.js"; + +const responseErrorContexts = new WeakMap(); function parseJsonErrorText(value) { if (typeof value !== "string") return null; @@ -43,6 +51,10 @@ function normalizeErrorPayload(value, depth = 0) { 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); @@ -99,8 +111,31 @@ export function isCodexRequestSchemaError(provider, status, errorValue = "") { return metadataAllowsMessageOnly && schemaField && CODEX_REQUEST_SCHEMA_MESSAGE_PATTERN.test(message); } -export function classifyProviderError(provider, status, errorText, backoffLevel = 0) { - if (isCodexRequestSchemaError(provider, status, errorText)) { +export function isClaudeRequestSchemaErrorForRequest(targetFormat, status, errorValue = "") { + if (Number(status) !== 400 || targetFormat !== FORMATS.CLAUDE) return false; + + const error = normalizeErrorPayload(errorValue); + const type = String(error?.type || "").toLowerCase(); + const code = String(error?.code || "").toLowerCase(); + const message = String(error?.message || (typeof error?.error === "string" ? error.error : "")); + + if (code === "invalid_prompt" || type === "invalid_prompt" + || CLAUDE_INVALID_PROMPT_MESSAGE_PATTERN.test(message)) return false; + if (type && type !== "invalid_request_error") return false; + if (CLAUDE_PERMISSION_MESSAGE_PATTERN.test(message)) return false; + + return CLAUDE_SCHEMA_FIELD_MESSAGE_PATTERN.test(message) + || CLAUDE_BETA_HEADER_MESSAGE_PATTERN.test(message); +} + +export function isClaudeRequestSchemaError(provider, status, errorValue = "") { + return isClaudeRequestSchemaErrorForRequest(getTargetFormat(provider), status, errorValue); +} + +export function classifyProviderErrorForRequest(provider, status, errorText, backoffLevel = 0, context = null) { + const targetFormat = context?.targetFormat || getTargetFormat(provider); + if (isCodexRequestSchemaError(provider, status, errorText) + || isClaudeRequestSchemaErrorForRequest(targetFormat, status, errorText)) { return { ...REQUEST_SCHEMA_CLASSIFICATION }; } @@ -114,6 +149,23 @@ export function classifyProviderError(provider, status, errorText, backoffLevel }; } +export function classifyProviderError(provider, status, errorText, backoffLevel = 0) { + return classifyProviderErrorForRequest(provider, status, errorText, backoffLevel); +} + +export function setResponseErrorContext(response, context) { + if (response && (typeof response === "object" || typeof response === "function")) { + responseErrorContexts.set(response, context); + } + return response; +} + +export function getResponseErrorContext(response) { + return response && (typeof response === "object" || typeof response === "function") + ? responseErrorContexts.get(response) || null + : null; +} + /** * Calculate exponential backoff cooldown for rate limits (429) * Level 1: 1s, Level 2: 2s, Level 3: 4s... → max 4 min diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 7d639a21a0..14e4b512dc 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -2,7 +2,7 @@ * Shared combo (model combo) handling with fallback support */ -import { classifyProviderError, formatRetryAfter } from "./accountFallback.js"; +import { classifyProviderErrorForRequest, formatRetryAfter, getResponseErrorContext } from "./accountFallback.js"; import { parseModel } from "./model.js"; import { unavailableResponse } from "../utils/error.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; @@ -250,9 +250,10 @@ function retryAfterFromResponse(response) { * @param {string} [options.comboStrategy] - Strategy: "fallback" or "round-robin" * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @param {Function} [options.resolveModelProvider] - Resolve aliases to canonical provider IDs + * @param {Function} [options.resolveModelContext] - Resolve provider and final outbound format * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true, resolveModelProvider = null }) { +export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true, resolveModelProvider = null, resolveModelContext = null }) { // Apply rotation strategy if enabled let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); @@ -272,15 +273,25 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co let earliestRetryAfter = null; let lastStatus = null; let requestSchemaResponse = null; - const blockedProviders = new Set(); + const blockedProviderFormats = new Set(); + + const providerFormatKey = (provider, targetFormat) => `${provider || "unknown"}\u0000${targetFormat || "*"}`; + const providerHasAnyBlock = (provider) => { + const prefix = `${provider || "unknown"}\u0000`; + return [...blockedProviderFormats].some(key => key.startsWith(prefix)); + }; for (let i = 0; i < rotatedModels.length; i++) { const modelStr = rotatedModels[i]; try { - const provider = await resolveComboProvider(modelStr, resolveModelProvider); - if (provider && blockedProviders.has(provider)) { - log.info("COMBO", `Skipping model ${modelStr}: provider ${provider} rejected the request schema`); + const modelContext = resolveModelContext ? await resolveModelContext(modelStr) : null; + const provider = modelContext?.provider || await resolveComboProvider(modelStr, resolveModelProvider); + const targetFormat = modelContext?.targetFormat || null; + const blocked = blockedProviderFormats.has(providerFormatKey(provider, targetFormat)) + || (!targetFormat && providerHasAnyBlock(provider)); + if (provider && blocked) { + log.info("COMBO", `Skipping model ${modelStr}: provider ${provider} rejected ${targetFormat || "this"} request schema`); continue; } log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); @@ -315,10 +326,18 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } } - const classification = classifyProviderError(provider, result.status, errorPayload || errorText); + const responseContext = getResponseErrorContext(result); + const effectiveTargetFormat = responseContext?.targetFormat || targetFormat; + const classification = responseContext?.classification || classifyProviderErrorForRequest( + provider, + result.status, + errorPayload || errorText, + 0, + { targetFormat: effectiveTargetFormat } + ); if (classification.comboScope === "provider") { if (!requestSchemaResponse) requestSchemaResponse = result; - if (provider) blockedProviders.add(provider); + if (provider) blockedProviderFormats.add(providerFormatKey(provider, effectiveTargetFormat)); log.warn("COMBO", `Provider ${provider || "unknown"} rejected the request schema`, { status: result.status }); continue; } diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1b02cd83e4..c98bc8fe8a 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -1,5 +1,6 @@ import { PROVIDERS } from "../config/providers.js"; import { OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; +import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { @@ -146,6 +147,22 @@ export function resolveTransport(provider, sourceFormat) { return transports.find(t => t.format === sourceFormat) || null; } +export function resolveRequestRoute(provider, model, sourceFormat) { + const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; + const modelTargetFormat = getModelTargetFormat(alias, model); + const transportFormat = modelTargetFormat || sourceFormat; + const runtimeTransport = resolveTransport(provider, transportFormat); + + return { + targetFormat: modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider), + runtimeTransport, + }; +} + +export function resolveRequestTargetFormat(provider, model, sourceFormat) { + return resolveRequestRoute(provider, model, sourceFormat).targetFormat; +} + // Check if last message is from user export function isLastMessageFromUser(body) { const messages = body.messages || body.contents; diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js index 6778c70dc8..a9616681b5 100644 --- a/open-sse/translator/formats/responsesApi.js +++ b/open-sse/translator/formats/responsesApi.js @@ -7,35 +7,13 @@ const UNTRUSTED_STATELESS_ID_TYPES = new Set([ RESPONSES_ITEM.CUSTOM_TOOL_CALL, RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT, ]); -const RESPONSE_ITEM_ID_PREFIXES = new Map([ - [RESPONSES_ITEM.ADDITIONAL_TOOLS, "at"], - [RESPONSES_ITEM.MESSAGE, "msg"], - [RESPONSES_ITEM.AGENT_MESSAGE, "amsg"], - [RESPONSES_ITEM.REASONING, "rs"], - [RESPONSES_ITEM.LOCAL_SHELL_CALL, "lsh"], - [RESPONSES_ITEM.FUNCTION_CALL, "fc"], - [RESPONSES_ITEM.TOOL_SEARCH_CALL, "tsc"], - [RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, "fco"], - [RESPONSES_ITEM.CUSTOM_TOOL_CALL, "ctc"], - [RESPONSES_ITEM.CUSTOM_TOOL_CALL_OUTPUT, "ctco"], - [RESPONSES_ITEM.TOOL_SEARCH_OUTPUT, "tso"], - [RESPONSES_ITEM.WEB_SEARCH_CALL, "ws"], - [RESPONSES_ITEM.IMAGE_GENERATION_CALL, "ig"], - [RESPONSES_ITEM.COMPACTION, "cmp"], - [RESPONSES_ITEM.CONTEXT_COMPACTION, "cmp"], -]); - -function getResponseItemType(item) { - return item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null); -} /** * Remove stored references and untrusted item IDs from a stateless Responses replay. * Tool call/output IDs are always omitted because call_id is the correlation key. - * Other known IDs are retained only when their type-specific prefix is valid. - * Unknown types retain plausible typed IDs but drop generic item_* replay IDs. + * Every other item is preserved because its ID may carry provider-specific state. */ -export function normalizeStatelessResponseInput(input, { stripUnknownIds = false } = {}) { +export function normalizeStatelessResponseInput(input) { const strippedIds = {}; if (!Array.isArray(input)) return { input, strippedIds }; @@ -45,23 +23,11 @@ export function normalizeStatelessResponseInput(input, { stripUnknownIds = false if (item.type === RESPONSES_ITEM.ITEM_REFERENCE) return []; if (!Object.hasOwn(item, "id")) return [item]; - const type = getResponseItemType(item); - const expectedPrefix = RESPONSE_ITEM_ID_PREFIXES.get(type); - const hasExpectedId = typeof item.id === "string" - && expectedPrefix - && item.id.startsWith(`${expectedPrefix}_`) - && item.id.length > expectedPrefix.length + 1; - const hasPlausibleUnknownId = typeof item.id === "string" - && item.id.length > 0 - && !item.id.startsWith("item_"); - const shouldStrip = UNTRUSTED_STATELESS_ID_TYPES.has(type) - || (expectedPrefix ? !hasExpectedId : (stripUnknownIds && !hasPlausibleUnknownId)); - - if (!shouldStrip) return [item]; + const type = item.type; + if (!UNTRUSTED_STATELESS_ID_TYPES.has(type)) return [item]; const normalizedItem = { ...item }; delete normalizedItem.id; - const countKey = type || "unknown"; - strippedIds[countKey] = (strippedIds[countKey] || 0) + 1; + strippedIds[type] = (strippedIds[type] || 0) + 1; return [normalizedItem]; }); diff --git a/open-sse/utils/anthropicOutbound.js b/open-sse/utils/anthropicOutbound.js new file mode 100644 index 0000000000..91529e9fea --- /dev/null +++ b/open-sse/utils/anthropicOutbound.js @@ -0,0 +1,132 @@ +import { FORMATS } from "../translator/formats.js"; +import { + ANTHROPIC_BETA_FEATURES, + ANTHROPIC_BETA_HEADER, + CLAUDE_CODE_BETA, + CLAUDE_SESSION_HEADER, + CONTEXT_MANAGEMENT_BETA, + MAX_CLAUDE_SESSION_ID_LENGTH, + VALID_ANTHROPIC_BETA, +} from "../config/anthropicBeta.js"; + +function getHeader(headers, name) { + if (!headers) return undefined; + if (typeof headers.get === "function") return headers.get(name) ?? undefined; + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +function getHeaderValues(headers, name) { + if (!headers) return []; + if (typeof headers.get === "function") { + const value = headers.get(name); + return value == null ? [] : [value]; + } + return Object.keys(headers) + .filter(candidate => candidate.toLowerCase() === name.toLowerCase()) + .map(key => headers[key]); +} + +function deleteHeader(headers, name) { + for (const key of Object.keys(headers || {})) { + if (key.toLowerCase() === name.toLowerCase()) delete headers[key]; + } +} + +function setCanonicalHeader(headers, name, value) { + deleteHeader(headers, name); + if (value) headers[name.toLowerCase()] = value; +} + +function parseBetaFlags(value, validate = false) { + const raw = Array.isArray(value) ? value.join(",") : String(value || ""); + return raw + .split(",") + .map(flag => flag.trim()) + .filter(Boolean) + .filter(flag => !validate || VALID_ANTHROPIC_BETA.test(flag)); +} + +function getRequestSessionId(headers) { + const value = getHeader(headers, CLAUDE_SESSION_HEADER); + if (typeof value !== "string") return null; + if (value.length > MAX_CLAUDE_SESSION_ID_LENGTH || /[\r\n]/.test(value)) return null; + const sessionId = value.trim(); + return sessionId || null; +} + +function isOfficialAnthropicUrl(url) { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && parsed.hostname === "api.anthropic.com"; + } catch { + return false; + } +} + +function allowsAnthropicBetaFeature(policy, flag) { + return Array.isArray(policy) && policy.includes(flag); +} + +export function finalizeAnthropicOutboundRequest({ + url, + headers, + transformedBody, + credentials, + provider, + targetFormat, + featurePolicy, +}) { + if (targetFormat !== FORMATS.CLAUDE) return { url, headers, transformedBody }; + + const finalizedHeaders = { ...headers }; + const officialAnthropic = isOfficialAnthropicUrl(url); + let finalizedBody = transformedBody; + + // Session identity is request-scoped and only valid for the official endpoint. + deleteHeader(finalizedHeaders, CLAUDE_SESSION_HEADER); + const requestSessionId = getRequestSessionId(credentials?.rawHeaders); + if (officialAnthropic && requestSessionId) { + finalizedHeaders[CLAUDE_SESSION_HEADER] = requestSessionId; + } + + const contextManagementPresent = ANTHROPIC_BETA_FEATURES[0].present(transformedBody); + const contextManagementAllowed = officialAnthropic + || allowsAnthropicBetaFeature(featurePolicy, CONTEXT_MANAGEMENT_BETA); + if (contextManagementPresent && !contextManagementAllowed) { + finalizedBody = { ...transformedBody }; + delete finalizedBody.context_management; + } + + const betaFlags = new Set(parseBetaFlags(getHeaderValues(finalizedHeaders, ANTHROPIC_BETA_HEADER))); + if (!officialAnthropic) { + // Dynamic Anthropic-compatible gateways may accept only Bearer auth. + if (provider?.startsWith?.("anthropic-compatible-") + && credentials?.apiKey + && !getHeader(finalizedHeaders, "authorization")) { + finalizedHeaders.Authorization = `Bearer ${credentials.apiKey}`; + } + betaFlags.delete(CLAUDE_CODE_BETA); + if (!contextManagementAllowed) betaFlags.delete(CONTEXT_MANAGEMENT_BETA); + deleteHeader(finalizedHeaders, "anthropic-dangerous-direct-browser-access"); + deleteHeader(finalizedHeaders, "x-app"); + } + + const requestBetaFlags = parseBetaFlags( + getHeaderValues(credentials?.rawHeaders, ANTHROPIC_BETA_HEADER), + true + ); + for (const flag of requestBetaFlags) { + if (officialAnthropic || allowsAnthropicBetaFeature(featurePolicy, flag)) betaFlags.add(flag); + } + + for (const feature of ANTHROPIC_BETA_FEATURES) { + if (!feature.present(finalizedBody)) continue; + if (officialAnthropic || allowsAnthropicBetaFeature(featurePolicy, feature.flag)) { + betaFlags.add(feature.flag); + } + } + + setCanonicalHeader(finalizedHeaders, ANTHROPIC_BETA_HEADER, Array.from(betaFlags).join(",")); + return { url, headers: finalizedHeaders, transformedBody: finalizedBody }; +} diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.js index 11b2eb81ee..43fb590c9e 100644 --- a/open-sse/utils/claudeHeaderCache.js +++ b/open-sse/utils/claudeHeaderCache.js @@ -6,7 +6,6 @@ const CLAUDE_IDENTITY_HEADERS = [ "user-agent", - "anthropic-beta", "anthropic-version", "anthropic-dangerous-direct-browser-access", "x-app", @@ -19,7 +18,6 @@ const CLAUDE_IDENTITY_HEADERS = [ "x-stainless-arch", "x-stainless-os", "x-stainless-timeout", - "x-claude-code-session-id", "package-version", "runtime-version", "os", diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index e1525e36d1..cde7c421a7 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -1,5 +1,5 @@ import "open-sse/index.js"; -import { classifyProviderError } from "open-sse/services/accountFallback.js"; +import { classifyProviderErrorForRequest, setResponseErrorContext } from "open-sse/services/accountFallback.js"; import { getProviderCredentials, @@ -20,10 +20,28 @@ import { handleComboChat, handleFusionChat } from "open-sse/services/combo.js"; import { handleBypassRequest } from "open-sse/utils/bypassHandler.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import { detectFormatByEndpoint } from "open-sse/translator/formats.js"; +import { detectFormat, resolveRequestRoute } from "open-sse/services/provider.js"; +import { getExecutor } from "open-sse/executors/index.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { getProjectIdForConnection } from "open-sse/services/projectId.js"; +function resolveSourceFormat(request, body) { + if (!request?.url) return detectFormat(body); + return detectFormatByEndpoint(new URL(request.url).pathname, body) || detectFormat(body); +} + +function resolveModelRequestContext(modelInfo, body, request) { + const { provider, model } = modelInfo; + const sourceFormat = resolveSourceFormat(request, body); + const { targetFormat: requestTargetFormat, runtimeTransport } = resolveRequestRoute(provider, model, sourceFormat); + const targetFormat = getExecutor(provider).getOutboundFormat?.(model, { + requestTargetFormat, + runtimeTransport, + }) || requestTargetFormat; + return { provider, model, sourceFormat, targetFormat }; +} + /** * Handle chat completion request * Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats @@ -126,6 +144,10 @@ export async function handleChat(request, clientRawRequest = null) { comboStrategy, comboStickyLimit, resolveModelProvider: async (comboModel) => (await getModelInfo(comboModel)).provider, + resolveModelContext: async (comboModel) => { + const modelInfo = await getModelInfo(comboModel); + return resolveModelRequestContext(modelInfo, body, request); + }, }); } @@ -180,6 +202,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re comboStrategy, comboStickyLimit, resolveModelProvider: async (comboModel) => (await getModelInfo(comboModel)).provider, + resolveModelContext: async (comboModel) => { + const modelInfo = await getModelInfo(comboModel); + return resolveModelRequestContext(modelInfo, body, request); + }, }); } log.warn("CHAT", "Invalid model format", { model: modelStr }); @@ -187,6 +213,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re } const { provider, model } = modelInfo; + const requestContext = resolveModelRequestContext(modelInfo, body, request); // Routing shown in the unified "▶" line (client model → provider/model) @@ -260,7 +287,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re onPxpipeEvent: appendPxpipeEvent, providerThinking, // Detect source format by endpoint + body - sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null, + sourceFormatOverride: requestContext.sourceFormat, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { ...newCreds, @@ -275,14 +302,29 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re if (result.success) return result.response; - const classification = classifyProviderError(provider, result.status, result.error); + const targetFormat = result.targetFormat || requestContext.targetFormat; + const classification = classifyProviderErrorForRequest( + provider, + result.status, + result.error, + 0, + { targetFormat } + ); if (classification.category === "request_schema") { - log.warn("REQUEST", `Non-retryable Codex request schema error (${result.status})`); - return result.response; + log.warn("REQUEST", `Non-retryable provider request schema error (${result.status})`, { provider }); + return setResponseErrorContext(result.response, { classification, targetFormat }); } // Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs) - const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs); + const { shouldFallback } = await markAccountUnavailable( + credentials.connectionId, + result.status, + result.error, + provider, + model, + result.resetsAtMs, + { targetFormat } + ); if (shouldFallback) { log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index ef38978413..3b615ec6ae 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1,6 +1,6 @@ import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb"; import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy"; -import { formatRetryAfter, classifyProviderError, isModelLockActive, buildModelLockUpdate, getModelLockUntil } from "open-sse/services/accountFallback.js"; +import { formatRetryAfter, classifyProviderErrorForRequest, isModelLockActive, buildModelLockUpdate, getModelLockUntil } from "open-sse/services/accountFallback.js"; import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js"; import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js"; import * as log from "../utils/logger.js"; @@ -204,10 +204,26 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu * @param {string} errorText * @param {string|null} provider * @param {string|null} model - The specific model that triggered the error + * @param {number|null} resetsAtMs - Optional provider-reported reset time + * @param {{targetFormat?: string}|null} classificationContext - Current outbound format * @returns {{ shouldFallback: boolean, cooldownMs: number }} */ -export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null) { +export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null, classificationContext = null) { if (!connectionId || connectionId === "noauth") return { shouldFallback: false, cooldownMs: 0 }; + + // Deterministic request-schema failures are request-scoped, even if an error + // wrapper carries a stale or provider-derived reset timestamp. + const preliminaryClassification = classifyProviderErrorForRequest( + provider, + status, + errorText, + 0, + classificationContext + ); + if (preliminaryClassification.category === "request_schema") { + return { shouldFallback: false, cooldownMs: 0 }; + } + const connections = await getProviderConnections({ provider }); const conn = connections.find(c => c.id === connectionId); const backoffLevel = conn?.backoffLevel || 0; @@ -219,7 +235,9 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); newBackoffLevel = 0; } else { - const classification = classifyProviderError(provider, status, errorText, backoffLevel); + const classification = backoffLevel === 0 + ? preliminaryClassification + : classifyProviderErrorForRequest(provider, status, errorText, backoffLevel, classificationContext); shouldFallback = classification.accountFallback; cooldownMs = classification.cooldownMs; newBackoffLevel = classification.newBackoffLevel; diff --git a/tests/unit/auth-model-lock-isolation.test.js b/tests/unit/auth-model-lock-isolation.test.js index b860f28688..1e0a5e087f 100644 --- a/tests/unit/auth-model-lock-isolation.test.js +++ b/tests/unit/auth-model-lock-isolation.test.js @@ -140,9 +140,17 @@ describe("model lock isolation", () => { mocks.getProviderConnections.mockResolvedValue([connection("account-a")]); const error = `[400]: ${JSON.stringify({ error: payload })}`; - const result = await markAccountUnavailable("account-a", 400, error, "codex", "gpt"); + const result = await markAccountUnavailable( + "account-a", + 400, + error, + "codex", + "gpt", + NOW.getTime() + 30000, + ); expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + expect(mocks.getProviderConnections).not.toHaveBeenCalled(); expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); }); diff --git a/tests/unit/base-finalize-outbound.test.js b/tests/unit/base-finalize-outbound.test.js new file mode 100644 index 0000000000..3289d5d43a --- /dev/null +++ b/tests/unit/base-finalize-outbound.test.js @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "../translator/registerAll.js"; + +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock("open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: fetchMock, +})); + +describe("BaseExecutor.finalizeOutboundRequest", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + status: 200, + headers: { get: () => null }, + }); + }); + + it("uses the finalized URL, headers, and body for the upstream fetch", async () => { + const { BaseExecutor } = await import("open-sse/executors/base.js"); + class FinalizingExecutor extends BaseExecutor { + finalizeOutboundRequest({ url, headers, transformedBody }) { + return { + url: `${url}?finalized=true`, + headers: { ...headers, "x-finalized": "yes" }, + transformedBody: { ...transformedBody, finalized: true }, + }; + } + } + + const executor = new FinalizingExecutor("fixture", { + baseUrl: "https://upstream.example.com/messages", + headers: {}, + retry: {}, + }); + const result = await executor.execute({ + model: "fixture-model", + body: { messages: [] }, + stream: false, + credentials: { apiKey: "fixture-key" }, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://upstream.example.com/messages?finalized=true", + expect.objectContaining({ + headers: expect.objectContaining({ "x-finalized": "yes" }), + body: JSON.stringify({ messages: [], finalized: true }), + }), + null + ); + expect(result.url).toBe("https://upstream.example.com/messages?finalized=true"); + expect(result.transformedBody.finalized).toBe(true); + }); + + it.each([ + ["minimax-m3", false, "claude"], + ["glm-5.2", true, "openai"], + ])("applies the shared policy to OpenCode Go route %s", async (model, keepsContext, requestFormat) => { + const { OpenCodeGoExecutor } = await import("open-sse/executors/opencode-go.js"); + const executor = new OpenCodeGoExecutor(); + const result = await executor.execute({ + model, + body: { messages: [], context_management: null }, + stream: false, + credentials: { apiKey: "connection-key" }, + }); + + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(Object.prototype.hasOwnProperty.call(sent, "context_management")).toBe(keepsContext); + expect(result.requestFormat).toBe(requestFormat); + }); + + it("keeps OpenCode's dormant messages route behind the same policy", async () => { + const { OpenCodeExecutor } = await import("open-sse/executors/opencode.js"); + const executor = new OpenCodeExecutor(); + executor.usesMessagesEndpoint = () => true; + + await executor.execute({ + model: "future-claude-model", + body: { messages: [], context_management: null }, + stream: false, + credentials: {}, + }); + + const sent = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sent).not.toHaveProperty("context_management"); + expect(fetchMock.mock.calls[0][0]).toContain("/messages"); + }); + + it("runs GitHub's direct messages fetch through the finalizer without forwarding client auth", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "fixture" }, + }), { status: 400 })); + const { GithubExecutor } = await import("open-sse/executors/github.js"); + class InspectableGithubExecutor extends GithubExecutor { + finalizeOutboundRequest(options) { + const finalized = super.finalizeOutboundRequest(options); + return { + ...finalized, + headers: { ...finalized.headers, "x-finalized": "yes" }, + }; + } + } + const executor = new InspectableGithubExecutor(); + + const result = await executor.executeWithMessagesEndpoint({ + model: "claude-opus-4.8", + body: { messages: [{ role: "user", content: "hello" }], context_management: null }, + stream: false, + credentials: { + copilotToken: "connection-token", + rawHeaders: { + authorization: "Bearer client-token", + "x-api-key": "client-key", + cookie: "client-cookie=1", + }, + }, + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe("Bearer connection-token"); + expect(options.headers["x-api-key"]).toBeUndefined(); + expect(options.headers.cookie).toBeUndefined(); + expect(options.headers["x-finalized"]).toBe("yes"); + expect(result.requestFormat).toBe("claude"); + }); +}); diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 24dee432ca..5900115dd9 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -34,9 +34,12 @@ describe("getCapabilitiesForModel", () => { "claude-opus-5", "claude-opus-5-thinking-agentic", "claude-sonnet-4.6", + "claude-sonnet-4.6-1m", "claude-sonnet-4-6-thinking", + "claude-sonnet-4.6-thinking-1m", "anthropic/claude-sonnet-4.7-fast-agentic", "vendor/claude-sonnet-5-2026-07-31", + "vendor/claude-opus-5-1m", "claude-fable-5", "anthropic/claude-fable-5-fast", ]) { diff --git a/tests/unit/chat-claude-schema-isolation.test.js b/tests/unit/chat-claude-schema-isolation.test.js new file mode 100644 index 0000000000..3dc94be033 --- /dev/null +++ b/tests/unit/chat-claude-schema-isolation.test.js @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + getSettings: vi.fn(), + getModelInfo: vi.fn(), + getComboModels: vi.fn(), + handleChatCore: vi.fn(), + checkAndRefreshToken: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("open-sse/index.js", () => ({})); + +vi.mock("@/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.getProviderCredentials, + markAccountUnavailable: mocks.markAccountUnavailable, + clearAccountError: mocks.clearAccountError, + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ getSettings: mocks.getSettings })); + +vi.mock("@/sse/services/model.js", () => ({ + getModelInfo: mocks.getModelInfo, + getComboModels: mocks.getComboModels, +})); + +vi.mock("open-sse/handlers/chatCore.js", () => ({ handleChatCore: mocks.handleChatCore })); + +vi.mock("@/sse/services/tokenRefresh.js", () => ({ + checkAndRefreshToken: mocks.checkAndRefreshToken, + updateProviderCredentials: vi.fn(), +})); + +vi.mock("@/lib/headroom/detect", () => ({ DEFAULT_HEADROOM_URL: "http://localhost:8787" })); +vi.mock("@/lib/pxpipe/loader.js", () => ({ getTransform: vi.fn(() => null) })); +vi.mock("@/lib/pxpipe/events.js", () => ({ appendPxpipeEvent: vi.fn() })); +vi.mock("open-sse/utils/bypassHandler.js", () => ({ handleBypassRequest: vi.fn(() => null) })); +vi.mock("open-sse/services/projectId.js", () => ({ getProjectIdForConnection: vi.fn() })); +vi.mock("@/sse/utils/logger.js", () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: mocks.logWarn, + error: vi.fn(), + maskKey: vi.fn(() => "masked"), +})); + +import { handleChat } from "../../src/sse/handlers/chat.js"; + +const SCHEMA_ERROR = `[anthropic/claude-opus-4-8] [400]: ${JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: "context_management: Extra inputs are not permitted", + }, +})} (reset after 16s)`; + +function account(id) { + return { + connectionId: id, + connectionName: id, + accessToken: "TOKEN", + providerSpecificData: {}, + _connection: { id }, + }; +} + +function request(overrides = {}) { + return new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-opus-4-8", + max_tokens: 1024, + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + context_management: null, + ...overrides, + }), + }); +} + +function openAIRequest(overrides = {}) { + return new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "minimax/MiniMax-M3", + max_tokens: 1024, + messages: [{ role: "user", content: "hello" }], + context_management: null, + ...overrides, + }), + }); +} + +describe("Claude schema 400 account isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireApiKey: false }); + mocks.getModelInfo.mockResolvedValue({ provider: "anthropic", model: "claude-opus-4-8" }); + mocks.getComboModels.mockResolvedValue(null); + mocks.getProviderCredentials.mockResolvedValue(account("anthropic-account-1")); + mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials); + mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: true, cooldownMs: 30000 }); + }); + + it("returns the original response without account writes, rotation, or cooldown", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("anthropic-account-1")) + .mockResolvedValueOnce(account("anthropic-account-2")); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: SCHEMA_ERROR, + response: originalResponse, + }); + + const response = await handleChat(request()); + + expect(response).toBe(originalResponse); + expect(mocks.getProviderCredentials).toHaveBeenCalledTimes(1); + expect(mocks.handleChatCore).toHaveBeenCalledTimes(1); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + expect(mocks.clearAccountError).not.toHaveBeenCalled(); + expect(response.headers.get("Retry-After")).toBeNull(); + expect([...response.headers.keys()]).not.toContain("x-9router-target-format"); + expect(mocks.logWarn).toHaveBeenCalledWith( + "REQUEST", + "Non-retryable provider request schema error (400)", + { provider: "anthropic" }, + ); + expect(mocks.logWarn.mock.calls.flat().join(" ")).not.toContain("Codex request schema"); + }); + + it("does not carry a Claude schema failure into the next valid request", async () => { + const failedResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { status: 400 }); + const successResponse = new Response(JSON.stringify({ id: "msg_ok", content: [] }), { status: 200 }); + mocks.getProviderCredentials + .mockReset() + .mockResolvedValueOnce(account("anthropic-account-1")) + .mockResolvedValueOnce(account("anthropic-account-1")); + mocks.handleChatCore + .mockReset() + .mockResolvedValueOnce({ success: false, status: 400, error: SCHEMA_ERROR, response: failedResponse }) + .mockImplementationOnce(async (options) => { + await options.onRequestSuccess(); + return { success: true, response: successResponse }; + }); + + const first = await handleChat(request()); + const second = await handleChat(request({ context_management: undefined })); + + expect(first.status).toBe(400); + expect(second).toBe(successResponse); + expect(await second.clone().text()).not.toContain("context_management"); + expect(JSON.stringify(mocks.handleChatCore.mock.calls[1][0].body)).not.toContain("context_management"); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + expect(mocks.clearAccountError).toHaveBeenCalledTimes(1); + }); + + it("keeps an OpenAI route on a mixed provider on normal account fallback", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: "response_format: Extra inputs are not permitted" } }), { + status: 400, + }); + mocks.getModelInfo.mockResolvedValue({ provider: "opencode-go", model: "glm-5.2" }); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: "response_format: Extra inputs are not permitted", + targetFormat: "openai", + response: originalResponse, + }); + mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: false, cooldownMs: 30000 }); + + const response = await handleChat(request({ model: "opencode-go/glm-5.2" })); + + expect(response).toBe(originalResponse); + expect(mocks.markAccountUnavailable).toHaveBeenCalledOnce(); + expect(mocks.markAccountUnavailable.mock.calls[0][6]).toEqual({ targetFormat: "openai" }); + }); + + it("isolates the Claude route on a mixed provider before account state writes", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { + status: 400, + }); + mocks.getModelInfo.mockResolvedValue({ provider: "opencode-go", model: "minimax-m3" }); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: SCHEMA_ERROR, + targetFormat: "claude", + response: originalResponse, + }); + + const response = await handleChat(request({ model: "opencode-go/minimax-m3" })); + + expect(response).toBe(originalResponse); + expect(mocks.getProviderCredentials).toHaveBeenCalledTimes(1); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); + + it("classifies a model-forced Claude route from OpenAI input using the same route resolver", async () => { + const originalResponse = new Response(JSON.stringify({ error: { message: SCHEMA_ERROR } }), { + status: 400, + }); + mocks.getModelInfo.mockResolvedValue({ provider: "minimax", model: "MiniMax-M3" }); + mocks.handleChatCore.mockResolvedValue({ + success: false, + status: 400, + error: SCHEMA_ERROR, + response: originalResponse, + }); + + const response = await handleChat(openAIRequest()); + + expect(response).toBe(originalResponse); + expect(mocks.handleChatCore).toHaveBeenCalledOnce(); + expect(mocks.handleChatCore.mock.calls[0][0].sourceFormatOverride).toBe("openai"); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/chat-core-request-route.test.js b/tests/unit/chat-core-request-route.test.js new file mode 100644 index 0000000000..d9e5900baf --- /dev/null +++ b/tests/unit/chat-core-request-route.test.js @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const executeMock = vi.hoisted(() => vi.fn()); + +vi.mock("open-sse/executors/index.js", () => ({ + getExecutor: () => ({ + noAuth: true, + getOutboundFormat: (_model, credentials) => credentials?.requestTargetFormat, + execute: executeMock, + }), +})); + +vi.mock("open-sse/utils/requestLogger.js", () => ({ + createRequestLogger: async () => ({ + logClientRawRequest: vi.fn(), + logRawRequest: vi.fn(), + logOpenAIRequest: vi.fn(), + logTargetRequest: vi.fn(), + logError: vi.fn(), + }), +})); + +vi.mock("@/lib/usageDb.js", () => ({ + trackPendingRequest: vi.fn(), + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), +})); + +import { handleChatCore } from "open-sse/handlers/chatCore.js"; + +describe("handleChatCore request route", () => { + beforeEach(() => { + vi.clearAllMocks(); + executeMock.mockResolvedValue({ + response: new Response(JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "fixture" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }), + url: "https://api.minimax.io/anthropic/v1/messages?beta=true", + headers: { "x-api-key": "fixture-key" }, + transformedBody: { messages: [] }, + requestFormat: "claude", + }); + }); + + it("passes one model-resolved Claude route to translation, transport, and error context", async () => { + const credentials = { apiKey: "fixture-key", providerSpecificData: {} }; + const result = await handleChatCore({ + body: { + model: "MiniMax-M3", + stream: false, + max_tokens: 256, + messages: [{ role: "user", content: "hello" }], + }, + modelInfo: { provider: "minimax", model: "MiniMax-M3" }, + credentials, + sourceFormatOverride: "openai", + connectionId: "route-fixture", + clientRawRequest: { + endpoint: "/v1/chat/completions", + headers: { accept: "application/json" }, + body: {}, + }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + expect(credentials.runtimeTransport?.format).toBe("claude"); + expect(credentials.requestTargetFormat).toBe("claude"); + expect(executeMock).toHaveBeenCalledOnce(); + expect(executeMock.mock.calls[0][0].credentials).toBe(credentials); + expect(executeMock.mock.calls[0][0].body.messages).toEqual([{ + role: "user", + content: [{ type: "text", text: "hello" }], + }]); + expect(result).toEqual(expect.objectContaining({ + success: false, + status: 400, + targetFormat: "claude", + })); + }); +}); diff --git a/tests/unit/claude-adaptive-family-executor.test.js b/tests/unit/claude-adaptive-family-executor.test.js new file mode 100644 index 0000000000..d83952bdb5 --- /dev/null +++ b/tests/unit/claude-adaptive-family-executor.test.js @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: (...args) => fetchMock(...args), +})); + +const { GithubExecutor } = await import("../../open-sse/executors/github.js"); + +describe("Claude adaptive family final outbound payload", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response("upstream fixture", { status: 400 })); + }); + + it.each([ + "claude-opus-4.6", + "claude-opus-4-8-thinking", + "vendor/claude-opus-4.8-fast-20260731", + "claude-sonnet-4.6-1m", + "claude-sonnet-4.6-thinking-1m", + "claude-sonnet-4.7-agentic", + "claude-sonnet-5-2026-07-31", + "claude-fable-5-fast", + ])("sends %s with adaptive thinking and the 128K output ceiling", async (model) => { + const executor = new GithubExecutor(); + const result = await executor.execute({ + model, + body: { + messages: [{ role: "user", content: "hi" }], + max_tokens: 200000, + reasoning_effort: "high", + }, + stream: true, + credentials: { copilotToken: "TOKEN" }, + signal: undefined, + log: null, + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toContain("/v1/messages"); + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody).toEqual(result.transformedBody); + expect(sentBody.max_tokens).toBe(128000); + expect(sentBody.output_config).toEqual({ effort: "high" }); + expect(sentBody.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(sentBody.thinking).not.toHaveProperty("budget_tokens"); + }); +}); diff --git a/tests/unit/claude-header-forwarding.test.js b/tests/unit/claude-header-forwarding.test.js index acad66a9d1..2fc8a3bc42 100644 --- a/tests/unit/claude-header-forwarding.test.js +++ b/tests/unit/claude-header-forwarding.test.js @@ -11,6 +11,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { FORMATS } from "open-sse/translator/formats.js"; // ─── claudeHeaderCache ──────────────────────────────────────────────────────── @@ -42,6 +43,7 @@ describe("claudeHeaderCache", () => { "x-stainless-helper-method": "stream", "x-stainless-retry-count": "0", "x-stainless-timeout": "600", + "x-claude-code-session-id": "session-must-not-be-global", "anthropic-dangerous-direct-browser-access": "true", // Non-identity header — should NOT be captured "content-type": "application/json", @@ -50,7 +52,8 @@ describe("claudeHeaderCache", () => { const cached = cacheModule.getCachedClaudeHeaders(); expect(cached).not.toBeNull(); expect(cached["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0"); - expect(cached["anthropic-beta"]).toBe("claude-code-20250219,oauth-2025-04-20"); + expect(cached["anthropic-beta"]).toBeUndefined(); + expect(cached["x-claude-code-session-id"]).toBeUndefined(); expect(cached["x-app"]).toBe("cli"); expect(cached["x-stainless-os"]).toBe("MacOS"); // Non-identity header must not leak in @@ -126,7 +129,7 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { const cache = await import("open-sse/utils/claudeHeaderCache.js"); cache.cacheClaudeHeaders({ "user-agent": "claude-code/2.1.63 node/24.3.0", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14", + "anthropic-beta": "request-only-beta-2099-01-01", "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", @@ -150,11 +153,12 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { // Live values should win over static providers.js values expect(headers["user-agent"]).toBe("claude-code/2.1.63 node/24.3.0"); - // Beta flags are MERGED (static + cached) to preserve required flags like oauth - const betaFlags = headers["anthropic-beta"].split(",").map(s => s.trim()); + // Request beta is intentionally excluded from the global identity cache. + const betaFlags = (headers["anthropic-beta"] || headers["Anthropic-Beta"]).split(",").map(s => s.trim()); expect(betaFlags).toContain("claude-code-20250219"); expect(betaFlags).toContain("oauth-2025-04-20"); expect(betaFlags).toContain("interleaved-thinking-2025-05-14"); + expect(betaFlags).not.toContain("request-only-beta-2099-01-01"); expect(headers["x-stainless-package-version"]).toBe("0.74.0"); expect(headers["x-stainless-os"]).toBe("MacOS"); }); @@ -163,9 +167,10 @@ describe("DefaultExecutor.buildHeaders() — claude provider", () => { const executor = new DefaultExecutor("claude"); const headers = executor.buildHeaders({ apiKey: "sk-test" }, true); - // Title-Case variants from providers.js must be gone + // Cached identity headers replace their Title-Case static variants. expect(headers["Anthropic-Version"]).toBeUndefined(); - expect(headers["Anthropic-Beta"]).toBeUndefined(); + // Beta remains static until request-scoped finalization canonicalizes it. + expect(headers["Anthropic-Beta"]).toBeDefined(); expect(headers["User-Agent"]).toBeUndefined(); expect(headers["X-App"]).toBeUndefined(); // Lowercase variants must be present @@ -228,11 +233,17 @@ describe("DefaultExecutor.buildHeaders() — claude provider cold start (no cach }); }); -// ─── anthropic-compatible header stripping ──────────────────────────────────── +// ─── anthropic-compatible final outbound policy ─────────────────────────────── -describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () => { +describe("DefaultExecutor.finalizeOutboundRequest() — anthropic-compatible stripping", () => { let DefaultExecutor; + function finalize(executor, credentials, body = { messages: [] }, stream = true) { + const url = executor.buildUrl("claude-test", stream, 0, credentials); + const headers = executor.buildHeaders(credentials, stream); + return executor.finalizeOutboundRequest({ url, headers, transformedBody: body, credentials }); + } + beforeEach(async () => { vi.resetModules(); const mod = await import("open-sse/executors/default.js"); @@ -241,29 +252,44 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () it("strips x-app and anthropic-dangerous-direct-browser-access for non-Anthropic host", () => { const executor = new DefaultExecutor("anthropic-compatible-custom"); - const headers = executor.buildHeaders( - { - apiKey: "key", - providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, - }, - true - ); + const credentials = { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }; + const initialHeaders = executor.buildHeaders(credentials, true); + initialHeaders["x-app"] = "cli"; + initialHeaders["anthropic-dangerous-direct-browser-access"] = "true"; + initialHeaders["x-claude-code-session-id"] = "stale-global-session"; + const { headers } = executor.finalizeOutboundRequest({ + url: executor.buildUrl("claude-test", true, 0, credentials), + headers: initialHeaders, + transformedBody: { messages: [] }, + credentials, + }); expect(headers["x-app"]).toBeUndefined(); expect(headers["X-App"]).toBeUndefined(); expect(headers["anthropic-dangerous-direct-browser-access"]).toBeUndefined(); expect(headers["Anthropic-Dangerous-Direct-Browser-Access"]).toBeUndefined(); + expect(headers["x-claude-code-session-id"]).toBeUndefined(); }); it("removes claude-code-20250219 from anthropic-beta for non-Anthropic host", () => { const executor = new DefaultExecutor("anthropic-compatible-custom"); - const headers = executor.buildHeaders( - { - apiKey: "key", - providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, - }, - true - ); + const credentials = { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }; + const initialHeaders = { + ...executor.buildHeaders(credentials, true), + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }; + const { headers } = executor.finalizeOutboundRequest({ + url: executor.buildUrl("claude-test", true, 0, credentials), + headers: initialHeaders, + transformedBody: { messages: [] }, + credentials, + }); const betaVal = headers["anthropic-beta"] || headers["Anthropic-Beta"] || ""; expect(betaVal).not.toContain("claude-code-20250219"); @@ -271,15 +297,20 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () it("keeps other beta flags intact after stripping", () => { const executor = new DefaultExecutor("anthropic-compatible-custom"); - // The static CLAUDE_API_HEADERS used by anthropic-compatible providers include - // 'interleaved-thinking-2025-05-14' — check it survives stripping - const headers = executor.buildHeaders( - { - apiKey: "key", - providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, - }, - false - ); + const credentials = { + apiKey: "key", + providerSpecificData: { baseUrl: "https://myproxy.example.com/v1" }, + }; + const initialHeaders = { + ...executor.buildHeaders(credentials, false), + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }; + const { headers } = executor.finalizeOutboundRequest({ + url: executor.buildUrl("claude-test", false, 0, credentials), + headers: initialHeaders, + transformedBody: { messages: [] }, + credentials, + }); const betaVal = headers["anthropic-beta"] || headers["Anthropic-Beta"] || ""; // If any beta value remains it should not be empty and should not have the stripped value @@ -290,13 +321,11 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () it("does NOT strip headers when baseUrl is api.anthropic.com", () => { const executor = new DefaultExecutor("anthropic-compatible-official"); - const headers = executor.buildHeaders( - { - apiKey: "key", - providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, - }, - true - ); + const credentials = { + apiKey: "key", + providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, + }; + const { headers } = finalize(executor, credentials); // No stripping — anthropic-version should survive const hasVersion = @@ -306,13 +335,8 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () it("does NOT strip headers when baseUrl is empty (defaults to Anthropic)", () => { const executor = new DefaultExecutor("anthropic-compatible-official"); - const headers = executor.buildHeaders( - { - apiKey: "key", - providerSpecificData: {}, - }, - true - ); + const credentials = { apiKey: "key", providerSpecificData: {} }; + const { headers } = finalize(executor, credentials); const hasVersion = headers["Anthropic-Version"] || headers["anthropic-version"]; @@ -320,6 +344,239 @@ describe("DefaultExecutor.buildHeaders() — anthropic-compatible stripping", () }); }); +describe("DefaultExecutor.finalizeOutboundRequest() — Anthropic beta policy", () => { + let DefaultExecutor; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("open-sse/executors/default.js"); + DefaultExecutor = mod.DefaultExecutor || mod.default; + }); + + function finalize(provider, { url, body, credentials = {}, headers = {} }) { + const executor = new DefaultExecutor(provider); + return executor.finalizeOutboundRequest({ url, headers, transformedBody: body, credentials }); + } + + it("derives all required beta flags from the final body for the official endpoint", () => { + const credentials = { + apiKey: "connection-key", + rawHeaders: { + "anthropic-beta": "future-feature-2099-01-01,invalid beta,header\r\ninjection", + "x-claude-code-session-id": "session-current-request", + authorization: "Bearer client-token", + "x-api-key": "client-key", + cookie: "session=client-cookie", + "x-arbitrary": "not-forwarded", + }, + }; + const executor = new DefaultExecutor("anthropic"); + const headers = executor.buildHeaders(credentials, true); + const body = { + messages: [], + context_management: null, + output_config: { effort: null, format: null }, + tools: [{ name: "search", input_examples: [] }], + }; + const finalized = executor.finalizeOutboundRequest({ + url: "https://api.anthropic.com/v1/messages", + headers, + transformedBody: body, + credentials, + }); + + const betaFlags = finalized.headers["anthropic-beta"].split(","); + expect(betaFlags).toEqual(expect.arrayContaining([ + "claude-code-20250219", + "interleaved-thinking-2025-05-14", + "future-feature-2099-01-01", + "context-management-2025-06-27", + "effort-2025-11-24", + "advanced-tool-use-2025-11-20", + "structured-outputs-2025-12-15", + ])); + expect(betaFlags).not.toContain("invalid beta"); + expect(finalized.transformedBody.context_management).toBeNull(); + expect(finalized.headers["x-api-key"]).toBe("connection-key"); + expect(finalized.headers["x-claude-code-session-id"]).toBe("session-current-request"); + expect(finalized.headers.Authorization).toBeUndefined(); + expect(finalized.headers.cookie).toBeUndefined(); + expect(finalized.headers["x-arbitrary"]).toBeUndefined(); + }); + + it.each([ + "session-with\r\ninjected-header", + "\r\nsession-with-leading-newline", + "session-with-trailing-newline\r\n", + "x".repeat(257), + `${" ".repeat(257)}session-after-overlong-whitespace`, + ])("rejects an invalid request-scoped Claude session ID", sessionId => { + const result = finalize("anthropic", { + url: "https://api.anthropic.com/v1/messages", + credentials: { rawHeaders: { "x-claude-code-session-id": sessionId } }, + headers: { "x-claude-code-session-id": "stale-global-session" }, + body: { messages: [] }, + }); + + expect(result.headers["x-claude-code-session-id"]).toBeUndefined(); + }); + + it.each([ + { + provider: "claude", + credentials: { accessToken: "oauth-token" }, + expectedAuth: ["Authorization", "Bearer oauth-token"], + }, + { + provider: "anthropic-compatible-official", + credentials: { + apiKey: "compatible-key", + providerSpecificData: { baseUrl: "https://api.anthropic.com/v1" }, + }, + expectedAuth: ["x-api-key", "compatible-key"], + }, + ])("applies the official policy for $provider", ({ provider, credentials, expectedAuth }) => { + const executor = new DefaultExecutor(provider); + const body = { messages: [], context_management: { edits: [] } }; + const url = executor.buildUrl("claude-test", true, 0, credentials); + const result = executor.finalizeOutboundRequest({ + url, + headers: executor.buildHeaders(credentials, true), + transformedBody: body, + credentials, + }); + + expect(result.transformedBody.context_management).toEqual({ edits: [] }); + expect(result.headers["anthropic-beta"]).toContain("context-management-2025-06-27"); + expect(result.headers[expectedAuth[0]]).toBe(expectedAuth[1]); + if (provider.startsWith("anthropic-compatible-")) { + expect(result.headers.Authorization).toBeUndefined(); + } + }); + + it.each([ + "http://api.anthropic.com/v1/messages", + "https://api.anthropic.com.evil.example/v1/messages", + "https://anthropic.example.com/v1/messages", + ])("treats %s as non-official and removes context management", url => { + const result = finalize("anthropic-compatible-custom", { + url, + headers: { + "Anthropic-Beta": "context-management-2025-06-27,interleaved-thinking-2025-05-14", + }, + body: { messages: [], context_management: null }, + }); + + expect(result.transformedBody).not.toHaveProperty("context_management"); + expect(result.headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14"); + }); + + it("allows an internal Claude transport to opt in to explicit beta features", () => { + const credentials = { + rawHeaders: { "anthropic-beta": "allowed-future-2099-01-01,blocked-future-2099-01-01" }, + runtimeTransport: { + format: FORMATS.CLAUDE, + quirks: { + anthropicBetaFeatures: [ + "context-management-2025-06-27", + "allowed-future-2099-01-01", + ], + }, + }, + }; + const result = finalize("anthropic-compatible-internal", { + url: "https://internal-claude.example.com/messages", + credentials, + headers: {}, + body: { messages: [], context_management: null }, + }); + + expect(result.transformedBody.context_management).toBeNull(); + expect(result.headers["anthropic-beta"].split(",")).toEqual([ + "allowed-future-2099-01-01", + "context-management-2025-06-27", + ]); + }); + + it("does not fall back to provider quirks when a runtime Claude transport is selected", () => { + const executor = new DefaultExecutor("kimi"); + executor.config = { + ...executor.config, + quirks: { anthropicBetaFeatures: ["context-management-2025-06-27"] }, + }; + const credentials = { + runtimeTransport: { format: FORMATS.CLAUDE, quirks: {} }, + }; + const result = executor.finalizeOutboundRequest({ + url: "https://internal-claude.example.com/messages", + headers: { "Anthropic-Beta": "context-management-2025-06-27" }, + transformedBody: { messages: [], context_management: null }, + credentials, + }); + + expect(result.transformedBody).not.toHaveProperty("context_management"); + expect(result.headers["anthropic-beta"]).toBeUndefined(); + }); + + it.each(["kimi", "minimax"])("leaves %s OpenAI runtime transport requests untouched", provider => { + const executor = new DefaultExecutor(provider); + const runtimeTransport = executor.config.transports.find(transport => transport.format === FORMATS.OPENAI); + const credentials = { + apiKey: "runtime-key", + runtimeTransport, + rawHeaders: { + "anthropic-beta": "request-beta-2099-01-01", + "x-claude-code-session-id": "request-session", + }, + }; + const url = executor.buildUrl("runtime-model", true, 0, credentials); + const headers = executor.buildHeaders(credentials, true); + const body = { messages: [], context_management: null }; + const result = executor.finalizeOutboundRequest({ + url, + headers, + transformedBody: body, + credentials, + }); + + expect(result.headers).toBe(headers); + expect(result.transformedBody).toBe(body); + expect(result.transformedBody.context_management).toBeNull(); + expect(result.headers["anthropic-beta"]).toBeUndefined(); + expect(result.headers["x-claude-code-session-id"]).toBeUndefined(); + }); + + it("keeps request beta flags and session IDs isolated across concurrent finalization", async () => { + const executor = new DefaultExecutor("anthropic"); + const run = (beta, sessionId) => Promise.resolve().then(() => executor.finalizeOutboundRequest({ + url: "https://api.anthropic.com/v1/messages", + headers: { + "Anthropic-Beta": "static-beta-2025-01-01", + "x-claude-code-session-id": "stale-global-session", + }, + transformedBody: { messages: [] }, + credentials: { + rawHeaders: { + "anthropic-beta": beta, + "x-claude-code-session-id": sessionId, + }, + }, + })); + + const [first, second] = await Promise.all([ + run("request-one-2099-01-01", "session-one"), + run("request-two-2099-01-01", "session-two"), + ]); + + expect(first.headers["anthropic-beta"]).toContain("request-one-2099-01-01"); + expect(first.headers["anthropic-beta"]).not.toContain("request-two-2099-01-01"); + expect(second.headers["anthropic-beta"]).toContain("request-two-2099-01-01"); + expect(second.headers["anthropic-beta"]).not.toContain("request-one-2099-01-01"); + expect(first.headers["x-claude-code-session-id"]).toBe("session-one"); + expect(second.headers["x-claude-code-session-id"]).toBe("session-two"); + }); +}); + // ─── proxyFetch anthropicFetch routing ──────────────────────────────────────── describe("proxyAwareFetch — api.anthropic.com routing", () => { diff --git a/tests/unit/claude-schema-fallback.test.js b/tests/unit/claude-schema-fallback.test.js new file mode 100644 index 0000000000..38aa0e8444 --- /dev/null +++ b/tests/unit/claude-schema-fallback.test.js @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const authMocks = vi.hoisted(() => ({ + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), + getSettings: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: authMocks.getProviderConnections, + updateProviderConnection: authMocks.updateProviderConnection, + getSettings: authMocks.getSettings, + getProxyPools: vi.fn().mockResolvedValue([]), + validateApiKey: vi.fn(), +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: vi.fn().mockResolvedValue({ + connectionProxyEnabled: false, + connectionProxyUrl: "", + connectionNoProxy: "", + proxyPoolId: null, + vercelRelayUrl: "", + }), + pickProxyPoolId: vi.fn(), +})); + +vi.mock("@/shared/constants/providers.js", () => ({ + resolveProviderId: vi.fn((provider) => provider), + FREE_PROVIDERS: {}, +})); + +vi.mock("@/sse/utils/logger.js", () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), +})); + +import { + classifyProviderError, + classifyProviderErrorForRequest, + isClaudeRequestSchemaError, + isClaudeRequestSchemaErrorForRequest, + setResponseErrorContext, +} from "../../open-sse/services/accountFallback.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { handleComboChat } from "../../open-sse/services/combo.js"; +import { markAccountUnavailable } from "../../src/sse/services/auth.js"; + +const CLAUDE_SCHEMA_ERROR = { + type: "error", + error: { + type: "invalid_request_error", + message: "context_management: Extra inputs are not permitted", + }, + request_id: "req_probe", +}; + +const SCREENSHOT_ERROR = `[anthropic/claude-opus-4-8] [400]: ${JSON.stringify(CLAUDE_SCHEMA_ERROR)} (reset after 16s)`; +const CLASSIFICATION = { + category: "request_schema", + accountFallback: false, + cooldownMs: 0, + comboScope: "provider", +}; +const log = { info: vi.fn(), warn: vi.fn() }; + +function errorResponse(status, error) { + return new Response(JSON.stringify({ error }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Claude request schema classification", () => { + beforeEach(() => { + vi.clearAllMocks(); + authMocks.getSettings.mockResolvedValue({ fallbackStrategy: "fill-first" }); + authMocks.updateProviderConnection.mockResolvedValue(undefined); + }); + + it.each([ + ["Anthropic error envelope", "anthropic", CLAUDE_SCHEMA_ERROR], + ["raw JSON", "anthropic", JSON.stringify(CLAUDE_SCHEMA_ERROR)], + ["status-wrapped JSON", "anthropic", `[400]: ${JSON.stringify(CLAUDE_SCHEMA_ERROR)}`], + ["screenshot wrapper with stale cooldown", "anthropic", SCREENSHOT_ERROR], + ["missing inner type", "anthropic", { error: { message: "messages.4.content.1.cache_control: Extra inputs are not permitted" } }], + ["quoted field path", "anthropic", { error: { message: "'context_management': Extra inputs are not permitted" } }], + ["Claude OAuth provider", "claude", CLAUDE_SCHEMA_ERROR], + ["Anthropic-compatible provider", "anthropic-compatible-company", CLAUDE_SCHEMA_ERROR], + ["invalid anthropic-beta value", "anthropic", { + type: "error", + error: { + type: "invalid_request_error", + message: "anthropic-beta header contains an unsupported value: future-beta-2099-01-01", + }, + }], + ])("classifies %s without account fallback", (_name, provider, value) => { + expect(isClaudeRequestSchemaError(provider, 400, value)).toBe(true); + expect(classifyProviderError(provider, 400, value)).toEqual(CLASSIFICATION); + }); + + it.each([ + ["DeepSeek Claude transport", "deepseek"], + ["OpenCode Go Claude model", "opencode-go"], + ["Kimi Claude transport", "kimi"], + ])("classifies %s only for the current Claude wire format", (_name, provider) => { + expect(isClaudeRequestSchemaErrorForRequest(FORMATS.CLAUDE, 400, CLAUDE_SCHEMA_ERROR)).toBe(true); + expect(classifyProviderErrorForRequest(provider, 400, CLAUDE_SCHEMA_ERROR, 0, { + targetFormat: FORMATS.CLAUDE, + })).toEqual(CLASSIFICATION); + }); + + it.each(["opencode-go", "deepseek", "kimi"])( + "keeps %s OpenAI route schema errors on normal fallback", + provider => { + const openaiError = { + type: "invalid_request_error", + message: "response_format: Extra inputs are not permitted", + }; + expect(isClaudeRequestSchemaErrorForRequest(FORMATS.OPENAI, 400, openaiError)).toBe(false); + expect(classifyProviderErrorForRequest(provider, 400, openaiError, 0, { + targetFormat: FORMATS.OPENAI, + })).toEqual({ + category: "provider_error", + accountFallback: true, + cooldownMs: 30000, + comboScope: "model", + }); + } + ); + + it.each([ + ["non-Claude provider", "openai", 400, CLAUDE_SCHEMA_ERROR], + ["unauthorized", "anthropic", 401, CLAUDE_SCHEMA_ERROR], + ["forbidden", "anthropic", 403, CLAUDE_SCHEMA_ERROR], + ["rate limit", "anthropic", 429, CLAUDE_SCHEMA_ERROR], + ["server error", "anthropic", 500, CLAUDE_SCHEMA_ERROR], + ["invalid_prompt code", "anthropic", 400, { error: { type: "invalid_request_error", code: "invalid_prompt", message: "context_management: Extra inputs are not permitted" } }], + ["invalid_prompt type", "anthropic", 400, { error: { type: "invalid_prompt", message: "context_management: Extra inputs are not permitted" } }], + ["invalid_prompt message", "anthropic", 400, { error: { type: "invalid_request_error", message: "Invalid prompt: context_management: Extra inputs are not permitted" } }], + ["context limit", "anthropic", 400, { error: { type: "invalid_request_error", message: "prompt is too long: 220000 tokens > 200000 maximum" } }], + ["model permission", "anthropic", 400, { error: { type: "invalid_request_error", message: "You do not have access to model claude-opus-4-8" } }], + ["account beta permission", "anthropic", 400, { error: { type: "invalid_request_error", message: "Your account does not have permission to use the anthropic-beta header value" } }], + ["unsupported beta for account", "anthropic", 400, { error: { type: "invalid_request_error", message: "Unsupported anthropic-beta header value for this account" } }], + ["organization beta entitlement", "anthropic", 400, { error: { type: "invalid_request_error", message: "The anthropic-beta header value is not enabled for this organization" } }], + ["model beta entitlement", "anthropic", 400, { error: { type: "invalid_request_error", message: "Model claude-opus-4-8 does not have access to this anthropic-beta header value" } }], + ["model not found", "anthropic", 400, { error: { type: "invalid_request_error", message: "model: claude-opus-4-8 not found" } }], + ["capacity", "anthropic", 400, { error: { type: "invalid_request_error", message: "Selected model is at capacity" } }], + ["overloaded type", "anthropic", 400, { error: { type: "overloaded_error", message: "context_management: Extra inputs are not permitted" } }], + ["no field path", "anthropic", 400, { error: { type: "invalid_request_error", message: "Extra inputs are not permitted" } }], + ["unrelated invalid request", "anthropic", 400, { error: { type: "invalid_request_error", message: "temperature must be between 0 and 1" } }], + ])("does not classify %s", (_name, provider, status, value) => { + expect(isClaudeRequestSchemaError(provider, status, value)).toBe(false); + }); + + it("does not persist an account lock when the defensive marker receives the schema 400", async () => { + authMocks.getProviderConnections.mockResolvedValue([{ + id: "anthropic-account-1", + provider: "anthropic", + displayName: "anthropic-account-1", + backoffLevel: 0, + }]); + + const result = await markAccountUnavailable( + "anthropic-account-1", + 400, + SCREENSHOT_ERROR, + "anthropic", + "claude-opus-4-8", + ); + + expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + expect(authMocks.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("does not persist an account lock for a provider with Claude-target models", async () => { + authMocks.getProviderConnections.mockResolvedValue([{ + id: "opencode-go-account-1", + provider: "opencode-go", + displayName: "opencode-go-account-1", + backoffLevel: 0, + }]); + + const result = await markAccountUnavailable( + "opencode-go-account-1", + 400, + CLAUDE_SCHEMA_ERROR, + "opencode-go", + "minimax-m3", + null, + { targetFormat: FORMATS.CLAUDE }, + ); + + expect(result).toEqual({ shouldFallback: false, cooldownMs: 0 }); + expect(authMocks.updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("persists normal fallback for an OpenAI route on the same mixed provider", async () => { + authMocks.getProviderConnections.mockResolvedValue([{ + id: "opencode-go-account-1", + provider: "opencode-go", + displayName: "opencode-go-account-1", + backoffLevel: 0, + }]); + + const result = await markAccountUnavailable( + "opencode-go-account-1", + 400, + { type: "invalid_request_error", message: "response_format: Extra inputs are not permitted" }, + "opencode-go", + "glm-5.2", + null, + { targetFormat: FORMATS.OPENAI }, + ); + + expect(result).toEqual({ shouldFallback: true, cooldownMs: 30000 }); + expect(authMocks.updateProviderConnection).toHaveBeenCalledOnce(); + }); + + it.each([ + "Your account does not have permission to use the anthropic-beta header value", + "Unsupported anthropic-beta header value for this account", + "The anthropic-beta header value is not enabled for this organization", + "Model claude-opus-4-8 does not have access to this anthropic-beta header value", + ])("keeps beta permission errors on normal account fallback: %s", message => { + expect(classifyProviderError("anthropic", 400, { + error: { type: "invalid_request_error", message }, + })).toEqual({ + category: "provider_error", + accountFallback: true, + cooldownMs: 30000, + comboScope: "model", + }); + }); +}); + +describe("Claude provider-scoped Combo fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips the remaining Anthropic models after the first schema 400", async () => { + const firstResponse = errorResponse(400, CLAUDE_SCHEMA_ERROR); + const handleSingleModel = vi.fn().mockResolvedValue(firstResponse); + + const response = await handleComboChat({ + body: {}, + models: ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-6"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResponse); + expect(handleSingleModel).toHaveBeenCalledTimes(1); + }); + + it("continues a heterogeneous Combo after the Anthropic schema 400", async () => { + const calls = []; + const handleSingleModel = vi.fn(async (_body, model) => { + calls.push(model); + return model.startsWith("openai/") + ? new Response("ok", { status: 200 }) + : errorResponse(400, CLAUDE_SCHEMA_ERROR); + }); + + const response = await handleComboChat({ + body: {}, + models: ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-6", "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(calls).toEqual(["anthropic/claude-opus-4-8", "openai/gpt-5.5"]); + }); + + it("returns the first Claude schema 400 when other providers also fail", async () => { + const firstResponse = errorResponse(400, CLAUDE_SCHEMA_ERROR); + const handleSingleModel = vi.fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(errorResponse(429, { type: "rate_limit_error", message: "rate limit" })); + + const response = await handleComboChat({ + body: {}, + models: ["anthropic/claude-opus-4-8", "openai/gpt-5.5"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response).toBe(firstResponse); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); + + it.each([429, 502, 503, 504])("keeps normal fallback for HTTP %s", async (status) => { + const handleSingleModel = vi.fn() + .mockResolvedValueOnce(errorResponse(status, { message: status === 429 ? "rate limit" : "upstream unavailable" })) + .mockResolvedValueOnce(new Response("ok", { status: 200 })); + + const response = await handleComboChat({ + body: {}, + models: ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-6"], + handleSingleModel, + log, + autoSwitch: false, + }); + + expect(response.status).toBe(200); + expect(handleSingleModel).toHaveBeenCalledTimes(2); + }); + + it("blocks only the rejected Claude format within a mixed provider", async () => { + const calls = []; + const claudeError = errorResponse(400, CLAUDE_SCHEMA_ERROR); + setResponseErrorContext(claudeError, { + classification: CLASSIFICATION, + targetFormat: FORMATS.CLAUDE, + }); + const contexts = { + "opencode-go/minimax-m3": { provider: "opencode-go", targetFormat: FORMATS.CLAUDE }, + "opencode-go/qwen3.7-max": { provider: "opencode-go", targetFormat: FORMATS.CLAUDE }, + "opencode-go/glm-5.2": { provider: "opencode-go", targetFormat: FORMATS.OPENAI }, + }; + const handleSingleModel = vi.fn(async (_body, model) => { + calls.push(model); + return model.endsWith("glm-5.2") ? new Response("ok", { status: 200 }) : claudeError; + }); + + const response = await handleComboChat({ + body: {}, + models: Object.keys(contexts), + handleSingleModel, + log, + autoSwitch: false, + resolveModelContext: async model => contexts[model], + }); + + expect(response.status).toBe(200); + expect(calls).toEqual(["opencode-go/minimax-m3", "opencode-go/glm-5.2"]); + }); + + it("does not let an OpenAI schema 400 block the Claude route on the same provider", async () => { + const calls = []; + const contexts = { + "opencode-go/glm-5.2": { provider: "opencode-go", targetFormat: FORMATS.OPENAI }, + "opencode-go/minimax-m3": { provider: "opencode-go", targetFormat: FORMATS.CLAUDE }, + }; + const handleSingleModel = vi.fn(async (_body, model) => { + calls.push(model); + return model.endsWith("minimax-m3") + ? new Response("ok", { status: 200 }) + : errorResponse(400, { + type: "invalid_request_error", + message: "response_format: Extra inputs are not permitted", + }); + }); + + const response = await handleComboChat({ + body: {}, + models: Object.keys(contexts), + handleSingleModel, + log, + autoSwitch: false, + resolveModelContext: async model => contexts[model], + }); + + expect(response.status).toBe(200); + expect(calls).toEqual(Object.keys(contexts)); + }); +}); diff --git a/tests/unit/codex-function-call-item-id.test.js b/tests/unit/codex-function-call-item-id.test.js index b1c2143dfc..645f3f2ac4 100644 --- a/tests/unit/codex-function-call-item-id.test.js +++ b/tests/unit/codex-function-call-item-id.test.js @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { CodexExecutor } from "../../open-sse/executors/codex.js"; -import { DefaultExecutor } from "../../open-sse/executors/default.js"; function transformBody(input, overrides = {}) { const executor = new CodexExecutor(); @@ -87,7 +86,7 @@ describe("CodexExecutor stateless item IDs", () => { }); }); - it("preserves valid IDs for every known non-tool item type and removes invalid prefixes", () => { + it("preserves every non-tool item ID without applying generic prefix validation", () => { const fixtures = [ ["additional_tools", "at", { tools: [] }], ["message", "msg", { role: "assistant", content: [{ type: "output_text", text: "ok" }] }], @@ -112,39 +111,28 @@ describe("CodexExecutor stateless item IDs", () => { const input = transformInput(source); - fixtures.forEach(([, prefix, payload], fixtureIndex) => { - const items = input.slice(fixtureIndex * 5, fixtureIndex * 5 + 5); - expect(items[0]).toEqual(expect.objectContaining({ id: `${prefix}_valid_1`, ...payload })); - for (const item of items.slice(1)) { - expect(item).toEqual(expect.objectContaining(payload)); - expect(item).not.toHaveProperty("id"); - } - }); + expect(input).toEqual(source); expect(source).toEqual(snapshot); }); - it("validates implicit message IDs when the item only has a role", () => { + it("preserves implicit message IDs when the item only has a role", () => { const input = transformInput([ { id: "msg_valid_1", role: "user", content: "hello" }, { id: "item_replayed_1", role: "user", content: "again" }, ]); expect(input[0]).toEqual({ id: "msg_valid_1", role: "user", content: "hello" }); - expect(input[1]).toEqual({ role: "user", content: "again" }); + expect(input[1]).toEqual({ id: "item_replayed_1", role: "user", content: "again" }); }); - it("removes generic replay IDs from unknown canonical item types but preserves plausible typed IDs", () => { + it("preserves IDs from unknown or future item types", () => { const source = [ { type: "computer_call", id: "item_computer", call_id: "call_computer", action: { type: "screenshot" } }, { type: "apply_patch_call", id: "patch_1", call_id: "call_patch", operation: { type: "update_file" } }, { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, ]; - expect(transformInput(source)).toEqual([ - { type: "computer_call", call_id: "call_computer", action: { type: "screenshot" } }, - { type: "apply_patch_call", id: "patch_1", call_id: "call_patch", operation: { type: "update_file" } }, - { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, - ]); + expect(transformInput(source)).toEqual(source); }); it("removes bare stored references and item_reference objects only", () => { @@ -259,6 +247,7 @@ describe("CodexExecutor stateless item IDs", () => { expect(transformed).not.toBe(source); expect(transformed.input[0]).toEqual({ type: "message", + id: "item_system", role: "developer", content: [{ type: "input_text", text: "system prompt", metadata: { keep: true } }], }); @@ -291,67 +280,3 @@ describe("CodexExecutor stateless item IDs", () => { } }); }); - -describe("OpenAI-compatible Responses stateless item IDs", () => { - it("applies the same prefix validation without mutating the source request", () => { - const executor = new DefaultExecutor("openai-compatible-responses-local"); - const body = { - model: "TARGET", - store: false, - input: [ - { type: "custom_tool_call", id: "item_probe_58", call_id: "call_58", name: "tool", input: "PAYLOAD" }, - { type: "message", id: "item_message", role: "assistant", content: "hello" }, - { type: "reasoning", id: "rs_valid_1", encrypted_content: "ENCRYPTED" }, - { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, - ], - }; - const snapshot = structuredClone(body); - - const transformed = executor.transformRequest("TARGET", body); - - expect(body).toEqual(snapshot); - expect(transformed.input).toEqual([ - { type: "custom_tool_call", call_id: "call_58", name: "tool", input: "PAYLOAD" }, - { type: "message", role: "assistant", content: "hello" }, - { type: "reasoning", id: "rs_valid_1", encrypted_content: "ENCRYPTED" }, - { type: "future_response_item", id: "future_1", payload: "PAYLOAD" }, - ]); - }); - - it("normalizes stateless IDs for static Responses providers using DefaultExecutor", () => { - const executor = new DefaultExecutor("perplexity-agent"); - const body = { - model: "openai/gpt-5.5", - store: false, - input: [{ - type: "custom_tool_call", - id: "item_probe_58", - call_id: "call_58", - name: "tool", - input: "PAYLOAD", - }], - }; - - expect(executor.transformRequest("openai/gpt-5.5", body).input).toEqual([{ - type: "custom_tool_call", - call_id: "call_58", - name: "tool", - input: "PAYLOAD", - }]); - }); - - it("does not sanitize stateful or Chat Completions-compatible requests", () => { - const input = [{ - type: "custom_tool_call", - id: "item_stateful", - call_id: "call_stateful", - name: "tool", - input: "PAYLOAD", - }]; - const responsesExecutor = new DefaultExecutor("openai-compatible-responses-local"); - const chatExecutor = new DefaultExecutor("openai-compatible-local"); - - expect(responsesExecutor.transformRequest("TARGET", { store: true, input }).input).toEqual(input); - expect(chatExecutor.transformRequest("TARGET", { store: false, input }).input).toEqual(input); - }); -}); diff --git a/tests/unit/request-route-resolution.test.js b/tests/unit/request-route-resolution.test.js new file mode 100644 index 0000000000..085eb0b746 --- /dev/null +++ b/tests/unit/request-route-resolution.test.js @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock("open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: fetchMock, +})); + +import { getExecutor } from "open-sse/executors/index.js"; +import { resolveRequestRoute } from "open-sse/services/provider.js"; +import { translateRequest } from "open-sse/translator/index.js"; +import { FORMATS } from "open-sse/translator/formats.js"; + +function getHeader(headers, name) { + const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === name.toLowerCase()); + return key ? headers[key] : undefined; +} + +async function executeOpenAIRequest({ provider, model, credentials }) { + const sourceBody = { + model, + stream: false, + max_tokens: 256, + messages: [{ role: "user", content: "hello" }], + }; + const route = resolveRequestRoute(provider, model, FORMATS.OPENAI); + const requestCredentials = { + ...credentials, + runtimeTransport: route.runtimeTransport, + requestTargetFormat: route.targetFormat, + }; + const translatedBody = translateRequest( + FORMATS.OPENAI, + route.targetFormat, + model, + structuredClone(sourceBody), + false, + requestCredentials, + provider, + ); + + const result = await getExecutor(provider).execute({ + model, + body: translatedBody, + stream: false, + credentials: requestCredentials, + }); + const [url, options] = fetchMock.mock.calls.at(-1); + + return { + route, + result, + url, + headers: options.headers, + body: JSON.parse(options.body), + }; +} + +describe("request route resolution", () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + status: 200, + headers: { get: () => null }, + }); + }); + + it("uses a model target format to select the matching transport atomically", () => { + const route = resolveRequestRoute("minimax", "MiniMax-M3", FORMATS.OPENAI); + + expect(route.targetFormat).toBe(FORMATS.CLAUDE); + expect(route.runtimeTransport?.format).toBe(FORMATS.CLAUDE); + expect(route.runtimeTransport?.baseUrl).toContain("/anthropic/v1/messages"); + }); + + it("uses the source-matched transport when the model has no override", () => { + const openaiRoute = resolveRequestRoute("kimi", "kimi-k3", FORMATS.OPENAI); + const claudeRoute = resolveRequestRoute("kimi", "kimi-k3", FORMATS.CLAUDE); + + expect(openaiRoute).toEqual(expect.objectContaining({ targetFormat: FORMATS.OPENAI })); + expect(openaiRoute.runtimeTransport?.format).toBe(FORMATS.OPENAI); + expect(claudeRoute).toEqual(expect.objectContaining({ targetFormat: FORMATS.CLAUDE })); + expect(claudeRoute.runtimeTransport?.format).toBe(FORMATS.CLAUDE); + }); + + it("sends MiniMax M3 OpenAI input through the Claude endpoint and wire format", async () => { + const outbound = await executeOpenAIRequest({ + provider: "minimax", + model: "MiniMax-M3", + credentials: { apiKey: "minimax-key" }, + }); + + expect(outbound.route.targetFormat).toBe(FORMATS.CLAUDE); + expect(outbound.url).toBe("https://api.minimax.io/anthropic/v1/messages?beta=true"); + expect(getHeader(outbound.headers, "x-api-key")).toBe("minimax-key"); + expect(getHeader(outbound.headers, "authorization")).toBeUndefined(); + expect(outbound.body).toEqual(expect.objectContaining({ + model: "MiniMax-M3", + max_tokens: 256, + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + })); + }); + + it("sends Xiaomi's Claude-native model to the regional Claude endpoint", async () => { + const outbound = await executeOpenAIRequest({ + provider: "xiaomi-tokenplan", + model: "mimo-v2.5-pro-claude", + credentials: { + apiKey: "xiaomi-key", + providerSpecificData: { region: "sgp" }, + }, + }); + + expect(outbound.route.targetFormat).toBe(FORMATS.CLAUDE); + expect(outbound.url).toBe("https://token-plan-sgp.xiaomimimo.com/anthropic/v1/messages"); + expect(getHeader(outbound.headers, "x-api-key")).toBe("xiaomi-key"); + expect(getHeader(outbound.headers, "authorization")).toBeUndefined(); + expect(outbound.body.messages).toEqual([{ + role: "user", + content: [{ type: "text", text: "hello" }], + }]); + }); + + it("keeps Kimi OpenAI input on the OpenAI endpoint and auth scheme", async () => { + const outbound = await executeOpenAIRequest({ + provider: "kimi", + model: "kimi-k3", + credentials: { + accessToken: "kimi-token", + providerSpecificData: { deviceId: "device-fixture" }, + }, + }); + + expect(outbound.route.targetFormat).toBe(FORMATS.OPENAI); + expect(outbound.url).toBe("https://api.kimi.com/coding/v1/chat/completions"); + expect(getHeader(outbound.headers, "authorization")).toBe("Bearer kimi-token"); + expect(getHeader(outbound.headers, "x-api-key")).toBeUndefined(); + expect(outbound.body).toEqual(expect.objectContaining({ + model: "kimi-k3", + messages: [{ role: "user", content: "hello" }], + })); + expect(outbound.result.requestFormat).toBe(FORMATS.OPENAI); + }); +});