mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
* chore(release): open v3.8.13 development cycle Bump 3.8.12 → 3.8.13 across package.json, lockfile, electron/, open-sse/, and docs/reference/openapi.yaml; add the [3.8.13] cycle placeholder to the root CHANGELOG and the 41 i18n mirrors. Integration branch for the v3.8.13 cycle — fixes/features land here via per-issue PRs and it merges to main at release time. * fix(ci): skip auto-deploy when VPS host is unreachable from the runner (#3299) Integrated into release/v3.8.13 * fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301) Integrated into release/v3.8.13 * feat(api): accept path-scoped API keys on client API routes (#3300) Integrated into release/v3.8.13 * fix(sse): harden against empty responses causing Copilot Chat failures (#3297) Integrated into release/v3.8.13 * fix(api): remove Completions.me rickroll provider (discussion #3293) (#3302) Integrated into release/v3.8.13 * fix(opencode-provider): extract contextLength from live model catalog (#3298) Integrated into release/v3.8.13 * feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292) Integrated into release/v3.8.13 * docs(changelog): record the v3.8.13 PRs merged this round (#3292/#3300/#3297/#3298/#3301/#3302/#3299) * fix(auth): harden URL token extraction — drop query-string fallback, gate to client routes (security follow-up to #3300) (#3309) Security follow-up to #3300 — integrated into release/v3.8.13 * docs: rename resolve-issues → review-issues skill references * fix(dashboard): keep no-auth providers visible under 'Show configured only' (#3290) (#3312) no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) never create a DB connection row so stats.total stays 0, which the configured-only filter treated as 'unconfigured' and hid them — even though they are always usable and appear unconditionally in /v1/models. filterConfiguredProviderEntries now treats displayAuthType === 'no-auth' as configured. Co-authored-by: uniQta <uniQta@users.noreply.github.com> * fix(cli): resolve update paths relative to script + recursive backup (#3295) (#3313) omniroute update always failed on a global install: - getCurrentVersion() read package.json from process.cwd(), which on a global npm/brew install is the user's working dir, not the package root → null → 'Could not determine current version'. - createBackup() resolved bin/ from cwd too, and passed the 'cli' directory to copyFileSync → EISDIR, swallowed by the catch → 'Failed to create backup'. Both now resolve package.json/bin relative to the script via import.meta.url, and the backup uses cpSync({recursive:true}) so the cli/ directory is copied. Co-authored-by: uniQta <uniQta@users.noreply.github.com> * fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) (#3314) On the cached-token path the executor never enters the refresh branch, so the same upstream Response was read with .text() twice (token-rejection check + final body). A Response body is single-use, so the second read threw 'Body is unusable: Body has already been read', caught and surfaced as [502]. Read the body once into finalBody and only re-read after a token-rejection refetch. Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com> * fix(sse): strip leaked internal tool envelopes from streaming output (#3311) Integrated into release/v3.8.13 * fix(sse): expose Claude + Gemini budget tiers in the antigravity catalog (#3184) (#3303) Integrated into release/v3.8.13 (#3184) * fix(catalog): compute combo context_length from known targets only (#3304) Integrated into release/v3.8.13 — live contextLength + known-targets combo context (#3298 follow-up) * chore(i18n): add message keys for proxy UI + vscode/ollama endpoint (#3307) Integrated into release/v3.8.13 — i18n message keys for proxy UI + vscode/ollama * feat(dashboard): i18n the proxy settings UI (#3310) Integrated into release/v3.8.13 — i18n the proxy settings UI * feat(api): model catalog enrichment + MCP model-catalog tools (#3306) Integrated into release/v3.8.13 — model catalog enrichment + MCP model-catalog tools, reconciled with #3309 URL-token hardening * test(catalog): align Antigravity preview-alias test with #3303 budget tiers #3303 added the Gemini `-high`/`-low` budget tiers to ANTIGRAVITY_PUBLIC_MODELS (user-callable on the Antigravity OAuth backend, verified via #3184), but did not update the catalog-route test that asserted `antigravity/gemini-3.1-pro-high` must NOT be exposed. The assertion now reflects the intended behavior — the client-visible budget alias IS surfaced — while keeping the legacy `gemini-claude-*` alias keys unexposed. Caught running the full catalog suite on the merged release HEAD (the #3303 round only ran the antigravity-aliases and usage-hardening files). * docs(changelog): record the 6 PRs merged this review round into v3.8.13 #3306/#3307/#3310 (New Features — VS Code split: catalog+MCP, i18n keys, proxy UI i18n), #3311/#3303/#3304 (Bug Fixes — SSE envelope sanitizer, antigravity budget tiers, combo known-targets context_length). * chore(release): finalize v3.8.13 changelog and cleanup Finalize the v3.8.13 changelog with release date, maintenance notes, and contributor credits. Update MCP docs to reference the correct tool inventory diagram, exclude nested .claude worktrees from ESLint scans, and tighten a response sanitizer type guard. * fix(dashboard): refresh connections after provider auth import (#3320) Integrated into release/v3.8.13 — refresh connections after provider auth import * fix(codex): strip client-only params on native /responses passthrough (#3317) (#3325) A /v1/responses request against the built-in codex/ provider does an openai-responses -> openai-responses passthrough (CodexExecutor.transformRequest returns the body early for _nativeCodexPassthrough). It forwarded client-only fields verbatim and the Codex upstream rejected them with 400 Unsupported parameter: prompt_cache_retention / safety_identifier / user — breaking Factory Droid (which injects all three). The chat-completions path already strips these (base.ts #1884, openai-responses translator #2770) but the passthrough skips translation. Strip the three fields in the shared block before the passthrough return; user is removed unconditionally since Codex /responses always rejects it. Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> * fix(dashboard): normalize agent-bridge /state response to stop page crash (#3318) (#3326) The Agent Bridge page seeded a well-shaped initialData default then replaced it wholesale with the raw /api/tools/agent-bridge/state response. The route returns { server, agents } but the UI reads { serverState, agentStates, bypassPatterns, mappings }, so serverState became undefined and AgentBridgeServerCard crashed on serverState.running — surfaced as the full-page 'Internal Server Error' boundary (client render error, not a real 5xx). Add a shared normalizeAgentBridgeState() that maps the route shape into the page contract (server.running/certExists -> serverState) and always returns safe defaults (never undefined serverState). Wired into both the SSR loader (page.tsx) and the polling hook. The legacy 'agents' entry shape differs from AgentStateEntry so it is not coerced; full route<->page contract reconciliation (port, upstreamCa, bypassPatterns, mappings, agentStates) is a follow-up. Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> * docs: VS Code/Ollama endpoints + env & i18n tooling (#3319) Integrated into release/v3.8.13 — VS Code/Ollama docs + env & i18n tooling * feat(provider): test-all endpoint, rate-limit overrides, visibility f… (#3267) Integrated into release/v3.8.13 — provider test-all endpoint, rate-limit overrides, model visibility * feat: auto-combo optimization, playground model dropdown, only-configured toggle (#3322) Integrated into release/v3.8.13 — auto-combo candidate expansion + playground dropdown + only-configured toggle * feat(api): VS Code Copilot Ollama-compatible BYOK endpoint (#3316) Integrated into release/v3.8.13 — VS Code Copilot Ollama-compatible BYOK endpoint (reconciled with #3306/#3309 auth hardening) * chore(release): document #3320 in the v3.8.13 changelog + contributor credits --------- Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: uniQta <uniQta@users.noreply.github.com> Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com> Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> Co-authored-by: Vinayrnani <vinayrnani@gmail.com>
1439 lines
53 KiB
TypeScript
1439 lines
53 KiB
TypeScript
import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults";
|
|
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
|
import {
|
|
BaseExecutor,
|
|
mergeUpstreamExtraHeaders,
|
|
setUserAgentHeader,
|
|
type ExecutorLog,
|
|
type ExecuteInput,
|
|
type ProviderCredentials,
|
|
} from "./base.ts";
|
|
import {
|
|
CODEX_CHAT_DEFAULT_INSTRUCTIONS,
|
|
CODEX_DEFAULT_INSTRUCTIONS,
|
|
} from "../config/codexInstructions.ts";
|
|
import { PROVIDERS } from "../config/constants.ts";
|
|
import {
|
|
getCodexClientVersion,
|
|
getCodexUserAgent,
|
|
normalizeCodexSessionId,
|
|
} from "../config/codexClient.ts";
|
|
import {
|
|
applyCodexClientIdentityHeaders,
|
|
applyCodexClientMetadata,
|
|
createCodexClientIdentity,
|
|
type CodexClientIdentity,
|
|
} from "../config/codexIdentity.ts";
|
|
import { getAccessToken } from "../services/tokenRefresh.ts";
|
|
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
|
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
|
import { CORS_HEADERS } from "../utils/cors.ts";
|
|
import { createRequire } from "module";
|
|
|
|
// ─── wreq-js lazy loader ───────────────────────────────────────────────────
|
|
// wreq-js is a Rust-native module that requires platform-specific .node binaries.
|
|
// Loading it eagerly crashes the server when the binary is missing (pnpm, Docker
|
|
// Alpine, unsupported architectures). We lazy-load with try/catch to gracefully
|
|
// fall back to HTTP transport when the WebSocket transport is unavailable.
|
|
const _wreqRequire = createRequire(import.meta.url);
|
|
|
|
type WreqWebSocket = {
|
|
send: (data: string) => void;
|
|
close: (code?: number, reason?: string) => void;
|
|
onmessage: ((event: { data: unknown }) => void) | null;
|
|
onerror: ((event: { message?: string }) => void) | null;
|
|
onclose: (() => void) | null;
|
|
};
|
|
type WebsocketFn = (url: string, opts?: Record<string, unknown>) => Promise<WreqWebSocket>;
|
|
type ResponsesMessageInput = {
|
|
role?: unknown;
|
|
phase?: unknown;
|
|
content?: unknown;
|
|
};
|
|
|
|
let _websocketFn: WebsocketFn | null = null;
|
|
let _wreqChecked = false;
|
|
let _websocketOverride: WebsocketFn | null | undefined;
|
|
|
|
function getCodexWebSocketTransport(): WebsocketFn | null {
|
|
if (_websocketOverride !== undefined) return _websocketOverride;
|
|
if (_wreqChecked) return _websocketFn;
|
|
_wreqChecked = true;
|
|
try {
|
|
const mod = _wreqRequire("wreq-js") as { websocket?: WebsocketFn };
|
|
_websocketFn = typeof mod.websocket === "function" ? mod.websocket : null;
|
|
} catch {
|
|
console.warn("[codex] wreq-js import failed, websocket disabled");
|
|
_websocketFn = null;
|
|
}
|
|
return _websocketFn;
|
|
}
|
|
|
|
export function __setCodexWebSocketTransportForTesting(
|
|
websocket: WebsocketFn | null | undefined
|
|
): void {
|
|
_websocketOverride = websocket;
|
|
}
|
|
|
|
function codexWebSocketUnavailableResponse(): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
code: "wreq_unavailable",
|
|
message:
|
|
"Codex WebSocket transport unavailable: wreq-js native module is missing for this platform",
|
|
},
|
|
}),
|
|
{
|
|
status: 503,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...CORS_HEADERS,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
|
|
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
|
|
// Exhausting one should NOT block requests to the other.
|
|
// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex)
|
|
|
|
/**
|
|
* Maps model name substrings to their rate-limit scope.
|
|
* Checked in order — first match wins.
|
|
*/
|
|
const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [
|
|
{ pattern: "codex-spark", scope: "spark" },
|
|
{ pattern: "spark", scope: "spark" },
|
|
{ pattern: "codex", scope: "codex" },
|
|
{ pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc.
|
|
];
|
|
|
|
/**
|
|
* T09: Determine the rate-limit scope for a Codex model.
|
|
* Use this key as the suffix for per-scope rate limit state:
|
|
* `${accountId}:${getModelScope(model)}`
|
|
*
|
|
* @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini")
|
|
* @returns "codex" | "spark"
|
|
*/
|
|
export function getCodexModelScope(model: string): "codex" | "spark" {
|
|
const lower = model.toLowerCase();
|
|
for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) {
|
|
if (lower.includes(pattern)) return scope;
|
|
}
|
|
return "codex"; // default scope
|
|
}
|
|
|
|
/**
|
|
* T09: Get the scope-keyed rate limit identifier for an account+model combination.
|
|
* Use this as the key for rateLimitState maps to ensure scope isolation.
|
|
*/
|
|
export function getCodexRateLimitKey(accountId: string, model: string): string {
|
|
return `${accountId}:${getCodexModelScope(model)}`;
|
|
}
|
|
|
|
/**
|
|
* T03: Parsed quota snapshot from Codex response headers.
|
|
* Codex includes per-account usage windows that allow precise reset scheduling.
|
|
* Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown)
|
|
*/
|
|
export interface CodexQuotaSnapshot {
|
|
usage5h: number; // tokens used in 5h window
|
|
limit5h: number; // token limit for 5h window
|
|
resetAt5h: string | null; // ISO timestamp when 5h window resets
|
|
usage7d: number; // tokens used in 7d window
|
|
limit7d: number; // token limit for 7d window
|
|
resetAt7d: string | null; // ISO timestamp when 7d window resets
|
|
}
|
|
|
|
/**
|
|
* T03: Parse Codex-specific quota headers from a provider response.
|
|
* Returns null if none of the relevant headers are present.
|
|
*
|
|
* Extracts:
|
|
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
|
|
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
|
|
*/
|
|
export function parseCodexQuotaHeaders(headers: Record<string, string>): CodexQuotaSnapshot | null {
|
|
const usage5h = headers["x-codex-5h-usage"] ?? null;
|
|
const limit5h = headers["x-codex-5h-limit"] ?? null;
|
|
const resetAt5h = headers["x-codex-5h-reset-at"] ?? null;
|
|
const usage7d = headers["x-codex-7d-usage"] ?? null;
|
|
const limit7d = headers["x-codex-7d-limit"] ?? null;
|
|
const resetAt7d = headers["x-codex-7d-reset-at"] ?? null;
|
|
|
|
// Return null if none of the quota headers are present (not a quota-aware response)
|
|
if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
usage5h: usage5h ? parseFloat(usage5h) : 0,
|
|
limit5h: limit5h ? parseFloat(limit5h) : Infinity,
|
|
resetAt5h: resetAt5h ?? null,
|
|
usage7d: usage7d ? parseFloat(usage7d) : 0,
|
|
limit7d: limit7d ? parseFloat(limit7d) : Infinity,
|
|
resetAt7d: resetAt7d ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* T03: Get the soonest quota reset time from a CodexQuotaSnapshot.
|
|
* 7d window takes priority (wider window, harder limit) but we use whichever
|
|
* is further in the future to avoid releasing the block too early.
|
|
*
|
|
* @returns Unix timestamp (ms) of the soonest effective reset, or null
|
|
*/
|
|
export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
|
|
const times: number[] = [];
|
|
if (quota.resetAt7d) {
|
|
const t = new Date(quota.resetAt7d).getTime();
|
|
if (!isNaN(t) && t > Date.now()) times.push(t);
|
|
}
|
|
if (quota.resetAt5h) {
|
|
const t = new Date(quota.resetAt5h).getTime();
|
|
if (!isNaN(t) && t > Date.now()) times.push(t);
|
|
}
|
|
if (times.length === 0) return null;
|
|
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
|
|
}
|
|
|
|
/**
|
|
* T03 (Item 3): Compute the minimum-necessary cooldown based on which window
|
|
* is actually exhausted. Prevents over-blocking the account:
|
|
*
|
|
* - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted)
|
|
* - If 5h window >= threshold: cooldown until 5h reset only (short-term limit)
|
|
* - Otherwise: 0 (account is healthy, no cooldown needed)
|
|
*
|
|
* Called after parsing quota headers from a successful/429 response to
|
|
* mark the account accordingly without overly long cooldowns.
|
|
*
|
|
* @param quota - Parsed quota snapshot from response headers
|
|
* @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95)
|
|
* @returns Cooldown duration in milliseconds (0 = no cooldown needed)
|
|
*/
|
|
export function getCodexDualWindowCooldownMs(
|
|
quota: CodexQuotaSnapshot,
|
|
threshold = 0.95
|
|
): { cooldownMs: number; window: "7d" | "5h" | "none" } {
|
|
const now = Date.now();
|
|
|
|
// Compute per-window usage ratios (0..1)
|
|
const ratio7d =
|
|
quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0;
|
|
const ratio5h =
|
|
quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0;
|
|
|
|
// 7d window takes priority — if the weekly budget is near-exhausted,
|
|
// we must wait until the weekly reset (not just 5h).
|
|
if (ratio7d >= threshold && quota.resetAt7d) {
|
|
const resetTime = new Date(quota.resetAt7d).getTime();
|
|
if (resetTime > now) {
|
|
return { cooldownMs: resetTime - now, window: "7d" };
|
|
}
|
|
}
|
|
|
|
// 5h window (primary short-term rate limit)
|
|
if (ratio5h >= threshold && quota.resetAt5h) {
|
|
const resetTime = new Date(quota.resetAt5h).getTime();
|
|
if (resetTime > now) {
|
|
return { cooldownMs: resetTime - now, window: "5h" };
|
|
}
|
|
}
|
|
|
|
return { cooldownMs: 0, window: "none" };
|
|
}
|
|
|
|
// Ordered list of effort levels from lowest to highest
|
|
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
|
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
|
const CODEX_FAST_WIRE_VALUE = "priority";
|
|
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
|
|
function splitCodexReasoningSuffix(model: unknown): {
|
|
baseModel: string;
|
|
effort: EffortLevel | null;
|
|
} {
|
|
const modelId = typeof model === "string" ? model : "";
|
|
for (const level of EFFORT_ORDER) {
|
|
if (modelId.endsWith(`-${level}`)) {
|
|
return {
|
|
baseModel: modelId.slice(0, -`-${level}`.length),
|
|
effort: level,
|
|
};
|
|
}
|
|
}
|
|
return { baseModel: modelId, effort: null };
|
|
}
|
|
|
|
export function getCodexUpstreamModel(model: unknown): string {
|
|
return splitCodexReasoningSuffix(model).baseModel;
|
|
}
|
|
|
|
/**
|
|
* Convert role=system messages in `input` to role=developer.
|
|
*
|
|
* GPT-5 models support the `developer` role in input, but reject `system`.
|
|
* This keeps the content inside
|
|
* the `input` array where it benefits from OpenAI's automatic prompt caching.
|
|
*
|
|
* OpenAI's prompt caching matches on the serialized prefix of the `input` array
|
|
* (+ tools). The `instructions` field is NOT included in the cache key for
|
|
* GPT-5 models. Moving system prompts from `input` to `instructions` therefore
|
|
* removes them from the cacheable prefix, resulting in 0% cache hit rates.
|
|
*
|
|
* Ref: https://community.openai.com/t/caching-is-borked-for-gpt-5-models/1359574
|
|
* Ref: https://community.openai.com/t/no-caching-with-model-responses/1338627
|
|
*/
|
|
function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
|
|
if (!Array.isArray(body.input)) return;
|
|
|
|
for (const itemValue of body.input) {
|
|
if (!itemValue || typeof itemValue !== "object" || Array.isArray(itemValue)) {
|
|
continue;
|
|
}
|
|
|
|
const item = itemValue as Record<string, unknown>;
|
|
const role = typeof item.role === "string" ? item.role : "";
|
|
const type = typeof item.type === "string" ? item.type : "";
|
|
const isSystemMessage = role === "system" && (!type || type === "message");
|
|
if (isSystemMessage) {
|
|
item.role = "developer";
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip server-generated item IDs from the input array.
|
|
*
|
|
* The Codex /codex/responses endpoint does not persist response items even when
|
|
* store=true is sent. When proxy clients (e.g. OpenClaw) include response items
|
|
* from previous turns in the input array, those items carry server-assigned IDs
|
|
* (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to
|
|
* validate these IDs against its persistence store and returns 404 when the items
|
|
* are not found (because store was effectively false).
|
|
*
|
|
* This function:
|
|
* 1. Removes bare string references ("rs_abc123") from the input array
|
|
* 2. Removes object items with type "item_reference" (explicit stored-item refs)
|
|
* 3. Strips the "id" field from any object in input whose id matches a
|
|
* server-generated prefix (rs_, fc_, resp_, msg_) — so the content is
|
|
* preserved but the backend won't try to look it up
|
|
*/
|
|
function stripStoredItemReferences(body: Record<string, unknown>): void {
|
|
if (Array.isArray(body.input) && body.input.length === 0) {
|
|
body.input = [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "continue" }],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (!Array.isArray(body.input)) return;
|
|
|
|
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
|
|
let strippedCount = 0;
|
|
|
|
body.input = body.input.filter((item) => {
|
|
// Bare string references: "rs_abc123", "resp_abc123"
|
|
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) {
|
|
strippedCount++;
|
|
return false;
|
|
}
|
|
|
|
// Object references: { type: "item_reference", id: "rs_..." }
|
|
if (
|
|
item &&
|
|
typeof item === "object" &&
|
|
!Array.isArray(item) &&
|
|
(item as Record<string, unknown>).type === "item_reference"
|
|
) {
|
|
strippedCount++;
|
|
return false;
|
|
}
|
|
|
|
// Object items with server-generated IDs: strip the id field but keep the item.
|
|
// e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id
|
|
// e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id
|
|
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
const record = item as Record<string, unknown>;
|
|
if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) {
|
|
delete record.id;
|
|
strippedCount++;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (strippedCount > 0) {
|
|
console.debug(
|
|
`[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input`
|
|
);
|
|
}
|
|
}
|
|
|
|
function repairMissingCodexFunctionCallOutputs(body: Record<string, unknown>): void {
|
|
if (!Array.isArray(body.input)) return;
|
|
|
|
const existingOutputIds = new Set<string>();
|
|
for (const item of body.input) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const record = item as Record<string, unknown>;
|
|
if (record.type !== "function_call_output") continue;
|
|
if (typeof record.call_id === "string" && record.call_id.trim()) {
|
|
existingOutputIds.add(record.call_id.trim());
|
|
}
|
|
}
|
|
|
|
const repaired: unknown[] = [];
|
|
let insertedCount = 0;
|
|
for (const item of body.input) {
|
|
repaired.push(item);
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const record = item as Record<string, unknown>;
|
|
if (record.type !== "function_call") continue;
|
|
const callId = typeof record.call_id === "string" ? record.call_id.trim() : "";
|
|
if (!callId || existingOutputIds.has(callId)) continue;
|
|
|
|
repaired.push({
|
|
type: "function_call_output",
|
|
call_id: callId,
|
|
output: "",
|
|
});
|
|
existingOutputIds.add(callId);
|
|
insertedCount++;
|
|
}
|
|
|
|
if (insertedCount > 0) {
|
|
body.input = repaired;
|
|
console.debug(
|
|
`[Codex] repairMissingCodexFunctionCallOutputs: inserted ${insertedCount} empty function_call_output item(s)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Responses-API hosted tool types that OpenAI/Codex executes server-side.
|
|
// These arrive shaped as `{ type, ...params }` with no `function` object and no `name` —
|
|
// e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or
|
|
// `{ type: "namespace", name: "mcp__atlassian__", tools: [...] }` for MCP tool groups.
|
|
// Keep them through `normalizeCodexTools` so upstream can execute them.
|
|
const CODEX_HOSTED_TOOL_TYPES: ReadonlySet<string> = new Set([
|
|
"tool_search",
|
|
"image_generation",
|
|
"web_search",
|
|
"web_search_preview",
|
|
"file_search",
|
|
"computer",
|
|
"computer_use_preview",
|
|
"code_interpreter",
|
|
"mcp",
|
|
"local_shell",
|
|
]);
|
|
|
|
// #2980: a free-plan Codex account (workspacePlanType === "free", from the OAuth
|
|
// id_token) cannot run the server-side `image_generation` hosted tool. The Codex
|
|
// CLI injects it into every Responses request regardless of plan, so it must be
|
|
// dropped for free-plan accounts (mirrors CLIProxyAPI's isCodexFreePlanAuth).
|
|
export function isCodexFreePlan(providerSpecificData: unknown): boolean {
|
|
if (!providerSpecificData || typeof providerSpecificData !== "object") return false;
|
|
const plan = (providerSpecificData as { workspacePlanType?: unknown }).workspacePlanType;
|
|
return typeof plan === "string" && plan.trim().toLowerCase() === "free";
|
|
}
|
|
|
|
export function normalizeCodexTools(
|
|
body: Record<string, unknown>,
|
|
options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean }
|
|
): void {
|
|
if (!Array.isArray(body.tools)) return;
|
|
|
|
const validToolNames = new Set<string>();
|
|
body.tools = body.tools.filter((toolValue) => {
|
|
if (!toolValue || typeof toolValue !== "object" || Array.isArray(toolValue)) {
|
|
return false;
|
|
}
|
|
|
|
const tool = toolValue as Record<string, unknown>;
|
|
const toolType = typeof tool.type === "string" ? tool.type : "";
|
|
|
|
// Preserve namespace tools (MCP tool groups used by Codex/OpenAI Responses API).
|
|
// Codex API supports them natively; register sub-tool names for tool_choice validation.
|
|
if (toolType === "namespace") {
|
|
if (Array.isArray(tool.tools)) {
|
|
for (const st of tool.tools as unknown[]) {
|
|
if (st && typeof st === "object" && !Array.isArray(st)) {
|
|
const subTool = st as Record<string, unknown>;
|
|
const name = typeof subTool.name === "string" ? subTool.name.trim().slice(0, 128) : "";
|
|
if (name) validToolNames.add(name);
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Native Codex clients send Responses API custom tools such as apply_patch as:
|
|
// { type: "custom", name, format }. Preserve those only on native passthrough;
|
|
// translated/non-native requests can still contain provider-specific "custom"
|
|
// shapes that the Codex backend would reject.
|
|
if (toolType === "custom" && options?.preserveCustomTools === true) {
|
|
const name = typeof tool.name === "string" ? tool.name.trim().slice(0, 128) : "";
|
|
if (!name) return false;
|
|
tool.name = name;
|
|
validToolNames.add(name);
|
|
return true;
|
|
}
|
|
|
|
if (toolType !== "function") {
|
|
const hasFunctionObject = tool.function && typeof tool.function === "object";
|
|
const hasName = typeof tool.name === "string";
|
|
if (!toolType || hasFunctionObject || hasName) {
|
|
return false;
|
|
}
|
|
if (CODEX_HOSTED_TOOL_TYPES.has(toolType)) {
|
|
// #2980: drop the CLI-injected image_generation tool for free-plan
|
|
// accounts, which can't run it server-side (upstream 400 otherwise).
|
|
if (toolType === "image_generation" && options?.dropImageGeneration === true) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
console.debug(`[Codex] dropping unknown hosted tool type: ${toolType}`);
|
|
return false;
|
|
}
|
|
|
|
const rawName =
|
|
typeof tool.name === "string"
|
|
? tool.name
|
|
: tool.function &&
|
|
typeof tool.function === "object" &&
|
|
!Array.isArray(tool.function) &&
|
|
typeof (tool.function as Record<string, unknown>).name === "string"
|
|
? ((tool.function as Record<string, unknown>).name as string)
|
|
: "";
|
|
const name = rawName.trim();
|
|
if (!name) {
|
|
return false;
|
|
}
|
|
|
|
// Codex Responses API requires function tools in flat Responses format:
|
|
// { type: "function", name, description, parameters }
|
|
// Some clients/translators send Chat Completions shape:
|
|
// { type: "function", function: { name, description, parameters } }
|
|
// which upstream rejects with "Missing required parameter: tools[0].name".
|
|
// Flatten the nested `function` wrapper into top-level fields (#1914).
|
|
const functionObject =
|
|
tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
|
|
? (tool.function as Record<string, unknown>)
|
|
: null;
|
|
const description =
|
|
typeof tool.description === "string"
|
|
? tool.description
|
|
: typeof functionObject?.description === "string"
|
|
? functionObject.description
|
|
: "";
|
|
const parameters =
|
|
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
|
|
? tool.parameters
|
|
: functionObject?.parameters &&
|
|
typeof functionObject.parameters === "object" &&
|
|
!Array.isArray(functionObject.parameters)
|
|
? functionObject.parameters
|
|
: { type: "object", properties: {} };
|
|
const strict =
|
|
typeof tool.strict === "boolean"
|
|
? tool.strict
|
|
: typeof functionObject?.strict === "boolean"
|
|
? functionObject.strict
|
|
: undefined;
|
|
|
|
// Rewrite in-place to Responses format
|
|
for (const key of Object.keys(tool)) {
|
|
delete tool[key];
|
|
}
|
|
tool.type = "function";
|
|
tool.name = name.slice(0, 128);
|
|
if (description) tool.description = description;
|
|
tool.parameters = parameters;
|
|
if (strict !== undefined) tool.strict = strict;
|
|
|
|
validToolNames.add(name);
|
|
return true;
|
|
});
|
|
|
|
if (
|
|
body.tool_choice &&
|
|
typeof body.tool_choice === "object" &&
|
|
!Array.isArray(body.tool_choice)
|
|
) {
|
|
const toolChoice = body.tool_choice as Record<string, unknown>;
|
|
if (toolChoice.type === "function") {
|
|
const rawName = typeof toolChoice.name === "string" ? toolChoice.name.trim() : "";
|
|
if (!rawName || !validToolNames.has(rawName)) {
|
|
delete body.tool_choice;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getResponsesSubpath(endpointPath: unknown): string | null {
|
|
let normalizedEndpoint = String(endpointPath || "");
|
|
while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) {
|
|
normalizedEndpoint = normalizedEndpoint.slice(0, -1);
|
|
}
|
|
|
|
const lower = normalizedEndpoint.toLowerCase();
|
|
if (lower === "responses" || lower.endsWith("/responses")) {
|
|
return "";
|
|
}
|
|
|
|
const responsesSlash = "/responses/";
|
|
const idx = lower.lastIndexOf(responsesSlash);
|
|
if (idx !== -1) {
|
|
return normalizedEndpoint.slice(idx + "/responses".length);
|
|
}
|
|
|
|
if (lower.startsWith("responses/")) {
|
|
return normalizedEndpoint.slice("responses".length);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
|
|
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
|
|
}
|
|
|
|
function normalizeServiceTierValue(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (!normalized) return undefined;
|
|
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Maximum reasoning effort allowed per Codex model.
|
|
* Models not listed here default to "xhigh" (unrestricted).
|
|
* Update this table when Codex releases new models with different caps.
|
|
*/
|
|
const MAX_EFFORT_BY_MODEL: Record<string, EffortLevel> = {
|
|
"gpt-5.3-codex": "xhigh",
|
|
"gpt-5.2-codex": "xhigh",
|
|
"gpt-5.1-codex-max": "xhigh",
|
|
"gpt-5-mini": "high",
|
|
"gpt-5.1-mini": "high",
|
|
"gpt-4.1-mini": "high",
|
|
};
|
|
|
|
/**
|
|
* Clamp reasoning effort to the model's maximum allowed level.
|
|
* Returns the original value if within limits, or the cap if it exceeds it.
|
|
*/
|
|
function clampEffort(model: string, requested: string): string {
|
|
const max: EffortLevel = MAX_EFFORT_BY_MODEL[model] ?? "xhigh";
|
|
const reqIdx = EFFORT_ORDER.indexOf(requested as EffortLevel);
|
|
const maxIdx = EFFORT_ORDER.indexOf(max);
|
|
if (reqIdx > maxIdx) {
|
|
console.debug(`[Codex] clampEffort: "${requested}" → "${max}" (model: ${model})`);
|
|
return max;
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
function normalizeEffortValue(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "max") return "xhigh";
|
|
return normalized || undefined;
|
|
}
|
|
|
|
function consumeResponsesStoreMarker(body: Record<string, unknown>): unknown {
|
|
const marker = body._omnirouteResponsesStore;
|
|
delete body._omnirouteResponsesStore;
|
|
return marker;
|
|
}
|
|
|
|
/**
|
|
* Global Codex WebSocket kill-switch (feature flag OMNIROUTE_CODEX_WS_ENABLED,
|
|
* default ON). Fail-open: if the flag store is unreachable (e.g. DB not yet
|
|
* ready), treat as enabled so codex routing is never broken by the read itself.
|
|
*/
|
|
function isCodexWsGloballyEnabled(): boolean {
|
|
try {
|
|
return isFeatureFlagEnabled("OMNIROUTE_CODEX_WS_ENABLED");
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function isCodexResponsesWebSocketRequired(_model: string, credentials: unknown): boolean {
|
|
// Global kill-switch (default ON). When disabled, Codex never uses the WS
|
|
// transport — even per-connection codexTransport=websocket falls back to the
|
|
// HTTP Responses SSE endpoint.
|
|
if (!isCodexWsGloballyEnabled()) return false;
|
|
// OmniRoute is an HTTP→SSE gateway — WebSocket transport is unnecessary and
|
|
// breaks when upstream requests go through an HTTP proxy (403 on WS upgrade).
|
|
// Default to the standard HTTP Responses SSE endpoint for all Codex models.
|
|
// Users who need WebSocket can opt in via the provider codexTransport setting.
|
|
const providerSpecificData =
|
|
credentials && typeof credentials === "object"
|
|
? (credentials as { providerSpecificData?: Record<string, unknown> }).providerSpecificData
|
|
: null;
|
|
return !!(providerSpecificData?.codexTransport === "websocket" && getCodexWebSocketTransport());
|
|
}
|
|
|
|
function toStatusCode(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
|
|
const parsed = Number(value.trim());
|
|
return parsed >= 400 && parsed <= 599 ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function looksLikeQuotaOrRateLimit(code: string, type: string, message: string): boolean {
|
|
const haystack = `${code} ${type} ${message}`.toLowerCase();
|
|
return (
|
|
haystack.includes("usage_limit_reached") ||
|
|
haystack.includes("rate_limit") ||
|
|
haystack.includes("rate limit") ||
|
|
haystack.includes("quota") ||
|
|
haystack.includes("too many requests") ||
|
|
haystack.includes("limit has been reached") ||
|
|
haystack.includes("limit reached")
|
|
);
|
|
}
|
|
|
|
function toCodexResponseFailedEvent(parsed: Record<string, unknown>): Record<string, unknown> {
|
|
const response =
|
|
parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
|
|
? (parsed.response as Record<string, unknown>)
|
|
: null;
|
|
const upstreamError =
|
|
response?.error && typeof response.error === "object" && !Array.isArray(response.error)
|
|
? (response.error as Record<string, unknown>)
|
|
: parsed.error && typeof parsed.error === "object" && !Array.isArray(parsed.error)
|
|
? (parsed.error as Record<string, unknown>)
|
|
: parsed;
|
|
const code =
|
|
typeof upstreamError.code === "string"
|
|
? upstreamError.code
|
|
: typeof upstreamError.type === "string"
|
|
? upstreamError.type
|
|
: "upstream_error";
|
|
const type = typeof upstreamError.type === "string" ? upstreamError.type : "";
|
|
const message =
|
|
typeof upstreamError.message === "string" && upstreamError.message.trim()
|
|
? upstreamError.message
|
|
: "Codex upstream error";
|
|
const error: Record<string, unknown> = { code, message };
|
|
const explicitStatus =
|
|
toStatusCode(parsed.status_code) ??
|
|
toStatusCode(parsed.status) ??
|
|
toStatusCode(response?.status_code) ??
|
|
toStatusCode(response?.status) ??
|
|
toStatusCode(upstreamError.status_code) ??
|
|
toStatusCode(upstreamError.status);
|
|
const statusCode =
|
|
explicitStatus ?? (looksLikeQuotaOrRateLimit(code, type, message) ? 429 : null);
|
|
|
|
if (type) error.type = type;
|
|
if (statusCode !== null) error.status_code = statusCode;
|
|
|
|
return {
|
|
type: "response.failed",
|
|
response: {
|
|
id: typeof response?.id === "string" ? response.id : null,
|
|
status: "failed",
|
|
error,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function encodeResponseSseEvent(raw: string): { sse: string; terminal: boolean } {
|
|
let eventType = "message";
|
|
let payload = raw;
|
|
let terminal = false;
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed.type === "string" && parsed.type.trim()) {
|
|
eventType = parsed.type.trim();
|
|
if (eventType === "error" || eventType === "response.failed") {
|
|
const failed = toCodexResponseFailedEvent(parsed as Record<string, unknown>);
|
|
payload = JSON.stringify(failed);
|
|
eventType = "response.failed";
|
|
}
|
|
terminal = eventType === "response.completed" || eventType === "response.failed";
|
|
}
|
|
} catch {
|
|
console.warn("[codex] SSE payload parse failed, using raw payload");
|
|
// Keep message as the generic SSE event for non-JSON upstream payloads.
|
|
}
|
|
|
|
return { sse: `event: ${eventType}\ndata: ${payload}\n\n`, terminal };
|
|
}
|
|
|
|
function toWebSocketUrl(url: string): string {
|
|
// Symmetric scheme map that PRESERVES the caller's transport choice by
|
|
// rewriting only the leading scheme: https→secure WS (production, e.g.
|
|
// chatgpt.com), http→plain WS (local/dev only). Not a hardcoded cleartext
|
|
// endpoint — the production codex upstream is the secure CODEX_RESPONSES_WS_URL.
|
|
if (/^wss?:\/\//.test(url)) return url;
|
|
if (url.startsWith("https:")) return url.replace(/^https:/, "wss:");
|
|
if (url.startsWith("http:")) return url.replace(/^http:/, "ws:");
|
|
return CODEX_RESPONSES_WS_URL;
|
|
}
|
|
|
|
function normalizeCodexWsHeaders(headers: Record<string, string>): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
const lower = key.toLowerCase();
|
|
if (
|
|
lower === "host" ||
|
|
lower === "connection" ||
|
|
lower === "upgrade" ||
|
|
lower === "sec-websocket-key" ||
|
|
lower === "sec-websocket-version" ||
|
|
lower === "sec-websocket-extensions"
|
|
) {
|
|
continue;
|
|
}
|
|
result[key] = value;
|
|
}
|
|
result.Origin = "https://chatgpt.com";
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
|
* Automatically injects default instructions if missing.
|
|
* IMPORTANT: Includes chatgpt-account-id header for workspace binding.
|
|
*/
|
|
export class CodexExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("codex", PROVIDERS.codex);
|
|
}
|
|
|
|
async execute(input: ExecuteInput) {
|
|
const sessionId = this.getPromptCacheSessionId(
|
|
input.credentials,
|
|
input.body as Record<string, unknown> | null
|
|
);
|
|
const identity = createCodexClientIdentity(
|
|
sessionId,
|
|
input.credentials?.providerSpecificData ?? null
|
|
);
|
|
const credentials = identity
|
|
? {
|
|
...input.credentials,
|
|
providerSpecificData: {
|
|
...(input.credentials?.providerSpecificData || {}),
|
|
codexClientIdentity: identity,
|
|
},
|
|
}
|
|
: input.credentials;
|
|
const nextInput = { ...input, credentials };
|
|
|
|
if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) {
|
|
return super.execute(nextInput);
|
|
}
|
|
|
|
const url = CODEX_RESPONSES_WS_URL;
|
|
const headers = normalizeCodexWsHeaders(this.buildHeaders(nextInput.credentials, true));
|
|
mergeUpstreamExtraHeaders(headers, nextInput.upstreamExtraHeaders);
|
|
|
|
const transformedBody = (await this.transformRequest(
|
|
nextInput.model,
|
|
nextInput.body,
|
|
true,
|
|
nextInput.credentials
|
|
)) as Record<string, unknown>;
|
|
transformedBody.model = getCodexUpstreamModel(transformedBody.model || nextInput.model);
|
|
delete transformedBody.stream;
|
|
delete transformedBody.stream_options;
|
|
|
|
const bodyString = JSON.stringify({
|
|
type: "response.create",
|
|
...transformedBody,
|
|
});
|
|
|
|
const websocketFn = getCodexWebSocketTransport();
|
|
if (!websocketFn) {
|
|
return {
|
|
response: codexWebSocketUnavailableResponse(),
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
};
|
|
}
|
|
|
|
const encoder = new TextEncoder();
|
|
let closed = false;
|
|
let ws: WreqWebSocket | null = null;
|
|
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
|
|
const closeUpstream = (reason: string) => {
|
|
try {
|
|
ws?.close(1000, reason);
|
|
} catch {
|
|
console.warn("[codex] closeUpstream: socket close race ignored");
|
|
// ignore close races
|
|
}
|
|
};
|
|
|
|
let abortHandler: (() => void) | null = null;
|
|
const removeAbortListener = () => {
|
|
if (!abortHandler) return;
|
|
nextInput.signal?.removeEventListener("abort", abortHandler);
|
|
abortHandler = null;
|
|
};
|
|
|
|
const finishStream = ({
|
|
reason,
|
|
emitDone = true,
|
|
closeController = true,
|
|
closeSocket = true,
|
|
}: {
|
|
reason: string;
|
|
emitDone?: boolean;
|
|
closeController?: boolean;
|
|
closeSocket?: boolean;
|
|
}) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
removeAbortListener();
|
|
if (closeSocket) closeUpstream(reason);
|
|
|
|
const controller = streamController;
|
|
if (!controller || !closeController) return;
|
|
if (emitDone) {
|
|
try {
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
} catch {
|
|
console.warn("[codex] finishStream: failed to enqueue [DONE]");
|
|
// The downstream may already have gone away.
|
|
}
|
|
}
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
console.warn("[codex] finishStream: failed to close controller");
|
|
// The controller may already be closed.
|
|
}
|
|
};
|
|
|
|
const failController = (code: string, message: string) => {
|
|
if (closed) return;
|
|
const controller = streamController;
|
|
const payload = JSON.stringify({
|
|
type: "response.failed",
|
|
response: {
|
|
id: null,
|
|
status: "failed",
|
|
error: { code, message },
|
|
},
|
|
});
|
|
try {
|
|
controller?.enqueue(encoder.encode(`event: response.failed\ndata: ${payload}\n\n`));
|
|
} catch {
|
|
// Downstream closed before the failure could be delivered.
|
|
}
|
|
finishStream({ reason: "upstream_failed" });
|
|
};
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
streamController = controller;
|
|
abortHandler = () => {
|
|
finishStream({ reason: "client_aborted" });
|
|
};
|
|
nextInput.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
|
|
try {
|
|
ws = await websocketFn(toWebSocketUrl(url), {
|
|
browser: "chrome_142",
|
|
os: "windows",
|
|
headers,
|
|
});
|
|
if (closed) return;
|
|
if (nextInput.signal?.aborted) {
|
|
finishStream({ reason: "client_aborted" });
|
|
return;
|
|
}
|
|
ws.onmessage = (event) => {
|
|
if (closed) return;
|
|
const raw =
|
|
typeof event.data === "string"
|
|
? event.data
|
|
: Buffer.from(event.data as Buffer).toString("utf8");
|
|
const sseEvent = encodeResponseSseEvent(raw);
|
|
if (closed) return;
|
|
try {
|
|
controller.enqueue(encoder.encode(sseEvent.sse));
|
|
} catch {
|
|
finishStream({
|
|
reason: "downstream_closed",
|
|
emitDone: false,
|
|
closeController: false,
|
|
});
|
|
return;
|
|
}
|
|
if (sseEvent.terminal) {
|
|
finishStream({ reason: "terminal_event" });
|
|
}
|
|
};
|
|
ws.onerror = (event) => {
|
|
failController(
|
|
"upstream_websocket_error",
|
|
event.message || "Codex upstream WebSocket error"
|
|
);
|
|
};
|
|
ws.onclose = () => {
|
|
finishStream({ reason: "upstream_closed", closeSocket: false });
|
|
};
|
|
if (!closed) {
|
|
ws.send(bodyString);
|
|
}
|
|
} catch (error) {
|
|
failController(
|
|
"upstream_websocket_connect_failed",
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
},
|
|
cancel() {
|
|
finishStream({ reason: "client_cancelled", emitDone: false, closeController: false });
|
|
},
|
|
});
|
|
|
|
return {
|
|
response: new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
}),
|
|
url,
|
|
headers,
|
|
transformedBody,
|
|
};
|
|
}
|
|
|
|
buildUrl(
|
|
model: string,
|
|
stream: boolean,
|
|
urlIndex = 0,
|
|
credentials: ProviderCredentials | null = null
|
|
) {
|
|
void model;
|
|
void stream;
|
|
void urlIndex;
|
|
|
|
const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath);
|
|
if (responsesSubpath !== null) {
|
|
const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, "");
|
|
if (baseUrl.endsWith("/responses")) {
|
|
return `${baseUrl}${responsesSubpath}`;
|
|
}
|
|
return `${baseUrl}/responses${responsesSubpath}`;
|
|
}
|
|
|
|
return super.buildUrl(model, stream, urlIndex, credentials);
|
|
}
|
|
|
|
/**
|
|
* Codex Responses endpoint is SSE-first.
|
|
* Always request event-stream from upstream, even when client requested stream=false.
|
|
* Includes chatgpt-account-id header for strict workspace binding.
|
|
*/
|
|
buildHeaders(credentials: ProviderCredentials, stream = true) {
|
|
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
|
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
|
|
headers.Version = getCodexClientVersion();
|
|
setUserAgentHeader(headers, getCodexUserAgent());
|
|
|
|
// Add workspace binding header if workspaceId is persisted
|
|
const workspaceId = credentials?.providerSpecificData?.workspaceId;
|
|
if (typeof workspaceId === "string" && workspaceId) {
|
|
headers["chatgpt-account-id"] = workspaceId;
|
|
}
|
|
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as
|
|
| CodexClientIdentity
|
|
| null
|
|
| undefined;
|
|
|
|
// Originator header — identifies the client type to the Codex backend.
|
|
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
|
|
headers["originator"] = "codex_cli_rs";
|
|
|
|
// session_id header — enables prompt cache affinity on the Codex backend.
|
|
// The official Codex client sets this to conversation_id (a stable UUID per session).
|
|
// Ref: openai/codex codex-api/src/requests/headers.rs build_conversation_headers()
|
|
const cacheSessionId = this.getPromptCacheSessionId(credentials, null);
|
|
if (cacheSessionId) {
|
|
headers["session_id"] = cacheSessionId;
|
|
}
|
|
applyCodexClientIdentityHeaders(headers, clientIdentity);
|
|
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Derive a stable session ID for prompt cache affinity.
|
|
* Priority: per-conversation session_id/conversation_id from request body → workspaceId.
|
|
* The official Codex client uses conversation_id (a unique UUID per session), NOT
|
|
* the account-wide workspaceId. Using workspaceId caps cache hit-rate at ~49%
|
|
* because all conversations share the same cache partition. (#1643)
|
|
* Ref: openai/codex core/src/client.rs line 853
|
|
*/
|
|
private getPromptCacheSessionId(
|
|
credentials: ProviderCredentials | null | undefined,
|
|
body: Record<string, unknown> | null
|
|
): string | null {
|
|
const promptCacheKey = normalizeCodexSessionId(body?.prompt_cache_key);
|
|
if (promptCacheKey) return promptCacheKey;
|
|
|
|
// Prefer per-session identifiers from the client request body
|
|
const sessionId = body?.session_id ?? body?.conversation_id;
|
|
const normalizedSessionId = normalizeCodexSessionId(sessionId);
|
|
if (normalizedSessionId) {
|
|
return normalizedSessionId;
|
|
}
|
|
// Fall back to workspaceId (account-wide) — better than nothing
|
|
return normalizeCodexSessionId(credentials?.providerSpecificData?.workspaceId) || null;
|
|
}
|
|
|
|
/**
|
|
* Refresh Codex OAuth credentials when a 401 is received.
|
|
* OpenAI uses rotating (one-time-use) refresh tokens — if the token was already
|
|
* consumed by a concurrent refresh, this returns null to signal re-auth is needed.
|
|
*
|
|
* Fixes #251: After a server restart/upgrade, previously cached access tokens may
|
|
* have expired or become invalid. chatCore.ts calls this on 401; previously the
|
|
* base class returned null causing the request to fail instead of refreshing.
|
|
*/
|
|
async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) {
|
|
if (!credentials?.refreshToken) {
|
|
log?.warn?.("TOKEN_REFRESH", "Codex: no refresh token available, re-authentication required");
|
|
return null;
|
|
}
|
|
const result = await getAccessToken("codex", credentials, log);
|
|
if (!result) {
|
|
log?.warn?.("TOKEN_REFRESH", "Codex: token refresh failed — re-authentication required");
|
|
return null;
|
|
}
|
|
if (result.error) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Codex: token refresh failed (${result.error}) — re-authentication required`
|
|
);
|
|
// Return null (not the error-only object): base.ts spreads any truthy
|
|
// result onto activeCredentials and persists it via onCredentialsRefreshed.
|
|
// Spreading `{ error }` would keep the stale/expired accessToken in place
|
|
// and write garbage to the connection. Returning null leaves the original
|
|
// credentials untouched so the upstream 401/403 drives the proper
|
|
// re-auth / mark-expired path instead.
|
|
return null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Transform request before sending - inject default instructions if missing
|
|
*/
|
|
transformRequest(
|
|
model: string,
|
|
bodyInput: unknown,
|
|
stream: boolean,
|
|
credentials: ProviderCredentials
|
|
) {
|
|
void stream;
|
|
// Do not mutate the caller's payload in place. Combo quality checks and
|
|
// other post-execute paths still inspect the original request body.
|
|
const body: Record<string, unknown> =
|
|
bodyInput && typeof bodyInput === "object"
|
|
? structuredClone(bodyInput as Record<string, unknown>)
|
|
: {};
|
|
|
|
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
|
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
|
const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData);
|
|
const thinkingBudgetConfig = getThinkingBudgetConfig();
|
|
const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH;
|
|
consumeResponsesStoreMarker(body);
|
|
|
|
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
|
|
if (isCompactRequest) {
|
|
delete body.stream;
|
|
delete body.stream_options;
|
|
delete body.client_metadata;
|
|
} else {
|
|
body.stream = true;
|
|
}
|
|
delete body._nativeCodexPassthrough;
|
|
|
|
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
|
if (requestServiceTier) {
|
|
body.service_tier = requestServiceTier;
|
|
} else if (requestDefaults.serviceTier) {
|
|
body.service_tier = requestDefaults.serviceTier;
|
|
}
|
|
|
|
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
|
|
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
|
|
if (!body.input && Array.isArray(body.messages)) {
|
|
body.input = body.messages.map((msg: ResponsesMessageInput) => ({
|
|
type: "message",
|
|
role: typeof msg.role === "string" ? msg.role : "user",
|
|
...(typeof msg.phase === "string" ? { phase: msg.phase } : {}),
|
|
content:
|
|
typeof msg.content === "string"
|
|
? [{ type: "input_text", text: msg.content }]
|
|
: Array.isArray(msg.content)
|
|
? msg.content.map((contentPart: unknown) => {
|
|
if (
|
|
contentPart &&
|
|
typeof contentPart === "object" &&
|
|
!Array.isArray(contentPart) &&
|
|
(contentPart as Record<string, unknown>).type === "text"
|
|
) {
|
|
return {
|
|
type: "input_text",
|
|
text: (contentPart as Record<string, unknown>).text,
|
|
};
|
|
}
|
|
return contentPart;
|
|
})
|
|
: [],
|
|
}));
|
|
} else if (!body.input && typeof body.prompt === "string" && body.prompt.trim()) {
|
|
// Issue #1872: Cursor occasionally passes the request as `prompt` instead of `messages`.
|
|
body.input = [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: body.prompt }],
|
|
},
|
|
];
|
|
} else if (!body.input && Array.isArray(body.prompt)) {
|
|
body.input = body.prompt.map((p: unknown) => ({
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: typeof p === "string" ? p : JSON.stringify(p) }],
|
|
}));
|
|
}
|
|
|
|
if (Array.isArray(body.input)) {
|
|
body.input = sanitizeResponsesInputItems(body.input, false, {
|
|
dropInternalAssistantMessages: !nativeCodexPassthrough,
|
|
});
|
|
}
|
|
repairMissingCodexFunctionCallOutputs(body);
|
|
|
|
// ── Cache-aware system prompt handling (both paths) ──
|
|
//
|
|
// Convert system → developer role IN-PLACE so system prompts remain in the
|
|
// `input` array where they contribute to the automatic prompt cache prefix.
|
|
// The `instructions` field is NOT included in the cache key for GPT-5 models.
|
|
//
|
|
// This applies to BOTH native passthrough (Responses API) and translated
|
|
// (Chat Completions) paths. Previously the translated path used
|
|
// hoistSystemMessagesToInstructions() which moved system content out of
|
|
// `input` and into `instructions`, destroying cache eligibility.
|
|
//
|
|
// Ref: PR #1346 (original fix for passthrough only)
|
|
convertSystemToDeveloperRole(body);
|
|
|
|
if (nativeCodexPassthrough) {
|
|
// Passthrough: minimal placeholder instructions.
|
|
if (
|
|
!body.instructions ||
|
|
(typeof body.instructions === "string" && body.instructions.trim() === "")
|
|
) {
|
|
body.instructions = "Follow the developer instructions in the conversation.";
|
|
}
|
|
} else {
|
|
// Translated: keep the full Codex tool instructions only for tool-capable
|
|
// requests. Bare chat requests still need a neutral instructions value
|
|
// because the Codex Responses backend rejects requests without it.
|
|
const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
|
|
if (
|
|
!body.instructions ||
|
|
(typeof body.instructions === "string" && body.instructions.trim() === "")
|
|
) {
|
|
if (hasTools) {
|
|
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
|
|
} else {
|
|
body.instructions = CODEX_CHAT_DEFAULT_INSTRUCTIONS;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store: regular Codex Responses rejects store=true with
|
|
// "Store must be set to false", while /responses/compact rejects the
|
|
// store field entirely. Default regular requests to false unless the
|
|
// provider explicitly opts in (e.g. API-key accounts that support persistence).
|
|
// Ref: sub2api openai_codex_transform.go line 75-80
|
|
const explicitStoreSetting =
|
|
credentials?.providerSpecificData &&
|
|
typeof credentials.providerSpecificData === "object" &&
|
|
!Array.isArray(credentials.providerSpecificData)
|
|
? credentials.providerSpecificData.openaiStoreEnabled
|
|
: undefined;
|
|
if (isCompactRequest) {
|
|
delete body.store;
|
|
} else if (explicitStoreSetting === true) {
|
|
body.store = true;
|
|
} else {
|
|
// backend rejects store=true ("Store must be set to false"), so default to false.
|
|
body.store = false;
|
|
}
|
|
|
|
// Codex Responses only supports function tools with non-empty names.
|
|
// Cursor may include custom tools (e.g. ApplyPatch) that work locally but are
|
|
// invalid upstream, and translation bugs can leave orphaned/empty tool_choice names.
|
|
normalizeCodexTools(body, {
|
|
dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData),
|
|
preserveCustomTools: nativeCodexPassthrough,
|
|
});
|
|
|
|
// Strip stored response item references (rs_, resp_, msg_ IDs) from input.
|
|
// The /codex/responses endpoint does not persist responses even with store=true,
|
|
// so any references to previous response items would cause 404 errors.
|
|
stripStoredItemReferences(body);
|
|
|
|
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
|
|
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
|
|
delete body.messages;
|
|
delete body.prompt;
|
|
|
|
let modelEffort: string | null = null;
|
|
let cleanModel = typeof body.model === "string" ? body.model : model;
|
|
const splitModel = splitCodexReasoningSuffix(cleanModel);
|
|
if (splitModel.effort) {
|
|
modelEffort = splitModel.effort;
|
|
body.model = splitModel.baseModel;
|
|
cleanModel = splitModel.baseModel;
|
|
}
|
|
|
|
const reasoningRecord =
|
|
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
|
|
? (body.reasoning as Record<string, unknown>)
|
|
: null;
|
|
const explicitReasoning = normalizeEffortValue(reasoningRecord?.effort);
|
|
const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort);
|
|
const fallbackReasoningEffort = allowConnectionReasoningDefaults
|
|
? requestDefaults.reasoningEffort || "medium"
|
|
: undefined;
|
|
// Issue #2331: model suffix aliases (for example gpt-5.5-xhigh) represent an
|
|
// explicit model selection, so they must override client-injected defaults such
|
|
// as OpenCode's automatic reasoning.effort=medium for GPT-5-family requests.
|
|
const rawEffort =
|
|
modelEffort || explicitReasoning || requestReasoningEffort || fallbackReasoningEffort;
|
|
|
|
if (rawEffort) {
|
|
body.reasoning = {
|
|
...(reasoningRecord || {}),
|
|
effort: clampEffort(cleanModel, rawEffort),
|
|
};
|
|
}
|
|
delete body.reasoning_effort;
|
|
|
|
// Remove unsupported token limit parameters BEFORE the passthrough return.
|
|
// Codex API rejects both max_tokens and max_output_tokens regardless of
|
|
// whether the request came via native passthrough or translation.
|
|
delete body.max_tokens;
|
|
delete body.max_output_tokens;
|
|
// VS Code Copilot BYOK Responses requests include `truncation` (for example
|
|
// "auto" or "disabled"). The Codex /responses backend currently rejects this
|
|
// field entirely with 400 Unsupported parameter: truncation, so strip it for
|
|
// both native passthrough and translated requests.
|
|
delete body.truncation;
|
|
delete body.background; // Droid CLI sends this but Codex Responses API rejects it
|
|
|
|
// Issue #3317: strip client-only fields the Codex Responses API rejects with
|
|
// 400 "Unsupported parameter" — for BOTH the native passthrough (early return
|
|
// below) and the translated path. The chat-completions path already removes
|
|
// these (base.ts prompt_cache_retention #1884; openai-responses translator
|
|
// safety_identifier #2770), but the responses->responses passthrough skips
|
|
// translation. `user` is always rejected by Codex /responses, so it is removed
|
|
// unconditionally here (unlike base.ts, which only drops it when empty).
|
|
delete body.prompt_cache_retention;
|
|
delete body.safety_identifier;
|
|
delete body.user;
|
|
|
|
// Inject prompt_cache_key for Codex prompt caching.
|
|
// The official Codex client sets this to conversation_id (a stable UUID per session).
|
|
// Ref: openai/codex core/src/client.rs line 853:
|
|
// let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
|
|
// IMPORTANT: Capture session/conversation IDs BEFORE deletion below (#1643).
|
|
if (!body.prompt_cache_key) {
|
|
const cacheSessionId = this.getPromptCacheSessionId(credentials, body);
|
|
if (cacheSessionId) {
|
|
body.prompt_cache_key = cacheSessionId;
|
|
}
|
|
}
|
|
if (!isCompactRequest) {
|
|
applyCodexClientMetadata(
|
|
body,
|
|
credentials?.providerSpecificData?.codexClientIdentity as
|
|
| CodexClientIdentity
|
|
| null
|
|
| undefined
|
|
);
|
|
}
|
|
|
|
// Delete session_id and conversation_id from the body.
|
|
// These are often injected by OmniRoute's fallback logic for store=true,
|
|
// but the upstream Codex API strictly rejects them as unsupported parameters.
|
|
delete body.session_id;
|
|
delete body.conversation_id;
|
|
|
|
if (nativeCodexPassthrough) {
|
|
return body;
|
|
}
|
|
|
|
// Issue #2608: Use an allowlist of known Responses API fields instead of a
|
|
// denylist of Chat Completions fields. The denylist approach missed fields
|
|
// like `stop`, `response_format`, `logit_bias`, `function_call`, `functions`,
|
|
// `max_completion_tokens`, and `parallel_tool_calls` — causing gpt-5.5 to
|
|
// reject with "routing_unsupported" (400). An allowlist is future-proof:
|
|
// any unknown field from Chat Completions (or other formats) is stripped.
|
|
const RESPONSES_API_ALLOWLIST = new Set([
|
|
"model",
|
|
"input",
|
|
"instructions",
|
|
"tools",
|
|
"tool_choice",
|
|
"stream",
|
|
"store",
|
|
"reasoning",
|
|
"service_tier",
|
|
"include",
|
|
"previous_response_id",
|
|
"prompt_cache_key",
|
|
"client_metadata",
|
|
// Internal markers used by OmniRoute pipeline
|
|
"_omnirouteResponsesStore",
|
|
]);
|
|
|
|
for (const key of Object.keys(body)) {
|
|
if (!RESPONSES_API_ALLOWLIST.has(key)) {
|
|
delete body[key];
|
|
}
|
|
}
|
|
|
|
return body;
|
|
}
|
|
}
|