diff --git a/bin/cli/commands/setup-opencode.mjs b/bin/cli/commands/setup-opencode.mjs index cddc867233..f2d5889f22 100644 --- a/bin/cli/commands/setup-opencode.mjs +++ b/bin/cli/commands/setup-opencode.mjs @@ -2,7 +2,7 @@ * omniroute setup-opencode — Remote-aware OpenCode provider generator * (openai-compatible). Distinct from `omniroute setup opencode` (which wires the * @omniroute/opencode-plugin). This writes the `omniroute` provider into - * ~/.config/opencode/opencode.json with every catalog model, so you can run + * the active OpenCode JSON/JSONC config with every catalog model, so you can run * `opencode -m omniroute/`. * * Reuses the proven server-side generator (config-generator/opencode.ts) for the @@ -10,12 +10,13 @@ */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import os from "node:os"; +import { basename, dirname } from "node:path"; +import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}"; +const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 }; /** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */ export function resolveOpencodeTarget(opts = {}) { @@ -29,7 +30,8 @@ export function resolveOpencodeTarget(opts = {}) { } catch { /* no context */ } - if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + if (!baseUrl) + baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; } let apiKey = opts.apiKey ?? opts["api-key"]; @@ -48,32 +50,61 @@ export function resolveOpencodeTarget(opts = {}) { /** * Post-process the generator output: reference the API key by env var (keep the * secret off disk) and optionally keep only models whose id matches `only`. - * Pure + testable. Returns the final JSON string. + * Pure + testable. Returns the final JSONC string while preserving comments + * outside the OmniRoute-managed fields. * * @param {string} rawJson output of generateOpencodeConfig * @param {{ only?: string[] }} [opts] * @returns {{ json: string, modelCount: number }} */ export function postProcessOpencodeConfig(rawJson, opts = {}) { - const config = JSON.parse(rawJson); - const prov = config.provider?.omniroute; - if (prov?.options) prov.options.apiKey = ENV_KEY_REF; + const errors = []; + const config = parse(rawJson, errors, { allowTrailingComma: true, disallowComments: false }); + if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) { + const details = errors + .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`) + .join(", "); + throw new Error(`Failed to parse generated OpenCode config${details ? `: ${details}` : ""}`); + } + const prov = config.provider?.omniroute; + let json = rawJson; + if (prov?.options) { + json = applyEdits( + json, + modify(json, ["provider", "omniroute", "options", "apiKey"], ENV_KEY_REF, { + formattingOptions: JSON_FORMATTING_OPTIONS, + }) + ); + } + + let models = prov?.models; if (opts.only && opts.only.length && prov?.models) { const kept = {}; for (const [id, entry] of Object.entries(prov.models)) { if (opts.only.some((f) => id.includes(f))) kept[id] = entry; } - prov.models = kept; + models = kept; + json = applyEdits( + json, + modify(json, ["provider", "omniroute", "models"], kept, { + formattingOptions: JSON_FORMATTING_OPTIONS, + }) + ); } - const modelCount = prov?.models ? Object.keys(prov.models).length : 0; - return { json: JSON.stringify(config, null, 2) + "\n", modelCount }; + const modelCount = models ? Object.keys(models).length : 0; + return { json: json.endsWith("\n") ? json : `${json}\n`, modelCount }; } export async function runSetupOpencodeCommand(opts = {}) { const { baseUrl, apiKey } = resolveOpencodeTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; + const only = opts.only + ? opts.only + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; printHeading("OmniRoute → OpenCode provider (openai-compatible)"); printInfo(`Connecting to ${baseUrl} …`); @@ -81,20 +112,28 @@ export async function runSetupOpencodeCommand(opts = {}) { // Deferred import: opencode.ts is TypeScript; tsx is registered by // bin/omniroute.mjs before any command runs, so importing here is safe. let raw; + let configPath; try { - const { generateOpencodeConfig } = await import( - "../../../src/lib/cli-helper/config-generator/opencode.ts" - ); - raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" }); + const { generateOpencodeConfig } = + await import("../../../src/lib/cli-helper/config-generator/opencode.ts"); + const { resolveOpencodeConfigPath } = + await import("../../../src/shared/services/opencodeConfigPath.ts"); + configPath = resolveOpencodeConfigPath(); + raw = await generateOpencodeConfig({ + baseUrl, + apiKey, + model: opts.model, + providerId: "omniroute", + configPath, + }); } catch (err) { - printError(`Failed to generate opencode.json: ${err?.message || err}`); + printError(`Failed to generate OpenCode config: ${err?.message || err}`); printInfo("Make sure OmniRoute is running and --remote/--api-key are correct."); return 1; } const { json, modelCount } = postProcessOpencodeConfig(raw, { only }); - const configDir = join(os.homedir(), ".config", "opencode"); - const configPath = join(configDir, "opencode.json"); + const configDir = dirname(configPath); if (dryRun) { console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json); @@ -104,7 +143,9 @@ export async function runSetupOpencodeCommand(opts = {}) { if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); writeFileSync(configPath, json, "utf8"); - printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`); + printSuccess( + `${basename(configPath)} updated at ${configPath} (${modelCount} models under 'omniroute')` + ); printInfo('Use it: opencode -m omniroute/ "..." (export OMNIROUTE_API_KEY first)'); return 0; } @@ -113,7 +154,7 @@ export function registerSetupOpencode(program) { program .command("setup-opencode") .description( - "Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " + + "Generate the OmniRoute openai-compatible provider in the active OpenCode config " + "from the live model catalog (local or remote VPS)" ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") diff --git a/bin/cli/utils/parseEnvValue.mjs b/bin/cli/utils/parseEnvValue.mjs new file mode 100644 index 0000000000..3388bda419 --- /dev/null +++ b/bin/cli/utils/parseEnvValue.mjs @@ -0,0 +1,21 @@ +/** + * Parse a `.env` value with dotenv-compatible comment handling. + * + * Without this, `KEY=value # note` stored the comment text as part of the + * value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and + * consumers compare it with `===`, so annotating a variable inline silently + * disabled it (#10100). + * + * Quoted values are returned verbatim — a `#` inside quotes is data. For + * unquoted values a `#` *preceded by whitespace* starts a comment, so + * `pass#word` is preserved. + */ +export function parseEnvValue(raw) { + const value = String(raw).trim(); + + const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/); + if (quoted) return quoted[2]; + + const commentIdx = value.search(/\s#/); + return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim(); +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index bfec91fead..c023879da8 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -23,6 +23,7 @@ import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSuppo import { getDefaultDataDir } from "./cli/data-dir.mjs"; import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs"; import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs"; +import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -93,28 +94,6 @@ function migrateElectronServerEnv(dataDir) { } } -/** - * Parse a `.env` value with dotenv-compatible comment handling. - * - * Without this, `KEY=value # note` stored the comment text as part of the - * value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and - * consumers compare it with `===`, so annotating a variable inline silently - * disabled it (#10100). - * - * Quoted values are returned verbatim — a `#` inside quotes is data. For - * unquoted values a `#` *preceded by whitespace* starts a comment, so - * `pass#word` is preserved. - */ -function parseEnvValue(raw) { - const value = String(raw).trim(); - - const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/); - if (quoted) return quoted[2]; - - const commentIdx = value.search(/\s#/); - return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim(); -} - function loadEnvFile() { const envPaths = []; const loadedEnvPaths = []; diff --git a/changelog.d/fixes/10247-provider-icon-data-url-save.md b/changelog.d/fixes/10247-provider-icon-data-url-save.md new file mode 100644 index 0000000000..6ad4538b86 --- /dev/null +++ b/changelog.d/fixes/10247-provider-icon-data-url-save.md @@ -0,0 +1 @@ +- **fix(providers):** compatible/custom providers now save valid Data URL icons and show Add/Edit save failures instead of silently doing nothing ([#10247](https://github.com/diegosouzapw/OmniRoute/pull/10247)) — thanks @xz-dev diff --git a/changelog.d/fixes/10248-custom-model-overrides.md b/changelog.d/fixes/10248-custom-model-overrides.md new file mode 100644 index 0000000000..711e48b9d5 --- /dev/null +++ b/changelog.d/fixes/10248-custom-model-overrides.md @@ -0,0 +1 @@ +- **fix(models):** custom model metadata and compatible-provider context overrides now take precedence over discovered metadata, while deleting a synced model no longer creates a permanent tombstone so a later provider sync can restore it ([#10248](https://github.com/diegosouzapw/OmniRoute/pull/10248)) — thanks @jackjinke diff --git a/changelog.d/fixes/pending-opencode-jsonc-config.md b/changelog.d/fixes/pending-opencode-jsonc-config.md new file mode 100644 index 0000000000..0951ae78f2 --- /dev/null +++ b/changelog.d/fixes/pending-opencode-jsonc-config.md @@ -0,0 +1 @@ +- **fix(cli):** recognize native `opencode.jsonc` files in OpenCode detection, generated-provider setup, and dashboard save/apply flows; preserve unrelated JSONC comments and provider settings, write updates back to the selected file, and refuse to overwrite invalid config ([#10227](https://github.com/diegosouzapw/OmniRoute/issues/10227)) — thanks @tito13kfm diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9432c5c002..6e8eaf1238 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1701,11 +1701,6 @@ "count": 43 } }, - "tests/unit/call-log-file-rotation.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "tests/unit/call-log-startup.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 64c5e75c8e..bbdc9d16f0 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", "_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", "_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.", "_rebaseline_2026_08_09_9351_antigravity_switch_auth": "PR #9351 own growth during the 2026-08-09 rebase: open-sse/executors/antigravity.ts 1528->1536 (+8 = switchAuth threaded out of tryResolveRetryFromErrorBody into handleAntigravityRateLimit's short-retry guard, so a decide429 switch decision beats the 60s same-account sleep; cohesive at the existing resolve chokepoint, not extractable). Covered by tests/unit/antigravity-429-switch-auth.test.ts.", @@ -444,7 +445,8 @@ "src/shared/constants/providers/apikey/gateways.ts": 1250, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", - "src/lib/modelCapabilities.ts": 1006 + "src/lib/modelCapabilities.ts": 1006, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014 }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index a66523435f..7c7f2bb67b 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -102,7 +102,7 @@ "_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939." }, "deadExports": { - "value": 248, + "value": 409, "direction": "down", "_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.", "_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.", @@ -110,7 +110,8 @@ "_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.", "_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines — 0 production-code change. Structural cleanup tracked as debt.", "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", - "_rebaseline_2026_08_11_v3850_merge_storm": "230 -> 248. Own drift from the 2026-08-11 merge storm (99 PRs into release/v3.8.50 via authorized sweep): new providers/executors/handlers added dead exports that knip cannot see as used. Measured on the base-fix tip (7ca73697b0 + this repair PR). Owner authorized rebaseline (2026-08-11) — structural cleanup remains separate debt." + "_rebaseline_2026_08_11_v3850_merge_storm": "230 -> 248. Own drift from the 2026-08-11 merge storm (99 PRs into release/v3.8.50 via authorized sweep): new providers/executors/handlers added dead exports that knip cannot see as used. Measured on the base-fix tip (7ca73697b0 + this repair PR). Owner authorized rebaseline (2026-08-11) — structural cleanup remains separate debt.", + "_rebaseline_2026_08_13_v3850_knip_bump": "248 -> 409. NOT code-added dead exports: dependabot bump #10043 (2026-08-13) upgraded knip 6.27.0 -> 6.32.x, and the new knip detects 162 MORE genuinely-unused exports (331 vs 169 deadExports) that 6.27 missed. DEAD_FILES unchanged (78). Reproduced identically on the clean release/v3.8.50 tip 266e39d3 with a fresh knip 6.32 node_modules — so every PR is born red on this gate until the tool change is absorbed. Owner authorized rebaseline (2026-08-13, via base-reds PR #10260). Structural cleanup of the 162 newly-surfaced dead exports remains separate debt." }, "cognitiveComplexity": { "value": 1223, diff --git a/eslint.config.mjs b/eslint.config.mjs index 1a447da98d..4d54c7e91a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,6 +62,10 @@ const eslintConfig = [ "no-implied-eval": "error", "no-new-func": "error", "no-restricted-imports": ["error", IMPORT_BOUNDARY_RESTRICTIONS], + // New rule shipped by the eslint-config-next bump (#10043); flags 6 pre-existing + // window.location.href navigations, several of which are deliberate full-page + // reloads (login/logout state reset). Off pending per-case review — issue #10292. + "@next/next/no-location-assign-relative-destination": "off", }, }, // G14: DB internals may use the compatibility barrel while it is decomposed; all diff --git a/open-sse/config/codexIdentity.ts b/open-sse/config/codexIdentity.ts index 7a52f5a0f1..bc45fa1cf6 100644 --- a/open-sse/config/codexIdentity.ts +++ b/open-sse/config/codexIdentity.ts @@ -3,81 +3,410 @@ import { createHash, randomUUID } from "node:crypto"; import { normalizeCodexSessionId } from "./codexClient.ts"; const CODEX_INSTALLATION_SALT = "omniroute-codex-installation"; +const CODEX_SESSION_SEED_PREFIX = "omniroute:codex-session-id:v1:"; +const CODEX_THREAD_SEED_PREFIX = "omniroute:codex-thread-id:v1:"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +export const CODEX_FINGERPRINT_MODES = ["off", "device", "session", "full"] as const; +export type CodexFingerprintMode = (typeof CODEX_FINGERPRINT_MODES)[number]; +export const CODEX_FINGERPRINT_MODE_KEY = "codexFingerprintMode"; + export type CodexClientIdentity = { + mode: CodexFingerprintMode; + installationId: string; sessionId: string; + threadId: string; turnId: string; windowId: string; - installationId: string; + turnStartedAtUnixMs: number; +}; + +type CodexIdentityOptions = { + mode?: CodexFingerprintMode; + accountKey?: string | null; + isOAuth?: boolean; }; function normalizeUuid(value: unknown): string | null { return typeof value === "string" && UUID_PATTERN.test(value.trim()) ? value.trim() : null; } -function uuidFromStableValue(value: string): string { +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized || null; +} + +/** Keep the historical installation-id layout so existing accounts stay stable. */ +function uuidFromLegacyInstallationValue(value: string): string { const hash = createHash("sha256").update(value).digest("hex"); return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`; } +/** RFC4122 v4 from SHA-256. Same seed → same UUID. */ +export function deriveStableUUIDv4(seed: string): string { + const digest = createHash("sha256").update(seed).digest(); + const bytes = Buffer.from(digest.subarray(0, 16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + return [ + bytes.subarray(0, 4).toString("hex"), + bytes.subarray(4, 6).toString("hex"), + bytes.subarray(6, 8).toString("hex"), + bytes.subarray(8, 10).toString("hex"), + bytes.subarray(10, 16).toString("hex"), + ].join("-"); +} + +function accountSeed( + providerSpecificData?: Record | null, + accountKey?: string | null +): string { + return ( + nonEmptyString(accountKey) || + nonEmptyString(providerSpecificData?.connectionId) || + nonEmptyString(providerSpecificData?.workspaceId) || + nonEmptyString(providerSpecificData?.accountId) || + nonEmptyString(providerSpecificData?.email) || + "default" + ); +} + +function readNamedHeader( + headers: Headers | Record | null | undefined, + name: string +): string { + if (!headers) return ""; + if (headers instanceof Headers) return headers.get(name)?.trim() || ""; + const wanted = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === wanted && typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +export function isCodexOAuthCredentials( + credentials?: { + accessToken?: unknown; + refreshToken?: unknown; + } | null +): boolean { + return Boolean( + nonEmptyString(credentials?.accessToken) || nonEmptyString(credentials?.refreshToken) + ); +} + +export function getCodexFingerprintMode( + providerSpecificData?: Record | null, + isOAuth = true +): CodexFingerprintMode { + if (!isOAuth) return "off"; + const raw = ( + nonEmptyString(providerSpecificData?.[CODEX_FINGERPRINT_MODE_KEY]) || + nonEmptyString(providerSpecificData?.codex_fingerprint_mode) || + "" + ).toLowerCase(); + return (CODEX_FINGERPRINT_MODES as readonly string[]).includes(raw) + ? (raw as CodexFingerprintMode) + : "session"; +} + export function getCodexInstallationId( - providerSpecificData?: Record | null + providerSpecificData?: Record | null, + accountKey?: string | null ): string { const explicit = normalizeUuid(providerSpecificData?.codexInstallationId); if (explicit) return explicit; - const stableSource = - typeof providerSpecificData?.workspaceId === "string" && providerSpecificData.workspaceId.trim() - ? providerSpecificData.workspaceId.trim() - : typeof providerSpecificData?.accountId === "string" && providerSpecificData.accountId.trim() - ? providerSpecificData.accountId.trim() - : typeof providerSpecificData?.email === "string" && providerSpecificData.email.trim() - ? providerSpecificData.email.trim() - : "default"; + const legacyStableSource = + nonEmptyString(providerSpecificData?.workspaceId) || + nonEmptyString(providerSpecificData?.accountId) || + nonEmptyString(providerSpecificData?.email); + if (legacyStableSource) { + return uuidFromLegacyInstallationValue(`${CODEX_INSTALLATION_SALT}:${legacyStableSource}`); + } - return uuidFromStableValue(`${CODEX_INSTALLATION_SALT}:${stableSource}`); + return deriveStableUUIDv4( + `${CODEX_INSTALLATION_SALT}:${accountSeed(providerSpecificData, accountKey)}` + ); } +export function getCodexConvergedSessionId( + providerSpecificData?: Record | null, + accountKey?: string | null +): string { + return deriveStableUUIDv4( + `${CODEX_SESSION_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}` + ); +} + +export function getCodexConvergedThreadId( + clientSessionId: string | null, + providerSpecificData?: Record | null, + accountKey?: string | null +): string { + if (!nonEmptyString(clientSessionId)) return ""; + return deriveStableUUIDv4( + `${CODEX_THREAD_SEED_PREFIX}${accountSeed(providerSpecificData, accountKey)}:${clientSessionId}` + ); +} + +export function getCodexClientSessionId( + headers: Headers | Record | null | undefined +): string | null { + return ( + normalizeCodexSessionId(readNamedHeader(headers, "session-id")) || + normalizeCodexSessionId(readNamedHeader(headers, "session_id")) || + null + ); +} + +/** + * One identity object for every carrier in one upstream turn. + * accountKey may be the OmniRoute connection id; it is never sent upstream. + */ export function createCodexClientIdentity( - sessionId: string | null, - providerSpecificData?: Record | null + clientSessionId: string | null, + providerSpecificData?: Record | null, + options: CodexIdentityOptions = {} ): CodexClientIdentity | null { - const normalizedSessionId = normalizeCodexSessionId(sessionId); - if (!normalizedSessionId) return null; + const mode = + options.mode ?? getCodexFingerprintMode(providerSpecificData, options.isOAuth ?? true); + if (mode === "off") return null; + + const installationId = getCodexInstallationId(providerSpecificData, options.accountKey); + if (mode === "device") { + return { + mode, + installationId, + sessionId: "", + threadId: "", + turnId: "", + windowId: "", + turnStartedAtUnixMs: Date.now(), + }; + } + + const sessionId = getCodexConvergedSessionId(providerSpecificData, options.accountKey); + const threadId = + mode === "full" + ? sessionId + : getCodexConvergedThreadId(clientSessionId, providerSpecificData, options.accountKey) || + sessionId; + return { - sessionId: normalizedSessionId, + mode, + installationId, + sessionId, + threadId, turnId: randomUUID(), - windowId: `${normalizedSessionId}:0`, - installationId: getCodexInstallationId(providerSpecificData), + windowId: `${threadId}:0`, + turnStartedAtUnixMs: Date.now(), }; } +function isCompactRequestEndpoint(path: unknown): boolean { + if (typeof path !== "string") return false; + const normalized = path.trim().toLowerCase().replace(/\\/g, "/"); + return normalized === "/compact" || /(?:^|\/)responses\/compact(?:\/|$)/.test(normalized); +} + +const CODEX_IDENTITY_HEADER_NAMES = [ + "session-id", + "session_id", + "thread-id", + "thread_id", + "x-client-request-id", + "x-codex-installation-id", + "x-codex-window-id", + "x-codex-turn-metadata", +] as const; + +type CodexCredentialIdentityInput = { + connectionId?: string; + requestEndpointPath?: string; + accessToken?: unknown; + refreshToken?: unknown; + providerSpecificData?: Record | null; +}; + +export function resolveCodexOriginalIdentityHeaders(input: { + credentials?: CodexCredentialIdentityInput | null; + clientHeaders?: Headers | Record | null; +}): Record | null { + const credentials = input.credentials; + if (!credentials || isCompactRequestEndpoint(credentials.requestEndpointPath)) return null; + const providerSpecificData = credentials.providerSpecificData ?? null; + if ( + !isCodexOAuthCredentials(credentials) || + getCodexFingerprintMode(providerSpecificData, true) !== "off" + ) { + return null; + } + + const result: Record = {}; + for (const name of CODEX_IDENTITY_HEADER_NAMES) { + const value = readNamedHeader(input.clientHeaders, name); + if (value) result[name] = value; + } + return Object.keys(result).length > 0 ? result : null; +} + +/** One identity for headers, body, nested metadata, and WS payload. Compact skips. */ +export function resolveCodexFingerprintIdentity(input: { + credentials?: CodexCredentialIdentityInput | null; + clientHeaders?: Headers | Record | null; + body?: unknown; +}): CodexClientIdentity | null { + const credentials = input.credentials; + if (!credentials || isCompactRequestEndpoint(credentials.requestEndpointPath)) return null; + + const providerSpecificData = credentials.providerSpecificData ?? null; + const isOAuth = isCodexOAuthCredentials(credentials); + if (getCodexFingerprintMode(providerSpecificData, isOAuth) === "off") return null; + + return createCodexClientIdentity( + getCodexClientSessionId(input.clientHeaders), + providerSpecificData, + { + accountKey: credentials.connectionId ?? null, + isOAuth, + } + ); +} + +export function withCodexFingerprintCredentials( + credentials: T, + clientHeaders?: Headers | Record | null, + body?: unknown +): T { + const identity = resolveCodexFingerprintIdentity({ credentials, clientHeaders, body }); + const original = resolveCodexOriginalIdentityHeaders({ credentials, clientHeaders }); + if (!identity && !original) return credentials; + return { + ...credentials, + providerSpecificData: { + ...(credentials.providerSpecificData || {}), + ...(identity ? { codexClientIdentity: identity } : {}), + ...(original ? { codexOriginalIdentityHeaders: original } : {}), + }, + }; +} + +function mergeTurnMetadata( + raw: unknown, + identity: CodexClientIdentity, + includeSessionFields: boolean +): string { + let metadata: Record = {}; + let hadExisting = false; + if (typeof raw === "string" && raw.trim()) { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + metadata = parsed as Record; + hadExisting = true; + } + } catch { + // Keep non-JSON metadata only when we do not need a complete carrier. + } + } + + if (!hadExisting && includeSessionFields) { + metadata.thread_source = "user"; + metadata.sandbox = "none"; + } + + metadata.installation_id = identity.installationId; + if (includeSessionFields) { + metadata.session_id = identity.sessionId; + metadata.thread_id = identity.threadId || identity.sessionId; + metadata.turn_id = identity.turnId; + metadata.window_id = identity.windowId; + metadata.turn_started_at_unix_ms = identity.turnStartedAtUnixMs; + } + return JSON.stringify(metadata); +} + +export function applyCodexOriginalIdentityHeaders( + headers: Record, + original?: Record | null +): void { + if (!original) return; + for (const name of CODEX_IDENTITY_HEADER_NAMES) { + const value = original[name]; + if (typeof value === "string" && value) headers[name] = value; + } +} + export function applyCodexClientIdentityHeaders( headers: Record, identity?: CodexClientIdentity | null ): void { if (!identity) return; + + headers["x-codex-installation-id"] = identity.installationId; + if (identity.mode === "device") { + if (headers["x-codex-turn-metadata"] !== undefined) { + headers["x-codex-turn-metadata"] = mergeTurnMetadata( + headers["x-codex-turn-metadata"], + identity, + false + ); + } + return; + } + + headers["session-id"] = identity.sessionId; headers["session_id"] = identity.sessionId; - headers["x-client-request-id"] = identity.sessionId; + headers["thread-id"] = identity.threadId || identity.sessionId; + headers["x-client-request-id"] = identity.threadId || identity.sessionId; headers["x-codex-window-id"] = identity.windowId; - headers["x-codex-turn-metadata"] = JSON.stringify({ - session_id: identity.sessionId, - thread_source: "user", - turn_id: identity.turnId, - sandbox: "none", - }); + headers["x-codex-turn-metadata"] = mergeTurnMetadata( + headers["x-codex-turn-metadata"], + identity, + true + ); +} + +export function applyCodexClientMetadata( + body: Record, + identity?: CodexClientIdentity | null +): void { + if (!identity) return; + + const existing = + body.client_metadata && + typeof body.client_metadata === "object" && + !Array.isArray(body.client_metadata) + ? { ...(body.client_metadata as Record) } + : {}; + existing["x-codex-installation-id"] = identity.installationId; + + if (identity.mode !== "device") { + existing.session_id = identity.sessionId; + existing.thread_id = identity.threadId || identity.sessionId; + existing.turn_id = identity.turnId; + existing["x-codex-window-id"] = identity.windowId; + } + + if (existing["x-codex-turn-metadata"] !== undefined) { + existing["x-codex-turn-metadata"] = mergeTurnMetadata( + existing["x-codex-turn-metadata"], + identity, + identity.mode !== "device" + ); + } + + body.client_metadata = existing; } /** * #3697: detect the Codex CLI as the request *client* (not the routed provider) from * request headers, so the model-echo shim can fire regardless of which upstream provider * ultimately serves the request (e.g. `codex/gpt-5.5-xhigh` routed through a combo). - * Mirrors the `originator`/User-Agent detection proven in `isCodexModelCatalogClient` - * (PR #3481, `src/app/api/v1/models/catalogRequest.ts`) — Codex CLI sends an `originator` - * header of `codex_exec`/`codex_cli_rs` and a matching `codex_*` User-Agent — but works off - * a plain headers bag (`Headers` or a header-name→value record) instead of a `Request`, - * since chatCore's `clientRawRequest.headers` is not always a `Request`. */ export function isCodexOriginatedHeaders( headers: Headers | Record | null | undefined @@ -132,20 +461,3 @@ export function isVerifiedNativeCodexRequest( ): boolean { return isCodexOriginatedHeaders(headers) && hasNativeCodexTurnBinding(body); } - -export function applyCodexClientMetadata( - body: Record, - identity?: CodexClientIdentity | null -): void { - if (!identity) return; - const existing = - body.client_metadata && - typeof body.client_metadata === "object" && - !Array.isArray(body.client_metadata) - ? (body.client_metadata as Record) - : {}; - body.client_metadata = { - ...existing, - "x-codex-installation-id": identity.installationId, - }; -} diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index c2e1f6b199..93e5a2fe16 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -27,8 +27,9 @@ import { import { applyCodexClientIdentityHeaders, applyCodexClientMetadata, - createCodexClientIdentity, + applyCodexOriginalIdentityHeaders, type CodexClientIdentity, + withCodexFingerprintCredentials, } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; @@ -765,23 +766,11 @@ export class CodexExecutor extends BaseExecutor { input.model ); const requestInput = requestBody === input.body ? input : { ...input, body: requestBody }; - const sessionId = this.getPromptCacheSessionId( + const credentials = withCodexFingerprintCredentials( requestInput.credentials, - requestInput.body as Record | null + requestInput.clientHeaders, + requestInput.body ); - const identity = createCodexClientIdentity( - sessionId, - requestInput.credentials?.providerSpecificData ?? null - ); - const credentials = identity - ? { - ...requestInput.credentials, - providerSpecificData: { - ...(requestInput.credentials?.providerSpecificData || {}), - codexClientIdentity: identity, - }, - } - : requestInput.credentials; const nextInput = { ...requestInput, credentials }; if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) { @@ -1054,6 +1043,8 @@ export class CodexExecutor extends BaseExecutor { } const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as CodexClientIdentity | null | undefined; + const originalIdentityHeaders = credentials?.providerSpecificData + ?.codexOriginalIdentityHeaders as Record | 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" @@ -1066,6 +1057,7 @@ export class CodexExecutor extends BaseExecutor { if (cacheSessionId) { headers["session_id"] = cacheSessionId; } + applyCodexOriginalIdentityHeaders(headers, originalIdentityHeaders); applyCodexClientIdentityHeaders(headers, clientIdentity); return headers; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d762d8bd2d..35e579a45d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -104,7 +104,13 @@ import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts"; import { createStreamController } from "../utils/streamHandler.ts"; import * as streamFailure from "../utils/streamFailureFinalization.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; -import { addBufferToUsage, filterUsageForFormat, estimateUsage, sanitizeUsagePayloadForRequest } from "../utils/usageTracking.ts"; +import { + addBufferToUsage, + filterUsageForFormat, + estimateUsage, + normalizeUsage, + sanitizeUsagePayloadForRequest, +} from "../utils/usageTracking.ts"; import { refreshWithRetry, isUnrecoverableRefreshError, @@ -271,7 +277,10 @@ import { appendNonStreamingSseTerminalSignal, type NonStreamingSseTerminalState, } from "./chatCore/nonStreamingSse.ts"; -import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts"; +import { + isJsonRecord, + parseNonStreamingResponseBody, +} from "./chatCore/nonStreamingResponseParse.ts"; import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts"; import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts"; import { @@ -387,6 +396,12 @@ import { isRpmExhausted, } from "../services/geminiRateLimitTracker.ts"; import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; + +type ChatCoreExecutorResult = ReturnType & { + _executionCredentials?: Record; + _accountSemaphoreRelease?: () => void; +}; + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -2731,7 +2746,7 @@ export async function handleChatCore({ let releaseRawResultAccountSemaphore = () => {}; try { - const rawResult = await (async () => { + const rawResult: ChatCoreExecutorResult = await (async () => { let attempts = 0; const isModelScopeForRequest = isModelScope(); const maxAttempts = isModelScopeForRequest ? 3 : provider === "codex" ? 3 : 1; @@ -3550,22 +3565,24 @@ export async function handleChatCore({ // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback). try { const retryModelId = String(translatedBody.model || effectiveModel); - const retryResult = await runWithCapture(providerRequestCapture, () => - executor.execute({ - model: retryModelId, - body: translatedBody, - stream: upstreamStream, - credentials: getExecutionCredentials(), - signal: streamController.signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), - clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), - clientResponseFormat, - onCredentialsRefreshed, - skipUpstreamRetry: isCombo, - contextEditing: { enabled: contextEditingEnabled }, - }) + const retryResult = normalizeExecutorResult( + await runWithCapture(providerRequestCapture, () => + executor.execute({ + model: retryModelId, + body: translatedBody, + stream: upstreamStream, + credentials: getExecutionCredentials(), + signal: streamController.signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId), + clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), + clientResponseFormat, + onCredentialsRefreshed, + skipUpstreamRetry: isCombo, + contextEditing: { enabled: contextEditingEnabled }, + }) + ) ); if (retryResult.response.ok) { @@ -4218,6 +4235,12 @@ export async function handleChatCore({ trackPendingRequest(model, provider, connectionId, false); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message); } + if (!isJsonRecord(unwrapped)) { + const invalidEnvelopeMessage = "Invalid JSON response from provider"; + persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error"); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidEnvelopeMessage); + } responseBody = unwrapped; } responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody); @@ -4504,13 +4527,14 @@ export async function handleChatCore({ ); translatedResponse = postCallGuardrails.response; - const responseUsage = - (usage && typeof usage === "object" ? usage : null) || - (translatedResponse?.usage && typeof translatedResponse.usage === "object" + const responseUsage = isJsonRecord(usage) + ? usage + : isJsonRecord(translatedResponse.usage) ? translatedResponse.usage - : null); - const estimatedCost = responseUsage - ? await calculateCost(provider, model, responseUsage, { serviceTier: effectiveServiceTier }) + : null; + const costUsage = normalizeUsage(responseUsage); + const estimatedCost = costUsage + ? await calculateCost(provider, model, costUsage, { serviceTier: effectiveServiceTier }) : 0; if (postCallGuardrails.blocked) { @@ -5045,6 +5069,8 @@ export async function handleChatCore({ apiKeyInfo, handleStreamFailure, copilotCompatibleReasoning, + false, + customToolNames, // openai-responses → openai translation still wants the namespace identity // map for #7936-style round-trip closure when the client also speaks // Responses (Codex CLI). diff --git a/open-sse/handlers/chatCore/clineResponseEnvelope.ts b/open-sse/handlers/chatCore/clineResponseEnvelope.ts index 75229773b0..ac2a91f53e 100644 --- a/open-sse/handlers/chatCore/clineResponseEnvelope.ts +++ b/open-sse/handlers/chatCore/clineResponseEnvelope.ts @@ -8,6 +8,11 @@ function hasOpenAIChoices(value: unknown): value is JsonRecord & { choices: unkn return isRecord(value) && Array.isArray(value.choices); } +export function unwrapClineNonStreamingEnvelope( + provider: string, + responseBody: JsonRecord +): JsonRecord; +export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown; export function unwrapClineNonStreamingEnvelope(provider: string, responseBody: unknown): unknown { if (provider !== "cline" || !isRecord(responseBody)) { return responseBody; diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 1569b61178..8eb96ab14f 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -20,6 +20,10 @@ type CredentialsLike = | null | undefined; +type ResolvedExecutionCredentials = Record & { + providerSpecificData: Record; +}; + function buildKimiThinkingMetadata( modelInfo: Record | null | undefined, staticThinkingPolicy: ReturnType @@ -79,7 +83,7 @@ export function resolveExecutionCredentials(opts: { provider: string | null | undefined; ccSessionId: string | null; modelInfo?: Record | null; -}) { +}): ResolvedExecutionCredentials { const { credentials, nativeCodexPassthrough, diff --git a/open-sse/handlers/chatCore/nonStreamingResponseParse.ts b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts index fcdf4d072e..617ad02c00 100644 --- a/open-sse/handlers/chatCore/nonStreamingResponseParse.ts +++ b/open-sse/handlers/chatCore/nonStreamingResponseParse.ts @@ -28,10 +28,16 @@ type LoggerLike = | null | undefined; +export type JsonRecord = Record; + +export function isJsonRecord(value: unknown): value is JsonRecord { + return !!value && typeof value === "object" && !Array.isArray(value); +} + export type NonStreamingParseResult = | { kind: "ok"; - responseBody: unknown; + responseBody: JsonRecord; responsePayloadFormat: string; looksLikeSSE: boolean; normalizedProviderPayload: unknown; @@ -113,7 +119,16 @@ export async function parseNonStreamingResponseBody(opts: { } try { - const responseBody = rawBody ? JSON.parse(rawBody) : {}; + const responseBody: unknown = rawBody ? JSON.parse(rawBody) : {}; + if (!isJsonRecord(responseBody)) { + return { + kind: "invalid_json", + message: "Invalid JSON response from provider", + detailedError: "Invalid JSON response from provider: expected an object payload", + looksLikeSSE: false, + normalizedProviderPayload, + }; + } return { kind: "ok", responseBody, diff --git a/open-sse/handlers/chatCore/upstreamTimeouts.ts b/open-sse/handlers/chatCore/upstreamTimeouts.ts index 551b952e2c..5de972dcae 100644 --- a/open-sse/handlers/chatCore/upstreamTimeouts.ts +++ b/open-sse/handlers/chatCore/upstreamTimeouts.ts @@ -98,17 +98,7 @@ export function getExecutorTimeoutMs(executor: unknown, provider?: string, model return resolveProviderTimeoutMs(executor); } -export function normalizeExecutorResult( - result: - | Response - | { - response: Response; - url?: string; - headers?: Record; - transformedBody?: unknown; - transport?: string; - } -): { +export function normalizeExecutorResult(result: unknown): { response: Response; url: string; headers: Record; @@ -118,12 +108,27 @@ export function normalizeExecutorResult( if (result instanceof Response) { return { response: result, url: "", headers: {}, transformedBody: null }; } + if ( + !result || + typeof result !== "object" || + !("response" in result) || + !(result.response instanceof Response) + ) { + throw new TypeError("Executor result must contain a Response"); + } + const normalized = result as { + response: Response; + url?: string; + headers?: Record; + transformedBody?: unknown; + transport?: string; + }; return { - response: result.response, - url: result.url || "", - headers: result.headers || {}, - transformedBody: result.transformedBody ?? null, - transport: result.transport, + response: normalized.response, + url: normalized.url || "", + headers: normalized.headers || {}, + transformedBody: normalized.transformedBody ?? null, + transport: normalized.transport, }; } diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index fcaa5677fb..06d3202f5f 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -257,6 +257,14 @@ export interface SanitizeOpenAIResponseOptions { parseTextualReasoningTags?: boolean; } +export function sanitizeOpenAIResponse( + body: JsonRecord, + options?: SanitizeOpenAIResponseOptions +): JsonRecord; +export function sanitizeOpenAIResponse( + body: unknown, + options?: SanitizeOpenAIResponseOptions +): unknown; export function sanitizeOpenAIResponse( body: unknown, options: SanitizeOpenAIResponseOptions = {} @@ -310,6 +318,8 @@ export function sanitizeOpenAIResponse( return sanitized; } +export function sanitizeResponsesApiResponse(body: JsonRecord): JsonRecord; +export function sanitizeResponsesApiResponse(body: unknown): unknown; export function sanitizeResponsesApiResponse(body: unknown): unknown { const bodyRecord = toRecord(body); if (!bodyRecord) return body; diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 3527cdfec5..eaa311f0cf 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -133,6 +133,18 @@ function findBestMessageText(output: unknown[]): { * * @param toolNameMap - Optional Map for Claude OAuth tool name stripping */ +export function translateNonStreamingResponse( + responseBody: JsonRecord, + targetFormat: string, + sourceFormat: string, + toolNameMap?: Map | null +): JsonRecord; +export function translateNonStreamingResponse( + responseBody: unknown, + targetFormat: string, + sourceFormat: string, + toolNameMap?: Map | null +): unknown; export function translateNonStreamingResponse( responseBody: unknown, targetFormat: string, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 58371dc93e..df86a79375 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1919,11 +1919,18 @@ export async function handleComboChat({ // skip the same-model retry when `nextTarget` (computed above) // actually gives us somewhere to fail over to — with no sibling // left, skipping just burns the last attempt for nothing. + // + // #10217 round-4 fix: this guard reads `failoverBeforeRetryExplicit` + // (opt-in only), NOT `config.failoverBeforeRetry` — that field + // defaults to true for the separate skipUpstreamRetry mechanism + // (see DEFAULT_COMBO_CONFIG comment in comboConfig.ts) and reading + // it here would silently skip the same-model retry for every combo, + // not just ones that explicitly opted in. if ( retry < maxRetries && isTransient && !providerExhausted && - (!config.failoverBeforeRetry || !nextTarget) + (!config.failoverBeforeRetryExplicit || !nextTarget) ) { if ( !protectedPriorityTarget && @@ -2438,7 +2445,15 @@ async function handleRoundRobinCombo({ }: HandleRoundRobinOptions): Promise { const config = settings ? resolveComboConfig(combo, settings) - : { ...getDefaultComboConfig(), ...(combo.config || {}) }; + : { + ...getDefaultComboConfig(), + ...(combo.config || {}), + // See resolveComboConfig's failoverBeforeRetryExplicit comment in + // comboConfig.ts (no `settings` here, so only the combo's own config + // can opt in). + failoverBeforeRetryExplicit: + (combo.config as Record | undefined)?.failoverBeforeRetry === true, + }; // #9158: clamp combo-level concurrency to a sane bound — a config carrying a // huge or negative value would otherwise open an unbounded semaphore and // flood targets (or deadlock at 0). @@ -3163,12 +3178,14 @@ async function handleRoundRobinCombo({ // just the lower-level skipUpstreamRetry mechanism. Only skip when // `offset + 1 < modelCount` means a sibling target is actually left // in this rotation; with none left, skipping just wastes the attempt. + // #10217 round-4 fix: opt-in only — read failoverBeforeRetryExplicit, + // not config.failoverBeforeRetry (see comboConfig.ts comment). const hasNextRrTarget = offset + 1 < modelCount; if ( retry < maxRetries && isTransient && !providerExhausted && - (!config.failoverBeforeRetry || !hasNextRrTarget) + (!config.failoverBeforeRetryExplicit || !hasNextRrTarget) ) { continue; } diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index 4489b6194a..a08a1312ec 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -126,6 +126,22 @@ const DEFAULT_COMBO_CONFIG = { resetAwareWeeklyWeight: 0.65, resetAwareTieBandPercent: 5, resetAwareExhaustionGuardPercent: 10, + // Historical default (predates #2417/#10217) — true. This value feeds TWO + // independent mechanisms and must stay true-by-default for one of them: + // 1. skipUpstreamRetry (src/sse/handlers/chat.ts:859,1126) — the + // lower-level executor retry skip. Always default-on; changing this + // default flips that mechanism's behavior for every combo, not just + // opted-in ones. + // 2. The #10217 same-model retry guard in this file's combo.ts callers + // (priority/auto + round-robin loops) — meant to be OPT-IN only. That + // guard must NOT read this field directly; it consults the sibling + // `failoverBeforeRetryExplicit` flag computed below in + // resolveComboConfig/resolveComboSetupConfig, which is true only when + // an actual cascade layer (combo/provider/global) set the flag to + // true, not merely inherited from this default. See round-4 base-red + // bisect (06f41cda63 vs d2fd88dfbc) — flipping THIS default to false + // "fixed" mechanism 2 but silently broke mechanism 1's default-on + // behavior for every combo without an explicit opt-in. failoverBeforeRetry: true, // Feature 4985: configurable response-body validation predicate (per-combo). When set, // a 200 OK whose body fails the predicate fails over to the next target. @@ -284,15 +300,32 @@ export function resolveComboConfig( ) ); + const cleanGlobal = clean(global); + const cleanProviderOverride = clean(providerOverride); + const cleanComboConfig = clean(comboConfig); + const merged = { ...DEFAULT_COMBO_CONFIG, - ...clean(global), - ...clean(providerOverride), - ...clean(comboConfig), + ...cleanGlobal, + ...cleanProviderOverride, + ...cleanComboConfig, }; + // #10217 round-4 fix: `failoverBeforeRetry` defaults to true (see comment on + // DEFAULT_COMBO_CONFIG above) and feeds two independent mechanisms. Callers + // that gate the OPT-IN same-model retry guard (combo.ts) must NOT read + // `merged.failoverBeforeRetry` directly — that stays true unless a layer + // explicitly disables it, which can't distinguish "inherited default" from + // "operator opted in". This flag is true only when some cascade layer + // literally set the value to true, i.e. a genuine opt-in. + const failoverBeforeRetryExplicit = + cleanComboConfig.failoverBeforeRetry === true || + cleanProviderOverride.failoverBeforeRetry === true || + cleanGlobal.failoverBeforeRetry === true; + return { ...merged, + failoverBeforeRetryExplicit, shadowRouting: { ...DEFAULT_COMBO_CONFIG.shadowRouting, ...(isRecord(global.shadowRouting) ? clean(global.shadowRouting) : {}), @@ -312,7 +345,13 @@ export function resolveComboConfig( * Get the default combo config (used when no overrides exist) */ export function getDefaultComboConfig() { - return { ...DEFAULT_COMBO_CONFIG }; + return { + ...DEFAULT_COMBO_CONFIG, + // Mirror resolveComboConfig's opt-in flag so a deepEqual against the + // default stays consistent (#10217 round-4 fix). With no cascade layer + // setting the flag, it is a genuine non-opt-in → false. + failoverBeforeRetryExplicit: false, + }; } /** @@ -322,7 +361,14 @@ export function getDefaultComboConfig() { * return type is the single source of truth for ComboContext.config (combo/context.ts). */ export function resolveComboSetupConfig(combo: ComboConfigLike, settings: ComboSettingsLike) { - return settings - ? resolveComboConfig(combo, settings) - : { ...getDefaultComboConfig(), ...((combo?.config as Record) || {}) }; + if (settings) return resolveComboConfig(combo, settings); + const comboConfig = (combo?.config as Record) || {}; + return { + ...getDefaultComboConfig(), + ...comboConfig, + // See resolveComboConfig's failoverBeforeRetryExplicit comment — same + // distinction applies here (no `settings`, so only the combo's own config + // can opt in). + failoverBeforeRetryExplicit: comboConfig.failoverBeforeRetry === true, + }; } diff --git a/open-sse/services/refreshSerializer.ts b/open-sse/services/refreshSerializer.ts index f71d5c4190..55f37c62ce 100644 --- a/open-sse/services/refreshSerializer.ts +++ b/open-sse/services/refreshSerializer.ts @@ -112,8 +112,8 @@ export async function serializeRefresh(provider: string, fn: () => Promise * and codex-lb's replica race-detection. */ export function wasRefreshTokenRotated( - attemptedRefreshToken: string | null | undefined, - latestRefreshToken: string | null | undefined + attemptedRefreshToken: unknown, + latestRefreshToken: unknown ): boolean { return ( typeof attemptedRefreshToken === "string" && diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 5ccbe52a3e..012026da3a 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -192,6 +192,7 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ // tests/unit/pack-artifact-entrypoint-closures.test.ts). "bin/cli/data-dir.mjs", "bin/cli/utils/ensureAndroidCacheDir.mjs", + "bin/cli/utils/parseEnvValue.mjs", "bin/cli/utils/storageKeyProvision.mjs", "bin/cli/utils/versionFastPath.mjs", "bin/mcp-server.mjs", diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 7e2a5fbc15..9255d268ff 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -317,6 +317,17 @@ function getAuthHeaders(requestUrl, requestHeaders) { if (isText(requestHeaders["x-forwarded-for"])) { headers["x-forwarded-for"] = requestHeaders["x-forwarded-for"]; } + for (const key of [ + "session-id", + "session_id", + "x-codex-installation-id", + "x-codex-window-id", + "x-codex-turn-metadata", + "originator", + "user-agent", + ]) { + if (isText(requestHeaders[key])) headers[key] = requestHeaders[key]; + } return headers; } diff --git a/scripts/dev/v1-ws-bridge.mjs b/scripts/dev/v1-ws-bridge.mjs index 3653bd159f..05d9e31010 100644 --- a/scripts/dev/v1-ws-bridge.mjs +++ b/scripts/dev/v1-ws-bridge.mjs @@ -185,6 +185,18 @@ function getForwardHeaders(requestUrl, requestHeaders) { headers.origin = origin; } + for (const key of [ + "session-id", + "session_id", + "x-codex-installation-id", + "x-codex-window-id", + "x-codex-turn-metadata", + "originator", + "user-agent", + ]) { + if (isText(requestHeaders[key])) headers[key] = requestHeaders[key]; + } + return headers; } diff --git a/skills/omni-settings/SKILL.md b/skills/omni-settings/SKILL.md index 79d930097b..9ab8f95dfb 100644 --- a/skills/omni-settings/SKILL.md +++ b/skills/omni-settings/SKILL.md @@ -319,15 +319,7 @@ curl -X PUT https://localhost:20128/api/settings/system-prompt \ Get thinking budget configuration -Returns proxy-level thinking/reasoning **request rewrite** settings: - -| Field | Meaning | -|-------|---------| -| `mode` | `passthrough` (leave client reasoning alone — **required for Codex visible thinking**), `auto` (**strips** all client thinking fields), `custom`, `adaptive` | -| `customBudget` | Fixed budget when `mode=custom` | -| `effortLevel` | Base effort when `mode=adaptive` | - -**Not** compression and **not** “decrypt encrypted reasoning”. Full guide: `docs/guides/THINKING_BUDGET.md`. +Returns the current thinking/reasoning budget settings for AI models. ```bash curl https://localhost:20128/api/settings/thinking-budget \ @@ -338,17 +330,13 @@ curl https://localhost:20128/api/settings/thinking-budget \ Update thinking budget configuration -Example — keep client-controlled reasoning (Codex/Desktop): - ```bash curl -X PUT https://localhost:20128/api/settings/thinking-budget \ - -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" -H "Content-Type: application/json" \ - -d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}' + -d '{}' ``` -Warning: `mode=auto` deletes `reasoning` / `reasoning_effort` / Claude `thinking` from the outbound body before upstream. That can empty thinking panels even when the client requested Ultra + summary. - ### GET /api/tags List Ollama-compatible model tags @@ -398,3 +386,30 @@ curl -X POST https://localhost:20128/api/settings/purge-usage-history \ ## Payloads See the full OpenAPI specification at `GET /api/openapi/spec` or `docs/openapi.yaml` for detailed request/response schemas. + + + + +### GET /api/settings/thinking-budget — behavior + +Returns proxy-level thinking/reasoning **request rewrite** settings: + +| Field | Meaning | +|-------|---------| +| `mode` | `passthrough` (leave client reasoning alone — **required for Codex visible thinking**), `auto` (**strips** all client thinking fields), `custom`, `adaptive` | +| `customBudget` | Fixed budget when `mode=custom` | +| `effortLevel` | Base effort when `mode=adaptive` | + +**Not** compression and **not** "decrypt encrypted reasoning". Full guide: `docs/guides/THINKING_BUDGET.md`. + +Example — keep client-controlled reasoning (Codex/Desktop): + +```bash +curl -X PUT https://localhost:20128/api/settings/thinking-budget \ + -H "Authorization: Bearer $OMNIROUTE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}' +``` + +Warning: `mode=auto` deletes `reasoning` / `reasoning_effort` / Claude `thinking` from the outbound body before upstream. That can empty thinking panels even when the client requested Ultra + summary. + diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index ed73056cf0..8afc94bc74 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -281,13 +281,12 @@ export default function ProviderDetailPageClient() { providerId, registryModels, syncedModels: syncedAvailableModels, - customModels: (modelMeta.customModels || []).map( - (cm: { id: string; name?: string; source?: string }) => ({ - id: cm.id, - name: cm.name || cm.id, - source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", - }) - ), + customModels: (modelMeta.customModels || []).map((cm) => ({ + ...cm, + id: cm.id, + name: cm.name || cm.id, + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", + })), usesCuratedModelsOnly, }); }, [ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx index bee9371593..6665109862 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx @@ -83,14 +83,10 @@ describe("providerPageHelpers — model-compat pure functions", () => { expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false); }); - it("isModelHiddenFn ignores deleted tombstones when reading visibility", () => { + it("isModelHiddenFn reads hidden compatibility overrides", () => { const customMap = buildCompatMap([]); - const overrideMap = buildCompatMap([ - { id: "gpt-4o-2024-11-20", isHidden: true, isDeleted: true }, - { id: "gpt-5-mini", isHidden: true }, - ]); + const overrideMap = buildCompatMap([{ id: "gpt-5-mini", isHidden: true }]); - expect(isModelHiddenFn("gpt-4o-2024-11-20", customMap, overrideMap)).toBe(false); expect(isModelHiddenFn("gpt-5-mini", customMap, overrideMap)).toBe(true); }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/CodexFingerprintFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/CodexFingerprintFields.tsx new file mode 100644 index 0000000000..9d13462cfb --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/CodexFingerprintFields.tsx @@ -0,0 +1,86 @@ +import { Select, Toggle } from "@/shared/components"; +import { type CodexServiceTier } from "@/lib/providers/requestDefaults"; +import { + CODEX_ACCOUNT_SERVICE_TIER_VALUES, + CODEX_FINGERPRINT_MODE_VALUES, + CODEX_REASONING_STRENGTH_OPTIONS, + getCodexFingerprintModeLabel, + getCodexServiceTierLabel, + providerText, + type CodexFingerprintModeValue, +} from "../../providerPageHelpers"; + +type Translator = Parameters[0]; + +export function CodexConnectionFields({ + t, + reasoningEffort, + serviceTier, + fingerprintMode, + openaiStoreEnabled, + showFingerprintMode, + onChange, +}: { + t: Translator; + reasoningEffort: string; + serviceTier: CodexServiceTier; + fingerprintMode: CodexFingerprintModeValue; + openaiStoreEnabled: boolean; + showFingerprintMode: boolean; + onChange: (patch: { + codexReasoningEffort?: string; + codexServiceTier?: CodexServiceTier; + codexFingerprintMode?: CodexFingerprintModeValue; + codexOpenaiStoreEnabled?: boolean; + }) => void; +}) { + return ( +
+ ({ + value, + label: getCodexServiceTierLabel(t, value), + }))} + onChange={(event) => onChange({ codexServiceTier: event.target.value as CodexServiceTier })} + hint={providerText( + t, + "codexServiceTierDescription", + "Default uses the normal Codex tier. Priority shows as Fast; Flex uses the flex service tier when available." + )} + /> + {showFingerprintMode && ( + setFormData({ ...formData, codexReasoningEffort: e.target.value })} - hint={t("defaultThinkingStrengthHint")} - /> -