Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a76e928
feat(accounts): opt in to auto-pause permanent account refusals
lidge-jun Oct 15, 2026
eb374a3
fix(accounts): register pause route and extracted Responses owners
lidge-jun Oct 15, 2026
a2b3ad8
Merge remote-tracking branch 'origin/dev' into feat/7101-auto-pause-p…
lidge-jun Oct 15, 2026
d3e41de
Merge remote-tracking branch 'origin/dev' into HEAD
lidge-jun Oct 15, 2026
957b075
Merge remote-tracking branch 'origin/dev' into HEAD
lidge-jun Oct 15, 2026
d1b1960
Merge remote-tracking branch 'origin/dev' into feat/7101-auto-pause-p…
lidge-jun Oct 15, 2026
a3b3a6d
Merge remote-tracking branch 'origin/dev' into HEAD
lidge-jun Oct 16, 2026
1d64b3b
fix(accounts): inject refusal observer across transport leaf boundary
lidge-jun Oct 16, 2026
b36cfb0
Merge dev into permanent-refusal auto-pause and compose distributed k…
lidge-jun Oct 16, 2026
36e01f9
Preserve pause ownership through distributed revisions and pacing rew…
lidge-jun Oct 16, 2026
b8d84a1
test(responses): cover replay ownership after auto-pause rotation
lidge-jun Oct 16, 2026
1be259c
test(responses): supply the replay conversation header
lidge-jun Oct 16, 2026
8a46924
fix(responses): rebind replay owners after auto-pause rotation
lidge-jun Oct 16, 2026
cbe6563
test(responses): require the exact sibling replay identity
lidge-jun Oct 16, 2026
16c1381
Merge dev into account auto-pause and retain compact gateway boundaries
lidge-jun Oct 16, 2026
af383c8
Merge origin/dev into feat/7101-auto-pause-permanent-refusals
lidge-jun Oct 17, 2026
bb6e6f1
fix(accounts): fence auto-pause against policy changes and address re…
lidge-jun Oct 17, 2026
6a2bf5c
fix(accounts): count auto-pause refusals only for the credential on t…
lidge-jun Oct 17, 2026
828defc
fix(accounts): report oauthDispatch final sends and fence WebSocket a…
lidge-jun Oct 17, 2026
1b75d31
fix(accounts): count a nested physical response once per account
lidge-jun Oct 17, 2026
9da3386
fix(accounts): keep live auto-pause fences across detached config loads
lidge-jun Oct 17, 2026
6222eb0
fix(accounts): fence observed disk policy changes across detached loads
lidge-jun Oct 17, 2026
04fefeb
fix(accounts): bind the Codex workspace into auto-pause ownership and…
lidge-jun Oct 17, 2026
33b81a2
Merge origin/dev into feat/7101-auto-pause-permanent-refusals
lidge-jun Oct 17, 2026
a7b05e9
fix(accounts): never re-enroll an observed-deleted provider from a st…
lidge-jun Oct 17, 2026
9e180ae
Merge origin/dev into feat/7101-auto-pause-permanent-refusals
lidge-jun Oct 17, 2026
06e146c
test(accounts): auto-pause attribution reads post-compression wire he…
lidge-jun Oct 17, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -795,7 +795,9 @@ Clear the manual Codex account selection without resolving an account id, so it

### `ocx account pause|resume <provider> <id|alias|main> [--json]`

