From 1ce8f81121ada8c9660f4420ef85b3e3fa7bec7e Mon Sep 17 00:00:00 2001 From: jasperlnz Date: Sat, 10 Oct 2026 02:59:03 +0200 Subject: [PATCH 1/2] feat(codex): let fresh spendable credits release the main-account hard lock (#6845) The main-account hard lock and the credits switch were independent by design: with the lock on, a main login holding spendable ChatGPT credits was still refused before any upstream send, and 'ocx account credits openai main on' could not change that. #6845 reports exactly this 429 while the official Codex client spends the same credits. Add codexMainAccountCreditsOverrideHardLock (default off). When on, a blocked main account whose credits are opted in and freshly observed as spendable is treated as usable by both selection (codexAccountUnusableReason) and admission (assertMainAccountPolicy), so the request reaches upstream and the overage draws credits. The release is only as fresh as the credit observation; a stale or refused balance re-arms the lock. Default behavior is unchanged and the existing 'allowing credits does not lift the main lock' test still passes. Wired through the settings API and 'ocx system settings --main-account-credits-override-hardlock'. --- src/cli/system-settings-parity.ts | 6 ++- src/codex/account-usability.ts | 6 ++- src/codex/auth-context.ts | 13 ++++-- src/codex/main-account-hard-lock.ts | 23 ++++++++++- src/config/schema/config-schema.ts | 3 ++ src/server/management/config-routes.ts | 19 ++++++++- src/types/config.ts | 11 +++++ .../codex-credits-after-limit-main.test.ts | 40 +++++++++++++++++++ 8 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/cli/system-settings-parity.ts b/src/cli/system-settings-parity.ts index 5078aa79148..a4f2fe59ecd 100644 --- a/src/cli/system-settings-parity.ts +++ b/src/cli/system-settings-parity.ts @@ -3,10 +3,12 @@ import { serializeManagementJson } from "./json-input"; import { printSettingsResult, projectSettingsApply } from "./settings-result"; import { CliUsageError, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api"; -export const SYSTEM_PARITY_OPTIONS = ["--show-codex-credits", "--account-picker", "--main-account-hard-lock", "--ultra-fast-tier", "--fast-rows"] as const; +export const SYSTEM_PARITY_OPTIONS = ["--show-codex-credits", "--account-picker", "--main-account-hard-lock", "--main-account-credits-override-hardlock", "--ultra-fast-tier", "--fast-rows"] as const; const BOOLEAN_FIELDS: Readonly> = { "--show-codex-credits": "showCodexCredits", "--account-picker": "codexAccountPickerEnabled", - "--main-account-hard-lock": "codexMainAccountHardLock", "--ultra-fast-tier": "ultraFastTier", "--fast-rows": "fastRows", + "--main-account-hard-lock": "codexMainAccountHardLock", + "--main-account-credits-override-hardlock": "codexMainAccountCreditsOverrideHardLock", + "--ultra-fast-tier": "ultraFastTier", "--fast-rows": "fastRows", "--auto-start": "codexAutoStart", "--desktop-authless": "codexDesktopAuthless", "--client-compaction": "codexClientCompaction", }; function record(value: unknown): value is Record { diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index df7ef92d5be..8f8941c473e 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -8,7 +8,7 @@ import { import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; -import { isMainAccountHardLocked } from "./main-account-hard-lock"; +import { isMainAccountHardLocked, mainAccountCreditsReleaseHardLock } from "./main-account-hard-lock"; export interface CodexAccountUsabilityOptions { /** Route using cached runtime state only; the caller must reject selected main before auth. */ @@ -80,7 +80,9 @@ export function codexAccountUnusableReason( return "model_not_entitled"; } if (accountId === MAIN_CODEX_ACCOUNT_ID) { - if (isMainAccountHardLocked(config)) return "main_hard_locked"; + // A hard lock released by fresh spendable credits (#6845) leaves main selectable here, so + // selection and admission agree; the release re-arms the moment the balance ages out. + if (isMainAccountHardLocked(config) && !mainAccountCreditsReleaseHardLock(config)) return "main_hard_locked"; // Startup recovery owns the physical auth/vault boundary. Never parse or select // native __main__ while an encrypted switch journal is pending or inconclusive. if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return "main_traffic_blocked"; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 3903e295fde..e68700d3da5 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -77,6 +77,7 @@ import { getMainAccountHardLockStatus, isMainAccountHardLockEnabled, isMainAccountHardLocked, + mainAccountCreditsReleaseHardLock, } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, @@ -621,7 +622,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError { export type CodexAuthPolicyConfig = Readonly>; interface CodexAuthMaterializationOptions { @@ -772,11 +773,17 @@ function mainCreditsHoldResetAt(config: Pick } function assertMainAccountPolicy( - config: Pick | undefined, + config: Pick | undefined, ): void { if (config) { const status = getMainAccountHardLockStatus(config); - if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); + if (status.state === "blocked") { + // Opt-in relief: only a fresh spendable balance releases the lock, and the credit hold + // below then passes on that same evidence. Default off throws exactly as before. + if (!mainAccountCreditsReleaseHardLock(config)) { + throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds); + } + } const creditsResetAt = mainCreditsHoldResetAt(config); if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt); } diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index fda12552410..5242483d079 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -1,6 +1,8 @@ import type { OcxConfig } from "../types"; 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"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { codexAccountUsesCreditsAfterLimit } from "./account-credit-use"; +import { hasSpendableCodexCredits, MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types"; export { MAIN_ACCOUNT_HARD_LOCK_PERCENT }; @@ -92,3 +94,22 @@ export function getMainAccountHardLockStatus( export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean { return getMainAccountHardLockStatus(config, now).state === "blocked"; } + +type CreditReleaseConfig = Pick; + +/** + * Whether a fresh spendable credit balance may release the main-account hard lock (#6845). + * + * Off by default and doubly gated: the operator must opt in with + * `codexMainAccountCreditsOverrideHardLock`, the account must be credit-consented, and a spendable + * balance must sit inside the credit freshness window. Any missing input leaves the lock armed, so + * a stale or refused balance re-locks the account instead of spending blind. + * + * Lives here so selection (`codexAccountUnusableReason`) and admission + * (`assertMainAccountPolicy`) read one definition and cannot disagree about whether the lock holds. + */ +export function mainAccountCreditsReleaseHardLock(config: CreditReleaseConfig, now = Date.now()): boolean { + return config.codexMainAccountCreditsOverrideHardLock === true + && codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID) + && hasSpendableCodexCredits(getMainPolicyQuota(), now); +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 4979fdf85a3..e86c97a6a92 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -215,6 +215,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), + // Opt-in credit relief for the main lock (#6845): absence and malformed hand edits stay off, + // so the lock and the credits switch keep their independent defaults. + codexMainAccountCreditsOverrideHardLock: z.boolean().optional().catch(false), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 1ea79b09d4a..391e4ae97a8 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -424,6 +424,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/tests/codex-integration/codex-credits-after-limit-main.test.ts b/tests/codex-integration/codex-credits-after-limit-main.test.ts index dae93421fbf..7e8cd635c00 100644 --- a/tests/codex-integration/codex-credits-after-limit-main.test.ts +++ b/tests/codex-integration/codex-credits-after-limit-main.test.ts @@ -265,3 +265,43 @@ test("with the default hard lock on, allowing credits does not lift the main loc await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) .rejects.toBeInstanceOf(CodexMainAccountHardLockError); }); + +test("opt-in credit relief releases the hard-locked main login for a fresh spendable balance (#6845)", async () => { + const cfg = config(); + delete cfg.codexMainAccountHardLock; + cfg.codexMainAccountCreditsOverrideHardLock = true; + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainWeekly(100, Date.now() + DAY_MS); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "main-pool", accountId: MAIN }); +}); + +test("opt-in credit relief needs both the toggle and the credits consent", async () => { + const cfg = config(); + delete cfg.codexMainAccountHardLock; + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainWeekly(100, Date.now() + DAY_MS); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + + const withoutConsent = config(); + delete withoutConsent.codexMainAccountHardLock; + withoutConsent.codexMainAccountCreditsOverrideHardLock = true; + mainWeekly(100, Date.now() + DAY_MS); + await expect(resolveCodexAuthContext(new Headers(), withoutConsent, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); +}); + +test("opt-in credit relief re-locks the main login once the balance observation ages out", async () => { + const cfg = config(); + delete cfg.codexMainAccountHardLock; + cfg.codexMainAccountCreditsOverrideHardLock = true; + setCodexAccountCreditsAfterLimit(cfg, MAIN, true); + mainWeekly(100, Date.now() + DAY_MS); + const writer = captureMainQuotaWriter(accountId)!; + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: Date.now() + DAY_MS, + credits: { hasCredits: true, balance: 42.5, observedAt: Date.now() - 300_001 } }, undefined, writer); + expect(hasSpendableCodexCredits(getAccountQuota(MAIN))).toBe(false); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); +}); From 836e01fd5ccd6749485b039edc351bc91d3953d9 Mon Sep 17 00:00:00 2001 From: jasperlnz Date: Sat, 10 Oct 2026 03:18:48 +0200 Subject: [PATCH 2/2] feat(gui): add the main-account credit-override toggle (#6845) Adds a second switch to the main-account protection card, beside the lock switch, wired to codexMainAccountCreditsOverrideHardLock. Off by default. It shares the card's single /api/settings read and PUT, so the existing read-count and focus contracts are unchanged; the four new locale keys ship in all eleven catalogs (zh-TW translated, not an English placeholder). --- .../components/MainAccountHardLockSetting.tsx | 56 ++++++++++++++++--- gui/src/i18n/de.ts | 4 ++ gui/src/i18n/en.ts | 4 ++ gui/src/i18n/fr.ts | 4 ++ gui/src/i18n/ja.ts | 4 ++ gui/src/i18n/ko.ts | 4 ++ gui/src/i18n/pt.ts | 4 ++ gui/src/i18n/ru.ts | 4 ++ gui/src/i18n/tr.ts | 4 ++ gui/src/i18n/vi.ts | 4 ++ gui/src/i18n/zh-TW.ts | 4 ++ gui/src/i18n/zh.ts | 4 ++ gui/src/styles-codex-set.css | 2 + .../main-account-hard-lock-setting.test.tsx | 26 +++++++++ 14 files changed, 119 insertions(+), 9 deletions(-) diff --git a/gui/src/components/MainAccountHardLockSetting.tsx b/gui/src/components/MainAccountHardLockSetting.tsx index e46079b9e8e..82b03cd1d81 100644 --- a/gui/src/components/MainAccountHardLockSetting.tsx +++ b/gui/src/components/MainAccountHardLockSetting.tsx @@ -5,7 +5,12 @@ import { useT } from "../i18n/shared"; import { hardLockThresholds, type MainAccountHardLockStatus } from "../hooks/useCodexAccountPool"; type Props = { apiBase: string; onSaved: () => Promise }; -type Snapshot = { codexMainAccountHardLock: boolean; mainAccountHardLock: MainAccountHardLockStatus }; +type Field = "codexMainAccountHardLock" | "codexMainAccountCreditsOverrideHardLock"; +type Snapshot = { + codexMainAccountHardLock: boolean; + codexMainAccountCreditsOverrideHardLock: boolean; + mainAccountHardLock: MainAccountHardLockStatus; +}; function readSnapshot(value: unknown): Snapshot { if (!value || typeof value !== "object") throw new Error("settings shape"); @@ -17,7 +22,14 @@ 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; + // Absent means off: an older proxy that predates the key renders the switch off rather than + // erroring, which is also the server default. + return { + codexMainAccountHardLock: payload.codexMainAccountHardLock, + codexMainAccountCreditsOverrideHardLock: typeof payload.codexMainAccountCreditsOverrideHardLock === "boolean" + ? payload.codexMainAccountCreditsOverrideHardLock : false, + mainAccountHardLock: policy, + }; } function HardLockConfirmation({ pending, thresholds, onCancel, onConfirm }: { @@ -78,6 +90,7 @@ function HardLockSetting({ apiBase, onSaved }: Props) { const [saveError, setSaveError] = useState(false); const [refreshError, setRefreshError] = useState(false); const [saved, setSaved] = useState(null); + const [overrideSaved, setOverrideSaved] = useState(null); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); const busyRef = useRef(false); @@ -85,6 +98,8 @@ function HardLockSetting({ apiBase, onSaved }: Props) { const generationRef = useRef(0); const readAbortRef = useRef(null); const toggleRef = useRef(null); + const overrideToggleRef = useRef(null); + const focusTargetRef = useRef(null); const sectionRef = useRef(null); const restoreFocusRef = useRef(false); @@ -102,6 +117,7 @@ function HardLockSetting({ apiBase, onSaved }: Props) { setSnapshot(next); setLoadError(false); setSaved(null); + setOverrideSaved(null); } catch { if (mountedRef.current && generation === generationRef.current) setLoadError(true); } finally { @@ -124,8 +140,9 @@ function HardLockSetting({ apiBase, onSaved }: Props) { useEffect(() => { if (confirming || saving || !restoreFocusRef.current) return; - if (toggleRef.current && !toggleRef.current.disabled) { - toggleRef.current.focus(); + const target = focusTargetRef.current ?? toggleRef.current; + if (target && !target.disabled) { + target.focus(); restoreFocusRef.current = false; } else { // Keep the intent through failed/pending authoritative reads: the section is @@ -140,7 +157,7 @@ function HardLockSetting({ apiBase, onSaved }: Props) { if (mountedRef.current) setRefreshError(!confirmed); }; - const save = async (requested: boolean) => { + const save = async (field: Field, requested: boolean) => { // The toggle requires a successful read before opening confirmation. A later poll // failure must not silently turn an already-open confirmation into a no-op. if (busyRef.current || !snapshot) return; @@ -151,12 +168,13 @@ function HardLockSetting({ apiBase, onSaved }: Props) { setSaveError(false); setRefreshError(false); setSaved(null); + setOverrideSaved(null); 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({ codexMainAccountHardLock: requested }), signal: bounded.signal, + body: JSON.stringify({ [field]: requested }), signal: bounded.signal, }); if (!response.ok) throw new Error("save"); const payload: unknown = await response.json(); @@ -168,7 +186,8 @@ function HardLockSetting({ apiBase, onSaved }: Props) { if (mountedRef.current) { setSnapshot(next); setLoadError(false); - setSaved(next.codexMainAccountHardLock); + if (field === "codexMainAccountHardLock") setSaved(next.codexMainAccountHardLock); + else setOverrideSaved(next.codexMainAccountCreditsOverrideHardLock); } } catch { if (mountedRef.current) { @@ -200,6 +219,7 @@ function HardLockSetting({ apiBase, onSaved }: Props) { if (mountedRef.current) setSaving(false); }; const enabled = snapshot?.codexMainAccountHardLock; + const overrideEnabled = snapshot?.codexMainAccountCreditsOverrideHardLock; const thresholds = hardLockThresholds(snapshot?.mainAccountHardLock.thresholds); return (
{ if (busyRef.current || enabled === undefined || loadError) return; restoreFocusRef.current = true; - if (enabled) void save(false); + focusTargetRef.current = toggleRef.current; + if (enabled) void save("codexMainAccountHardLock", false); else setConfirming(true); }}>
@@ -234,7 +255,24 @@ function HardLockSetting({ apiBase, onSaved }: Props) {

}
- {confirming && { void save(true); }} />} +
); } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8ad26c35ab3..30b01dd6325 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1837,6 +1837,10 @@ 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.mainCreditsOverrideTitle": "Credits zum Übergehen des Hauptkonto-Schutzes verwenden", + "codexAuth.mainCreditsOverrideDesc": "Wenn der Schutz das Hauptkonto blockiert, ein frisches verfügbares Credit-Guthaben weiter bedienen lassen. Standardmäßig aus; die Sperre greift wieder, sobald das Guthaben verbraucht, abgelehnt oder veraltet ist.", + "codexAuth.mainCreditsOverrideOn": "Credit-Übergehung ist an. Verfügbare Credits heben die Hauptkonto-Sperre auf.", + "codexAuth.mainCreditsOverrideOff": "Credit-Übergehung ist aus.", "codexAuth.ultraFastDesc": "Verhindert, dass ein selbst konfigurierter ultrafast-Diensttarif beim Neuaufbau des Katalogs entfernt wird, und benennt ihn in den Anfrageprotokollen. Ultra Fast wird nicht in die Modellauswahl aufgenommen: Upstream kündigt nur Fast an, ein Eintrag würde also eine Geschwindigkeit anbieten, die die Leitung nicht liefern kann.", "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 ce81fed2972..e941bbacfcc 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2448,6 +2448,10 @@ 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.mainCreditsOverrideTitle": "Use credits to override main-account protection", + "codexAuth.mainCreditsOverrideDesc": "When protection blocks the main account, let a fresh spendable credit balance keep it serving. Off by default; the lock re-arms when the balance is spent, refused, or stale.", + "codexAuth.mainCreditsOverrideOn": "Credit override is on. Spendable credits release the main-account lock.", + "codexAuth.mainCreditsOverrideOff": "Credit override is off.", "codexAuth.ultraFastDesc": "Keeps an ultrafast service tier you configured yourself from being stripped when the catalog is regenerated, and names it in the request logs. It does not add Ultra Fast to the model picker: upstream advertises only Fast, so a picker row would offer a speed the wire cannot deliver.", "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 a63d3c768fd..a404c4a1c34 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2375,6 +2375,10 @@ 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.mainCreditsOverrideTitle": "Utiliser les crédits pour contourner la protection du compte principal", + "codexAuth.mainCreditsOverrideDesc": "Lorsque la protection bloque le compte principal, laisser un solde de crédits disponible et récent continuer à le servir. Désactivé par défaut ; le verrou se réarme dès que le solde est épuisé, refusé ou obsolète.", + "codexAuth.mainCreditsOverrideOn": "Le contournement par crédits est activé. Les crédits disponibles lèvent le verrou du compte principal.", + "codexAuth.mainCreditsOverrideOff": "Le contournement par crédits est désactivé.", "codexAuth.ultraFastDesc": "Empêche la suppression d’un niveau de service ultrafast que vous avez configuré vous-même lors de la régénération du catalogue, et le nomme dans les journaux de requêtes. Ultra Fast n’est pas ajouté au sélecteur de modèles : l’amont n’annonce que Fast, une entrée proposerait donc une vitesse que le transport ne peut pas fournir.", "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 1777cc26017..1e79f1e4d63 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2270,6 +2270,10 @@ export const ja: Record = { "codexAuth.mainHardLockMonitoring": "保護有効・監視中", "codexAuth.mainExternalUsageWarning": "opencodex以外で使用量が増えた可能性があります。制限しても上限に達する場合があります。", "codexAuth.mainHardLockManage": "保護設定を表示", + "codexAuth.mainCreditsOverrideTitle": "クレジットでメインアカウント保護を上書き", + "codexAuth.mainCreditsOverrideDesc": "保護がメインアカウントをブロックしたとき、新しく確認された利用可能なクレジット残高で処理を続行させます。既定はオフで、残高が使い切られる・拒否される・古くなるとロックが再び有効になります。", + "codexAuth.mainCreditsOverrideOn": "クレジット上書きはオンです。利用可能なクレジットがメインアカウントのロックを解除します。", + "codexAuth.mainCreditsOverrideOff": "クレジット上書きはオフです。", "codexAuth.ultraFastDesc": "自分で設定した ultrafast サービスティアがカタログ再生成時に削除されないようにし、リクエストログにそのティア名を記録します。モデルピッカーに Ultra Fast は追加しません。アップストリームは Fast しか公開しておらず、ピッカーに項目を出すと実際には出せない速度を選ばせることになるためです。", "codexAuth.ultraFastLoadFailed": "Ultra Fast 設定を読み取れませんでした。", "codexAuth.ultraFastEnabled": "Ultra Fast ティアを有効にしました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7e295a542c9..0005d868378 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1881,6 +1881,10 @@ export const ko: Record = { "codexAuth.mainHardLockMonitoring": "보호 켜짐", "codexAuth.mainExternalUsageWarning": "opencodex 밖에서 사용량이 늘었을 수 있습니다. 차단해도 한도를 소진할 수 있습니다.", "codexAuth.mainHardLockManage": "차단 설정 보기", + "codexAuth.mainCreditsOverrideTitle": "크레딧으로 기본 계정 보호 재정의", + "codexAuth.mainCreditsOverrideDesc": "보호 기능이 기본 계정을 차단할 때, 새로 확인된 사용 가능한 크레딧 잔액으로 계속 처리하도록 허용합니다. 기본값은 꺼짐이며, 잔액이 소진·거부·만료되면 잠금이 다시 활성화됩니다.", + "codexAuth.mainCreditsOverrideOn": "크레딧 재정의가 켜져 있습니다. 사용 가능한 크레딧이 기본 계정 잠금을 해제합니다.", + "codexAuth.mainCreditsOverrideOff": "크레딧 재정의가 꺼져 있습니다.", "codexAuth.ultraFastDesc": "직접 설정한 ultrafast 서비스 티어가 카탈로그를 다시 만들 때 지워지지 않게 하고, 요청 로그에 그 티어 이름을 남깁니다. 모델 피커에 Ultra Fast를 추가하지는 않습니다. 업스트림은 Fast만 알리기 때문에, 피커에 칸을 만들면 실제로 낼 수 없는 속도를 고르게 하는 셈입니다.", "codexAuth.ultraFastLoadFailed": "Ultra Fast 설정을 읽지 못했습니다.", "codexAuth.ultraFastEnabled": "Ultra Fast 티어를 켰습니다", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index b76dfd4d823..70f6fd07e7a 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2422,6 +2422,10 @@ 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.mainCreditsOverrideTitle": "Usar créditos para sobrepor a proteção da conta principal", + "codexAuth.mainCreditsOverrideDesc": "Quando a proteção bloqueia a conta principal, permitir que um saldo de créditos recente e disponível continue a atendê-la. Desativado por padrão; o bloqueio volta a ativar-se quando o saldo é gasto, recusado ou fica desatualizado.", + "codexAuth.mainCreditsOverrideOn": "A sobreposição por créditos está ativada. Créditos disponíveis liberam o bloqueio da conta principal.", + "codexAuth.mainCreditsOverrideOff": "A sobreposição por créditos está desativada.", "codexAuth.ultraFastDesc": "Impede que um nível de serviço ultrarrápido configurado por você seja removido quando o catálogo é regenerado e o nomeia nos logs de requisição. Não adiciona o Ultra Fast ao seletor de modelos: o upstream anuncia apenas o Fast, então uma linha no seletor ofereceria uma velocidade que a conexão não consegue entregar.", "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 dbb43b8da43..16bc896200c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2361,6 +2361,10 @@ export const ru: Record = { "codexAuth.mainHardLockMonitoring": "Защита включена · наблюдение", "codexAuth.mainExternalUsageWarning": "Возможно использование вне opencodex. Блокировка может не предотвратить исчерпание квоты.", "codexAuth.mainHardLockManage": "Открыть настройку защиты", + "codexAuth.mainCreditsOverrideTitle": "Использовать кредиты в обход защиты основной учётной записи", + "codexAuth.mainCreditsOverrideDesc": "Когда защита блокирует основную учётную запись, разрешить свежему доступному балансу кредитов продолжать обслуживание. По умолчанию выключено; блокировка снова срабатывает, когда баланс израсходован, отклонён или устарел.", + "codexAuth.mainCreditsOverrideOn": "Обход через кредиты включён. Доступные кредиты снимают блокировку основной учётной записи.", + "codexAuth.mainCreditsOverrideOff": "Обход через кредиты выключен.", "codexAuth.ultraFastDesc": "Не даёт удалить настроенный вами уровень ultrafast при перегенерации каталога и записывает его имя в журналы запросов. Ultra Fast не добавляется в выбор моделей: вышестоящий сервис объявляет только Fast, поэтому пункт в списке предлагал бы скорость, которую канал не может обеспечить.", "codexAuth.ultraFastLoadFailed": "Не удалось прочитать настройку Ultra Fast.", "codexAuth.ultraFastEnabled": "Уровень Ultra Fast включён", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7544549a08e..8ea3186c898 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2399,6 +2399,10 @@ 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.mainCreditsOverrideTitle": "Ana hesap korumasını kredilerle geçersiz kıl", + "codexAuth.mainCreditsOverrideDesc": "Koruma ana hesabı engellediğinde, yeni ve harcanabilir bir kredi bakiyesinin hizmet vermeye devam etmesine izin ver. Varsayılan olarak kapalı; bakiye harcandığında, reddedildiğinde veya eskidiğinde kilit yeniden devreye girer.", + "codexAuth.mainCreditsOverrideOn": "Kredi geçersiz kılma açık. Harcanabilir krediler ana hesap kilidini kaldırır.", + "codexAuth.mainCreditsOverrideOff": "Kredi geçersiz kılma kapalı.", "codexAuth.ultraFastDesc": "Kendi yapılandırdığınız ultrafast hizmet katmanının katalog yeniden oluşturulurken silinmesini önler ve istek günlüklerinde bu katmanın adını yazar. Ultra Fast’i model seçicisine eklemez: üst kaynak yalnızca Fast duyurur, bu yüzden bir satır eklemek hattın veremeyeceği bir hızı seçtirmek olurdu.", "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 6d5e54f6576..862bc03fa0d 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2403,6 +2403,10 @@ 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.mainCreditsOverrideTitle": "Dùng tín dụng để ghi đè bảo vệ tài khoản chính", + "codexAuth.mainCreditsOverrideDesc": "Khi tính năng bảo vệ chặn tài khoản chính, cho phép số dư tín dụng mới, khả dụng tiếp tục phục vụ. Mặc định tắt; khóa sẽ bật lại khi số dư bị dùng hết, bị từ chối hoặc đã cũ.", + "codexAuth.mainCreditsOverrideOn": "Ghi đè bằng tín dụng đang bật. Tín dụng khả dụng sẽ mở khóa tài khoản chính.", + "codexAuth.mainCreditsOverrideOff": "Ghi đè bằng tín dụng đang tắt.", "codexAuth.ultraFastDesc": "Ngăn không cho dịch vụ cấp độ Ultra Fast mà bạn đã thiết lập bị loại bỏ khi catalog được tạo lại, và đưa tên nó vào các request log. Nó không thêm Ultra Fast vào bộ chọn model: upstream chỉ quảng cáo Fast, do đó mục chọn sẽ đưa ra tốc độ mà đường truyền không thể đáp ứng.", "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 561d41d5065..368aedcbe35 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1756,6 +1756,10 @@ export const zhTW: Record = { "codexAuth.mainHardLockMonitoring": "保護已開啟 · 監測中", "codexAuth.mainExternalUsageWarning": "可能存在 opencodex 以外的使用。封鎖可能無法避免額度耗盡。", "codexAuth.mainHardLockManage": "查看保護設定", + "codexAuth.mainCreditsOverrideTitle": "使用額度覆寫主帳號保護", + "codexAuth.mainCreditsOverrideDesc": "當保護機制阻擋主帳號時,允許新的可用額度餘額繼續為其提供服務。預設為關閉;當餘額用盡、被拒絕或過期後,鎖定會重新生效。", + "codexAuth.mainCreditsOverrideOn": "額度覆寫已開啟。可用額度會解除主帳號鎖定。", + "codexAuth.mainCreditsOverrideOff": "額度覆寫已關閉。", "codexAuth.ultraFastDesc": "讓你自行設定的 ultrafast 服務層級在重新產生目錄時不被移除,並在請求記錄中寫下該層級名稱。它不會把 Ultra Fast 加入模型選擇器:上游只公布 Fast,選擇器出現該項等於讓使用者挑一個實際無法提供的速度。", "codexAuth.ultraFastLoadFailed": "無法讀取 Ultra Fast 設定。", "codexAuth.ultraFastEnabled": "已啟用 Ultra Fast 層級", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 55cd5988453..b3d890c7f59 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1854,6 +1854,10 @@ export const zh: Record = { "codexAuth.mainHardLockMonitoring": "保护已开启 · 监测中", "codexAuth.mainExternalUsageWarning": "可能存在 opencodex 之外的使用。阻止可能无法避免额度耗尽。", "codexAuth.mainHardLockManage": "查看保护设置", + "codexAuth.mainCreditsOverrideTitle": "使用额度覆盖主账号保护", + "codexAuth.mainCreditsOverrideDesc": "当保护机制阻止主账号时,允许新的可用额度余额继续为其提供服务。默认关闭;当余额用尽、被拒绝或过期后,锁定会重新生效。", + "codexAuth.mainCreditsOverrideOn": "额度覆盖已开启。可用额度会解除主账号锁定。", + "codexAuth.mainCreditsOverrideOff": "额度覆盖已关闭。", "codexAuth.ultraFastDesc": "让你自己配置的 ultrafast 服务层级在重新生成目录时不被删除,并在请求日志中记录该层级名称。它不会把 Ultra Fast 加入模型选择器:上游只公布 Fast,选择器中出现该项等于让用户选择一个实际无法提供的速度。", "codexAuth.ultraFastLoadFailed": "无法读取 Ultra Fast 设置。", "codexAuth.ultraFastEnabled": "已启用 Ultra Fast 层级", diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index 68141f17a8e..dab1bd161f8 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -410,6 +410,8 @@ gap: 12px; } .codex-main-hard-lock-copy { flex: 1 1 240px; min-width: 0; } +/* Forces the credit-override row onto its own line without wrapping the toggles a test selects. */ +.codex-main-hard-lock-break { flex: 1 0 100%; height: 0; } .codex-main-hard-lock-setting > .toggle { flex: 0 0 auto; margin-left: auto; } .codex-main-hard-lock-feedback { flex: 1 0 100%; min-width: 0; } .codex-main-hard-lock-feedback:empty { display: none; } diff --git a/gui/tests/main-account-hard-lock-setting.test.tsx b/gui/tests/main-account-hard-lock-setting.test.tsx index 24e79f7e574..72eb270acf3 100644 --- a/gui/tests/main-account-hard-lock-setting.test.tsx +++ b/gui/tests/main-account-hard-lock-setting.test.tsx @@ -258,6 +258,32 @@ describe("main account protection setting", () => { expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); expect(testWindow.document.activeElement).toBe(toggle(host)); }); + + test("the credit-override switch reflects the server and saves its own field", async () => { + const bodies: unknown[] = []; + const host = await mount((async (_input, init) => { + if (init?.method === "PUT") { + bodies.push(JSON.parse(String(init.body))); + return response({ ok: true, ...settings(true, "blocked"), codexMainAccountCreditsOverrideHardLock: true }); + } + return response({ ...settings(true, "blocked"), codexMainAccountCreditsOverrideHardLock: false }); + }) as typeof fetch); + const switches = host.querySelectorAll("#codex-main-hard-lock-setting > .toggle"); + expect(switches).toHaveLength(2); + expect(switches[1]!.getAttribute("aria-pressed")).toBe("false"); + await click(switches[1]!); + expect(bodies).toEqual([{ codexMainAccountCreditsOverrideHardLock: true }]); + expect(switches[1]!.getAttribute("aria-pressed")).toBe("true"); + expect(host.textContent).toContain("Credit override is on"); + }); + + test("an older proxy without the override key renders the switch off, not errored", async () => { + const host = await mount((async () => response(settings(true, "ready"))) as typeof fetch); + const switches = host.querySelectorAll("#codex-main-hard-lock-setting > .toggle"); + expect(switches).toHaveLength(2); + expect(switches[1]!.getAttribute("aria-pressed")).toBe("false"); + expect(switches[1]!.disabled).toBe(false); + }); }); function mainAccount(state: MainAccountHardLockStatus["state"]): CodexAccountEntry {