From de5503e5e793bb56ce123353f1cd6625325521e1 Mon Sep 17 00:00:00 2001
From: Blazetes
Date: Wed, 14 Oct 2026 19:20:59 +0800
Subject: [PATCH 1/3] feat(codex): route hard-locked main-account traffic to a
configured fallback model
The main-account hard lock (#5694) refuses requests once the 5h window reads
its 90% threshold or the weekly window reads 98%, and a monthly-only account
governs on monthly. On a single-account setup that refusal is the whole
session: Codex Desktop and CLI turns fail until the window resets even though
another provider in the same config could serve the turn.
Add extended settings to the same feature: codexMainAccountHardLockFallback
names a model and an optional reasoning effort, and while the lock blocks, a
qualifying request is rewritten to that selector instead of failing. The
rewrite is bounded on every side so it never widens what the lock protects:
- It fires only while the lock reads blocked; release stays the same fresh
observation that admits, so the reroute ends with the lock, on its own.
- getEligiblePoolAccounts is the dispatch's own eligibility choke point, so a
usable Pool account keeps the request on Codex; an empty eligible list is
what makes the fallback the only way through.
- Only bare Codex-native selectors qualify. Provider-qualified rows, combos,
account namespaces, policy routes and thread spawns name their own
destination on purpose; Luna Reserve keeps its own admission.
- A Codex-native fallback target never applies -- rerouting gpt-* to gpt-*
would re-enter the same refusal, so the honest behavior is the refusal.
- A malformed section disables the reroute entirely, the same
disable-don't-widen reading the compaction override takes.
The management API projects the section in GET /api/settings, validates PUTs
against a strict schema before mutation, and null clears it. The dashboard's
main-account protection card grows the model and effort fields with the same
acknowledged-write contract as the toggle. The usage log keeps reporting the
selector the client asked for.
---
.../docs/reference/cli/providers-accounts.md | 16 ++
.../docs/reference/configuration/server.md | 1 +
.../components/MainAccountHardLockSetting.tsx | 144 ++++++++++++++-
gui/src/i18n/de.ts | 7 +
gui/src/i18n/en.ts | 7 +
gui/src/i18n/fr.ts | 7 +
gui/src/i18n/ja.ts | 7 +
gui/src/i18n/ko.ts | 7 +
gui/src/i18n/pt.ts | 7 +
gui/src/i18n/ru.ts | 7 +
gui/src/i18n/tr.ts | 7 +
gui/src/i18n/vi.ts | 7 +
gui/src/i18n/zh-TW.ts | 7 +
gui/src/i18n/zh.ts | 7 +
.../main-account-hard-lock-setting.test.tsx | 98 +++++++++++
scripts/test-layout/layout.json | 2 +-
src/codex/main-account-hard-lock.ts | 28 ++-
src/config/diagnostics.ts | 3 +
src/config/load-degrade.ts | 2 +
src/config/schema/config-schema.ts | 4 +
.../schema/main-account-hard-lock-fallback.ts | 26 +++
src/router.ts | 3 +-
src/server/management/config-routes.ts | 23 ++-
src/server/responses/core-options.ts | 3 +
.../responses/main-hard-lock-routing.ts | 76 ++++++++
src/server/responses/request-prepare.ts | 16 +-
src/types/config.ts | 7 +
structure/codex-account-controls.md | 28 +++
...account-hard-lock-fallback-routing.test.ts | 165 ++++++++++++++++++
.../settings-main-account-hard-lock.test.ts | 68 ++++++++
tests/fixtures/test-layout-expected.json | 2 +-
31 files changed, 782 insertions(+), 10 deletions(-)
create mode 100644 src/config/schema/main-account-hard-lock-fallback.ts
create mode 100644 src/server/responses/main-hard-lock-routing.ts
create mode 100644 tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts
diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md
index 27387b52951..50c2210ef79 100644
--- a/docs-site/src/content/docs/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md
@@ -374,6 +374,22 @@ percentages, for example `{ "short": 90, "long": 98 }`. Both must be integers fr
and `short` must not exceed `long`; omitted fields use the defaults. The settings API accepts the
same object and returns the effective values in `mainAccountHardLock.thresholds`.
+**While the lock blocks, requests can fall back to another model instead of failing.** The
+**Fallback while blocked** row sits beside the protection switch and works like the shadow-call
+row: picking a model — for example `deepseek/deepseek-chat` — turns the reroute on immediately,
+the optional thinking effort applies to it, and the switch pauses the reroute without forgetting
+the model. While the block is in force and no Codex account can serve a request for a native Codex
+model, that request is routed to the fallback model (with the configured effort) instead of being
+refused. A usable Pool account still serves the request first; provider-qualified selectors,
+combos, account-qualified rows and Luna Reserve keep their own destinations; and a fallback that
+itself names a native Codex model is ignored, because rerouting gpt-* to gpt-* would only re-enter
+the same refusal. The lock itself is unchanged: it releases on its own as before, and the reroute
+ends with it. Configure `codexMainAccountHardLockFallback` in `config.json` as
+`{ "model": "deepseek/deepseek-chat", "reasoningEffort": "low" }`; `enabled: false` pauses the
+reroute while keeping the model. An absent section, an omitted model, or a malformed section means
+requests are refused as usual; `reasoningEffort` must be a declared effort. The settings API
+accepts the same object, rejects invalid writes before mutation, and `null` clears the section.
+
The dashboard and `ocx status` show **possible usage outside opencodex** when fresh readings rise
by at least one point in the same reset episode without recent main-account activity through this
proxy. This is an advisory observation, not proof: a long-running turn can also cause it. The lock
diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md
index 45da7168de8..35d687063ca 100644
--- a/docs-site/src/content/docs/reference/configuration/server.md
+++ b/docs-site/src/content/docs/reference/configuration/server.md
@@ -41,6 +41,7 @@ runs helper features around provider requests.
| `codexProviderDisplayName?` | `string` | `"OpenCodex Proxy"` | Label Codex shows for the injected `opencodex` provider, written as its `name` field in `config.toml` and the reference profile. Presentation only: routing resolves through the provider id `opencodex`, so a rename never moves `model_provider = "opencodex"` or the `[model_providers.opencodex]` header and cannot orphan threads already tagged with that id. Codex refuses to load a provider with no name, so there is no way to omit the field — choose a neutral label instead. A blank, over-128-character, or control-character value is ignored and the default label is written. |
| `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). |
| `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. |
+| `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). |
| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. |
| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. |
| `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. |
diff --git a/gui/src/components/MainAccountHardLockSetting.tsx b/gui/src/components/MainAccountHardLockSetting.tsx
index e46079b9e8e..73d3612bc36 100644
--- a/gui/src/components/MainAccountHardLockSetting.tsx
+++ b/gui/src/components/MainAccountHardLockSetting.tsx
@@ -1,15 +1,34 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createBoundedFetch } from "../bounded-fetch";
import { startVisibilityPoll } from "../visibility-poll";
-import { useT } from "../i18n/shared";
+import { useT, type TKey } from "../i18n/shared";
+import { Select } from "../ui";
+import { formatNamespacedModelId } from "../provider-icons";
import { hardLockThresholds, type MainAccountHardLockStatus } from "../hooks/useCodexAccountPool";
type Props = { apiBase: string; onSaved: () => Promise };
-type Snapshot = { codexMainAccountHardLock: boolean; mainAccountHardLock: MainAccountHardLockStatus };
+type FallbackSetting = { enabled?: boolean; model?: string; reasoningEffort?: string } | null;
+type Snapshot = { codexMainAccountHardLock: boolean; mainAccountHardLock: MainAccountHardLockStatus; codexMainAccountHardLockFallback: FallbackSetting };
+type ModelInfo = { id: string; provider: string; namespaced: string };
+
+/** Same ladder the memory-models and compaction-routing panels offer for their targets. */
+const FALLBACK_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
+
+function readFallback(value: unknown): FallbackSetting {
+ if (value == null) return null;
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("settings shape");
+ const model = "model" in value && typeof value.model === "string" ? value.model.trim() : "";
+ if (!model) throw new Error("settings shape");
+ const effort = "reasoningEffort" in value ? value.reasoningEffort : undefined;
+ if (effort !== undefined && (typeof effort !== "string" || !FALLBACK_EFFORTS.includes(effort))) throw new Error("settings shape");
+ const enabled = "enabled" in value ? value.enabled : undefined;
+ if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("settings shape");
+ return { model, ...(effort ? { reasoningEffort: effort } : {}), ...(enabled !== undefined ? { enabled } : {}) };
+}
function readSnapshot(value: unknown): Snapshot {
if (!value || typeof value !== "object") throw new Error("settings shape");
- const payload = value as Partial;
+ const payload = value as Partial & { codexMainAccountHardLockFallback?: unknown };
const policy = payload.mainAccountHardLock;
if (typeof payload.codexMainAccountHardLock !== "boolean" || !policy
|| policy.enabled !== payload.codexMainAccountHardLock
@@ -17,7 +36,8 @@ function readSnapshot(value: unknown): Snapshot {
|| (policy.resetAt !== undefined && (typeof policy.resetAt !== "number" || !Number.isFinite(policy.resetAt)))) {
throw new Error("settings shape");
}
- return payload as Snapshot;
+ const fallback = readFallback(payload.codexMainAccountHardLockFallback);
+ return { ...payload, codexMainAccountHardLock: payload.codexMainAccountHardLock, mainAccountHardLock: policy, codexMainAccountHardLockFallback: fallback } as Snapshot;
}
function HardLockConfirmation({ pending, thresholds, onCancel, onConfirm }: {
@@ -80,6 +100,10 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
const [saved, setSaved] = useState(null);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
+ const [fallback, setFallback] = useState(null);
+ const [models, setModels] = useState([]);
+ const [fallbackSaving, setFallbackSaving] = useState(false);
+ const [fallbackError, setFallbackError] = useState(false);
const busyRef = useRef(false);
const mountedRef = useRef(false);
const generationRef = useRef(0);
@@ -100,6 +124,7 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
const next = readSnapshot(await response.json());
if (!mountedRef.current || generation !== generationRef.current) return;
setSnapshot(next);
+ setFallback(next.codexMainAccountHardLockFallback);
setLoadError(false);
setSaved(null);
} catch {
@@ -113,11 +138,28 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
mountedRef.current = true;
const timeout = window.setTimeout(() => { void load(); }, 0);
const stop = startVisibilityPoll(() => { void load(); }, 30_000);
+ // The picker is a convenience over the catalog; a failed read degrades it to the
+ // saved value only, the same way the shadow-call row reads as bare selectors.
+ const modelsController = new AbortController();
+ const modelsTimer = window.setTimeout(async () => {
+ try {
+ const response = await fetch(`${apiBase}/api/models`, { signal: modelsController.signal });
+ if (!response.ok) throw new Error("models");
+ const rows: unknown = await response.json();
+ if (!Array.isArray(rows)) throw new Error("models");
+ if (mountedRef.current) {
+ setModels(rows.filter(row => row !== null && typeof row === "object"
+ && typeof (row as ModelInfo).namespaced === "string") as ModelInfo[]);
+ }
+ } catch { /* saved value stays selectable */ }
+ }, 0);
return () => {
mountedRef.current = false;
generationRef.current += 1;
readAbortRef.current?.abort();
window.clearTimeout(timeout);
+ modelsController.abort();
+ window.clearTimeout(modelsTimer);
stop();
};
}, [load]);
@@ -190,6 +232,63 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
const cancel = () => {
if (!busyRef.current) setConfirming(false);
};
+
+ // One immediate save per change, the same contract as the shadow-call row: the switch,
+ // the model and the effort each PUT alone, and the switch keeps the configured model
+ // while off so turning it back on restores the same destination.
+ const saveFallback = async (next: Exclude) => {
+ if (busyRef.current || fallbackSaving || !snapshot) return;
+ busyRef.current = true;
+ generationRef.current += 1;
+ readAbortRef.current?.abort();
+ setFallbackSaving(true);
+ setFallbackError(false);
+ const bounded = createBoundedFetch(15_000);
+ let acknowledged = false;
+ try {
+ const response = await fetch(`${apiBase}/api/settings`, {
+ method: "PUT", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ codexMainAccountHardLockFallback: next }),
+ signal: bounded.signal,
+ });
+ if (!response.ok) throw new Error("save");
+ const payload: unknown = await response.json();
+ if (!payload || typeof payload !== "object" || !("ok" in payload) || payload.ok !== true) {
+ throw new Error("unconfirmed");
+ }
+ const accepted = readSnapshot(payload);
+ acknowledged = true;
+ if (mountedRef.current) {
+ setSnapshot(accepted);
+ setFallback(accepted.codexMainAccountHardLockFallback);
+ setLoadError(false);
+ }
+ } catch {
+ if (mountedRef.current) setFallbackError(true);
+ } finally {
+ bounded.clear();
+ }
+ if (acknowledged) await refreshMain();
+ busyRef.current = false;
+ if (!mountedRef.current) return;
+ setFallbackSaving(false);
+ if (!acknowledged) void load();
+ };
+ const fallbackOn = fallback?.enabled !== false && Boolean(fallback?.model);
+ const toggleFallback = () => {
+ if (!fallback?.model) return;
+ void saveFallback({ ...fallback, enabled: !fallbackOn });
+ };
+ const changeFallbackModel = (model: string) => {
+ if (!model) return;
+ // Picking a destination is an enabling act: the model always lands with the reroute
+ // on, so a fresh setup configures in one pick and the switch only pauses/resumes.
+ void saveFallback({ ...(fallback ?? {}), model });
+ };
+ const changeFallbackEffort = (effort: string) => {
+ void saveFallback({ ...(fallback ?? {}), ...(effort ? { reasoningEffort: effort } : { reasoningEffort: undefined }) });
+ };
+
const retryRefresh = async () => {
if (busyRef.current) return;
busyRef.current = true;
@@ -234,6 +333,43 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
{confirming && { void save(true); }} />}
);
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 1844dfbaeea..cd70d589e72 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1896,6 +1896,13 @@ export const de: Record = {
"codexAuth.mainHardLockMonitoring": "Schutz aktiv · Überwachung",
"codexAuth.mainExternalUsageWarning": "Mögliche Nutzung außerhalb von opencodex. Die Sperre verhindert möglicherweise keine Erschöpfung.",
"codexAuth.mainHardLockManage": "Schutzeinstellung anzeigen",
+ "codexAuth.mainHardLockFallbackTitle": "Ausweichmodell bei Blockierung",
+ "codexAuth.mainHardLockFallbackDesc": "Während der Blockierung, wenn kein Codex-Konto eine Anfrage bedienen kann, werden Anfragen an native Codex-Modelle an dieses Modell umgeleitet, statt zu fehlschlagen. Leer lassen, um wie üblich abzulehnen; die Sperre endet von selbst.",
+ "codexAuth.mainHardLockFallbackModel": "Ausweichmodell",
+ "codexAuth.mainHardLockFallbackEffort": "Denkaufwand",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Anfrage-Aufwand behalten",
+ "codexAuth.mainHardLockFallbackActive": "Jetzt blockiert — native Codex-Anfragen werden an {model} umgeleitet.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Speichern des Ausweichmodells konnte nicht bestätigt werden. Einstellung neu laden und erneut versuchen.",
"codexAuth.ultraFastDesc": "Fügt GPT-6 Astra und GPT-6.1 Sol in der Codex-Modellauswahl die Geschwindigkeitsoption Ultrafast hinzu und verhindert, dass ein selbst konfigurierter ultrafast-Diensttarif auf gerouteten Modellen beim Neuaufbau des Katalogs entfernt wird. Ultrafast erfordert einen berechtigten ChatGPT-Tarif; aktivieren Sie dies nur, wenn Ihr Tarif ihn enthält.",
"codexAuth.ultraFastLoadFailed": "Die Ultra-Fast-Einstellung konnte nicht gelesen werden.",
"codexAuth.ultraFastEnabled": "Ultra-Fast-Tarif aktiviert",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index bff6fef8e56..2248cc325fc 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2507,6 +2507,13 @@ export const en = {
"codexAuth.mainHardLockMonitoring": "Protection on · monitoring",
"codexAuth.mainExternalUsageWarning": "Possible usage outside opencodex. The lock may not prevent exhaustion.",
"codexAuth.mainHardLockManage": "View protection setting",
+ "codexAuth.mainHardLockFallbackTitle": "Fallback while blocked",
+ "codexAuth.mainHardLockFallbackDesc": "While blocked and no Codex account can serve a request, requests for native Codex models are routed to this model instead of failing. Leave empty to refuse them as usual; the lock itself releases on its own.",
+ "codexAuth.mainHardLockFallbackModel": "Fallback model",
+ "codexAuth.mainHardLockFallbackEffort": "Thinking effort",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Keep request effort",
+ "codexAuth.mainHardLockFallbackActive": "Blocking now — native Codex requests are routed to {model}.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Could not confirm the fallback save. Reload the setting before trying again.",
"codexAuth.ultraFastDesc": "Adds the Ultrafast speed choice to GPT-6 Astra and GPT-6.1 Sol in the Codex model picker, and keeps an ultrafast tier you configured yourself on routed models when the catalog is regenerated. Ultrafast requires an eligible ChatGPT plan; turn this on only if your plan includes it.",
"codexAuth.ultraFastLoadFailed": "Could not read the Ultra Fast setting.",
"codexAuth.ultraFastEnabled": "Ultra Fast tier enabled",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 5681f60e2d2..d23203eed9c 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2434,6 +2434,13 @@ export const fr: Record = {
"codexAuth.mainHardLockMonitoring": "Protection active · surveillance",
"codexAuth.mainExternalUsageWarning": "Utilisation possible hors opencodex. Le blocage peut ne pas empêcher l’épuisement du quota.",
"codexAuth.mainHardLockManage": "Voir le réglage de protection",
+ "codexAuth.mainHardLockFallbackTitle": "Modèle de repli pendant le blocage",
+ "codexAuth.mainHardLockFallbackDesc": "Pendant le blocage, quand aucun compte Codex ne peut servir une requête, les requêtes vers les modèles Codex natifs sont routées vers ce modèle au lieu d'échouer. Laissez vide pour refuser comme d'habitude ; le blocage se lève tout seul.",
+ "codexAuth.mainHardLockFallbackModel": "Modèle de repli",
+ "codexAuth.mainHardLockFallbackEffort": "Effort de réflexion",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Conserver l'effort de la requête",
+ "codexAuth.mainHardLockFallbackActive": "Blocage en cours — les requêtes Codex natives sont routées vers {model}.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Impossible de confirmer l'enregistrement du repli. Rechargez le réglage avant de réessayer.",
"codexAuth.ultraFastDesc": "Ajoute l’option de vitesse Ultrafast à GPT-6 Astra et GPT-6.1 Sol dans le sélecteur de modèles de Codex, et empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même sur des modèles routés lors de la régénération du catalogue. Ultrafast nécessite un forfait ChatGPT éligible ; activez-le uniquement si votre forfait l’inclut.",
"codexAuth.ultraFastLoadFailed": "Impossible de lire le réglage Ultra Fast.",
"codexAuth.ultraFastEnabled": "Niveau Ultra Fast activé",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index ce6e00d9c7e..496e7086ab8 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2329,6 +2329,13 @@ export const ja: Record = {
"codexAuth.mainHardLockMonitoring": "保護有効・監視中",
"codexAuth.mainExternalUsageWarning": "opencodex以外で使用量が増えた可能性があります。制限しても上限に達する場合があります。",
"codexAuth.mainHardLockManage": "保護設定を表示",
+ "codexAuth.mainHardLockFallbackTitle": "ブロック中のフォールバック",
+ "codexAuth.mainHardLockFallbackDesc": "ブロック中にどのCodexアカウントもリクエストを処理できない場合、ネイティブCodexモデルへのリクエストは失敗せずこのモデルへルーティングされます。空欄のままでは従来どおり拒否されます。ブロックは自動的に解除されます。",
+ "codexAuth.mainHardLockFallbackModel": "フォールバックモデル",
+ "codexAuth.mainHardLockFallbackEffort": "思考エフォート",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "リクエストのエフォートを維持",
+ "codexAuth.mainHardLockFallbackActive": "ブロック中 — ネイティブCodexリクエストは{model}へルーティングされます。",
+ "codexAuth.mainHardLockFallbackSaveFailed": "フォールバックの保存を確認できませんでした。設定を再読み込みしてから再試行してください。",
"codexAuth.ultraFastDesc": "Codex のモデルピッカーで GPT-6 Astra と GPT-6.1 Sol に Ultrafast の速度選択肢を追加し、ルーティングモデルに自分で設定した ultrafast ティアがカタログ再生成時に削除されないようにします。Ultrafast には対象の ChatGPT プランが必要です。プランに含まれている場合のみ有効にしてください。",
"codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。",
"codexAuth.ultraFastEnabled": "Ultra Fast ティアを有効にしました",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index c1e1e77d9f2..9d31acd6a73 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1940,6 +1940,13 @@ export const ko: Record = {
"codexAuth.mainHardLockMonitoring": "보호 켜짐",
"codexAuth.mainExternalUsageWarning": "opencodex 밖에서 사용량이 늘었을 수 있습니다. 차단해도 한도를 소진할 수 있습니다.",
"codexAuth.mainHardLockManage": "차단 설정 보기",
+ "codexAuth.mainHardLockFallbackTitle": "차단 중 폴백",
+ "codexAuth.mainHardLockFallbackDesc": "차단 중이고 Codex 계정이 요청을 처리할 수 없으면 네이티브 Codex 모델 요청은 실패하지 않고 이 모델로 라우팅됩니다. 비워 두면 기존처럼 거부되며, 차단은 자동으로 해제됩니다.",
+ "codexAuth.mainHardLockFallbackModel": "폴백 모델",
+ "codexAuth.mainHardLockFallbackEffort": "사고 강도",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "요청 강도 유지",
+ "codexAuth.mainHardLockFallbackActive": "차단 중 — 네이티브 Codex 요청이 {model}(으)로 라우팅됩니다.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "폴백 저장을 확인하지 못했습니다. 설정을 다시 불러온 뒤 재시도하세요.",
"codexAuth.ultraFastDesc": "Codex 모델 피커에서 GPT-6 Astra와 GPT-6.1 Sol에 Ultrafast 속도 선택지를 추가하고, 라우팅 모델에 직접 설정한 ultrafast 티어가 카탈로그를 다시 만들 때 지워지지 않게 합니다. Ultrafast는 해당 ChatGPT 요금제가 필요하므로, 요금제에 포함된 경우에만 켜세요.",
"codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.",
"codexAuth.ultraFastEnabled": "Ultra Fast 티어를 켰습니다",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index cca8aaa6964..fc6d6573f9f 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2480,6 +2480,13 @@ export const pt: Record = {
"codexAuth.mainHardLockMonitoring": "Proteção ativa · monitorando",
"codexAuth.mainExternalUsageWarning": "Possível uso fora do opencodex. O bloqueio pode não impedir o esgotamento.",
"codexAuth.mainHardLockManage": "Ver configuração de proteção",
+ "codexAuth.mainHardLockFallbackTitle": "Fallback durante o bloqueio",
+ "codexAuth.mainHardLockFallbackDesc": "Durante o bloqueio, quando nenhuma conta Codex pode atender a uma solicitação, as solicitações a modelos Codex nativos são roteadas para este modelo em vez de falhar. Deixe vazio para recusar como de costume; o bloqueio termina sozinho.",
+ "codexAuth.mainHardLockFallbackModel": "Modelo de fallback",
+ "codexAuth.mainHardLockFallbackEffort": "Esforço de raciocínio",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Manter esforço da solicitação",
+ "codexAuth.mainHardLockFallbackActive": "Bloqueando agora — solicitações Codex nativas são roteadas para {model}.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Não foi possível confirmar o salvamento do fallback. Recarregue a configuração antes de tentar de novo.",
"codexAuth.ultraFastDesc": "Adiciona a opção de velocidade Ultrafast ao GPT-6 Astra e ao GPT-6.1 Sol no seletor de modelos do Codex e impede que um nível de serviço ultrafast configurado por você em modelos roteados seja removido quando o catálogo é regenerado. O Ultrafast exige um plano do ChatGPT elegível; ative apenas se o seu plano o incluir.",
"codexAuth.ultraFastLoadFailed": "Não foi possível ler a configuração do Ultra Fast.",
"codexAuth.ultraFastEnabled": "Nível Ultra Fast ativado",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 2709713c0bb..7fe2516215c 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2420,6 +2420,13 @@ export const ru: Record = {
"codexAuth.mainHardLockMonitoring": "Защита включена · наблюдение",
"codexAuth.mainExternalUsageWarning": "Возможно использование вне opencodex. Блокировка может не предотвратить исчерпание квоты.",
"codexAuth.mainHardLockManage": "Открыть настройку защиты",
+ "codexAuth.mainHardLockFallbackTitle": "Резервная модель при блокировке",
+ "codexAuth.mainHardLockFallbackDesc": "Пока блокировка действует и ни один аккаунт Codex не может обработать запрос, запросы к нативным моделям Codex направляются на эту модель вместо отказа. Оставьте пустым, чтобы отклонять как обычно; блокировка снимается сама.",
+ "codexAuth.mainHardLockFallbackModel": "Резервная модель",
+ "codexAuth.mainHardLockFallbackEffort": "Усилие размышления",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Сохранить усилие запроса",
+ "codexAuth.mainHardLockFallbackActive": "Сейчас блокировано — нативные запросы Codex направляются на {model}.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Не удалось подтвердить сохранение резервной модели. Перезагрузите настройку и повторите.",
"codexAuth.ultraFastDesc": "Добавляет вариант скорости Ultrafast для GPT-6 Astra и GPT-6.1 Sol в выбор моделей Codex и не даёт удалить настроенный вами уровень ultrafast на маршрутизируемых моделях при перегенерации каталога. Для Ultrafast нужен подходящий тариф ChatGPT; включайте, только если он входит в ваш тариф.",
"codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.",
"codexAuth.ultraFastEnabled": "Уровень Ultra Fast включён",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 154d0356c53..9cca2153046 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2458,6 +2458,13 @@ export const tr: Record = {
"codexAuth.mainHardLockMonitoring": "Koruma açık · izleniyor",
"codexAuth.mainExternalUsageWarning": "opencodex dışında kullanım olabilir. Engel, kotanın tükenmesini önlemeyebilir.",
"codexAuth.mainHardLockManage": "Koruma ayarını göster",
+ "codexAuth.mainHardLockFallbackTitle": "Engel sırasında yedek model",
+ "codexAuth.mainHardLockFallbackDesc": "Engel etkinken hiçbir Codex hesabı isteği karşılayamıyorsa, yerel Codex modeli istekleri başarısız olmak yerine bu modele yönlendirilir. Her zamanki gibi reddetmek için boş bırakın; engel kendiliğinden kalkar.",
+ "codexAuth.mainHardLockFallbackModel": "Yedek model",
+ "codexAuth.mainHardLockFallbackEffort": "Düşünme çabası",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "İsteğin çabasını koru",
+ "codexAuth.mainHardLockFallbackActive": "Şu anda engelleniyor — yerel Codex istekleri {model} modeline yönlendirilir.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Yedek kaydı onaylanamadı. Ayarı yeniden yükleyip tekrar deneyin.",
"codexAuth.ultraFastDesc": "Codex model seçicisinde GPT-6 Astra ve GPT-6.1 Sol için Ultrafast hız seçeneğini ekler ve yönlendirilen modellerde kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler. Ultrafast uygun bir ChatGPT planı gerektirir; yalnızca planınız içeriyorsa açın.",
"codexAuth.ultraFastLoadFailed": "Ultra Fast ayarı okunamadı.",
"codexAuth.ultraFastEnabled": "Ultra Fast katmanı etkinleştirildi",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index a0b33ac014d..2ec4fa0f194 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2455,6 +2455,13 @@ export const vi: Record = {
"codexAuth.mainHardLockMonitoring": "Bảo vệ đang bật · đang theo dõi",
"codexAuth.mainExternalUsageWarning": "Có thể có sử dụng ngoài opencodex. Việc chặn có thể không ngăn hết hạn mức.",
"codexAuth.mainHardLockManage": "Xem thiết lập bảo vệ",
+ "codexAuth.mainHardLockFallbackTitle": "Mô hình dự phòng khi bị chặn",
+ "codexAuth.mainHardLockFallbackDesc": "Khi bị chặn và không tài khoản Codex nào phục vụ được yêu cầu, các yêu cầu tới mô hình Codex gốc được chuyển tới mô hình này thay vì thất bại. Để trống để từ chối như thường lệ; việc chặn tự kết thúc.",
+ "codexAuth.mainHardLockFallbackModel": "Mô hình dự phòng",
+ "codexAuth.mainHardLockFallbackEffort": "Mức suy luận",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "Giữ mức của yêu cầu",
+ "codexAuth.mainHardLockFallbackActive": "Đang chặn — yêu cầu Codex gốc được chuyển tới {model}.",
+ "codexAuth.mainHardLockFallbackSaveFailed": "Không xác nhận được việc lưu dự phòng. Hãy tải lại cài đặt rồi thử lại.",
"codexAuth.ultraFastDesc": "Thêm lựa chọn tốc độ Ultrafast cho GPT-6 Astra và GPT-6.1 Sol trong bộ chọn model của Codex, đồng thời ngăn dịch vụ cấp độ ultrafast mà bạn tự thiết lập trên các model định tuyến bị loại bỏ khi catalog được tạo lại. Ultrafast yêu cầu gói ChatGPT đủ điều kiện; chỉ bật khi gói của bạn bao gồm nó.",
"codexAuth.ultraFastLoadFailed": "Không thể đọc thiết lập Ultra Fast.",
"codexAuth.ultraFastEnabled": "Cấp độ Ultra Fast đã bật",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index f2378eaedfd..942e9002ba0 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -1809,6 +1809,13 @@ export const zhTW: Record = {
"codexAuth.mainHardLockMonitoring": "保護已開啟 · 監測中",
"codexAuth.mainExternalUsageWarning": "可能存在 opencodex 以外的使用。封鎖可能無法避免額度耗盡。",
"codexAuth.mainHardLockManage": "查看保護設定",
+ "codexAuth.mainHardLockFallbackTitle": "封鎖時的後備模型",
+ "codexAuth.mainHardLockFallbackDesc": "封鎖期間,當沒有 Codex 帳戶能服務請求時,原生 Codex 模型的請求會改路由到該模型而不是失敗。留空則照常拒絕;封鎖會自動解除。",
+ "codexAuth.mainHardLockFallbackModel": "後備模型",
+ "codexAuth.mainHardLockFallbackEffort": "思考強度",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "保留請求原強度",
+ "codexAuth.mainHardLockFallbackActive": "正在封鎖——原生 Codex 請求已路由到 {model}。",
+ "codexAuth.mainHardLockFallbackSaveFailed": "無法確認後備設定已儲存。請重新載入設定後再試。",
"codexAuth.ultraFastDesc": "在 Codex 模型選擇器中為 GPT-6 Astra 與 GPT-6.1 Sol 加入 Ultrafast 速度選項,並讓你在路由模型上自行設定的 ultrafast 服務層級在重新產生目錄時不被移除。Ultrafast 需要符合資格的 ChatGPT 方案,僅在你的方案包含它時開啟。",
"codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。",
"codexAuth.ultraFastEnabled": "已啟用 Ultra Fast 層級",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index cf32dcc5a88..5d6aad48043 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1913,6 +1913,13 @@ export const zh: Record = {
"codexAuth.mainHardLockMonitoring": "保护已开启 · 监测中",
"codexAuth.mainExternalUsageWarning": "可能存在 opencodex 之外的使用。阻止可能无法避免额度耗尽。",
"codexAuth.mainHardLockManage": "查看保护设置",
+ "codexAuth.mainHardLockFallbackTitle": "阻止时回退模型",
+ "codexAuth.mainHardLockFallbackDesc": "阻止期间,当没有 Codex 账户能服务请求时,原生 Codex 模型的请求会改路由到该模型而不是失败。留空则照常拒绝;阻止会自动解除。",
+ "codexAuth.mainHardLockFallbackModel": "回退模型",
+ "codexAuth.mainHardLockFallbackEffort": "思考强度",
+ "codexAuth.mainHardLockFallbackDefaultEffort": "保留请求原强度",
+ "codexAuth.mainHardLockFallbackActive": "正在阻止——原生 Codex 请求已路由到 {model}。",
+ "codexAuth.mainHardLockFallbackSaveFailed": "无法确认回退设置已保存。请重新加载设置后再试。",
"codexAuth.ultraFastDesc": "在 Codex 模型选择器中为 GPT-6 Astra 和 GPT-6.1 Sol 添加 Ultrafast 速度选项,并让你在路由模型上自行配置的 ultrafast 服务层级在重新生成目录时不被删除。Ultrafast 需要符合条件的 ChatGPT 套餐,仅在你的套餐包含它时开启。",
"codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。",
"codexAuth.ultraFastEnabled": "已启用 Ultra Fast 层级",
diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx
index fe841d79623..9acd31cba0a 100644
--- a/gui/tests/main-account-hard-lock-setting.test.tsx
+++ b/gui/tests/main-account-hard-lock-setting.test.tsx
@@ -161,6 +161,7 @@ describe("main account protection setting", () => {
let reads = 0;
let reloads = 0;
const host = await mount((async (_input, init) => {
+ if (String(_input).endsWith("/api/models")) return response([]);
if (init?.method === "PUT") return response(payload);
reads++;
return response(settings(reads === 1));
@@ -459,3 +460,100 @@ test.each([false, true])("retired Spark setting (legacy response: %s) cannot res
expect(toggle(host).disabled).toBe(false);
expect(host.textContent).not.toContain("Spark");
});
+
+describe("main account hard-lock fallback setting", () => {
+ const settingsWithFallback = (fallback: unknown, state: MainAccountHardLockStatus["state"] = "ready") => ({
+ ...settings(true, state),
+ codexMainAccountHardLockFallback: fallback,
+ });
+ const group = (host: ParentNode) => host.querySelector(".codex-main-hard-lock-fallback")!;
+ const fallbackSwitch = (host: ParentNode) =>
+ group(host).querySelector("button.switch")!;
+ const selectTriggers = (host: ParentNode) =>
+ Array.from(group(host).querySelectorAll("button[role=\"combobox\"]"));
+ const optionButtons = () =>
+ Array.from(testWindow.document.querySelectorAll('[role="option"]'));
+ async function chooseOption(host: ParentNode, index: number, label: string) {
+ await act(async () => { selectTriggers(host)[index]!.click(); await flush(); });
+ const option = optionButtons().find(button => button.textContent === label)!;
+ expect(option).toBeDefined();
+ await act(async () => { option.click(); await flush(); });
+ }
+
+ test("an absent section renders the switch off and the model picker ready", async () => {
+ const host = await mount((async (_input, init) => {
+ if (String(_input).endsWith("/api/models")) return response([]);
+ return response(settings(true));
+ }) as typeof fetch);
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("false");
+ // Nothing to route to yet, so the switch waits; the picker is how a fresh setup starts.
+ expect(fallbackSwitch(host).disabled).toBe(true);
+ expect(selectTriggers(host)[0]!.hasAttribute("disabled")).toBe(false);
+ expect(selectTriggers(host)[1]!.hasAttribute("disabled")).toBe(true);
+ });
+
+ test("a saved section renders the switch on with its model and effort", async () => {
+ const host = await mount((async () =>
+ response(settingsWithFallback({ model: "combo/x", reasoningEffort: "low" }))) as typeof fetch);
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("true");
+ expect(fallbackSwitch(host).disabled).toBe(false);
+ const triggers = selectTriggers(host);
+ expect(triggers[0]!.textContent).toContain("combo/x");
+ expect(triggers[1]!.textContent).toContain("Low");
+ });
+
+ test("the switch pauses without forgetting the model, then resumes", async () => {
+ const puts: unknown[] = [];
+ const host = await mount((async (_input, init) => {
+ if (init?.method === "PUT") {
+ const body = JSON.parse(String(init!.body)).codexMainAccountHardLockFallback;
+ puts.push(body);
+ return response({ ok: true, ...settingsWithFallback(body) });
+ }
+ return response(settingsWithFallback({ model: "combo/x", reasoningEffort: "low" }));
+ }) as typeof fetch);
+ await click(fallbackSwitch(host));
+ expect(puts).toEqual([{ model: "combo/x", reasoningEffort: "low", enabled: false }]);
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("false");
+ // Paused keeps the destination visible in its select, exactly like the shadow row;
+ // only the effort select waits for the reroute to be on again.
+ expect(selectTriggers(host)[0]!.textContent).toContain("combo/x");
+ expect(selectTriggers(host)[1]!.hasAttribute("disabled")).toBe(true);
+ await click(fallbackSwitch(host));
+ expect(puts[1]).toEqual({ model: "combo/x", reasoningEffort: "low", enabled: true });
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("true");
+ });
+
+ test("picking a model PUTs it immediately and enables the effort select", async () => {
+ const puts: unknown[] = [];
+ const host = await mount((async (_input, init) => {
+ if (init?.method === "PUT") {
+ const body = JSON.parse(String(init!.body)).codexMainAccountHardLockFallback;
+ puts.push(body);
+ return response({ ok: true, ...settingsWithFallback({ model: body.model }) });
+ }
+ if (String(_input).endsWith("/api/models")) {
+ return response([{ id: "chat", provider: "deepseek", namespaced: "deepseek/deepseek-chat" }]);
+ }
+ return response(settings(true));
+ }) as typeof fetch);
+ await act(async () => { await flush(); await flush(); });
+ await chooseOption(host, 0, "deepseek/deepseek-chat");
+ expect(puts).toEqual([{ model: "deepseek/deepseek-chat" }]);
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("true");
+ expect(selectTriggers(host)[1]!.hasAttribute("disabled")).toBe(false);
+ });
+
+ test("a failed save shows the alert and keeps the switch state the server last confirmed", async () => {
+ let puts = 0;
+ const host = await mount((async (_input, init) => {
+ if (init?.method === "PUT") { puts++; return response({}, 503); }
+ return response(settingsWithFallback({ model: "combo/x" }));
+ }) as typeof fetch);
+ await click(fallbackSwitch(host));
+ expect(group(host).textContent).toContain("Could not confirm the fallback save");
+ expect(puts).toBe(1);
+ // The draft-free row shows the last acknowledged state until the retry lands.
+ expect(fallbackSwitch(host).getAttribute("aria-pressed")).toBe("true");
+ });
+});
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 3696b56564c..ac926c8e531 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -1121,7 +1121,7 @@
"loopback-listener-admission.test.ts": "server",
"loopback-listener-integration.test.ts": "server",
"macos-serial-lanes.test.ts": "ci-workflows",
- "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-usage-limit.test.ts": "codex-integration",
+ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-fallback-routing.test.ts": "codex-integration", "main-account-hard-lock-usage-limit.test.ts": "codex-integration",
"main-account-hard-lock-default.test.ts": "codex-integration",
"main-account-hard-lock-policy.test.ts": "codex-integration",
"main-account-hard-lock-recovery.test.ts": "codex-integration", "main-account-credit-renewal.test.ts": "codex-integration",
diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts
index fda12552410..0253bd84045 100644
--- a/src/codex/main-account-hard-lock.ts
+++ b/src/codex/main-account-hard-lock.ts
@@ -1,4 +1,5 @@
import type { OcxConfig } from "../types";
+import { isDeclaredReasoningEffort } from "../reasoning-effort";
import { getMainPolicyQuota } from "./quota";
import { MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types";
@@ -13,7 +14,32 @@ export interface MainAccountHardLockStatus {
window?: "short" | "long";
}
-type PolicyConfig = Pick;
+type PolicyConfig = Pick;
+
+/** The reroute destination for requests the lock blocks, when one is configured and readable. */
+export interface MainAccountHardLockFallback {
+ model: string;
+ reasoningEffort?: string;
+}
+
+/**
+ * The configured fallback, or `undefined` when nothing qualifies. The dashboard row keeps
+ * the configured model while its switch is off, so `enabled: false` pauses the reroute
+ * without forgetting the destination. A malformed hand edit — blank or padded model,
+ * undeclared effort — disables the reroute entirely rather than degrading into a
+ * half-applied one: the request path must never guess which part of a broken setting to
+ * keep. This is the same disable-don't-widen reading the compaction override takes, and
+ * the load/write boundaries report the malformation separately.
+ */
+export function resolveMainAccountHardLockFallback(config: PolicyConfig | undefined): MainAccountHardLockFallback | undefined {
+ const raw = config?.codexMainAccountHardLockFallback;
+ if (!raw || raw.enabled === false) return undefined;
+ const model = typeof raw.model === "string" ? raw.model : "";
+ if (model.length === 0 || model !== model.trim() || /\s/.test(model)) return undefined;
+ if (raw.reasoningEffort !== undefined
+ && (typeof raw.reasoningEffort !== "string" || !isDeclaredReasoningEffort(raw.reasoningEffort))) return undefined;
+ return raw.reasoningEffort !== undefined ? { model, reasoningEffort: raw.reasoningEffort } : { model };
+}
export function resolveMainAccountHardLockThresholds(config: PolicyConfig | undefined): { short: number; long: number } {
const valid = (value: unknown, fallback: number): number => typeof value === "number"
diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts
index d91f8bd602b..fd401e80292 100644
--- a/src/config/diagnostics.ts
+++ b/src/config/diagnostics.ts
@@ -8,6 +8,7 @@ import * as z from "zod/v4";
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
+import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback";
import type { OcxConfig } from "../types";
import { anthropicAccountPoolConfigError, anthropicOAuthInstanceConfigError, anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
import { configReasoningPinsConfigError } from "./provider-validation";
@@ -136,6 +137,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
warnings.push(...degradedListenerWarnings(rawParsed, normalized));
if (blockedModelRedirectsError(rawParsed)) warnings.push("blockedModelRedirects ignored: expected a map of nonempty model keys to nonempty string targets");
+ if (mainAccountHardLockFallbackError(rawParsed)) warnings.push("codexMainAccountHardLockFallback ignored: expected a nonblank model selector and an optional declared reasoningEffort");
const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized);
if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning);
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
@@ -695,6 +697,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
const anthropicError = anthropicOAuthInstanceConfigError(value) ?? anthropicAccountPoolConfigError(value) ?? anthropicSidecarConfigError(value);
if (anthropicError) return { ok: false, error: anthropicError };
const boundaryError = blockedModelRedirectsError(value)
+ ?? mainAccountHardLockFallbackError(value)
?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value)
?? blankHostnameError(value)
?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts
index ccfb186f462..0924b085db4 100644
--- a/src/config/load-degrade.ts
+++ b/src/config/load-degrade.ts
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
+import { mainAccountHardLockFallbackError } from "./schema/main-account-hard-lock-fallback";
import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
import {
modelPinnedEffortsConfigError,
@@ -132,6 +133,7 @@ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxCon
if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically.");
if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
+ if (mainAccountHardLockFallbackError(rawParsed)) console.warn("⚠️ invalid codexMainAccountHardLockFallback ignored; the main-account hard lock still refuses while blocked");
const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
warnDegradedStreamMode(rawParsed, validated);
diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts
index 36a09ba78f9..a287bc3efa8 100644
--- a/src/config/schema/config-schema.ts
+++ b/src/config/schema/config-schema.ts
@@ -4,6 +4,7 @@ import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
import { compactionRecoverySchema } from "./compaction-recovery";
import { desktopCompatibilitySchema } from "./desktop-compatibility";
import { blockedModelRedirectsSchema } from "./blocked-model-redirects";
+import { mainAccountHardLockFallbackSchema } from "./main-account-hard-lock-fallback";
import {
agentTaskRecoverySchema,
catalogAutoRefreshSchema,
@@ -221,6 +222,9 @@ export const configSchema = z.object({
short: z.number().int().min(MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT).max(100).optional().catch(undefined),
long: z.number().int().min(MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT).max(100).optional().catch(undefined),
}).optional().catch(undefined),
+ // A malformed fallback section disables only the reroute, never the lock's refusal
+ // behavior; the management write boundary still rejects the bad value.
+ codexMainAccountHardLockFallback: mainAccountHardLockFallbackSchema.optional().catch(undefined),
// #2999: a malformed hand edit falls back to auto detection rather than rejecting the config.
nativeMainRefresh: z.enum(["codex", "opencodex"]).optional().catch(undefined),
// Future versions remain opaque through passthrough-compatible whole-config saves.
diff --git a/src/config/schema/main-account-hard-lock-fallback.ts b/src/config/schema/main-account-hard-lock-fallback.ts
new file mode 100644
index 00000000000..a8121b612ac
--- /dev/null
+++ b/src/config/schema/main-account-hard-lock-fallback.ts
@@ -0,0 +1,26 @@
+import * as z from "zod/v4";
+import { pinnedReasoningEffortConfigError } from "../provider-validation";
+
+/**
+ * Extended settings of the main-account hard lock (`codexMainAccountHardLockFallback`):
+ * while the lock blocks, a qualifying Codex-native request reroutes to `model` instead of
+ * failing. The dashboard row keeps the configured model when the switch is off, so
+ * `enabled: false` is a remembered pause, not a cleared setting; an absent section, an
+ * absent model, and a malformed section all mean the same thing — the lock refuses,
+ * exactly as it does today.
+ */
+export const mainAccountHardLockFallbackSchema = z.object({
+ enabled: z.boolean().optional(),
+ model: z.string().refine(model => model.length > 0 && model === model.trim() && !/\s/.test(model)),
+ reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
+}).strict();
+
+/** Candidate writes must reject sections the read schema would silently discard. */
+export function mainAccountHardLockFallbackError(value: unknown): string | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
+ const raw = (value as Record).codexMainAccountHardLockFallback;
+ if (raw === undefined) return undefined;
+ return mainAccountHardLockFallbackSchema.safeParse(raw).success
+ ? undefined
+ : "schema_invalid: codexMainAccountHardLockFallback requires a nonblank model selector and an optional declared reasoningEffort";
+}
diff --git a/src/router.ts b/src/router.ts
index 0ea69b230a3..22f32e802ab 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -622,7 +622,8 @@ function crossProviderRedirectTarget(config: OcxConfig, providerName: string, va
}
}
-function isBareOpenAiFamilyModel(modelId: string): boolean {
+/** Exported for the main-account hard-lock fallback: only this family is ever redirected. */
+export function isBareOpenAiFamilyModel(modelId: string): boolean {
return !modelId.includes("/")
&& (/^(?:gpt-|o1-|o3-|o4-)/.test(modelId) || CODEX_INTERNAL_OPENAI_MODELS.has(modelId));
}
diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts
index ce6de3b2d52..08c6ed46f50 100644
--- a/src/server/management/config-routes.ts
+++ b/src/server/management/config-routes.ts
@@ -4,6 +4,7 @@ import { getMainAccountExternalUsageWarning } from "../../codex/main-account-ext
import { getObservedMainQuotaIdentityKey } from "../../codex/main-account-cache";
import { compactionRoutingSchema, memoryModelsSchema } from "../../config/schema/leaf-validators";
import { compactionRecoverySchema } from "../../config/schema/compaction-recovery";
+import { mainAccountHardLockFallbackSchema } from "../../config/schema/main-account-hard-lock-fallback";
import { anthropicSidecarPatchError } from "../../config/schema/anthropic-account-pool";
import { isAnthropicInstanceId } from "../../providers/anthropic-instance-id";
import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
@@ -424,6 +425,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; model: string } | undefined {
+ if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
+ const raw = body as Record;
+ return typeof raw.model === "string" && raw.model.trim().length > 0
+ ? { raw, model: raw.model }
+ : undefined;
+}
+
+/**
+ * Extended main-account hard-lock settings: when the lock blocks and no Codex account can
+ * serve the request, rewrite it to the configured fallback model instead of letting the
+ * dispatch refuse.
+ *
+ * Every guard bounds the rewrite to exactly the traffic the lock would otherwise fail, so
+ * the setting can never widen what the lock protects:
+ *
+ * - The lock must be enabled AND reading `blocked` right now — `ready`, `unknown` and the
+ * explicit opt-out all admit, and the release is this same observation, so the reroute
+ * ends on its own the moment the blocking windows drop below their thresholds.
+ * - `getEligiblePoolAccounts` is the selection choke point the dispatch itself consults,
+ * so "the pool can serve it" is one shared answer, not a second opinion: a usable pool
+ * account (or any eligible account) keeps the request on Codex, and a pool drained to
+ * empty is what makes the fallback the request's only way through. The main account is
+ * already excluded there while blocked.
+ * - Only bare Codex-native selectors qualify. Provider-qualified rows, combos, account
+ * namespaces and policy routes name their own destination on purpose; rewriting those
+ * would override an explicit choice, so they keep the lock's refusal.
+ * - A Codex-native fallback target is refused outright: rerouting gpt-* to gpt-* would
+ * re-enter the same refusal, and the honest behavior is the refusal, not a loop.
+ * - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
+ * traffic is not operator chat and must not silently change models.
+ */
+export function applyMainHardLockFallbackOverride(
+ body: unknown,
+ config: OcxConfig,
+): MainHardLockFallbackOverride | null {
+ const parsed = rawModelOf(body);
+ if (!parsed) return null;
+ const fallback = resolveMainAccountHardLockFallback(config);
+ if (!fallback) return null;
+ if (isBareOpenAiFamilyModel(fallback.model)) return null;
+ if (!isBareOpenAiFamilyModel(parsed.model) || parsed.model === NATIVE_RESERVE_MODEL) return null;
+ if (getMainAccountHardLockStatus(config).state !== "blocked") return null;
+ if (getEligiblePoolAccounts(config).length > 0) return null;
+
+ parsed.raw.model = fallback.model;
+ if (fallback.reasoningEffort !== undefined) {
+ const reasoning = parsed.raw.reasoning;
+ parsed.raw.reasoning = {
+ ...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)
+ ? reasoning as Record
+ : {}),
+ effort: fallback.reasoningEffort,
+ };
+ }
+ return { sourceModel: parsed.model, targetModel: fallback.model };
+}
diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts
index 4cf050b1abd..99c37fcc7ae 100644
--- a/src/server/responses/request-prepare.ts
+++ b/src/server/responses/request-prepare.ts
@@ -149,6 +149,7 @@ import {
} from "./core-codex-account";
import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
+import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
import { codexAuthContextLogLabel } from "../../codex/account-label";
import {
conversationStateBindingFromAuth,
@@ -344,6 +345,16 @@ export async function prepareResponsesRequest(
}
}
}
+ // The hard-lock fallback owns ordinary turns only, and only after every more specific
+ // routing setting had its chance above: a shadow-intercepted, memory-phased, compaction
+ // or decision turn keeps its own destination, and an intercepted combo selector is
+ // already owned by the intercept. A spawned sub-agent turn names its model on purpose,
+ // the same reason the shadow intercept leaves it alone. Like the sites above it, this
+ // must run before comboIdFromRawBody reads `model`.
+ if (!concreteSelection && !options.compactionRoutingOverride && !options.internalDecisionCall
+ && !shadowCallIntercepted && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
+ options.mainHardLockFallbackOverride = applyMainHardLockFallbackOverride(body, config);
+ }
const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null;
// A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse
// into another decision, so the call is refused and the parent decision fails open.
@@ -561,7 +572,10 @@ export async function prepareResponsesRequest(
if (!logCtx.conversationId) {
logCtx.conversationId = resolvedConversationId;
}
- logCtx.requestedModel = options.compactionRoutingOverride?.sourceModel ?? parsed.modelId;
+ // A rewritten turn logs the selector the client asked for, so the usage log never
+ // claims the operator typed the fallback model themselves.
+ logCtx.requestedModel = options.mainHardLockFallbackOverride?.sourceModel
+ ?? options.compactionRoutingOverride?.sourceModel ?? parsed.modelId;
logCtx.requestedEffort = parsed.options.reasoning;
// What this request may spend beyond its input, for the durable spend reservation (#4707).
// Read from the caller rather than from the adapter's serialized body, because the
diff --git a/src/types/config.ts b/src/types/config.ts
index 9b56063c760..28d3798d8a1 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -605,6 +605,13 @@ export interface OcxConfig {
codexMainAccountHardLock?: boolean;
/** Per-window percentages: short defaults to 90, long to 98; integers 80..100, short <= long. */
codexMainAccountHardLockThresholds?: { short?: number; long?: number };
+ /**
+ * Extended hard-lock settings: while the lock blocks and no Codex account can serve a
+ * qualifying request, the request reroutes to `model` (with `reasoningEffort` when set)
+ * instead of failing. The lock itself stays on and releases on its own as before; an
+ * absent section or model means the refusal behavior is unchanged.
+ */
+ codexMainAccountHardLockFallback?: { enabled?: boolean; model?: string; reasoningEffort?: string };
/**
* Who refreshes the native main ChatGPT login in CODEX_HOME (#2999). "codex" asks the Codex
* binary through app-server `getAuthStatus` and never writes auth.json; "opencodex" keeps the
diff --git a/structure/codex-account-controls.md b/structure/codex-account-controls.md
index 9b6a0488af6..ddddc1e6b15 100644
--- a/structure/codex-account-controls.md
+++ b/structure/codex-account-controls.md
@@ -74,6 +74,34 @@ routing. Coverage: `tests/codex-integration/codex-credits-after-limit.test.ts`,
`gui/tests/codex-credit-spend.test.tsx`.
+## Hard-lock fallback routing
+
+`codexMainAccountHardLockFallback` extends the main-account hard lock: while the lock reads
+`blocked` and no Codex account can serve a request, `src/server/responses/main-hard-lock-routing.ts`
+rewrites that request to the configured model (plus an optional reasoning effort) before combo
+dispatch reads `model`. The rewrite is bounded on every side so it can never widen what the lock
+protects:
+
+- `getEligiblePoolAccounts` is the same eligibility choke point the dispatch consults, so "the
+ Pool can serve it" is one shared answer, not a second opinion. An empty eligible list is what
+ makes the fallback the request's only way through; the lock excludes the main account there
+ itself.
+- Only bare Codex-native selectors qualify. Provider-qualified rows, combos, account namespaces
+ and policy routes name their own destination on purpose, and Luna Reserve keeps its own
+ admission. A Codex-native fallback target never applies — rerouting gpt-* to gpt-* would
+ re-enter the same refusal, so the honest behavior is the refusal.
+- 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.
+
+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
+model — rather than clearing it. Settings projection and writes live beside the hard-lock keys in
+`src/server/management/config-routes.ts`; `null` clears the section. Coverage:
+`tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts` and the fallback cases in
+`tests/config/settings-main-account-hard-lock.test.ts` plus
+`gui/tests/main-account-hard-lock-setting.test.tsx`.
+
## Stored-account authentication policy
Exact pool selectors also honor the credits-after-limit policy. `pool-credit-policy.ts` reads cached usage and configured plan metadata without credential I/O. Authentication checks before and after credential acquisition; provider overrides, materialization and recovery rechecks retain the live policy privately by context identity. `createCodexAuthDispatchGuard` rechecks live credit policy at HTTP and Responses WebSocket dispatch after pacing or retry backoff, alongside the independent Reserve guard. A policy refusal releases unused probes and never quarantines a valid credential. Coverage: `tests/codex-integration/codex-pool-credit-policy.test.ts`.
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
new file mode 100644
index 00000000000..20c876dadd0
--- /dev/null
+++ b/tests/codex-integration/main-account-hard-lock-fallback-routing.test.ts
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { mkdtempSync } from "node:fs";
+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 { 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";
+import type { OcxConfig } from "../../src/types";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+
+const now = Date.UTC(2026, 8, 5);
+const FALLBACK = { model: "deepseek/deepseek-chat", reasoningEffort: "low" };
+let home: string;
+let previousHome: string | undefined;
+
+beforeEach(() => {
+ previousHome = process.env.OPENCODEX_HOME;
+ home = mkdtempSync(join(tmpdir(), "ocx-main-lock-fallback-"));
+ process.env.OPENCODEX_HOME = home;
+ clearAccountQuota();
+ clearMainAccountInfoCache();
+ observeMainQuotaIdentity("fallback-account-a");
+});
+
+afterEach(() => {
+ clearAccountQuota();
+ clearMainAccountInfoCache();
+ if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousHome;
+ removeTreeWithRetry(home);
+});
+
+function observe(quota: Omit): void {
+ const writer = captureMainQuotaWriter("fallback-account-a");
+ if (!writer) throw new Error("fixture identity was not observed");
+ setAccountQuotaFromParsed("__main__", quota, undefined, writer);
+}
+
+function config(overrides: Partial = {}): OcxConfig {
+ return { providers: {}, codexAccounts: [], ...overrides } as OcxConfig;
+}
+
+function withPoolAccount(target: OcxConfig): OcxConfig {
+ saveCodexAccountCredential("pool-a", {
+ accessToken: "access-pool-a",
+ refreshToken: "refresh-pool-a",
+ expiresAt: Date.now() + 5 * 60_000,
+ chatgptAccountId: "acct-pool-a",
+ });
+ return { ...target, codexAccounts: [{ id: "pool-a", email: "pool@example.test", isMain: false, plan: "pro" }] } as OcxConfig;
+}
+
+describe("main-account hard-lock fallback resolution", () => {
+ test("a malformed section disables the reroute instead of half-applying", () => {
+ expect(resolveMainAccountHardLockFallback(config())).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: " " } } as OcxConfig)).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: " deepseek/deepseek-chat" } } as OcxConfig)).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: "a b" } } as OcxConfig)).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", reasoningEffort: "extreme" } } as OcxConfig)).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: FALLBACK } as OcxConfig)).toEqual(FALLBACK);
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: "combo/x" } } as OcxConfig))
+ .toEqual({ model: "combo/x" });
+ // The dashboard row keeps the configured model while its switch is off: enabled false
+ // pauses the reroute without forgetting the destination, and a malformed enabled flag
+ // is a hand edit the section boundary never accepts.
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { ...FALLBACK, enabled: false } } as OcxConfig)).toBeUndefined();
+ expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { ...FALLBACK, enabled: true } } as OcxConfig)).toEqual(FALLBACK);
+ });
+});
+
+describe("applyMainHardLockFallbackOverride", () => {
+ test("a blocked lock with no eligible account rewrites the model and the effort", () => {
+ observe({ weeklyPercent: 98 });
+ const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } };
+ expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial)))
+ .toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" });
+ expect(body.model).toBe("deepseek/deepseek-chat");
+ expect(body.reasoning).toEqual({ effort: "low" });
+ });
+
+ test("each blocking window governs on its own", () => {
+ const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
+ observe({ shortPercent: 90 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ observe({ shortPercent: 0 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ observe({ weeklyPercent: 98 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ observe({ weeklyPercent: 0 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ });
+
+ test("a monthly-only account governs on monthly; supplementary monthly data never blocks", () => {
+ const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
+ // No short or weekly evidence at all: monthly is the account's only window.
+ observe({ monthlyPercent: 99 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ observe({ monthlyPercent: 50 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ // Supplementary monthly data on a 5h/weekly account never becomes a lock.
+ observe({ shortPercent: 10, weeklyPercent: 10, monthlyPercent: 99 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ });
+
+ test("release is the same observation that admits: dropping below the threshold stops the rewrite", () => {
+ const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
+ observe({ weeklyPercent: 99 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ observe({ weeklyPercent: 97.99 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ });
+
+ test("unknown usage, the opt-out, and a missing section all keep the refusal behavior", () => {
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
+ observe({ weeklyPercent: 100 });
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLock: false, codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
+ expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config())).toBeNull();
+ });
+
+ test("an eligible pool account keeps the request on Codex", () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
+ const body: Record = { model: "gpt-5.3" };
+ expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
+ expect(body.model).toBe("gpt-5.3");
+ });
+
+ test("only bare Codex-native selectors qualify", () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
+ for (const model of ["deepseek/deepseek-chat", "combo/x", "openai-codex/gpt-5.3", NATIVE_RESERVE_MODEL]) {
+ const body: Record = { model };
+ expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
+ expect(body.model).toBe(model);
+ }
+ });
+
+ test("a Codex-native fallback target never applies: the refusal is the honest answer", () => {
+ observe({ weeklyPercent: 100 });
+ const body: Record = { model: "gpt-5.3" };
+ expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: { model: "gpt-5.4" } } as Partial))).toBeNull();
+ expect(body.model).toBe("gpt-5.3");
+ });
+
+ test("a fallback without an effort leaves the request's own reasoning untouched", () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = config({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat" } } as Partial);
+ const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } };
+ expect(applyMainHardLockFallbackOverride(body, cfg)).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" });
+ expect(body.model).toBe("deepseek/deepseek-chat");
+ expect(body.reasoning).toEqual({ effort: "high" });
+ });
+
+ test("a body without a usable model selector is never touched", () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
+ expect(applyMainHardLockFallbackOverride(null, cfg)).toBeNull();
+ expect(applyMainHardLockFallbackOverride([], cfg)).toBeNull();
+ expect(applyMainHardLockFallbackOverride({ model: "" }, cfg)).toBeNull();
+ expect(applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull();
+ });
+});
diff --git a/tests/config/settings-main-account-hard-lock.test.ts b/tests/config/settings-main-account-hard-lock.test.ts
index 5b50dc95e41..437ec4e0a37 100644
--- a/tests/config/settings-main-account-hard-lock.test.ts
+++ b/tests/config/settings-main-account-hard-lock.test.ts
@@ -152,4 +152,72 @@ describe("main-account hard-lock setting", () => {
expect((await request(cfg, { codexMainAccountHardLockThresholds: { short: 88 } }))!.status).toBe(200);
expect(cfg.codexMainAccountHardLockThresholds).toEqual({ short: 88, long: 92 });
});
+
+ test("GET reports the fallback as null without a stored section", async () => {
+ const response = await request(config());
+ expect(await response!.json()).toMatchObject({ codexMainAccountHardLockFallback: null });
+ });
+
+ test("PUT fallback persists the section and GET projects it back", async () => {
+ const cfg = config();
+ const response = await request(cfg, { codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", reasoningEffort: "low" } });
+ expect(response!.status).toBe(200);
+ expect(await response!.json()).toMatchObject({
+ ok: true,
+ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", reasoningEffort: "low" },
+ });
+ expect(loadConfig().codexMainAccountHardLockFallback).toEqual({ model: "deepseek/deepseek-chat", reasoningEffort: "low" });
+ expect(await (await request(cfg))!.json()).toMatchObject({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat" } });
+ });
+
+ test("PUT null clears the section instead of persisting an empty decision", async () => {
+ const cfg = config();
+ cfg.codexMainAccountHardLockFallback = { model: "combo/x" };
+ expect((await request(cfg, { codexMainAccountHardLockFallback: null }))!.status).toBe(200);
+ expect(Object.hasOwn(cfg, "codexMainAccountHardLockFallback")).toBe(false);
+ expect(Object.hasOwn(JSON.parse(readFileSync(getConfigPath(), "utf8")), "codexMainAccountHardLockFallback")).toBe(false);
+ });
+
+ test("PUT enabled false pauses the reroute and keeps the model; enabled true resumes", async () => {
+ const cfg = config();
+ expect((await request(cfg, { codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", enabled: false } }))!.status).toBe(200);
+ expect(loadConfig().codexMainAccountHardLockFallback).toEqual({ model: "deepseek/deepseek-chat", enabled: false });
+ expect(await (await request(cfg))!.json()).toMatchObject({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", enabled: false } });
+ expect((await request(cfg, { codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat", reasoningEffort: "low", enabled: true } }))!.status).toBe(200);
+ expect(loadConfig().codexMainAccountHardLockFallback).toEqual({ model: "deepseek/deepseek-chat", reasoningEffort: "low", enabled: true });
+ });
+
+ test.each([[], "deepseek/deepseek-chat", {}, { model: "" }, { model: " deepseek/deepseek-chat" },
+ { model: "deepseek/deepseek-chat", reasoningEffort: "extreme" }, { model: "a/b", extra: 1 },
+ { model: "a/b", enabled: "yes" }].map(value => [value]))(
+ "invalid fallback %j rejects before any settings mutation", async fallback => {
+ const cfg = config();
+ const response = await request(cfg, { codexMainAccountHardLock: false, codexMainAccountHardLockFallback: fallback });
+ expect(response!.status).toBe(400);
+ expect(cfg.codexMainAccountHardLock).toBeUndefined();
+ expect(cfg.codexMainAccountHardLockFallback).toBeUndefined();
+ });
+
+ test("fallback save failure restores absent and present sections exactly", async () => {
+ for (const previous of [undefined, { model: "combo/x" }]) {
+ const cfg = config();
+ if (previous) cfg.codexMainAccountHardLockFallback = previous;
+ await expect(request(cfg, { codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat" } }, {
+ saveConfigPreservingClaudeCode: () => { throw new Error("fixture save failure"); },
+ })).rejects.toThrow("fixture save failure");
+ expect(cfg.codexMainAccountHardLockFallback).toEqual(previous);
+ expect(Object.hasOwn(cfg, "codexMainAccountHardLockFallback")).toBe(previous !== undefined);
+ }
+ });
+
+ test("malformed hand-edited fallback sections degrade to off without touching the lock", async () => {
+ saveConfig(config());
+ const path = getConfigPath();
+ const disk = JSON.parse(readFileSync(path, "utf8"));
+ writeFileSync(path, JSON.stringify({ ...disk, codexMainAccountHardLockFallback: { model: " " } }));
+ const loaded = loadConfig();
+ expect(loaded.codexMainAccountHardLockFallback).toBeUndefined();
+ const response = await request(loaded);
+ expect(await response!.json()).toMatchObject({ codexMainAccountHardLock: true, codexMainAccountHardLockFallback: null });
+ });
});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index d662008849b..5441f227793 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -753,7 +753,7 @@
"loop-reasoning-replay.test.ts": "images", "loop.test.ts": "images",
"loopback-companion-client-targets.test.ts": "server", "loopback-listener-admission.test.ts": "server",
"loopback-listener-integration.test.ts": "server", "macos-serial-lanes.test.ts": "ci-workflows",
- "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-usage-limit.test.ts": "codex-integration",
+ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-fallback-routing.test.ts": "codex-integration", "main-account-hard-lock-usage-limit.test.ts": "codex-integration",
"main-account-hard-lock-default.test.ts": "codex-integration",
"main-account-hard-lock-policy.test.ts": "codex-integration",
"main-account-hard-lock-recovery.test.ts": "codex-integration", "main-account-credit-renewal.test.ts": "codex-integration",
From be8c0dd8e31f8afc84ab8f3bb02a7b4a48f0e1c8 Mon Sep 17 00:00:00 2001
From: Blazetes
Date: Thu, 15 Oct 2026 15:36:51 +0800
Subject: [PATCH 2/3] fix(codex): narrow the hard-lock fallback guard by model
entitlement
Account-gated native models (gpt-daybreak-blue-latest, gpt-6-astra-minor)
match isBareOpenAiFamilyModel, so the fallback guard accepted them but asked
getEligiblePoolAccounts without the model's entitlement evidence. A generally
usable pool account then kept the guard non-empty even when its own roster
could not serve the model: the fallback was skipped and the dispatch refused
later with CodexModelAvailabilityError instead of using the configured
destination.
Resolve the same entitlement evidence the auth selection resolves for gated
models and pass it to the pool check as modelEligibleAccountIds. Ordinary
native models never pay the resolution. A failed discovery narrows nothing --
absence of evidence never widens the failure mode, so the request keeps
today's refusal behavior instead of rerouting on unknown state.
---
.../responses/main-hard-lock-routing.ts | 40 ++++++-
src/server/responses/request-prepare.ts | 2 +-
...account-hard-lock-fallback-routing.test.ts | 113 +++++++++++++-----
3 files changed, 116 insertions(+), 39 deletions(-)
diff --git a/src/server/responses/main-hard-lock-routing.ts b/src/server/responses/main-hard-lock-routing.ts
index 09537758f02..655eebf8b82 100644
--- a/src/server/responses/main-hard-lock-routing.ts
+++ b/src/server/responses/main-hard-lock-routing.ts
@@ -1,6 +1,10 @@
import type { OcxConfig } from "../../types";
import { isBareOpenAiFamilyModel } from "../../router";
-import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
+import {
+ ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
+ NATIVE_RESERVE_MODEL,
+} from "../../codex/catalog/native-models";
+import { entitledCodexAccountIdsForModel, resolveCodexModelEntitlements } from "../../codex/model-entitlements";
import {
getMainAccountHardLockStatus,
resolveMainAccountHardLockFallback,
@@ -17,6 +21,12 @@ export interface MainHardLockFallbackOverride {
targetModel: string;
}
+export interface MainHardLockFallbackOverrideOptions {
+ /** Test seam for the entitlement resolver; production uses the real bounded discovery. */
+ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
+ signal?: AbortSignal;
+}
+
function rawModelOf(body: unknown): { raw: Record; model: string } | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
const raw = body as Record;
@@ -40,7 +50,11 @@ function rawModelOf(body: unknown): { raw: Record; model: strin
* so "the pool can serve it" is one shared answer, not a second opinion: a usable pool
* account (or any eligible account) keeps the request on Codex, and a pool drained to
* empty is what makes the fallback the request's only way through. The main account is
- * already excluded there while blocked.
+ * already excluded there while blocked. Account-gated native models narrow that answer
+ * by the same entitlement evidence the auth selection resolves for them — a generally
+ * usable account that cannot serve THIS model must not hold the fallback back while the
+ * dispatch would refuse the request later with `CodexModelAvailabilityError`. A failed
+ * resolution narrows nothing: absence of evidence never widens the failure mode.
* - Only bare Codex-native selectors qualify. Provider-qualified rows, combos, account
* namespaces and policy routes name their own destination on purpose; rewriting those
* would override an explicit choice, so they keep the lock's refusal.
@@ -49,10 +63,11 @@ function rawModelOf(body: unknown): { raw: Record; model: strin
* - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
* traffic is not operator chat and must not silently change models.
*/
-export function applyMainHardLockFallbackOverride(
+export async function applyMainHardLockFallbackOverride(
body: unknown,
config: OcxConfig,
-): MainHardLockFallbackOverride | null {
+ options: MainHardLockFallbackOverrideOptions = {},
+): Promise {
const parsed = rawModelOf(body);
if (!parsed) return null;
const fallback = resolveMainAccountHardLockFallback(config);
@@ -60,7 +75,22 @@ export function applyMainHardLockFallbackOverride(
if (isBareOpenAiFamilyModel(fallback.model)) return null;
if (!isBareOpenAiFamilyModel(parsed.model) || parsed.model === NATIVE_RESERVE_MODEL) return null;
if (getMainAccountHardLockStatus(config).state !== "blocked") return null;
- if (getEligiblePoolAccounts(config).length > 0) return null;
+ let modelEligibleAccountIds: ReadonlySet | undefined;
+ if (ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(parsed.model)) {
+ try {
+ const snapshot = await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(
+ config,
+ { signal: options.signal },
+ );
+ modelEligibleAccountIds = entitledCodexAccountIdsForModel(snapshot, parsed.model);
+ } catch {
+ // Discovery failure is "unknown", not "unusable": leave selection exactly as it was.
+ modelEligibleAccountIds = undefined;
+ }
+ }
+ if (getEligiblePoolAccounts(config, undefined, Date.now(), undefined, { modelEligibleAccountIds }).length > 0) {
+ return null;
+ }
parsed.raw.model = fallback.model;
if (fallback.reasoningEffort !== undefined) {
diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts
index 99c37fcc7ae..9368b0841a9 100644
--- a/src/server/responses/request-prepare.ts
+++ b/src/server/responses/request-prepare.ts
@@ -353,7 +353,7 @@ export async function prepareResponsesRequest(
// must run before comboIdFromRawBody reads `model`.
if (!concreteSelection && !options.compactionRoutingOverride && !options.internalDecisionCall
&& !shadowCallIntercepted && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
- options.mainHardLockFallbackOverride = applyMainHardLockFallbackOverride(body, config);
+ options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(body, config);
}
const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null;
// A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse
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 20c876dadd0..027d7baf7ac 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
@@ -7,7 +7,8 @@ import { resolveMainAccountHardLockFallback } from "../../src/codex/main-account
import { captureMainQuotaWriter, clearMainAccountInfoCache, 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";
+import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models";
+import type { CodexModelEntitlementSnapshot } from "../../src/codex/model-entitlements";
import type { OcxConfig } from "../../src/types";
import { removeTreeWithRetry } from "../helpers/remove-tree";
@@ -54,7 +55,7 @@ function withPoolAccount(target: OcxConfig): OcxConfig {
}
describe("main-account hard-lock fallback resolution", () => {
- test("a malformed section disables the reroute instead of half-applying", () => {
+ test("a malformed section disables the reroute instead of half-applying", async () => {
expect(resolveMainAccountHardLockFallback(config())).toBeUndefined();
expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: " " } } as OcxConfig)).toBeUndefined();
expect(resolveMainAccountHardLockFallback({ codexMainAccountHardLockFallback: { model: " deepseek/deepseek-chat" } } as OcxConfig)).toBeUndefined();
@@ -72,94 +73,140 @@ describe("main-account hard-lock fallback resolution", () => {
});
describe("applyMainHardLockFallbackOverride", () => {
- test("a blocked lock with no eligible account rewrites the model and the effort", () => {
+ test("a blocked lock with no eligible account rewrites the model and the effort", async () => {
observe({ weeklyPercent: 98 });
const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } };
- expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial)))
+ expect(await applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial)))
.toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" });
expect(body.model).toBe("deepseek/deepseek-chat");
expect(body.reasoning).toEqual({ effort: "low" });
});
- test("each blocking window governs on its own", () => {
+ test("each blocking window governs on its own", async () => {
const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
observe({ shortPercent: 90 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
observe({ shortPercent: 0 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
observe({ weeklyPercent: 98 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
observe({ weeklyPercent: 0 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
});
- test("a monthly-only account governs on monthly; supplementary monthly data never blocks", () => {
+ test("a monthly-only account governs on monthly; supplementary monthly data never blocks", async () => {
const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
// No short or weekly evidence at all: monthly is the account's only window.
observe({ monthlyPercent: 99 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
observe({ monthlyPercent: 50 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
// Supplementary monthly data on a 5h/weekly account never becomes a lock.
observe({ shortPercent: 10, weeklyPercent: 10, monthlyPercent: 99 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
});
- test("release is the same observation that admits: dropping below the threshold stops the rewrite", () => {
+ test("release is the same observation that admits: dropping below the threshold stops the rewrite", async () => {
const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
observe({ weeklyPercent: 99 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).not.toBeNull();
observe({ weeklyPercent: 97.99 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg)).toBeNull();
});
- test("unknown usage, the opt-out, and a missing section all keep the refusal behavior", () => {
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
+ test("unknown usage, the opt-out, and a missing section all keep the refusal behavior", async () => {
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
observe({ weeklyPercent: 100 });
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLock: false, codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
- expect(applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config())).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config({ codexMainAccountHardLock: false, codexMainAccountHardLockFallback: FALLBACK } as Partial))).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, config())).toBeNull();
});
- test("an eligible pool account keeps the request on Codex", () => {
+ test("an eligible pool account keeps the request on Codex", async () => {
observe({ weeklyPercent: 100 });
const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
const body: Record = { model: "gpt-5.3" };
- expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
expect(body.model).toBe("gpt-5.3");
});
- test("only bare Codex-native selectors qualify", () => {
+ test("only bare Codex-native selectors qualify", async () => {
observe({ weeklyPercent: 100 });
const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
for (const model of ["deepseek/deepseek-chat", "combo/x", "openai-codex/gpt-5.3", NATIVE_RESERVE_MODEL]) {
const body: Record = { model };
- expect(applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride(body, cfg)).toBeNull();
expect(body.model).toBe(model);
}
});
- test("a Codex-native fallback target never applies: the refusal is the honest answer", () => {
+ test("a Codex-native fallback target never applies: the refusal is the honest answer", async () => {
observe({ weeklyPercent: 100 });
const body: Record = { model: "gpt-5.3" };
- expect(applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: { model: "gpt-5.4" } } as Partial))).toBeNull();
+ expect(await applyMainHardLockFallbackOverride(body, config({ codexMainAccountHardLockFallback: { model: "gpt-5.4" } } as Partial))).toBeNull();
expect(body.model).toBe("gpt-5.3");
});
- test("a fallback without an effort leaves the request's own reasoning untouched", () => {
+ test("a fallback without an effort leaves the request's own reasoning untouched", async () => {
observe({ weeklyPercent: 100 });
const cfg = config({ codexMainAccountHardLockFallback: { model: "deepseek/deepseek-chat" } } as Partial);
const body: Record = { model: "gpt-5.3", reasoning: { effort: "high" } };
- expect(applyMainHardLockFallbackOverride(body, cfg)).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" });
+ expect(await applyMainHardLockFallbackOverride(body, cfg)).toEqual({ sourceModel: "gpt-5.3", targetModel: "deepseek/deepseek-chat" });
expect(body.model).toBe("deepseek/deepseek-chat");
expect(body.reasoning).toEqual({ effort: "high" });
});
- test("a body without a usable model selector is never touched", () => {
+ test("a body without a usable model selector is never touched", async () => {
observe({ weeklyPercent: 100 });
const cfg = config({ codexMainAccountHardLockFallback: FALLBACK } as Partial);
- expect(applyMainHardLockFallbackOverride(null, cfg)).toBeNull();
- expect(applyMainHardLockFallbackOverride([], cfg)).toBeNull();
- expect(applyMainHardLockFallbackOverride({ model: "" }, cfg)).toBeNull();
- expect(applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride(null, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride([], cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: "" }, cfg)).toBeNull();
+ expect(await applyMainHardLockFallbackOverride({ model: 42 }, cfg)).toBeNull();
+ });
+});
+
+describe("applyMainHardLockFallbackOverride model entitlement", () => {
+ const gatedModel = NATIVE_DAYBREAK_BLUE_MODEL;
+ expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(gatedModel)).toBe(true);
+ const snapshot = (grantsPoolA: boolean): CodexModelEntitlementSnapshot => ({
+ modelsByAccount: new Map([["pool-a", new Set(grantsPoolA ? [gatedModel] : ["gpt-5.3"])]]),
+ clientVersionByAccount: new Map([["pool-a", "9.9.9"]]),
+ confirmedAccountIds: new Set(["pool-a"]),
+ credentialIdentities: new Map(),
+ });
+ const resolver = (grantsPoolA: boolean) => async () => snapshot(grantsPoolA);
+
+ test("an entitled pool account keeps the gated request on Codex", async () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
+ expect(await applyMainHardLockFallbackOverride({ model: gatedModel }, cfg, { resolveCodexModelEntitlements: resolver(true) })).toBeNull();
+ });
+
+ test("a pool account that cannot serve the gated model no longer holds the fallback back", async () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
+ const body: Record = { model: gatedModel };
+ expect(await applyMainHardLockFallbackOverride(body, cfg, { resolveCodexModelEntitlements: resolver(false) }))
+ .toEqual({ sourceModel: gatedModel, targetModel: "deepseek/deepseek-chat" });
+ expect(body.model).toBe("deepseek/deepseek-chat");
+ });
+
+ test("a failed entitlement resolution narrows nothing and keeps the refusal behavior", async () => {
+ observe({ weeklyPercent: 100 });
+ const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
+ const body: Record = { model: gatedModel };
+ expect(await applyMainHardLockFallbackOverride(body, cfg, {
+ resolveCodexModelEntitlements: async () => { throw new Error("discovery down"); },
+ })).toBeNull();
+ expect(body.model).toBe(gatedModel);
+ });
+
+ test("ordinary native models never pay the entitlement resolution", async () => {
+ observe({ weeklyPercent: 100 });
+ let calls = 0;
+ const cfg = withPoolAccount(config({ codexMainAccountHardLockFallback: FALLBACK } as Partial));
+ const spy = async () => { calls += 1; return snapshot(true); };
+ expect(await applyMainHardLockFallbackOverride({ model: "gpt-5.3" }, cfg, { resolveCodexModelEntitlements: spy })).toBeNull();
+ expect(calls).toBe(0);
});
});
From 84186aac2cfae202a1c4285d57093a9b74e73920 Mon Sep 17 00:00:00 2001
From: Blazetes
Date: Thu, 15 Oct 2026 15:36:52 +0800
Subject: [PATCH 3/3] fix(gui): qualify the hard-lock fallback row copy and
filter its picker
CodeRabbit review fixes on #7123:
- The fallback model picker offered bare native catalog rows, but the router
refuses bare OpenAI-family fallback targets, so a row could look enabled and
never route. Offer only qualified selectors; the saved value stays visible
even when it is one of those.
- The blocked-state notice claimed native Codex requests route to the fallback
while a usable Pool account may still serve them. Qualify the notice in all
eleven locales: routing applies only when no eligible Codex account can
serve the request.
- German copy: "statt zu fehlschlagen" -> "statt fehlzuschlagen" (separable
verb).
---
.../components/MainAccountHardLockSetting.tsx | 7 +++++-
gui/src/i18n/de.ts | 2 +-
gui/src/i18n/en.ts | 2 +-
gui/src/i18n/fr.ts | 2 +-
gui/src/i18n/ja.ts | 2 +-
gui/src/i18n/ko.ts | 2 +-
gui/src/i18n/pt.ts | 2 +-
gui/src/i18n/ru.ts | 2 +-
gui/src/i18n/tr.ts | 2 +-
gui/src/i18n/vi.ts | 2 +-
gui/src/i18n/zh-TW.ts | 2 +-
gui/src/i18n/zh.ts | 2 +-
.../main-account-hard-lock-setting.test.tsx | 23 ++++++++++++++++++-
13 files changed, 39 insertions(+), 13 deletions(-)
diff --git a/gui/src/components/MainAccountHardLockSetting.tsx b/gui/src/components/MainAccountHardLockSetting.tsx
index 73d3612bc36..f7ab0e14f85 100644
--- a/gui/src/components/MainAccountHardLockSetting.tsx
+++ b/gui/src/components/MainAccountHardLockSetting.tsx
@@ -347,7 +347,12 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
model.namespaced)]
+ options={[...new Set([fallback?.model, ...models
+ // Native catalog rows carry bare ids the fallback may never target (the
+ // router refuses bare OpenAI-family destinations), so only qualified
+ // selectors are offered; the saved value stays visible regardless.
+ .filter(model => model.namespaced.includes("/"))
+ .map(model => model.namespaced)]
.filter((value): value is string => Boolean(value)))]
.map(value => ({ value, label: formatNamespacedModelId(value, t) }))}
onChange={changeFallbackModel}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index cd70d589e72..b0d9ed3b5f7 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1897,7 +1897,7 @@ export const de: Record = {
"codexAuth.mainExternalUsageWarning": "Mögliche Nutzung außerhalb von opencodex. Die Sperre verhindert möglicherweise keine Erschöpfung.",
"codexAuth.mainHardLockManage": "Schutzeinstellung anzeigen",
"codexAuth.mainHardLockFallbackTitle": "Ausweichmodell bei Blockierung",
- "codexAuth.mainHardLockFallbackDesc": "Während der Blockierung, wenn kein Codex-Konto eine Anfrage bedienen kann, werden Anfragen an native Codex-Modelle an dieses Modell umgeleitet, statt zu fehlschlagen. Leer lassen, um wie üblich abzulehnen; die Sperre endet von selbst.",
+ "codexAuth.mainHardLockFallbackDesc": "Während der Blockierung, wenn kein Codex-Konto eine Anfrage bedienen kann, werden Anfragen an native Codex-Modelle an dieses Modell umgeleitet, statt fehlzuschlagen. Leer lassen, um wie üblich abzulehnen; die Sperre endet von selbst.",
"codexAuth.mainHardLockFallbackModel": "Ausweichmodell",
"codexAuth.mainHardLockFallbackEffort": "Denkaufwand",
"codexAuth.mainHardLockFallbackDefaultEffort": "Anfrage-Aufwand behalten",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 2248cc325fc..fccba2c8089 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2512,7 +2512,7 @@ export const en = {
"codexAuth.mainHardLockFallbackModel": "Fallback model",
"codexAuth.mainHardLockFallbackEffort": "Thinking effort",
"codexAuth.mainHardLockFallbackDefaultEffort": "Keep request effort",
- "codexAuth.mainHardLockFallbackActive": "Blocking now — native Codex requests are routed to {model}.",
+ "codexAuth.mainHardLockFallbackActive": "Blocking now — native Codex requests use {model} only when no eligible Codex Pool account can serve them.",
"codexAuth.mainHardLockFallbackSaveFailed": "Could not confirm the fallback save. Reload the setting before trying again.",
"codexAuth.ultraFastDesc": "Adds the Ultrafast speed choice to GPT-6 Astra and GPT-6.1 Sol in the Codex model picker, and keeps an ultrafast tier you configured yourself on routed models when the catalog is regenerated. Ultrafast requires an eligible ChatGPT plan; turn this on only if your plan includes it.",
"codexAuth.ultraFastLoadFailed": "Could not read the Ultra Fast setting.",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index d23203eed9c..838400d5f94 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2439,7 +2439,7 @@ export const fr: Record = {
"codexAuth.mainHardLockFallbackModel": "Modèle de repli",
"codexAuth.mainHardLockFallbackEffort": "Effort de réflexion",
"codexAuth.mainHardLockFallbackDefaultEffort": "Conserver l'effort de la requête",
- "codexAuth.mainHardLockFallbackActive": "Blocage en cours — les requêtes Codex natives sont routées vers {model}.",
+ "codexAuth.mainHardLockFallbackActive": "Blocage en cours — les requêtes Codex natives utilisent {model} uniquement lorsqu'aucun compte Codex Pool éligible ne peut les traiter.",
"codexAuth.mainHardLockFallbackSaveFailed": "Impossible de confirmer l'enregistrement du repli. Rechargez le réglage avant de réessayer.",
"codexAuth.ultraFastDesc": "Ajoute l’option de vitesse Ultrafast à GPT-6 Astra et GPT-6.1 Sol dans le sélecteur de modèles de Codex, et empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même sur des modèles routés lors de la régénération du catalogue. Ultrafast nécessite un forfait ChatGPT éligible ; activez-le uniquement si votre forfait l’inclut.",
"codexAuth.ultraFastLoadFailed": "Impossible de lire le réglage Ultra Fast.",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 496e7086ab8..b0d5f224384 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2334,7 +2334,7 @@ export const ja: Record = {
"codexAuth.mainHardLockFallbackModel": "フォールバックモデル",
"codexAuth.mainHardLockFallbackEffort": "思考エフォート",
"codexAuth.mainHardLockFallbackDefaultEffort": "リクエストのエフォートを維持",
- "codexAuth.mainHardLockFallbackActive": "ブロック中 — ネイティブCodexリクエストは{model}へルーティングされます。",
+ "codexAuth.mainHardLockFallbackActive": "ブロック中 — 利用可能なCodex Poolアカウントがない場合、ネイティブCodexリクエストは{model}へルーティングされます。",
"codexAuth.mainHardLockFallbackSaveFailed": "フォールバックの保存を確認できませんでした。設定を再読み込みしてから再試行してください。",
"codexAuth.ultraFastDesc": "Codex のモデルピッカーで GPT-6 Astra と GPT-6.1 Sol に Ultrafast の速度選択肢を追加し、ルーティングモデルに自分で設定した ultrafast ティアがカタログ再生成時に削除されないようにします。Ultrafast には対象の ChatGPT プランが必要です。プランに含まれている場合のみ有効にしてください。",
"codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 9d31acd6a73..f18372d1ba2 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1945,7 +1945,7 @@ export const ko: Record = {
"codexAuth.mainHardLockFallbackModel": "폴백 모델",
"codexAuth.mainHardLockFallbackEffort": "사고 강도",
"codexAuth.mainHardLockFallbackDefaultEffort": "요청 강도 유지",
- "codexAuth.mainHardLockFallbackActive": "차단 중 — 네이티브 Codex 요청이 {model}(으)로 라우팅됩니다.",
+ "codexAuth.mainHardLockFallbackActive": "차단 중 — 사용 가능한 Codex 계정이 요청을 처리할 수 없을 때만 네이티브 Codex 요청이 {model}로 라우팅됩니다.",
"codexAuth.mainHardLockFallbackSaveFailed": "폴백 저장을 확인하지 못했습니다. 설정을 다시 불러온 뒤 재시도하세요.",
"codexAuth.ultraFastDesc": "Codex 모델 피커에서 GPT-6 Astra와 GPT-6.1 Sol에 Ultrafast 속도 선택지를 추가하고, 라우팅 모델에 직접 설정한 ultrafast 티어가 카탈로그를 다시 만들 때 지워지지 않게 합니다. Ultrafast는 해당 ChatGPT 요금제가 필요하므로, 요금제에 포함된 경우에만 켜세요.",
"codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index fc6d6573f9f..bf434364bf5 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2485,7 +2485,7 @@ export const pt: Record = {
"codexAuth.mainHardLockFallbackModel": "Modelo de fallback",
"codexAuth.mainHardLockFallbackEffort": "Esforço de raciocínio",
"codexAuth.mainHardLockFallbackDefaultEffort": "Manter esforço da solicitação",
- "codexAuth.mainHardLockFallbackActive": "Bloqueando agora — solicitações Codex nativas são roteadas para {model}.",
+ "codexAuth.mainHardLockFallbackActive": "Bloqueando agora — as solicitações Codex nativas usam {model} somente quando nenhuma conta Codex elegível pode atendê-las.",
"codexAuth.mainHardLockFallbackSaveFailed": "Não foi possível confirmar o salvamento do fallback. Recarregue a configuração antes de tentar de novo.",
"codexAuth.ultraFastDesc": "Adiciona a opção de velocidade Ultrafast ao GPT-6 Astra e ao GPT-6.1 Sol no seletor de modelos do Codex e impede que um nível de serviço ultrafast configurado por você em modelos roteados seja removido quando o catálogo é regenerado. O Ultrafast exige um plano do ChatGPT elegível; ative apenas se o seu plano o incluir.",
"codexAuth.ultraFastLoadFailed": "Não foi possível ler a configuração do Ultra Fast.",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 7fe2516215c..527a85b8582 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2425,7 +2425,7 @@ export const ru: Record = {
"codexAuth.mainHardLockFallbackModel": "Резервная модель",
"codexAuth.mainHardLockFallbackEffort": "Усилие размышления",
"codexAuth.mainHardLockFallbackDefaultEffort": "Сохранить усилие запроса",
- "codexAuth.mainHardLockFallbackActive": "Сейчас блокировано — нативные запросы Codex направляются на {model}.",
+ "codexAuth.mainHardLockFallbackActive": "Сейчас блокировано — нативные запросы Codex направляются на {model}, только если ни один подходящий аккаунт Codex не может их обработать.",
"codexAuth.mainHardLockFallbackSaveFailed": "Не удалось подтвердить сохранение резервной модели. Перезагрузите настройку и повторите.",
"codexAuth.ultraFastDesc": "Добавляет вариант скорости Ultrafast для GPT-6 Astra и GPT-6.1 Sol в выбор моделей Codex и не даёт удалить настроенный вами уровень ultrafast на маршрутизируемых моделях при перегенерации каталога. Для Ultrafast нужен подходящий тариф ChatGPT; включайте, только если он входит в ваш тариф.",
"codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 9cca2153046..00166279c30 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2463,7 +2463,7 @@ export const tr: Record = {
"codexAuth.mainHardLockFallbackModel": "Yedek model",
"codexAuth.mainHardLockFallbackEffort": "Düşünme çabası",
"codexAuth.mainHardLockFallbackDefaultEffort": "İsteğin çabasını koru",
- "codexAuth.mainHardLockFallbackActive": "Şu anda engelleniyor — yerel Codex istekleri {model} modeline yönlendirilir.",
+ "codexAuth.mainHardLockFallbackActive": "Engelleniyor — uygun bir Codex Pool hesabı isteği karşılayamadığında yerel Codex istekleri {model} modeline yönlendirilir.",
"codexAuth.mainHardLockFallbackSaveFailed": "Yedek kaydı onaylanamadı. Ayarı yeniden yükleyip tekrar deneyin.",
"codexAuth.ultraFastDesc": "Codex model seçicisinde GPT-6 Astra ve GPT-6.1 Sol için Ultrafast hız seçeneğini ekler ve yönlendirilen modellerde kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler. Ultrafast uygun bir ChatGPT planı gerektirir; yalnızca planınız içeriyorsa açın.",
"codexAuth.ultraFastLoadFailed": "Ultra Fast ayarı okunamadı.",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index 2ec4fa0f194..5a4738327ac 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2460,7 +2460,7 @@ export const vi: Record = {
"codexAuth.mainHardLockFallbackModel": "Mô hình dự phòng",
"codexAuth.mainHardLockFallbackEffort": "Mức suy luận",
"codexAuth.mainHardLockFallbackDefaultEffort": "Giữ mức của yêu cầu",
- "codexAuth.mainHardLockFallbackActive": "Đang chặn — yêu cầu Codex gốc được chuyển tới {model}.",
+ "codexAuth.mainHardLockFallbackActive": "Đang chặn — yêu cầu Codex gốc chỉ dùng {model} khi không tài khoản Codex nào đủ điều kiện phục vụ được.",
"codexAuth.mainHardLockFallbackSaveFailed": "Không xác nhận được việc lưu dự phòng. Hãy tải lại cài đặt rồi thử lại.",
"codexAuth.ultraFastDesc": "Thêm lựa chọn tốc độ Ultrafast cho GPT-6 Astra và GPT-6.1 Sol trong bộ chọn model của Codex, đồng thời ngăn dịch vụ cấp độ ultrafast mà bạn tự thiết lập trên các model định tuyến bị loại bỏ khi catalog được tạo lại. Ultrafast yêu cầu gói ChatGPT đủ điều kiện; chỉ bật khi gói của bạn bao gồm nó.",
"codexAuth.ultraFastLoadFailed": "Không thể đọc thiết lập Ultra Fast.",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index 942e9002ba0..c58aea57c92 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -1814,7 +1814,7 @@ export const zhTW: Record = {
"codexAuth.mainHardLockFallbackModel": "後備模型",
"codexAuth.mainHardLockFallbackEffort": "思考強度",
"codexAuth.mainHardLockFallbackDefaultEffort": "保留請求原強度",
- "codexAuth.mainHardLockFallbackActive": "正在封鎖——原生 Codex 請求已路由到 {model}。",
+ "codexAuth.mainHardLockFallbackActive": "正在封鎖——僅當沒有可用的 Codex 帳戶能服務請求時,原生 Codex 請求才路由到 {model}。",
"codexAuth.mainHardLockFallbackSaveFailed": "無法確認後備設定已儲存。請重新載入設定後再試。",
"codexAuth.ultraFastDesc": "在 Codex 模型選擇器中為 GPT-6 Astra 與 GPT-6.1 Sol 加入 Ultrafast 速度選項,並讓你在路由模型上自行設定的 ultrafast 服務層級在重新產生目錄時不被移除。Ultrafast 需要符合資格的 ChatGPT 方案,僅在你的方案包含它時開啟。",
"codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 5d6aad48043..73b81dba883 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1918,7 +1918,7 @@ export const zh: Record = {
"codexAuth.mainHardLockFallbackModel": "回退模型",
"codexAuth.mainHardLockFallbackEffort": "思考强度",
"codexAuth.mainHardLockFallbackDefaultEffort": "保留请求原强度",
- "codexAuth.mainHardLockFallbackActive": "正在阻止——原生 Codex 请求已路由到 {model}。",
+ "codexAuth.mainHardLockFallbackActive": "正在阻止——仅当没有可用的 Codex 账户能服务请求时,原生 Codex 请求才路由到 {model}。",
"codexAuth.mainHardLockFallbackSaveFailed": "无法确认回退设置已保存。请重新加载设置后再试。",
"codexAuth.ultraFastDesc": "在 Codex 模型选择器中为 GPT-6 Astra 和 GPT-6.1 Sol 添加 Ultrafast 速度选项,并让你在路由模型上自行配置的 ultrafast 服务层级在重新生成目录时不被删除。Ultrafast 需要符合条件的 ChatGPT 套餐,仅在你的套餐包含它时开启。",
"codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。",
diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx
index 9acd31cba0a..d7f3d109dd1 100644
--- a/gui/tests/main-account-hard-lock-setting.test.tsx
+++ b/gui/tests/main-account-hard-lock-setting.test.tsx
@@ -533,7 +533,11 @@ describe("main account hard-lock fallback setting", () => {
return response({ ok: true, ...settingsWithFallback({ model: body.model }) });
}
if (String(_input).endsWith("/api/models")) {
- return response([{ id: "chat", provider: "deepseek", namespaced: "deepseek/deepseek-chat" }]);
+ // Bare native rows are catalog-real but can never be a fallback destination.
+ return response([
+ { id: "chat", provider: "deepseek", namespaced: "deepseek/deepseek-chat" },
+ { id: "gpt-5.3", provider: "openai", namespaced: "gpt-5.3" },
+ ]);
}
return response(settings(true));
}) as typeof fetch);
@@ -544,6 +548,23 @@ describe("main account hard-lock fallback setting", () => {
expect(selectTriggers(host)[1]!.hasAttribute("disabled")).toBe(false);
});
+ test("the fallback picker never offers a bare native selector", async () => {
+ const host = await mount((async (_input, init) => {
+ if (String(_input).endsWith("/api/models")) {
+ return response([
+ { id: "chat", provider: "deepseek", namespaced: "deepseek/deepseek-chat" },
+ { id: "gpt-5.3", provider: "openai", namespaced: "gpt-5.3" },
+ ]);
+ }
+ return response(settings(true));
+ }) as typeof fetch);
+ await act(async () => { await flush(); await flush(); });
+ await act(async () => { selectTriggers(host)[0]!.click(); await flush(); });
+ const labels = optionButtons().map(button => button.textContent);
+ expect(labels).toContain("deepseek/deepseek-chat");
+ expect(labels).not.toContain("gpt-5.3");
+ });
+
test("a failed save shows the alert and keeps the switch state the server last confirmed", async () => {
let puts = 0;
const host = await mount((async (_input, init) => {