Pause or resume one account in the Codex, Anthropic, or generic OAuth provider pool, including
Accounts paused by the opt-in [per-provider auto-pause policy](/reference/configuration/providers/#auto-pause-permanently-refused-accounts) use the same commands. Listing shows the fixed pause reason; resume clears it.

Pause or resume one account in the Codex, Anthropic, generic OAuth, or API-key provider pool, including
`google-antigravity`. For the Codex pool, `main` identifies only the built-in Codex account;
OAuth accounts must be identified by id or a unique alias. A paused OAuth account
is excluded from request selection, 429 failover, and proactive token refresh, and cannot be
Expand Down
39 changes: 39 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1904,3 +1904,42 @@ original reasoning. Other model families require the preserve declaration.
Tool-enabled DeepSeek requests also require reasoning on earlier assistant answers without tool
calls. The configured placeholder fills these omissions; no original reasoning can be recovered
from the tool-call cache for those turns. Preserve their supplied reasoning for full fidelity.

## Auto-pause permanently refused accounts

`providers.<provider>.accountAutoPause` is opt-in and defaults to off. It applies to that
provider's OAuth accounts, Codex pool accounts (including the main account in pool mode),
and configured API-key pool entries. For example:

```sh
ocx config set providers.anthropic.accountAutoPause.enabled true
ocx config set providers.anthropic.accountAutoPause.consecutiveFailures 2
```

`consecutiveFailures` is an integer from 1 to 100; omitting it uses 2. Only exact,
account-scoped HTTP 401/403 refusals qualify: an organization forbidding OAuth, an inactive
or expired subscription, an account denied API/Claude Code access, or a downgrade to a free
plan with no credits. A model permission error, expired access token, quota window, 429,
5xx, malformed response or network failure never pauses an account.

The counter is process-local, serialized per account, and resets on a successful HTTP
response, explicit resume or a new login. Token refresh alone does not reset an OAuth
account's streak. Transient errors leave the counter unchanged. Restarting the proxy clears
counters; completed pauses, their fixed reason codes and timestamps persist. Independent
proxy processes maintain independent counters. Changing, disabling or re-enabling the policy, disabling the provider,
or changing its endpoint discards in-flight refusal observations, so a refusal from a request
sent under the earlier policy can never pause an account afterwards. A refusal counts only for the credential that actually
reached the upstream: when a local plugin rewrites the credential or destination of a send, that
send never counts toward a pause. For Codex pool accounts this includes the workspace
(`chatgpt-account-id`) the request was sent for.

At the threshold, OpenCodex excludes the account using its existing pause mechanism and
attempts an eligible sibling for the current request within the existing retry/send budget.
Fixed Codex account selectors and account-bound uploaded files retain their restrictions.
A pause never cancels already dispatched work or changes credentials. It emits one generic
log line without account identifiers or upstream error text.

`ocx account list <provider>` shows the pause reason. The dashboard's Paused badge exposes
the reason and time. Use `ocx account resume <provider> <account-id-or-alias>` to clear the
reason and allow selection again. On an API-key row, clicking Resume also activates the key.
This policy never resumes an account automatically.
20 changes: 20 additions & 0 deletions gui/src/account-pause-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { AccountPauseReason } from "../../src/types/account-auto-pause";
import type { Locale, TFn, TKey } from "./i18n/shared";

const REASON_KEYS = {
oauth_organization_disabled: "accountPause.reason.organizationDisabled",
account_permission_denied: "accountPause.reason.permissionDenied",
subscription_inactive: "accountPause.reason.subscriptionInactive",
subscription_expired: "accountPause.reason.subscriptionExpired",
plan_downgraded: "accountPause.reason.planDowngraded",
} satisfies Record<AccountPauseReason, TKey>;

/** Display fixed reason labels and timestamps in the selected dashboard language. */
export function formatAccountPauseTooltip(
t: TFn, locale: Locale, details: { reason: string; pausedAt: number },
): string {
const reason = Object.hasOwn(REASON_KEYS, details.reason)
? t(REASON_KEYS[details.reason as AccountPauseReason]) : t("codexAuth.paused");
const date = new Date(details.pausedAt);
return Number.isNaN(date.getTime()) ? reason : t("accountPause.tooltip", { reason, date: date.toLocaleString(locale) });
}
3 changes: 2 additions & 1 deletion gui/src/components/codex-account-pool-cards.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { formatAccountPauseTooltip } from "../account-pause-display";
import { useT, useI18n } from "../i18n/shared";
import { useCopyFeedback } from "./use-copy-feedback";
import { IconAlert, IconPause, IconPlay, IconX } from "../icons";
Expand Down Expand Up @@ -118,7 +119,7 @@ export function CodexAccountPoolCards({
</span>
)}
{a.paused && (
<span className="badge badge-muted" title={t("codexAuth.pausedHint")}>
<span className="badge badge-muted" title={a.pauseDetails ? formatAccountPauseTooltip(t, locale, a.pauseDetails) : t("codexAuth.pausedHint")}>
{t("codexAuth.paused")}
</span>
)}
Expand Down
11 changes: 7 additions & 4 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { anthropicInstanceRowShapeMatches, isAnthropicInstanceId } from "../../.
* handlers via props-down; no internal auth machinery.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n/shared";
import { useT, useI18n } from "../../i18n/shared";
import { formatAccountPauseTooltip } from "../../account-pause-display";
import { IconLock, IconRefresh, IconTrash } from "../../icons";
import type { WorkspaceItem } from "../../provider-workspace/catalog";
import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth";
Expand Down Expand Up @@ -201,6 +202,7 @@ export default function ProviderAuthPanel({
codexController?: CodexAccountPoolController;
}) {
const t = useT();
const { locale } = useI18n();
const [addingKey, setAddingKey] = useState(false);
const [kiroChooser, setKiroChooser] = useState<{ addAccount: boolean } | null>(null);
const kiroLoginTriggerRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -589,7 +591,7 @@ export default function ProviderAuthPanel({
)}
{showReauth && !healthLabel && <span className="badge badge-amber">{t("pws.reauth")}</span>}
{kiroSkipReasonKey(account, item.name) && <span className="badge badge-amber">{t(kiroSkipReasonKey(account, item.name)!)}</span>}
{account.paused && <span className="badge badge-muted">{t("codexAuth.paused")}</span>}
{account.paused && <span className="badge badge-muted" title={account.pauseDetails ? formatAccountPauseTooltip(t, locale, account.pauseDetails) : undefined}>{t("codexAuth.paused")}</span>}
{account.active && !account.paused && <span className="badge badge-primary">{t("prov.accountActive")}</span>}
{switching && <span className="badge badge-muted">{t("pws.accountSwitching")}</span>}
</button>
Expand Down Expand Up @@ -722,13 +724,14 @@ export default function ProviderAuthPanel({
<div className={`pwi-auth-row${entry.active ? " pwi-auth-row--active" : ""}`}>
<button type="button" className="pwi-auth-row-main"
onClick={() => void authHandlers.onSwitchApiKey(item.name, entry)}
disabled={entry.active}>
disabled={entry.active && !entry.disabled} aria-label={entry.disabled ? `${t("codexAuth.resume")} — ${entry.label ?? entry.masked}` : undefined}>
<span className={`pwi-auth-dot ${entry.active ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
<span className="pwi-auth-row-copy">
<span className="pwi-auth-row-label">{entry.label ?? entry.masked}</span>
{entry.label && <code className="pwi-auth-row-secondary">{entry.masked} · {t("prov.accountId")}: {entry.id}</code>}
</span>
{entry.active && <span className="badge badge-primary">{t("prov.accountActive")}</span>}
{entry.disabled && <span className="badge badge-muted" title={entry.pauseDetails ? formatAccountPauseTooltip(t, locale, entry.pauseDetails) : undefined}>{t("codexAuth.paused")} · {t("codexAuth.resume")}</span>}
{entry.active && !entry.disabled && <span className="badge badge-primary">{t("prov.accountActive")}</span>}
</button>
<button type="button" className="btn btn-ghost btn-sm"
onClick={() => void authHandlers.onEditAlias(item.name, "api-key", entry.id, entry.label)}>
Expand Down
3 changes: 3 additions & 0 deletions gui/src/components/provider-workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type OAuthAccountRow = AccountQuotaReading & {
autoSelectable?: boolean;
skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted";
paused?: boolean;
pauseDetails?: { reason: string; pausedAt: number };
autoSwitchThresholdOverride?: number | null;
autoSwitchThreshold?: number;
effectiveAutoSwitchThreshold?: number;
Expand All @@ -73,6 +74,8 @@ export type OAuthAccountRow = AccountQuotaReading & {
};

export type ApiKeyRow = AccountQuotaReading & {
disabled?: boolean;
pauseDetails?: { reason: string; pausedAt: number };
id: string;
label?: string;
masked: string;
Expand Down
1 change: 1 addition & 0 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface CodexAccountEntry {
isMain: boolean;
/** Persisted routing exclusion. Paused accounts remain visible but cannot be selected. */
paused: boolean;
pauseDetails?: { reason: string; pausedAt: number };
/** Selection order; higher is used earlier. Always present, 0 when unset. */
priority: number;
/** Null inherits global threshold; 0 disables usage-driven switching for this account. */
Expand Down
16 changes: 12 additions & 4 deletions gui/src/hooks/useProviderAccountPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface OAuthAccount extends AccountQuotaReading {
autoSelectable?: boolean;
skipReason?: "needs_reauth" | "paused" | "suspended" | "cooldown" | "quota_exhausted";
paused?: boolean;
pauseDetails?: { reason: string; pausedAt: number };
autoSwitchThresholdOverride?: number | null;
autoSwitchThreshold?: number;
effectiveAutoSwitchThreshold?: number;
Expand All @@ -33,7 +34,7 @@ export interface OAuthAccount extends AccountQuotaReading {
healthSummary?: string;
healthAction?: string;
}
export interface ApiKeyEntry extends AccountQuotaReading { id: string; label?: string; masked: string; active: boolean }
export interface ApiKeyEntry extends AccountQuotaReading { id: string; label?: string; masked: string; active: boolean; disabled?: boolean; pauseDetails?: { reason: string; pausedAt: number } }
export interface AccountSelectionTarget { provider: string; kind: "oauth" | "api-key" }

function selectionRows<T extends { id: string; active: boolean }>(rows: T[], id: string | null | undefined): T[] {
Expand Down Expand Up @@ -549,17 +550,24 @@ export function useProviderAccountPools(deps: {
};

const switchApiKey = async (provider: string, entry: ApiKeyEntry) => {
if (entry.active || selectionMutationsRef.current.has(`key:${provider}`)) return;
if (entry.active && !entry.disabled || selectionMutationsRef.current.has(`key:${provider}`)) return;
const key = invalidateSelectionReads(provider, "api-key");
const mutation = Symbol();
selectionMutationsRef.current.set(key, mutation);
const currentMutation = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase && selectionMutationsRef.current.get(key) === mutation;
let resumed = false;
try {
if (entry.disabled) {
const resumeResponse = await fetch(`${apiBase}/api/providers/keys/pause`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: provider, id: entry.id, paused: false }) });
if (!currentMutation()) return;
if (!resumeResponse.ok) { notify(t("prov.keySwitchFail"), false); return; }
resumed = true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const res = await fetch(`${apiBase}/api/providers/keys/active`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: provider, id: entry.id }) });
if (!currentMutation()) return;
if (!res.ok) {
const failure = await res.json().catch(() => ({})) as { error?: string };
if (currentMutation()) notify(failure.error || t("prov.keySwitchFail"), false);
if (currentMutation()) notify(resumed ? t("prov.keyResumedSwitchFailed") : failure.error || t("prov.keySwitchFail"), false);
return;
}
const data = await res.json().catch(() => ({})) as { activeId?: string | null };
Expand All @@ -570,7 +578,7 @@ export function useProviderAccountPools(deps: {
notify(t("prov.keySwitched", { key: entry.label ?? entry.masked }), true);
void fetchProviderQuotas(true);
} catch {
if (currentMutation()) notify(t("prov.keySwitchFail"), false);
if (currentMutation()) notify(t(resumed ? "prov.keyResumedSwitchFailed" : "prov.keySwitchFail"), false);
} finally {
if (currentMutation()) {
invalidateSelectionReads(provider, "api-key");
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 @@ -1977,6 +1977,13 @@ export const de: Record<TKey, string> = {
"codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar",
"codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.",
"codexAuth.resumeFailed": "{email} konnte nicht fortgesetzt werden. Es wurde nichts geändert.",
"accountPause.tooltip": "{reason} · {date}",
"accountPause.reason.organizationDisabled": "Organisation deaktiviert",
"accountPause.reason.permissionDenied": "Kontoberechtigung verweigert",
"accountPause.reason.subscriptionInactive": "Abonnement inaktiv",
"accountPause.reason.subscriptionExpired": "Abonnement abgelaufen",
"accountPause.reason.planDowngraded": "Tarif herabgestuft",
"prov.keyResumedSwitchFailed": "Schlüssel fortgesetzt, aber Aktivierung fehlgeschlagen. Der Schlüssel kann automatisch ausgewählt werden. Wählen Sie ihn erneut aus.",
"codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.",
"codexAuth.creditsAfterLimit": "Credits nach dem Limit nutzen",
"codexAuth.creditsAfterLimitHint": "Standardmäßig aus: Ein Konto, dessen Nutzungsfenster 100 % erreicht, wird bis zum Zurücksetzen ausgewechselt, sodass seine ChatGPT-Credits nicht verbraucht werden. Schalte ein Konto ein, damit es mit seinen Credits weiterarbeitet. Neue Konten starten ausgeschaltet.",
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 @@ -2599,6 +2599,13 @@ export const en = {
"codexAuth.resumeSucceeded": "{email} is available to the pool again",
"codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.",
"codexAuth.resumeFailed": "Could not resume {email}. Nothing was changed.",
"accountPause.tooltip": "{reason} · {date}",
"accountPause.reason.organizationDisabled": "Organization disabled",
"accountPause.reason.permissionDenied": "Account permission denied",
"accountPause.reason.subscriptionInactive": "Subscription inactive",
"accountPause.reason.subscriptionExpired": "Subscription expired",
"accountPause.reason.planDowngraded": "Plan downgraded",
"prov.keyResumedSwitchFailed": "Key resumed, but activation failed. The key is eligible for automatic selection. Try selecting it again.",
"codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.",
"codexAuth.creditsAfterLimit": "Use credits after limit",
"codexAuth.creditsAfterLimitHint": "Off by default: an account whose usage window reaches 100% is switched out until it resets, so its ChatGPT credits are not spent. Turn an account on to let it keep working from its credits. New accounts start off.",
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2526,6 +2526,13 @@ export const fr: Record<TKey, string> = {
"codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe",
"codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.",
"codexAuth.resumeFailed": "Impossible de réactiver {email}. Aucune modification apportée.",
"accountPause.tooltip": "{reason} · {date}",
"accountPause.reason.organizationDisabled": "Organisation désactivée",
"accountPause.reason.permissionDenied": "Accès au compte refusé",
"accountPause.reason.subscriptionInactive": "Abonnement inactif",
"accountPause.reason.subscriptionExpired": "Abonnement expiré",
"accountPause.reason.planDowngraded": "Offre rétrogradée",
"prov.keyResumedSwitchFailed": "Clé réactivée, mais la sélection a échoué. La clé peut être sélectionnée automatiquement. Essayez de la sélectionner à nouveau.",
"codexAuth.pausedHint": "Exclu du changement automatique, des nouvelles tentatives, de la récupération après délai et de la sélection manuelle jusqu’à sa réactivation.",
"codexAuth.creditsAfterLimit": "Utiliser les crédits après la limite",
"codexAuth.creditsAfterLimitHint": "Désactivé par défaut : un compte dont une fenêtre d’utilisation atteint 100 % est remplacé jusqu’à sa réinitialisation, et ses crédits ChatGPT ne sont pas dépensés. Activez un compte pour qu’il continue avec ses crédits. Les nouveaux comptes démarrent désactivés.",
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2421,6 +2421,13 @@ export const ja: Record<TKey, string> = {
"codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました",
"codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。",
"codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。",
"accountPause.tooltip": "{reason} · {date}",
"accountPause.reason.organizationDisabled": "組織が無効",
"accountPause.reason.permissionDenied": "アカウントの権限が拒否されました",
"accountPause.reason.subscriptionInactive": "サブスクリプションが無効",
"accountPause.reason.subscriptionExpired": "サブスクリプションの期限切れ",
"accountPause.reason.planDowngraded": "プランがダウングレードされました",
"prov.keyResumedSwitchFailed": "キーの一時停止は解除されましたが、有効化に失敗しました。自動選択は可能です。もう一度選択してください。",
"codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。",
"codexAuth.creditsAfterLimit": "上限後にクレジットを使用",
"codexAuth.creditsAfterLimitHint": "既定はオフです。使用量ウィンドウが100%に達したアカウントはリセットまで別のアカウントに切り替わり、ChatGPTクレジットを消費しません。オンにしたアカウントだけが上限後もクレジットで使われます。新しいアカウントはオフで始まります。",
Expand Down
Loading
Loading