mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* chore(release): open v3.8.37 development cycle * chore(ci): harden release flow — ratchet decoupling, fast-path drift gates, build-scope guard, heap default (#5054) Implements improvements 1-4 from the v3.8.36 release benchmark (_tasks/release-bench/v3.8.36/PLANO-MELHORIA.md): 1. Quality Ratchet decoupled from flaky coverage (ci.yml): the shard→coverage→ratchet chain meant a single flaky Coverage Shard SKIPPED the whole Quality Ratchet on the release PR (v3.8.36 #4854), so cycle drift only surfaced post-merge in #5029. The job now runs on !cancelled(); coverage download is continue-on-error and the ratchet runs --allow-missing, so the DETERMINISTIC gates (eslint/complexity/cognitive/duplication/ codeql) stay blocking even when coverage is unavailable. 2. Fast-path drift gates (quality.yml PR→release): added check:complexity, check:cognitive-complexity, and a new lightweight check:pack-policy (pack-artifact unexpected-files check WITHOUT a build, via --policy-only) so drift + stray-tarball-file regressions are caught/rebaselined PER-PR instead of cascading onto the release PR. 3. Build heap default 4096→8192 MB (build-next-isolated.mjs): the clean graph peaks ~3.9 GB and brushed the old 4 GB ceiling; 8 GB gives headroom. Comment notes heap is NOT the fix for a poisoned scope (run check:build-scope instead). 4. check:build-scope gate (new): fails if .ts/.tsx/.js/.jsx files in the tsconfig scope exceed a threshold — catches worktrees/cruft leaking into the build scope (the v3.8.36 OOM root cause: 355,215 vs 4,547 files) BEFORE it detonates next build. Wired into the fast-path. * fix(auth): only trust forwarding headers from loopback TCP peers (#4689) Integrated into release/v3.8.37 — loopback-gated forwarding headers (IP spoofing fix). Cherry-picked onto current release tip; ipUtils.test.ts 9/9 green. * fix(codex): treat OAuth 401 as unrecoverable refresh failure (#4686) Integrated into release/v3.8.37 — codex OAuth 401 treated as unrecoverable refresh. Cherry-picked onto release tip; token-refresh-service.test.ts 38/38 green. * fix(translator): preserve reasoning_effort for non-Copilot Responses clients (#4688) Integrated into release/v3.8.37 — preserve reasoning_effort for non-Copilot Responses clients. Cherry-picked onto release tip; tests 47/47 green. * fix(translator): coerce tool descriptions to strings in OpenAI normalization (#4675) Integrated into release/v3.8.37 — coerce tool descriptions to strings in OpenAI normalization. Cherry-picked onto release tip; tests 3/3 green. * feat(sse): x-omniroute-strip-reasoning header to drop reasoning_content (#4678) Integrated into release/v3.8.37 — x-omniroute-strip-reasoning header. Cherry-picked onto release tip (resolved chatCore.ts/headers.ts adjacency conflict, kept resolveCompressionHeader + isStripReasoningRequested); tests 8/8 green. * fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 (#4648) Integrated into release/v3.8.37 — flattenToolHistory helper (combo anti-503). Cherry-picked onto release tip; tests 9/9 green. * feat(headroom): proxy lifecycle management + dashboard UI (Docker sidecar supported) (#4649) Integrated into release/v3.8.37 — headroom proxy lifecycle (status/start/stop, local-only + spawn-capable per Rules #15/#17). Cherry-picked onto release tip; lifecycle 7/7 + route-guard 43/43 + check:cycles green. * feat(cli): multi-model support for Factory Droid CLI (#4682) Integrated into release/v3.8.37 — Factory Droid multi-model support. Cherry-picked onto release tip (kept readJsoncConfig + droidCustomModels imports); droid-custom-models 11/11 green. * fix(providers): require Default Model in compatible-provider API-key setup (#4641) Integrated into release/v3.8.37 — require Default Model in compatible-provider API-key setup. Cherry-picked fix + test-move onto release tip (kept release providerSpecificData + QuotaScrapingFields; fixed moved-test import path; baseline rebaseline unneeded, 865<866); UI test 2/2 green. * fix(dashboard): stop double-masking already-masked API key in list (E2E 3/9 regression) (#4671) Integrated into release/v3.8.37 — render server-masked key verbatim (drop redundant maskKey call). Note: release's maskKey already guards '****' (since v3.8.34), so this is a safe simplification; added a contract test pinning the **** passthrough invariant (2/2 green, would fail against the pre-guard maskKey = the historical double-mask bug). * chore(quality): rebaseline file-size for rc17 PR batch drift Own growth from the merged rc17 PRs (#4678/#4686/#4688) at existing chokepoints — cohesive, not extractable: - open-sse/handlers/responseSanitizer.ts 1103->1122 (SanitizeOpenAIResponseOptions + stripReasoning, #4678) - open-sse/services/tokenRefresh.ts 2070->2090 (codex 401 unrecoverable-refresh guard, #4686) - tests/unit/token-refresh-service.test.ts 1322->1353 (401 regression case, #4686) - tests/unit/translator-openai-responses-req.test.ts 1047->1050 (reasoning_effort assertion, #4688) * docs(env): document HEADROOM_URL in .env.example + ENVIRONMENT.md The headroom proxy lifecycle (#4649) reads HEADROOM_URL (src/lib/headroom/detect.ts, default http://localhost:8787) but it was missing from the env contract, tripping check:env-doc-sync. Adds the var to both .env.example (commented, has a default) and the Proxy Health table in ENVIRONMENT.md. * fix(sse): stream writer mock abort() returns a Promise (#4788) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): fall back to default data dir when DATA_DIR is not writable (#4767) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(oauth): verify Cursor installation on Linux before auto-import (#4770) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): track Ollama streaming usage from raw NDJSON chunks (#4754) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): strip enumDescriptions from antigravity tool schema (#4740) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): include low-level cause details in formatProviderError (#4741) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(translator): strip x-anthropic-billing-header in claude-to-openai (#4728) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): gate Kiro image attachments behind a Claude-capability check (#4763) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): read Antigravity usage from the response.usageMetadata envelope (#4785) Integrated into release/v3.8.37 — Antigravity response.usageMetadata envelope. Cherry-picked onto release tip (resolved test-tail adjacency with #4754 Ollama block); usage-extractor 23/23 green. * fix(api): fall back to existing access token for any OAuth provider on refresh failure (#4786) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): verify launchd registration + skip self-SIGTERM in macOS autostart (#4765) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(executors): anthropic-compatible-* gateways get Bearer alongside x-api-key (#4729) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): json_schema fallback for OpenAI-compatible providers (#4766) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): use workos auth token shape for cline (#4787) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * feat(sse): parse Gemini CLI 429 retryDelay from structured RetryInfo (#4738) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): finalize tool_calls finish_reason on early stream end in OpenAI Responses translator (#4764) Integrated into release/v3.8.37 — computeFinishReason finalizes tool_calls on early stream end (Responses translator). Cherry-picked onto release tip; responses-translation-fixes 29/29 green. * test(sse): golden-lock provider.ts translate-path across all providers (#4734) Integrated into release/v3.8.37 — golden-lock for provider.ts translate-path. Cherry-picked onto release tip; snapshot regenerated against the current provider set (UPDATE_GOLDEN=1, 167 entries); golden test 3/3 deterministic. * chore(quality): rebaseline file-size for rc17 leva2 PR batch drift Own growth from the merged leva2 PRs (cohesive, not extractable): - src/lib/usage/providerLimits.ts 950->955 (#4786) - open-sse/executors/default.ts NEW frozen @828 (#4729 + #4766 + #4787 header branches) - open-sse/translator/request/openai-to-kiro.ts 807->814 (#4763) - open-sse/translator/response/openai-responses.ts 923->937 (#4764) - tests/unit/executor-default-base.test.ts 1339->1440 (#4766) - tests/unit/translator-openai-to-kiro.test.ts 918->980 (#4763) * fix(dashboard): align Engine Combos editor engines with API schema (#4955) (#5062) The named-combos pipeline dropdown offered four engines (headroom, session-dedup, ccr, llmlingua) that stackedPipelineStepSchema rejects, so selecting one made PUT /api/context/combos/[id] return HTTP 400 while saveCombo swallowed the non-OK response (if (!res.ok) return). Editing the default 'Standard Savings' combo and changing an engine reproduced the 400. - Add canonical STACKED_PIPELINE_ENGINE_INTENSITIES next to the schema as the single source of truth; the client dropdown imports it so it can never drift from the discriminated union the API validates against. - Surface save errors and empty-name/empty-pipeline validation in the editor instead of failing silently. - Add a parity unit test asserting the UI engine map equals the schema union and that every (engine, intensity) the UI emits is accepted. * fix(sse): filter nameless hosted tools when converting Responses API to Chat format (#4789) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): keep desktop sidebar visible via explicit CSS class (#4812) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip enumDescriptions from Antigravity tool schemas (#4813) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): resolve passthrough model aliases by providerId in ModelSelectModal (#4815) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(oauth): allow per-connection refresh lead-time override via providerSpecificData.refreshLeadMs (#4818) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip X-Stainless-* headers and normalize SDK User-Agent for OpenAI-compatible endpoints (#4820) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Gemini built-in tools when functionDeclarations present in Antigravity envelope (#4821) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(api): surface a Docker-localhost hint on provider-node validation connection errors (#4822) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve bare model names to connection defaultModel before upstream calls (#4825) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(build): trace-include sql.js sql-wasm.wasm in standalone bundle (#4839) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Composer <|final|> sentinel markers leaking after Composer reasoning (#4842) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(config): sync full SiliconFlow model list into registry (#4844) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): close reasoning before message content in Responses stream (#4848) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): reject unsupported Kiro [1m] context suffix (#4816) Integrated into release/v3.8.37 — cherry-picked onto release tip; test-tail conflict with #4763 resolved (kept both image + [1m] test blocks); CHANGELOG re-merged; 29/29 green. * fix(db): validate HuggingFace tokens via whoami-v2 auth probe (#4819) Integrated into release/v3.8.37 — defining commit re-homed onto the god-file-split validation module (validateHuggingFaceProvider in validation/openaiFormat.ts + map wiring); 115/115 green. * fix(sse): make anthropic-version default-guard case-insensitive (#4823) Integrated into release/v3.8.37 — conflict with #4729 Bearer-fallback resolved (kept both Bearer fallback + case-insensitive anthropic-version guard); 48/48 green. * fix(sse): sanitize Kiro tool schemas to avoid 400 "Improperly formed request" (#4847) Integrated into release/v3.8.37 — conflict in kiro-to-openai.ts resolved (kept release fallbackToolCallId + adopted #1375 toolNameMap remap); 7/7 green. * feat(sse): add GPT-4 to the GitHub Copilot provider (#4798) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add GPT-4o mini to GitHub Copilot provider (#4797) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(api): add MiniMax-M3 pricing row (#4814) Integrated into release/v3.8.37 — pricing row re-homed onto god-file-split pricing/regional.ts (pricing.ts is now a barrel); 4/4 green. * fix(cli): save runtime deps with --save-exact so a sibling install can't prune them (#4841) Integrated into release/v3.8.37 — trayRuntime conflict resolved (kept release SYSTRAY_SPEC + added --save-exact); 2/2 green. * fix(sse): preserve required fields in antigravity tool schemas (#4843) Integrated into release/v3.8.37 — conflict resolved (kept #4740/#4813 enumDescriptions strip + typed normalizeSchemaTypes, added required-preservation helpers; test-tail merged keeping both enumDescriptions + required tests); 7/7 green. * chore(quality): rebaseline file-size for rc17b leva3 PR batch drift * fix(sse): strip reasoning blobs from agentic context to prevent O(n^2) token growth (#4849) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): unwrap Qoder HTTP 200 SSE error envelope so fallback can trigger (#4850) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip temperature for Claude models with extended thinking (#4853) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): emit valid concatenable kiro tool_calls.arguments deltas (#4855) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add toggleable tool-source diagnostics (#4856) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): redact api key from the AUTH debug log in the chat handler (#4858) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): forward AI SDK image parts in Responses translator (#4859) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve custom combos by id and case-insensitive name (#4446) (#4869) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): exclude WS bridge controller-closed error from provider breaker (#4602) (#4870) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(providers): add xAI Grok inbound translators and thinking patcher (#4910) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(embeddings): add dimensions override field to embedding combos (#4913) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(oauth): Codex bulk-import endpoint — POST /api/oauth/codex/import (#4914) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(antigravity): retry transient upstream failures (#4941) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): surface malformed HTTP-200 upstream responses (#4942) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): normalize Codex custom tools (apply_patch) to { input: string } schema (#4862) Integrated into release/v3.8.37 — conflict in request/openai-responses.ts resolved (kept #4789 nameless-tool skip + added #1007 custom-tool {input:string} normalization); 48/48 green incl. #4789/#4859 regression. * fix(sse): dense, deterministic output ordering in Responses API response.completed (#4906) Integrated into release/v3.8.37 — manual integration with #4862 in response/openai-responses.ts (custom-tool funcItem + dense recordCompletedItem). Fixed a latent #4848 interaction: the close-reasoning-before-message guard force-closed <think>-tag reasoning prematurely, which dense output (#4906) then snapshotted as a partial buffer ("plan" vs "planning") — scoped the guard to native reasoning_content (!inThinking) in BOTH transformer + translator paths. Full Responses suite 203/203 green incl. #4848/#4862 regression. * feat(sse): auto-promote successful combo model to position #1 (#4852) Integrated into release/v3.8.37 — dropped the stale file-size-baseline.json hunk (re-derived against the rc17b rebaseline); code+test applied clean; 13/13 green. * feat(providers): add Pioneer AI (Fastino Labs) provider (#4909) Integrated into release/v3.8.37 — providers.ts apikey block re-homed onto god-file-split src/shared/constants/providers/apikey/frontier-labs.ts (inline APIKEY_PROVIDERS no longer exists); registry/pioneer + providers/index.ts applied clean; 6/6 green. * add DGrid AI gateway provider (#4931) Integrated into release/v3.8.37 — rebased the contributor's commit onto the release tip; providers.ts god-file-split conflict resolved by relocating the dgrid APIKEY_PROVIDERS entry into apikey/gateways.ts; CHANGELOG added. 7/7 green. Thanks @dgridOP! * chore(quality): rebaseline file-size for rc17b leva4 PR batch drift * docs(routing): sync combo strategy docs for Fusion (17 strategies) (#5067) Fusion (16th strategy, panel fan-out + judge synthesis) and headroom shipped but the strategy-count docs were stale (14/15) and omitted both. Update every combo-strategy reference to the canonical 17, add fusion + headroom to all strategy tables, and add a dedicated Fusion section to AUTO-COMBO.md documenting judgeModel / fusionTuning config + an example. - CLAUDE.md, README.md, FEATURES.md, RESILIENCE_GUIDE.md, ARCHITECTURE.md, OPEN_SSE_ARCHITECTURE.md, OMNIROUTE_VS_ALTERNATIVES.md, docs/README.md, request-pipeline.mmd: 14/15 -> 17, list fusion + headroom - docs/routing/AUTO-COMBO.md: strategy table + new Fusion strategy section - docs/openapi.yaml: add reset-window, headroom, fusion to the strategy enum * fix(oauth): classify /api/oauth/cursor/auto-import as local-only (route-guard) (#5070) The Cursor auto-import route runs execFile("which", ["cursor"]) to verify a local Cursor install before importing credentials — a child-process spawn. The check:route-guard-membership gate (Hard Rules #15/#17) flagged it as an unclassified spawn-capable route: reachable past the loopback gate, an RCE-via-tunnel surface (a leaked JWT over a tunnel could trigger the spawn). Classify the specific path in LOCAL_ONLY_API_PREFIXES so loopback enforcement runs unconditionally before any auth check. Scoped to the exact path — the rest of /api/oauth/ (browser redirect/callback flows) stays remote-reachable. TDD: added a failing-then-passing assertion in route-guard-local-prefix.test.ts (classification + an over-broadening guard proving sibling OAuth paths stay remote). check:route-guard-membership now reports 0 new gaps. * chore(release): v3.8.37 — 2026-06-26 --------- Co-authored-by: dgridOP <dgrid_op@outlook.com>
1541 lines
58 KiB
TypeScript
1541 lines
58 KiB
TypeScript
import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults";
|
|
import {
|
|
getCodexModelScope,
|
|
getCodexRateLimitKey,
|
|
type CodexQuotaScope,
|
|
} from "../config/codexQuotaScopes.ts";
|
|
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 { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
|
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
|
import { CORS_HEADERS } from "../utils/cors.ts";
|
|
import * as prl from "../utils/providerRequestLogging.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)
|
|
export { getCodexModelScope, getCodexRateLimitKey, type CodexQuotaScope };
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export 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;
|
|
}
|
|
|
|
// Reasoning blobs (encrypted_content) are unusable with store=false since
|
|
// previous_response_id is deleted — strip them to avoid wasting context
|
|
// tokens (O(n^2) growth across agentic turns).
|
|
if (
|
|
item &&
|
|
typeof item === "object" &&
|
|
!Array.isArray(item) &&
|
|
(item as Record<string, unknown>).type === "reasoning"
|
|
) {
|
|
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;
|
|
}
|
|
|
|
const CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE = "reasoning.encrypted_content";
|
|
const CODEX_DEFAULT_REASONING_SUMMARY = "auto";
|
|
|
|
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 ensureCodexReasoningSummary(body: Record<string, unknown>): void {
|
|
const reasoning =
|
|
body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)
|
|
? (body.reasoning as Record<string, unknown>)
|
|
: null;
|
|
if (!reasoning || normalizeEffortValue(reasoning.effort) === "none") return;
|
|
|
|
if (!("summary" in reasoning)) {
|
|
reasoning.summary = CODEX_DEFAULT_REASONING_SUMMARY;
|
|
}
|
|
|
|
if (!Array.isArray(body.include)) {
|
|
body.include = [CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE];
|
|
return;
|
|
}
|
|
|
|
if (!body.include.includes(CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE)) {
|
|
body.include = [...body.include, CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE];
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Env-gated kill-switch: drop ALL non-standard `codex.*` SSE events (notably
|
|
// `codex.rate_limits`) from the Responses stream. These events are NOT part of
|
|
// the OpenAI Responses API — strict clients (e.g. the OpenAI SDK's
|
|
// `responses.stream()`) choke on the unknown event type / empty data field and
|
|
// tear the stream down, surfacing as "Invalid state: Controller is already
|
|
// closed". Opt-in so the default still forwards them for clients that want them.
|
|
function codexDropNonstandardEvents(): boolean {
|
|
const v = process.env.OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS;
|
|
return v === "true" || v === "1" || v === "yes";
|
|
}
|
|
|
|
// SSE block filter for the HTTP Responses path (super.execute). The HTTP
|
|
// transport forwards the upstream stream verbatim — including the non-standard
|
|
// `event: codex.rate_limits` frame (no data line) — so the WS-only filter in
|
|
// encodeResponseSseEvent never runs for it. When the kill-switch is on, strip
|
|
// every `codex.*` event block from the byte stream before it reaches the client.
|
|
// Exported for unit testing (#4715). Strips `codex.*` SSE event blocks from a
|
|
// streaming Response when the OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS kill-switch is on.
|
|
export function filterNonstandardCodexSse(response: Response): Response {
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
return response;
|
|
}
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
let buffer = "";
|
|
const dropBlock = (block: string): boolean => {
|
|
const match = /^event:\s*(.+)$/m.exec(block);
|
|
return !!match && match[1].trim().startsWith("codex.");
|
|
};
|
|
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
buffer += decoder.decode(chunk, { stream: true });
|
|
let sep: number;
|
|
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
const block = buffer.slice(0, sep + 2);
|
|
buffer = buffer.slice(sep + 2);
|
|
if (!dropBlock(block)) controller.enqueue(encoder.encode(block));
|
|
}
|
|
},
|
|
flush(controller) {
|
|
if (buffer && !dropBlock(buffer)) controller.enqueue(encoder.encode(buffer));
|
|
},
|
|
});
|
|
return new Response(response.body.pipeThrough(transform), {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
|
|
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.
|
|
}
|
|
|
|
// Env-gated: drop non-standard `codex.*` events (notably `codex.rate_limits`)
|
|
// before they reach the client. They are NOT part of the OpenAI Responses API
|
|
// and break strict consumers: the OpenAI SDK's responses.stream() chokes on
|
|
// the unknown event type / empty data and tears the stream down, surfacing as
|
|
// "Invalid state: Controller is already closed". The earlier empty-payload
|
|
// check below never caught codex.rate_limits — over WS the frame carries a
|
|
// non-empty JSON payload (`{"type":"codex.rate_limits", ...}`), so
|
|
// `!payload.trim()` is false. Match by event type instead. Opt-in via
|
|
// OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS (the HTTP transport is handled
|
|
// separately by filterNonstandardCodexSse, since super.execute forwards the
|
|
// upstream stream verbatim and never runs this function).
|
|
if (eventType.startsWith("codex.") && codexDropNonstandardEvents()) {
|
|
return { sse: "", terminal };
|
|
}
|
|
|
|
// Drop frames whose raw payload is empty (defensive; non-JSON / blank upstream
|
|
// chunks). Frames that carry a payload are preserved.
|
|
if (!payload.trim()) {
|
|
return { sse: "", terminal };
|
|
}
|
|
|
|
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)) {
|
|
const httpResult = await super.execute(nextInput);
|
|
if (codexDropNonstandardEvents()) {
|
|
const resp = (httpResult as { response?: Response }).response;
|
|
if (resp?.body) {
|
|
(httpResult as { response: Response }).response = filterNonstandardCodexSse(resp);
|
|
}
|
|
}
|
|
return httpResult;
|
|
}
|
|
|
|
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;
|
|
// Filtered events (codex.* / empty payload) return an empty `sse` —
|
|
// skip them so no empty frame reaches the client.
|
|
if (sseEvent.sse) {
|
|
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) {
|
|
await prl.captureCurrentProviderBody(url, headers, bodyString, nextInput.log);
|
|
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),
|
|
};
|
|
}
|
|
ensureCodexReasoningSummary(body);
|
|
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;
|
|
}
|
|
|
|
// GPT-5 verbosity: fold Chat-style `verbosity` / Responses `text.verbosity` into a
|
|
// single validated `text:{verbosity}` so the allowlist below (which now permits
|
|
// `text`) lets it reach upstream instead of dropping it silently.
|
|
normalizeCodexVerbosity(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",
|
|
// GPT-5 output verbosity ({ verbosity } — normalized above by normalizeCodexVerbosity).
|
|
"text",
|
|
// 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;
|
|
}
|
|
}
|