Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
149 changes: 145 additions & 4 deletions gui/src/components/MainAccountHardLockSetting.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
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<boolean> };
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<Snapshot>;
const payload = value as Partial<Snapshot> & { codexMainAccountHardLockFallback?: unknown };
const policy = payload.mainAccountHardLock;
if (typeof payload.codexMainAccountHardLock !== "boolean" || !policy
|| policy.enabled !== payload.codexMainAccountHardLock
|| !(policy.enabled ? ["unknown", "ready", "blocked"] : ["off"]).includes(policy.state)
|| (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 }: {
Expand Down Expand Up @@ -80,6 +100,10 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
const [saved, setSaved] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [fallback, setFallback] = useState<FallbackSetting>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
const [fallbackSaving, setFallbackSaving] = useState(false);
const [fallbackError, setFallbackError] = useState(false);
const busyRef = useRef(false);
const mountedRef = useRef(false);
const generationRef = useRef(0);
Expand All @@ -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 {
Expand All @@ -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]);
Expand Down Expand Up @@ -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<FallbackSetting, null>) => {
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;
Expand Down Expand Up @@ -234,6 +333,48 @@ function HardLockSetting({ apiBase, onSaved }: Props) {
<button type="button" className="link-btn" disabled={saving} onClick={() => { void retryRefresh(); }}>{t("common.retry")}</button>
</p>}
</div>
<div className="codex-main-hard-lock-fallback" role="group" aria-label={t("codexAuth.mainHardLockFallbackTitle")}
style={{ flexBasis: "100%" }}>
<div className="spread" style={{ alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<span className="font-semibold">{t("codexAuth.mainHardLockFallbackTitle")}</span>
</div>
<div className="setting-controls" style={{ flex: "0 1 auto" }}>
<button type="button" className={`switch ${fallbackOn ? "on" : ""}`}
onClick={toggleFallback}
disabled={fallbackSaving || loadError || !fallback?.model}
aria-label={t("codexAuth.mainHardLockFallbackTitle")} aria-pressed={fallbackOn}>
<span className="knob" />
</button>
<Select value={fallback?.model ?? ""}
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}
disabled={fallbackSaving || loadError}
label={t("codexAuth.mainHardLockFallbackModel")}
align="right" />
<Select value={fallback?.reasoningEffort ?? ""}
options={[{ value: "", label: t("codexAuth.mainHardLockFallbackDefaultEffort") },
...FALLBACK_EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]}
onChange={changeFallbackEffort}
disabled={fallbackSaving || loadError || !fallbackOn}
label={t("codexAuth.mainHardLockFallbackEffort")}
align="right" />
</div>
</div>
<div className="muted setting-hint">{t("codexAuth.mainHardLockFallbackDesc")}</div>
{snapshot?.mainAccountHardLock.state === "blocked" && fallbackOn && fallback?.model
&& <p role="status" className="codex-main-hard-lock-status is-blocked">{t("codexAuth.mainHardLockFallbackActive", { model: fallback.model })}</p>}
{fallbackError && <p role="alert">{t("codexAuth.mainHardLockFallbackSaveFailed")}{" "}
<button type="button" className="link-btn" disabled={fallbackSaving} onClick={() => { void load(); }}>{t("common.retry")}</button>
</p>}
</div>
Comment thread
Blazetes marked this conversation as resolved.
{confirming && <HardLockConfirmation pending={saving} thresholds={thresholds} onCancel={cancel} onConfirm={() => { void save(true); }} />}
</section>
);
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,13 @@ export const de: Record<TKey, string> = {
"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 fehlzuschlagen. 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",
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.",
"codexAuth.ultraFastEnabled": "Ultra Fast tier enabled",
Expand Down
Loading
Loading