@bitkyc08/opencodex 2.55.0 → 2.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Per-account cooldown for a stored Codex pool credential whose forced refresh
|
|
4
|
+
* failed without proving the grant is dead.
|
|
5
|
+
*
|
|
6
|
+
* A token-endpoint 5xx, a generation CAS loss, or a network blip is transient
|
|
7
|
+
* (#2887): it must not quarantine the account or drop its binding. Retrying the
|
|
8
|
+
* same doomed refresh on every request, though, is how a single unhealthy
|
|
9
|
+
* account pinned the pool at 503 while healthy siblings sat idle. Consecutive
|
|
10
|
+
* non-terminal failures open a bounded growing cooldown; during that window no
|
|
11
|
+
* new forced refresh starts, and selection prefers a sibling. The first
|
|
12
|
+
* successful refresh clears it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fallbackCodexAccountLogLabel } from "./account-label";
|
|
16
|
+
|
|
17
|
+
export const CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON = "codex_pool_refresh_incomplete";
|
|
18
|
+
|
|
19
|
+
/** Growing delays between forced-refresh attempts for one account. */
|
|
20
|
+
export const CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
|
|
21
|
+
|
|
22
|
+
export class CodexPoolRefreshCooldownError extends Error {
|
|
23
|
+
readonly retryable = true;
|
|
24
|
+
readonly code = "CODEX_REFRESH_COOLING";
|
|
25
|
+
|
|
26
|
+
constructor(message = "Codex credential refresh is cooling down") {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CodexPoolRefreshCooldownError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type RefreshFailureBackoff = {
|
|
33
|
+
consecutiveFailures: number;
|
|
34
|
+
cooldownUntil: number;
|
|
35
|
+
reason: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const backoffByAccount = new Map<string, RefreshFailureBackoff>();
|
|
39
|
+
/**
|
|
40
|
+
* Bumped whenever an account's failures are cleared because something proved them obsolete — a
|
|
41
|
+
* successful refresh, or a replacement credential written by login/reauth. A refresh flight that
|
|
42
|
+
* started before that moment is reporting on a grant that no longer exists, and its late failure
|
|
43
|
+
* must not re-quarantine the credential that replaced it.
|
|
44
|
+
*/
|
|
45
|
+
const fenceByAccount = new Map<string, number>();
|
|
46
|
+
/**
|
|
47
|
+
* Invalidates every account fence without having to know which refresh flights are currently in
|
|
48
|
+
* progress. A bulk routing-state reset can race a first failure for an account that has no map
|
|
49
|
+
* entry yet, so iterating either map cannot close this boundary.
|
|
50
|
+
*/
|
|
51
|
+
let globalFence = 0;
|
|
52
|
+
let nowOverride: number | undefined;
|
|
53
|
+
|
|
54
|
+
export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
|
|
55
|
+
nowOverride = now;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resetCodexPoolRefreshFailureBackoffForTests(): void {
|
|
59
|
+
backoffByAccount.clear();
|
|
60
|
+
fenceByAccount.clear();
|
|
61
|
+
globalFence = 0;
|
|
62
|
+
nowOverride = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The value a refresh flight captures before it starts, to be handed back on failure. */
|
|
66
|
+
export function codexPoolRefreshFence(accountId: string): string {
|
|
67
|
+
return `${globalFence}:${fenceByAccount.get(accountId) ?? 0}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearCodexPoolRefreshFailure(accountId: string): void {
|
|
71
|
+
backoffByAccount.delete(accountId);
|
|
72
|
+
fenceByAccount.set(accountId, (fenceByAccount.get(accountId) ?? 0) + 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drop every remembered failure. Called when the routing layer discards its per-account state,
|
|
77
|
+
* because a cooldown outliving the binding it was learned alongside would keep an account out of
|
|
78
|
+
* selection for a roster the operator has already replaced.
|
|
79
|
+
*/
|
|
80
|
+
export function clearAllCodexPoolRefreshFailures(): void {
|
|
81
|
+
backoffByAccount.clear();
|
|
82
|
+
fenceByAccount.clear();
|
|
83
|
+
globalFence += 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function currentNow(now?: number): number {
|
|
87
|
+
return now ?? nowOverride ?? Date.now();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function delayFor(consecutiveFailures: number): number {
|
|
91
|
+
const index = Math.min(Math.max(consecutiveFailures, 1), CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS.length) - 1;
|
|
92
|
+
return CODEX_POOL_REFRESH_FAILURE_BACKOFF_MS[index]!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many consecutive non-terminal failures must land before a refresh is WITHHELD.
|
|
97
|
+
*
|
|
98
|
+
* Withholding on the first failure was wrong twice over. A single token-endpoint blip is the
|
|
99
|
+
* ordinary case that the very next attempt clears, and -- worse -- a withheld refresh never runs,
|
|
100
|
+
* so an account whose grant is actually revoked can no longer discover that: the terminal 401 it
|
|
101
|
+
* owes the operator turns into a retryable 503 that never resolves. The cooldown exists for the
|
|
102
|
+
* account that keeps failing, not for the one that failed once.
|
|
103
|
+
*/
|
|
104
|
+
export const CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES = 3;
|
|
105
|
+
|
|
106
|
+
export function getCodexPoolRefreshCooldownUntil(accountId: string, now = currentNow()): number | null {
|
|
107
|
+
const entry = backoffByAccount.get(accountId);
|
|
108
|
+
if (!entry) return null;
|
|
109
|
+
if (entry.consecutiveFailures < CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES) return null;
|
|
110
|
+
return entry.cooldownUntil > now ? entry.cooldownUntil : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isCodexPoolRefreshCooling(accountId: string, now = currentNow()): boolean {
|
|
114
|
+
return getCodexPoolRefreshCooldownUntil(accountId, now) !== null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Record a non-terminal forced-refresh failure. Already-cooling accounts do not
|
|
119
|
+
* grow the window: growth requires another real attempt after the previous one
|
|
120
|
+
* expired. Logs the classified reason once per account per window, with the
|
|
121
|
+
* durable hash label — never a token and never an email.
|
|
122
|
+
*/
|
|
123
|
+
export function noteCodexPoolRefreshFailure(
|
|
124
|
+
accountId: string,
|
|
125
|
+
reason: string,
|
|
126
|
+
now = currentNow(),
|
|
127
|
+
fence?: string,
|
|
128
|
+
): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
|
|
129
|
+
const existing = backoffByAccount.get(accountId);
|
|
130
|
+
// A flight that started before the account's failures were cleared is speaking for a grant
|
|
131
|
+
// that has since been replaced or proven healthy. Recording it would put the new credential
|
|
132
|
+
// back in the quarantine its predecessor earned.
|
|
133
|
+
if (fence !== undefined && fence !== codexPoolRefreshFence(accountId)) {
|
|
134
|
+
return {
|
|
135
|
+
consecutiveFailures: existing?.consecutiveFailures ?? 0,
|
|
136
|
+
cooldownUntil: existing?.cooldownUntil ?? 0,
|
|
137
|
+
openedWindow: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// The "do not grow inside an open window" rule applies only once the window is actually
|
|
141
|
+
// WITHHOLDING. Below the threshold no refresh is being withheld, so every failure is a real
|
|
142
|
+
// attempt that really failed and must count -- otherwise a client retrying the 503 once a
|
|
143
|
+
// second can never reach the threshold the cooldown is meant to protect against.
|
|
144
|
+
const withholding = existing !== undefined
|
|
145
|
+
&& existing.consecutiveFailures >= CODEX_POOL_REFRESH_COOLDOWN_AFTER_FAILURES;
|
|
146
|
+
if (existing && withholding && existing.cooldownUntil > now) {
|
|
147
|
+
return {
|
|
148
|
+
consecutiveFailures: existing.consecutiveFailures,
|
|
149
|
+
cooldownUntil: existing.cooldownUntil,
|
|
150
|
+
openedWindow: false,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
|
|
154
|
+
const cooldownUntil = now + delayFor(consecutiveFailures);
|
|
155
|
+
backoffByAccount.set(accountId, { consecutiveFailures, cooldownUntil, reason });
|
|
156
|
+
const label = fallbackCodexAccountLogLabel(accountId);
|
|
157
|
+
console.warn(
|
|
158
|
+
`[codex-auth] Codex pool account ${label} credential refresh failed (${reason})`,
|
|
159
|
+
);
|
|
160
|
+
return { consecutiveFailures, cooldownUntil, openedWindow: true };
|
|
161
|
+
}
|
|
@@ -8,8 +8,41 @@ const RESET_ELIGIBLE_CODE_VALUES = [
|
|
|
8
8
|
export type CodexResetEligibleExhaustionCode =
|
|
9
9
|
(typeof RESET_ELIGIBLE_CODE_VALUES)[number];
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Upstream codes that name an ORGANIZATION- or PROJECT-scoped exhaustion (#4546).
|
|
13
|
+
*
|
|
14
|
+
* These are a different animal from the reset-eligible codes above, and the difference is the
|
|
15
|
+
* whole point. `usage_limit_exceeded` describes the account that was asked; another pool account
|
|
16
|
+
* carries its own plan allowance, so rotating to it is a real move. Every code here describes a
|
|
17
|
+
* limit the CREDENTIAL does not own -- a balance, a spend cap, or a usage cap held by the
|
|
18
|
+
* organization or project the credential belongs to. Two credentials inside that organization
|
|
19
|
+
* are refused by the same counter, so rotating between them pays a cold prompt prefix for zero
|
|
20
|
+
* new capacity, which is the send amplification this unit exists to stop.
|
|
21
|
+
*
|
|
22
|
+
* openai/codex reached the same classification from the client side: #44492 maps exactly these
|
|
23
|
+
* HTTP 429 codes to a terminal `QuotaExceeded` instead of a retry-limit failure, and #45602
|
|
24
|
+
* extends it to the SSE path while deliberately KEEPING `rate_limit_exceeded` and `slow_down`
|
|
25
|
+
* retryable. The platform documentation states the same rule for the whole class: "It does not
|
|
26
|
+
* mean that quota, billing, or other errors that require user action can be resolved by
|
|
27
|
+
* retrying."
|
|
28
|
+
*
|
|
29
|
+
* Membership here says nothing about reset credits. A reset credit reconciles a ChatGPT plan
|
|
30
|
+
* window; it cannot pay an organization's bill, so these codes are deliberately absent from
|
|
31
|
+
* {@link RESET_ELIGIBLE_CODE_VALUES} and never set `resetCreditEligible`.
|
|
32
|
+
*/
|
|
33
|
+
const SCOPED_EXHAUSTION_CODE_VALUES = [
|
|
34
|
+
"credit_balance_exhausted",
|
|
35
|
+
"organization_spend_limit_exceeded",
|
|
36
|
+
"project_spend_limit_exceeded",
|
|
37
|
+
"organization_usage_limit_exceeded",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type CodexScopedExhaustionCode =
|
|
41
|
+
(typeof SCOPED_EXHAUSTION_CODE_VALUES)[number];
|
|
42
|
+
|
|
11
43
|
export type CodexPreStreamRejectionKind =
|
|
12
44
|
| "reset-eligible-exhaustion"
|
|
45
|
+
| "scoped-quota-exhaustion"
|
|
13
46
|
| "generic-rate-limit"
|
|
14
47
|
| "unverified-billing-or-quota"
|
|
15
48
|
| "transient-server-error"
|
|
@@ -23,6 +56,12 @@ export interface CodexPreStreamRejection {
|
|
|
23
56
|
alternateRetryEligible: boolean;
|
|
24
57
|
resetCreditEligible: boolean;
|
|
25
58
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
59
|
+
/**
|
|
60
|
+
* The organization- or project-scoped exhaustion code the upstream body named, when it named
|
|
61
|
+
* one. Never accompanied by `semanticCode`: the two sets are disjoint, and only `semanticCode`
|
|
62
|
+
* may authorize a reset credit.
|
|
63
|
+
*/
|
|
64
|
+
scopedExhaustionCode?: CodexScopedExhaustionCode;
|
|
26
65
|
/**
|
|
27
66
|
* Structured denial evidence for a 403. Present only when the upstream body names a
|
|
28
67
|
* workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
|
|
@@ -97,6 +136,7 @@ function structuredDenialCode(payload: unknown): string | undefined {
|
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
|
|
139
|
+
const SCOPED_EXHAUSTION_CODES: ReadonlySet<string> = new Set(SCOPED_EXHAUSTION_CODE_VALUES);
|
|
100
140
|
|
|
101
141
|
const TRANSIENT_SERVER_STATUSES = new Set([500, 502, 503, 504, 520, 521, 522]);
|
|
102
142
|
const JSON_NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
@@ -107,6 +147,7 @@ function rejection(
|
|
|
107
147
|
options: {
|
|
108
148
|
alternateRetryEligible?: boolean;
|
|
109
149
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
150
|
+
scopedExhaustionCode?: CodexScopedExhaustionCode;
|
|
110
151
|
} = {},
|
|
111
152
|
): CodexPreStreamRejection {
|
|
112
153
|
return {
|
|
@@ -115,6 +156,7 @@ function rejection(
|
|
|
115
156
|
alternateRetryEligible: options.alternateRetryEligible === true,
|
|
116
157
|
resetCreditEligible: options.semanticCode !== undefined,
|
|
117
158
|
...(options.semanticCode ? { semanticCode: options.semanticCode } : {}),
|
|
159
|
+
...(options.scopedExhaustionCode ? { scopedExhaustionCode: options.scopedExhaustionCode } : {}),
|
|
118
160
|
};
|
|
119
161
|
}
|
|
120
162
|
|
|
@@ -210,9 +252,19 @@ function isUnsafeJsonDocument(text: string): boolean {
|
|
|
210
252
|
}
|
|
211
253
|
}
|
|
212
254
|
|
|
213
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Read the one exact, unambiguous code a container declares, and only if it is in `allowed`.
|
|
257
|
+
*
|
|
258
|
+
* Generic over the allowed set so the reset-eligible and organization-scoped classifications
|
|
259
|
+
* share one parser. They must: the strictness here -- a `code`/`type` pair that disagrees is
|
|
260
|
+
* rejected rather than resolved, and no trimming or case folding is applied -- is what keeps a
|
|
261
|
+
* near-miss from being read as an exact upstream code, and a second hand-written copy would
|
|
262
|
+
* drift away from that.
|
|
263
|
+
*/
|
|
264
|
+
function exactAllowedCode(
|
|
214
265
|
container: Record<string, unknown>,
|
|
215
|
-
|
|
266
|
+
allowed: ReadonlySet<string>,
|
|
267
|
+
): string | undefined {
|
|
216
268
|
const hasCode = hasOwnField(container, "code");
|
|
217
269
|
const hasType = hasOwnField(container, "type");
|
|
218
270
|
if (!hasCode && !hasType) return undefined;
|
|
@@ -226,49 +278,80 @@ function exactResetEligibleCode(
|
|
|
226
278
|
|
|
227
279
|
const value = hasCode ? code : type;
|
|
228
280
|
if (typeof value !== "string") return undefined;
|
|
229
|
-
return
|
|
230
|
-
? value as CodexResetEligibleExhaustionCode
|
|
231
|
-
: undefined;
|
|
281
|
+
return allowed.has(value) ? value : undefined;
|
|
232
282
|
}
|
|
233
283
|
|
|
234
|
-
function
|
|
284
|
+
function structuredAllowedCode(payload: unknown, allowed: ReadonlySet<string>): string | undefined {
|
|
235
285
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
236
286
|
const root = payload as Record<string, unknown>;
|
|
237
287
|
const hasRootDiscriminator = hasOwnField(root, "code") || hasOwnField(root, "type");
|
|
238
288
|
|
|
239
|
-
if (!hasOwnField(root, "error")) return
|
|
289
|
+
if (!hasOwnField(root, "error")) return exactAllowedCode(root, allowed);
|
|
240
290
|
if (hasRootDiscriminator) return undefined;
|
|
241
291
|
|
|
242
292
|
const nested = root.error;
|
|
243
293
|
if (!nested || typeof nested !== "object" || Array.isArray(nested)) return undefined;
|
|
244
|
-
return
|
|
294
|
+
return exactAllowedCode(nested as Record<string, unknown>, allowed);
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Parse one bounded body and classify its structured code against both sets at once.
|
|
299
|
+
*
|
|
300
|
+
* One read, because the caller holds a `Response` whose body may only be consumed once per
|
|
301
|
+
* clone and the two questions are asked about the same bytes.
|
|
302
|
+
*/
|
|
303
|
+
async function exhaustionCodeFromResponse(
|
|
248
304
|
response: Response,
|
|
249
305
|
signal?: AbortSignal,
|
|
250
|
-
): Promise<
|
|
306
|
+
): Promise<{
|
|
307
|
+
resetEligible?: CodexResetEligibleExhaustionCode;
|
|
308
|
+
scoped?: CodexScopedExhaustionCode;
|
|
309
|
+
}> {
|
|
251
310
|
try {
|
|
252
311
|
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
|
|
253
|
-
if (!body.displaySafe || body.truncated || !body.text.trim()) return
|
|
312
|
+
if (!body.displaySafe || body.truncated || !body.text.trim()) return {};
|
|
254
313
|
const payload = JSON.parse(body.text) as unknown;
|
|
255
314
|
// JSON.parse silently keeps the last duplicate key, making contradictory
|
|
256
315
|
// payloads order-dependent. Reject any duplicate at any object depth.
|
|
257
|
-
if (isUnsafeJsonDocument(body.text)) return
|
|
258
|
-
|
|
316
|
+
if (isUnsafeJsonDocument(body.text)) return {};
|
|
317
|
+
const resetEligible = structuredAllowedCode(payload, RESET_ELIGIBLE_CODES);
|
|
318
|
+
if (resetEligible !== undefined) {
|
|
319
|
+
return { resetEligible: resetEligible as CodexResetEligibleExhaustionCode };
|
|
320
|
+
}
|
|
321
|
+
const scoped = structuredAllowedCode(payload, SCOPED_EXHAUSTION_CODES);
|
|
322
|
+
return scoped === undefined ? {} : { scoped: scoped as CodexScopedExhaustionCode };
|
|
259
323
|
} catch {
|
|
260
324
|
// Classification must fail closed. A malformed, oversized, consumed, or
|
|
261
325
|
// cancelled body cannot authorize an irreversible reset-credit operation.
|
|
262
|
-
return
|
|
326
|
+
return {};
|
|
263
327
|
}
|
|
264
328
|
}
|
|
265
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The organization- or project-scoped exhaustion code this rejection names, if any.
|
|
332
|
+
*
|
|
333
|
+
* Exported for the account-rotation gate, which has to answer "may another pool account serve
|
|
334
|
+
* this?" before it has any reason to build a full classification. Fails closed to `undefined`:
|
|
335
|
+
* an unreadable, truncated, duplicate-keyed or aborted body leaves the caller's existing
|
|
336
|
+
* behaviour untouched, so only positive evidence can ever withhold a rotation.
|
|
337
|
+
*/
|
|
338
|
+
export async function codexScopedExhaustionCode(
|
|
339
|
+
response: Response,
|
|
340
|
+
options: { signal?: AbortSignal } = {},
|
|
341
|
+
): Promise<CodexScopedExhaustionCode | undefined> {
|
|
342
|
+
return (await exhaustionCodeFromResponse(response, options.signal)).scoped;
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
/**
|
|
267
346
|
* Classify an upstream Codex rejection before any response event is exposed.
|
|
268
347
|
*
|
|
269
348
|
* Only an exact structured exhaustion code on HTTP 429/402 is reset-eligible.
|
|
270
349
|
* Status alone and message text are intentionally insufficient. The broad
|
|
271
350
|
* alternate-account retry remains eligible for 429/402 to preserve #584.
|
|
351
|
+
*
|
|
352
|
+
* The one carve-out from that breadth is an organization- or project-scoped exhaustion
|
|
353
|
+
* ({@link SCOPED_EXHAUSTION_CODE_VALUES}), which reports `alternateRetryEligible: false`
|
|
354
|
+
* because every credential inside the refusing limit would be refused by the same counter.
|
|
272
355
|
*/
|
|
273
356
|
export async function classifyCodexPreStreamRejection(
|
|
274
357
|
response: Response,
|
|
@@ -283,13 +366,19 @@ export async function classifyCodexPreStreamRejection(
|
|
|
283
366
|
if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
|
|
284
367
|
if (status !== 429 && status !== 402) return rejection(status, "other");
|
|
285
368
|
|
|
286
|
-
const semanticCode = await
|
|
369
|
+
const { resetEligible: semanticCode, scoped } = await exhaustionCodeFromResponse(
|
|
370
|
+
response,
|
|
371
|
+
options.signal,
|
|
372
|
+
);
|
|
287
373
|
if (semanticCode) {
|
|
288
374
|
return rejection(status, "reset-eligible-exhaustion", {
|
|
289
375
|
alternateRetryEligible: true,
|
|
290
376
|
semanticCode,
|
|
291
377
|
});
|
|
292
378
|
}
|
|
379
|
+
if (scoped) {
|
|
380
|
+
return rejection(status, "scoped-quota-exhaustion", { scopedExhaustionCode: scoped });
|
|
381
|
+
}
|
|
293
382
|
return rejection(
|
|
294
383
|
status,
|
|
295
384
|
status === 429 ? "generic-rate-limit" : "unverified-billing-or-quota",
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { saveConfigPreservingClaudeCode } from "../../config";
|
|
2
|
+
import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority";
|
|
3
|
+
import {
|
|
4
|
+
POOL_KEY_CODEX,
|
|
5
|
+
normalizeCodexAccountPoolStrategy,
|
|
6
|
+
seedPoolRotationAccount,
|
|
7
|
+
} from "../pool-rotation";
|
|
8
|
+
import type { OcxConfig } from "../../types";
|
|
9
|
+
import { clearThreadAccountMap } from "./thread-affinity";
|
|
10
|
+
import {
|
|
11
|
+
NATIVE_MODEL_QUOTA_SCOPES,
|
|
12
|
+
codexPoolKeyForScope,
|
|
13
|
+
deleteAccountHealth,
|
|
14
|
+
deleteScopedHealth,
|
|
15
|
+
getAccountHealth,
|
|
16
|
+
isIndependentCodexQuotaScope,
|
|
17
|
+
listScopedHealthEntries,
|
|
18
|
+
preservedCooldownFields,
|
|
19
|
+
setAccountHealth,
|
|
20
|
+
setScopedHealth,
|
|
21
|
+
type CodexUpstreamHealth,
|
|
22
|
+
} from "./health-store";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Process-local cursor for automatic RR/fill-first (and quota-429 when not
|
|
26
|
+
* sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
|
|
27
|
+
* rotation as the operator's `activeCodexAccountId`. Manual selection clears it
|
|
28
|
+
* so disk/`config.activeCodexAccountId` remains authoritative.
|
|
29
|
+
*/
|
|
30
|
+
let runtimeActiveCodexAccountId: string | undefined;
|
|
31
|
+
|
|
32
|
+
/** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
|
|
33
|
+
export function resetCodexRoutingForManualSelection(accountId: string): void {
|
|
34
|
+
clearThreadAccountMap();
|
|
35
|
+
// Manual selection is the operator source of truth — drop any automatic runtime cursor.
|
|
36
|
+
runtimeActiveCodexAccountId = undefined;
|
|
37
|
+
// Record the pick as an unspent one-shot on the SHARED scope only. An independent scope
|
|
38
|
+
// gets no entry on purpose: every write site the guard protects is already skipped for
|
|
39
|
+
// independent scopes, so an entry there would be state nothing reads — and state nothing
|
|
40
|
+
// reads is what the next reader mistakes for a rule.
|
|
41
|
+
//
|
|
42
|
+
// Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
|
|
43
|
+
// or the pool would manufacture an operator intent nobody expressed.
|
|
44
|
+
manualPreference.set(POOL_KEY_CODEX, accountId);
|
|
45
|
+
// Seed the RR ring so the next unbound new session honors the manually selected account
|
|
46
|
+
// under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
|
|
47
|
+
// config.activeCodexAccountId, which the caller persists before invoking this.
|
|
48
|
+
seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
|
|
49
|
+
for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
|
|
50
|
+
if (isIndependentCodexQuotaScope(scope)) {
|
|
51
|
+
seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming
|
|
55
|
+
// this account has overruled it. The hard cooldown is the part that survives.
|
|
56
|
+
const overrule = (health: CodexUpstreamHealth) => {
|
|
57
|
+
const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health);
|
|
58
|
+
return retained;
|
|
59
|
+
};
|
|
60
|
+
const current = getAccountHealth(accountId);
|
|
61
|
+
if (current) {
|
|
62
|
+
const retained = overrule(current);
|
|
63
|
+
if (Object.keys(retained).length === 0) deleteAccountHealth(accountId);
|
|
64
|
+
else setAccountHealth(accountId, { consecutiveFailures: 0, ...retained });
|
|
65
|
+
}
|
|
66
|
+
// A reset-derived refusal records its avoidance on the SCOPED map and returns before the
|
|
67
|
+
// account-wide entry is written, so naming the account has to reach that map too. Stopping
|
|
68
|
+
// at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in
|
|
69
|
+
// the case that produces the avoidance this function exists to overrule.
|
|
70
|
+
for (const [scope, health] of [...(listScopedHealthEntries(accountId))]) {
|
|
71
|
+
const retained = overrule(health);
|
|
72
|
+
if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope);
|
|
73
|
+
else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Effective active: automatic runtime cursor, else operator/persisted selection. */
|
|
78
|
+
/**
|
|
79
|
+
* Unspent operator selections, keyed by pool scope.
|
|
80
|
+
*
|
|
81
|
+
* Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness
|
|
82
|
+
* cannot be detected by comparing values: a pool-driven promote legitimately moves the
|
|
83
|
+
* persisted active account, and reading that as staleness would silently spend the
|
|
84
|
+
* operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual
|
|
85
|
+
* selection, the account leaving the pool, or a successful dispatch on it.
|
|
86
|
+
*/
|
|
87
|
+
const manualPreference = new Map<string, string>();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Spend the one-shot for a pool scope once a dispatch on that account actually succeeded.
|
|
91
|
+
* This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks.
|
|
92
|
+
*
|
|
93
|
+
* Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in
|
|
94
|
+
* place and no consume site, the first manual selection freezes the automatic cursor
|
|
95
|
+
* permanently and 15 of 69 rotation tests fail.
|
|
96
|
+
*/
|
|
97
|
+
export function consumeManualPreference(accountId: string, poolKey: string): void {
|
|
98
|
+
if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Drop an account's preference in every scope. Pause and exclusion do not route through
|
|
103
|
+
* `resetCodexRoutingForManualSelection`, so without this a preference could outlive the
|
|
104
|
+
* account it names and keep suppressing the automatic cursor.
|
|
105
|
+
*/
|
|
106
|
+
export function forgetManualPreference(accountId: string): void {
|
|
107
|
+
for (const [poolKey, preferred] of manualPreference) {
|
|
108
|
+
if (preferred === accountId) manualPreference.delete(poolKey);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* True while an unspent operator selection for this scope names a DIFFERENT account than
|
|
114
|
+
* the automatic pick about to be recorded.
|
|
115
|
+
*
|
|
116
|
+
* Callers pass their own scope: an independent quota scope keeps its own entry and must
|
|
117
|
+
* never read the shared one. The failover promote does NOT consult this — see its call
|
|
118
|
+
* site for why.
|
|
119
|
+
*/
|
|
120
|
+
export function manualPreferenceBlocks(poolKey: string, accountId: string): boolean {
|
|
121
|
+
const preferred = manualPreference.get(poolKey);
|
|
122
|
+
return preferred !== undefined && preferred !== accountId;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
|
|
126
|
+
return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether the account routing is currently on is there because an operator asked
|
|
131
|
+
* for it, rather than because a strategy landed on it. Surfaces read this instead
|
|
132
|
+
* of comparing the stored pin themselves, which would report a pin that a later
|
|
133
|
+
* automatic pick has already moved past.
|
|
134
|
+
*/
|
|
135
|
+
export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
|
|
136
|
+
const pinned = pinnedCodexAccountId(config);
|
|
137
|
+
return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
|
|
142
|
+
* so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
|
|
143
|
+
*/
|
|
144
|
+
export function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
|
|
145
|
+
runtimeActiveCodexAccountId = accountId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* End the manual pin when routing moves to a different account. Returns whether
|
|
150
|
+
* the pin changed so the caller can fold it into a write it was already making.
|
|
151
|
+
*/
|
|
152
|
+
function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
|
|
153
|
+
const pinned = pinnedCodexAccountId(config);
|
|
154
|
+
if (pinned === undefined || pinned === accountId) return false;
|
|
155
|
+
clearCodexAccountPin(config);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Persist operator (or quota-strategy) active selection to config + disk. */
|
|
160
|
+
export function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
161
|
+
runtimeActiveCodexAccountId = undefined;
|
|
162
|
+
const releasedPin = releaseCodexAccountPinFor(config, accountId);
|
|
163
|
+
if (config.activeCodexAccountId === accountId && !releasedPin) return;
|
|
164
|
+
config.activeCodexAccountId = accountId;
|
|
165
|
+
saveConfigPreservingClaudeCode(config);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
|
|
169
|
+
export function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
170
|
+
if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
|
|
171
|
+
setActiveCodexAccount(config, accountId);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Runtime-only, like the cursor itself: a caller that persists (pause, delete)
|
|
175
|
+
// saves this release with its own write; a transient failover does not, so the
|
|
176
|
+
// pin survives a restart that also clears the failure history behind it.
|
|
177
|
+
releaseCodexAccountPinFor(config, accountId);
|
|
178
|
+
rememberActiveCodexAccount(config, accountId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function clearAllManualPreferences(): void {
|
|
182
|
+
manualPreference.clear();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function forgetRuntimeActiveCodexAccount(): void {
|
|
186
|
+
runtimeActiveCodexAccountId = undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function forgetRoutingPreferencesOutside(codexAccountIds: ReadonlySet<string>): void {
|
|
190
|
+
for (const [poolKey, preferred] of manualPreference) {
|
|
191
|
+
if (codexAccountIds.has(preferred)) continue;
|
|
192
|
+
manualPreference.delete(poolKey);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import type { CodexAccountUsabilityOptions } from "../account-usability";
|
|
3
|
+
import { isCodexAccountUsable } from "../account-usability";
|
|
4
|
+
import { isCacheAffinityEnabled, isUnknownUsage } from "./selection";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Does a healthy bound account keep its conversation when quota re-evaluation looks at it?
|
|
8
|
+
*
|
|
9
|
+
* Two independent reasons say yes, and they are not the same claim. `pool.cacheAffinity` is an
|
|
10
|
+
* operator preference about COST: provider prompt caches are account-isolated, so handing a bound
|
|
11
|
+
* conversation from account to account re-sends the whole prefix, and #4546 measured 7k-token
|
|
12
|
+
* turns becoming 150k-token ones. Setting it false restores capacity-first routing, and an
|
|
13
|
+
* operator who wants that keeps it.
|
|
14
|
+
*
|
|
15
|
+
* Uploaded-file retention is a claim about CORRECTNESS, so it does not take that instruction
|
|
16
|
+
* (#4778). Uploaded files are scoped to the account that issued them. Moving a conversation that
|
|
17
|
+
* carries live `file_id` references does not cost a cold prefix -- it orphans the reference, and
|
|
18
|
+
* because the reference stays in conversation history EVERY later turn is refused with
|
|
19
|
+
* `409 account_change_file_scope` until the user re-uploads under the serving account or starts
|
|
20
|
+
* over. That is a dead conversation rather than an expensive one, and `pool.cacheAffinity: false`
|
|
21
|
+
* was never asking to accept it: the flag trades cache locality for capacity, not correctness for
|
|
22
|
+
* capacity.
|
|
23
|
+
*
|
|
24
|
+
* This answers the VOLUNTARY move only. Its caller still releases the binding on genuine
|
|
25
|
+
* exhaustion or an unusable account, and every involuntary release that runs earlier in
|
|
26
|
+
* `resolveCodexAccountForThreadDetailed` -- quota refusal, failover streak, pause, cooldown, lost
|
|
27
|
+
* generation, affinity expiry -- never reaches here at all. So retention can never wedge a
|
|
28
|
+
* conversation on an account that cannot serve it, which is exactly why the #4710 refusal remains
|
|
29
|
+
* required: this makes that refusal rarer and does not replace it.
|
|
30
|
+
*
|
|
31
|
+
* It lives beside selection rather than inside `routing.ts` because it is a policy question two
|
|
32
|
+
* call sites ask -- the live path in `reevaluateAffinityQuota` and the side-effect-free
|
|
33
|
+
* `previewReusableAffinityAccount` that subagent fallback reads -- and those two must answer
|
|
34
|
+
* identically or preview hands fallback a different account than the request uses.
|
|
35
|
+
*/
|
|
36
|
+
export function retainsBoundAccountForQuota(
|
|
37
|
+
config: OcxConfig,
|
|
38
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
39
|
+
): boolean {
|
|
40
|
+
return isCacheAffinityEnabled(config)
|
|
41
|
+
|| selectionOptions?.retainAccountForUploadedFiles === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* May a LIVE binding be moved for quota reasons?
|
|
46
|
+
*
|
|
47
|
+
* Default: no. The bar is genuine exhaustion, because moving a bound conversation discards the
|
|
48
|
+
* prompt cache warmed on its account and a threshold crossing is a hint that the account is
|
|
49
|
+
* getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
|
|
50
|
+
* `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the old
|
|
51
|
+
* rule under a new name.
|
|
52
|
+
*
|
|
53
|
+
* When nothing retains, the historical rule comes back: a crossing of `autoSwitchThreshold` is
|
|
54
|
+
* enough. That is capacity-first routing, and an operator who asks for it keeps it -- it is just
|
|
55
|
+
* not what an install gets by never having heard of the flag.
|
|
56
|
+
*/
|
|
57
|
+
export function mayRebindAffinityForQuota(
|
|
58
|
+
config: OcxConfig,
|
|
59
|
+
accountId: string,
|
|
60
|
+
usage: number,
|
|
61
|
+
threshold: number,
|
|
62
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
63
|
+
): boolean {
|
|
64
|
+
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
65
|
+
if (!retainsBoundAccountForQuota(config, selectionOptions)) return overThreshold;
|
|
66
|
+
// The usable half is already guaranteed by both callers, which gate on
|
|
67
|
+
// isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
|
|
68
|
+
return !isCodexAccountUsable(config, accountId, selectionOptions)
|
|
69
|
+
|| (!isUnknownUsage(usage) && usage >= 100);
|
|
70
|
+
}
|