From 9e24bdd19a6bb45e16055d4ba8e51f298f2bf909 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 18 Oct 2026 17:28:11 -0700 Subject: [PATCH] fix(codex): revalidate hard-lock fallback targets before request rewriting --- .../responses/main-hard-lock-routing.ts | 18 +++- structure/codex-account-controls.md | 5 ++ ...account-hard-lock-fallback-routing.test.ts | 89 ++++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/server/responses/main-hard-lock-routing.ts b/src/server/responses/main-hard-lock-routing.ts index e24081f5d8b..831d927f48e 100644 --- a/src/server/responses/main-hard-lock-routing.ts +++ b/src/server/responses/main-hard-lock-routing.ts @@ -1,6 +1,8 @@ import type { OcxConfig } from "../../types"; import { resolveComboId } from "../../combos"; -import { isBareOpenAiFamilyModel } from "../../router"; +import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router"; +import { resolveChosenTarget } from "./shadow-target-availability"; +import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { getMainAccountHardLockStatus, @@ -95,6 +97,20 @@ export function applyMainHardLockFallbackOverride( return null; } + // Combos and policies keep their own dispatch admission and declared failover/evidence. + // Other chosen targets must resolve now; a removed qualifier cannot become a default send. + const policyId = resolvePolicyProfileId(config, fallback.model); + if (!isConfiguredComboSelector(config, fallback.model) + && !(policyId && getRoutingProfile(config, policyId))) { + try { + const target = resolveChosenTarget(fallback.model, model => previewRouteModel(config, model)); + if ("unavailable" in target) return null; + } catch { + // An unavailable target keeps the source lock's refusal without touching the body. + return null; + } + } + parsed.raw.model = fallback.model; if (fallback.reasoningEffort !== undefined) { const reasoning = parsed.raw.reasoning; diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md index 6b186fd74c9..3ee32d5c395 100644 --- a/structure/codex-account-controls.md +++ b/structure/codex-account-controls.md @@ -98,6 +98,11 @@ protects: - A malformed section (blank or padded model, undeclared effort) disables the reroute entirely — the same disable-don't-widen reading as the compaction override — while the load and write boundaries report the malformation. The lock's own refusal behavior is untouched either way. +- Before rewriting, concrete targets resolve against the current configuration through the + shared chosen-target check. Unavailable targets and qualified selectors that only reach the + terminal default provider retain the source lock's refusal. Bare default targets, declared + slash-containing model ids, configured combos and policy profiles keep their ordinary routing + semantics; policies retain request-evidence evaluation at dispatch preparation. The dashboard row mirrors the shadow-call row: the model pick, the effort and the switch each save immediately, and the switch writes `enabled: false` — a remembered pause that keeps the diff --git a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts index 14b91b46535..48152454997 100644 --- a/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts +++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyMainHardLockFallbackOverride } from "../../src/server/responses/main-hard-lock-routing"; import { resolveMainAccountHardLockFallback } from "../../src/codex/main-account-hard-lock"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { clearAccountQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; @@ -57,7 +57,7 @@ function observe(quota: Omit): void { } function config(overrides: Partial = {}): OcxConfig { - return { providers: {}, codexAccounts: [], ...overrides } as OcxConfig; + return { providers: { deepseek: { adapter: "openai-responses", authMode: "key", baseUrl: "https://deepseek.example.test/v1" } }, codexAccounts: [], ...overrides } as OcxConfig; } function withPoolAccount(target: OcxConfig): OcxConfig { @@ -165,6 +165,7 @@ describe("applyMainHardLockFallbackOverride", () => { deleteAllScopedHealth("pool-a"); const directCfg = withPoolAccount(config({ providers: { + deepseek: { adapter: "openai-responses", authMode: "key", baseUrl: "https://deepseek.example.test/v1" }, openai: { adapter: "openai-responses", authMode: "forward", @@ -226,6 +227,90 @@ describe("applyMainHardLockFallbackOverride", () => { expect(applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull(); }); + test("unavailable chosen targets keep the blocked source and reasoning intact", () => { + observe({ weeklyPercent: 100 }); + const gateway = { adapter: "openai-responses", authMode: "key", baseUrl: "https://gateway.example.test/v1" } as const; + for (const target of ["private/secret-model", "former/secret-model", "combo/removed", "policy/removed", "retired-account/gpt-5.3"]) { + for (const providers of [{ gateway }, {}]) { + const cfg = config({ providers, defaultProvider: "gateway", codexMainAccountHardLockFallback: { model: target, reasoningEffort: "low" } } as Partial); + const body = { model: "gpt-5.3", reasoning: { effort: "high" } }; + expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull(); + expect(body).toEqual({ model: "gpt-5.3", reasoning: { effort: "high" } }); + } + } + const cfg = config({ providers: { gateway, private: { ...gateway, disabled: true } }, defaultProvider: "gateway", codexMainAccountHardLockFallback: { model: "private/secret-model" } } as Partial); + expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull(); + }); + + test("target admission is rechecked after provider removal, re-addition and alias changes", () => { + observe({ weeklyPercent: 100 }); + const provider = { adapter: "openai-responses", authMode: "key", baseUrl: "https://chosen.example.test/v1", alias: "chosen" } as const; + const cfg = config({ providers: { private: provider }, defaultProvider: "private", codexMainAccountHardLockFallback: { model: "chosen/secret-model" } } as Partial); + const apply = () => applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg); + expect(apply()).not.toBeNull(); + delete cfg.providers.private; + expect(apply()).toBeNull(); + cfg.providers.private = provider; + expect(apply()).not.toBeNull(); + cfg.providers.private = { ...provider, alias: "renamed" }; + expect(apply()).toBeNull(); + cfg.codexMainAccountHardLockFallback = { model: "private/secret-model" }; + expect(apply()).not.toBeNull(); + }); + + test("bare fallback and explicitly declared slash model ids stay valid", () => { + observe({ weeklyPercent: 100 }); + const provider = { adapter: "openai-responses", authMode: "key", baseUrl: "https://chosen.example.test/v1", models: ["vendor/native-model"] } as const; + for (const model of ["bare-model", "vendor/native-model", "private/vendor/native-model"]) { + const cfg = config({ providers: { private: { ...provider, models: [...provider.models] } }, defaultProvider: "private", codexMainAccountHardLockFallback: { model } } as Partial); + expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)?.targetModel).toBe(model); + } + }); + + test("configured combo aliases, policy profiles and account namespaces keep their admission", () => { + observe({ weeklyPercent: 100 }); + const cfg = config({ + providers: { private: { adapter: "openai-responses", authMode: "key", baseUrl: "https://chosen.example.test/v1" }, + openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } }, + combos: { rescue: { alias: "chosen-combo", targets: [{ provider: "private", model: "secret-model" }] } }, + routingProfiles: { rescue: { alias: "chosen-policy", candidates: [{ provider: "private", model: "secret-model" }], require: { tools: true } } }, + codexAccountNamespaces: { pinned: "pool-a" }, + } as Partial); + for (const model of ["combo/rescue", "chosen-combo", "policy/rescue", "chosen-policy", "pinned/gpt-5.3"]) { + cfg.codexMainAccountHardLockFallback = { model }; + expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)?.targetModel).toBe(model); + } + }); + + test("a stale qualified fallback never sends to the enabled default provider", async () => { + observeMainQuotaCredential("fixture-caller", "fallback-account-a"); + observe({ weeklyPercent: 100 }); + const cfg = config({ + providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }, + gateway: { adapter: "openai-responses", authMode: "key", apiKey: "fixture-key", baseUrl: "https://gateway.example.test/v1" }, + }, + defaultProvider: "gateway", + codexMainAccountHardLockFallback: { model: "private/secret-model" }, + } as Partial); + let sends = 0; + const sentUrls: string[] = []; + globalThis.fetch = (async (input: unknown) => { + sends += 1; + sentUrls.push(String(input)); + return Response.json({ id: "resp_fixture", status: "completed", output: [] }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer fixture-caller", "chatgpt-account-id": "fallback-account-a" }, + body: JSON.stringify({ model: "gpt-5.3", stream: false, input: "fixture" }), + }), cfg, { model: "", provider: "" }); + await response.text(); + expect(sentUrls).toEqual([]); + expect(sends).toBe(0); + expect(response.status).toBeGreaterThanOrEqual(400); + }); + test("handleResponses strips caller credentials, preserves requestedModel on combo fallback, and yields to shadow/compaction turns", async () => { observe({ weeklyPercent: 100 }); const cfg = config({