@bitkyc08/opencodex 2.76.0 → 2.77.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/gui/dist/assets/App-COdEeqSj.css +1 -0
- package/gui/dist/assets/App-DEpCJEP_.js +54 -0
- package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
- package/gui/dist/assets/index-Cd7ittMv.css +1 -0
- package/gui/dist/assets/index-DwJKFJm5.js +94 -0
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
- package/gui/dist/index.html +4 -3
- package/package.json +1 -1
- package/src/adapters/antigravity-refusal.ts +28 -0
- package/src/adapters/cursor/protobuf-request.ts +6 -0
- package/src/adapters/cursor/request-builder.ts +3 -0
- package/src/adapters/cursor/structured-output.ts +13 -0
- package/src/adapters/cursor/types.ts +2 -0
- package/src/adapters/ollama-native.ts +29 -2
- package/src/adapters/openai-responses/passthrough.ts +133 -103
- package/src/adapters/openai-responses/request-strips.ts +10 -4
- package/src/adapters/zed.ts +26 -4
- package/src/bridge/response-json.ts +14 -14
- package/src/bridge/sse.ts +15 -5
- package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
- package/src/claude/agents-inject.ts +4 -43
- package/src/claude/gateway-cache.ts +16 -13
- package/src/claude/inbound-model-options.ts +9 -2
- package/src/claude/intercept/cli-catalog.ts +27 -16
- package/src/claude/intercept/picker-bootstrap.ts +37 -16
- package/src/claude/intercept/picker-budget.ts +41 -0
- package/src/claude/outbound.ts +2 -0
- package/src/claude/subagent-force-status.ts +53 -0
- package/src/claude/subagent-model.ts +93 -0
- package/src/cli/account-api.ts +3 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/agent.ts +10 -0
- package/src/cli/capabilities.ts +9 -0
- package/src/cli/chatgpt-command.ts +10 -7
- package/src/cli/claude.ts +22 -6
- package/src/cli/codex-shim-readiness.ts +89 -0
- package/src/cli/connect.ts +19 -0
- package/src/cli/dispatch.ts +3 -3
- package/src/cli/export-command.ts +20 -9
- package/src/cli/gui.ts +8 -3
- package/src/cli/help-catalog.ts +84 -0
- package/src/cli/help-models-context.ts +17 -0
- package/src/cli/help-navigation.ts +50 -0
- package/src/cli/help-recovery.ts +66 -0
- package/src/cli/help.ts +52 -8
- package/src/cli/models-runtime.ts +2 -1
- package/src/cli/opencode.ts +54 -10
- package/src/cli/provider.ts +3 -29
- package/src/cli/registry.ts +29 -5
- package/src/cli/root.ts +30 -9
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/tray-proxy.ts +3 -2
- package/src/clients/config-export/contracts.ts +100 -0
- package/src/clients/config-export/droid.ts +54 -2
- package/src/clients/config-export/kilo.ts +12 -2
- package/src/clients/config-export/model-metadata.ts +10 -4
- package/src/clients/config-export/reasoning-metadata.ts +47 -0
- package/src/clients/config-export.ts +46 -66
- package/src/clients/omo-role-models.ts +17 -3
- package/src/codex/account-credit-use.ts +4 -2
- package/src/codex/account-runtime-state.ts +21 -0
- package/src/codex/account-store.ts +11 -47
- package/src/codex/account-usability.ts +11 -8
- package/src/codex/auth-api/account-list.ts +73 -63
- package/src/codex/auth-api/main-account-probe.ts +25 -12
- package/src/codex/auth-api/pool-mode-gate.ts +6 -9
- package/src/codex/auth-api/pool-quota-probe.ts +2 -4
- package/src/codex/auth-api/reset-credit-service.ts +3 -2
- package/src/codex/auth-context.ts +30 -11
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/model-hints.ts +2 -1
- package/src/codex/catalog/model-visibility.ts +6 -3
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-models.ts +13 -4
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/chatgpt-refresh-failure.ts +56 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/main-account.ts +144 -11
- package/src/codex/quota-refresh-outcome.ts +17 -2
- package/src/codex/quota-types.ts +33 -1
- package/src/codex/quota.ts +56 -15
- package/src/codex/routing/cooldown-math.ts +10 -4
- package/src/codex/routing/selection.ts +11 -4
- package/src/codex/routing.ts +9 -9
- package/src/codex/shim-path-resolution.ts +133 -0
- package/src/codex/shim.ts +39 -39
- package/src/codex/subagent-model-fallback.ts +2 -1
- package/src/combos/failover.ts +82 -1
- package/src/combos/jev.ts +13 -3
- package/src/config/diagnostics.ts +4 -0
- package/src/config/load-degrade.ts +4 -0
- package/src/config/provider-validation.ts +3 -0
- package/src/config/schema/compaction-triggers.ts +8 -0
- package/src/config/schema/config-schema.ts +11 -0
- package/src/config/schema/leaf-validators.ts +5 -1
- package/src/config/subagent-models.ts +12 -0
- package/src/generated/compatibility-version.json +245 -153
- package/src/grok/session-identity.ts +33 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +4 -2
- package/src/integrations/state.ts +68 -6
- package/src/integrations/writer.ts +18 -4
- package/src/lib/gui-pair-capability.ts +17 -0
- package/src/lib/provider-client-headers.ts +62 -0
- package/src/oauth/anthropic-continuity.ts +65 -0
- package/src/oauth/anthropic-identity.ts +75 -0
- package/src/oauth/anthropic-routing.ts +3 -3
- package/src/oauth/anthropic.ts +7 -1
- package/src/oauth/generic-account-failover.ts +21 -0
- package/src/oauth/health.ts +32 -5
- package/src/oauth/index.ts +36 -27
- package/src/oauth/local-token-detect.ts +7 -0
- package/src/oauth/refresh-policy.ts +2 -0
- package/src/oauth/store.ts +55 -6
- package/src/oauth/types.ts +9 -0
- package/src/protocols/encoders/messages.ts +4 -3
- package/src/providers/antigravity-effort-families.ts +75 -0
- package/src/providers/antigravity-models.ts +54 -16
- package/src/providers/antigravity-wire-snapshot.ts +66 -0
- package/src/providers/derive.ts +2 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/new-model-policy.ts +48 -10
- package/src/providers/registry/entries-extended.ts +3 -0
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/model-seeds.ts +5 -0
- package/src/providers/registry/types.ts +4 -0
- package/src/responses/compaction.ts +19 -0
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +9 -0
- package/src/server/chat-completions.ts +10 -0
- package/src/server/claude-messages.ts +18 -11
- package/src/server/droid-reasoning-default.ts +50 -0
- package/src/server/github-copilot-responses-repair.ts +3 -6
- package/src/server/grok-responses-control-frame.ts +2 -2
- package/src/server/grok-responses-snapshot-repair.ts +4 -5
- package/src/server/gui-session.ts +33 -11
- package/src/server/index/claude-intercept-lifecycle.ts +4 -0
- package/src/server/index/serve-options.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +6 -129
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/integration-routes.ts +64 -6
- package/src/server/management/link-routes.ts +20 -13
- package/src/server/management/model-row-export-metadata.ts +150 -0
- package/src/server/management/model-rows.ts +148 -9
- package/src/server/management/route-registry.ts +4 -3
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/subagent-model-routes.ts +214 -0
- package/src/server/relay.ts +14 -44
- package/src/server/request-log-generation-window.ts +38 -0
- package/src/server/request-log.ts +13 -0
- package/src/server/responses/adapter-dispatch.ts +100 -1
- package/src/server/responses/codex-auth-error.ts +10 -2
- package/src/server/responses/combo-stream-preflight.ts +29 -3
- package/src/server/responses/compaction-routing.ts +10 -1
- package/src/server/responses/core-auth.ts +57 -26
- package/src/server/responses/core-codex-account.ts +6 -3
- package/src/server/responses/core-combo-failure.ts +5 -1
- package/src/server/responses/core-combo-native.ts +7 -1
- package/src/server/responses/core-combo.ts +18 -1
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/fetch-helpers.ts +9 -0
- package/src/server/responses/passthrough-delivery.ts +1 -1
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/request-prepare.ts +5 -0
- package/src/server/responses/request-spend.ts +30 -30
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/responses-hosted-image-display.ts +70 -9
- package/src/server/responses-terminal-repair.ts +15 -2
- package/src/server/sse-frame-buffer.ts +38 -36
- package/src/server/sse-payload-rewrite.ts +78 -49
- package/src/server/ws-bridge.ts +1 -11
- package/src/service/windows-taskxml.ts +1 -2
- package/src/service-manager-probe.ts +36 -2
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +18 -0
- package/src/update/npm-invocation.mjs +35 -3
- package/src/update/proxy-liveness-probe.mjs +38 -8
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +7 -0
- package/src/usage/summary.ts +82 -1
- package/src/web-search/loop.ts +11 -8
- package/src/web-search/run-turn-loop.ts +10 -7
- package/gui/dist/assets/App-CddorHdA.js +0 -51
- package/gui/dist/assets/App-DMudtISW.css +0 -1
- package/gui/dist/assets/index-Btze1I3T.css +0 -1
- package/gui/dist/assets/index-CvnY7E6i.js +0 -86
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
|
|
2
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { readFileSync, statSync } from "node:fs";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { readCodexTokens } from "./auth-collision";
|
|
5
7
|
import {
|
|
8
|
+
CHATGPT_CLIENT_ID,
|
|
9
|
+
CHATGPT_TOKEN_URL,
|
|
10
|
+
credsFromToken,
|
|
6
11
|
decodeJwtPayload,
|
|
7
12
|
extractAccountId,
|
|
8
|
-
refreshChatGPTToken,
|
|
9
13
|
} from "../oauth/chatgpt";
|
|
10
14
|
import type { OAuthCredentials } from "../oauth/types";
|
|
11
15
|
import { extractChatgptPlanType } from "./plan";
|
|
@@ -17,7 +21,12 @@ import {
|
|
|
17
21
|
import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
|
|
18
22
|
import { resolveCodexHomeDir } from "./home";
|
|
19
23
|
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
clearAccountNeedsReauth,
|
|
26
|
+
isMainRefreshGrantRejected,
|
|
27
|
+
markMainRefreshGrantRejected,
|
|
28
|
+
clearMainRefreshGrantRejection,
|
|
29
|
+
} from "./account-runtime-state";
|
|
21
30
|
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
|
|
22
31
|
import { withNativeMainExclusiveClaim } from "./native-main-claim";
|
|
23
32
|
import { resolveNativeProfileContext } from "./native-profile-store";
|
|
@@ -78,6 +87,68 @@ export class MainAccountTokenRefreshError extends Error {
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The native-main refresh, posted here rather than through `refreshChatGPTToken` so the token
|
|
92
|
+
* endpoint's structured `error` code survives to {@link classifyChatgptRefreshFailure}.
|
|
93
|
+
*
|
|
94
|
+
* `refreshChatGPTToken` collapses the refusal into one display string, and classifying a grant
|
|
95
|
+
* from that string is what let `refresh_token_reused` pass as transient -- the grant never
|
|
96
|
+
* retired, every request re-earning its own 401 -- while a 5xx whose description merely read
|
|
97
|
+
* "session expired" passed as proof and would have retired a live grant.
|
|
98
|
+
*
|
|
99
|
+
* Same endpoint, client id and grant type as every other ChatGPT refresh, and the success path
|
|
100
|
+
* is `credsFromToken` itself, so only the failure branch differs: it classifies by code, exactly
|
|
101
|
+
* as the stored-pool refresh does.
|
|
102
|
+
*/
|
|
103
|
+
async function refreshNativeMainGrant(
|
|
104
|
+
refreshToken: string,
|
|
105
|
+
options: { signal: AbortSignal },
|
|
106
|
+
): Promise<OAuthCredentials> {
|
|
107
|
+
const resp = await fetch(CHATGPT_TOKEN_URL, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
110
|
+
body: new URLSearchParams({
|
|
111
|
+
grant_type: "refresh_token",
|
|
112
|
+
client_id: CHATGPT_CLIENT_ID,
|
|
113
|
+
refresh_token: refreshToken,
|
|
114
|
+
}).toString(),
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
});
|
|
117
|
+
if (!resp.ok) {
|
|
118
|
+
const body = await readBoundedResponseBody(resp, { signal: options.signal, maxBytes: 16_384, fatalUtf8: true })
|
|
119
|
+
.catch(() => undefined);
|
|
120
|
+
const failure = classifyChatgptRefreshFailure(resp.status, body?.displaySafe ? body.text : "");
|
|
121
|
+
noteChatgptRefreshFailure("native main", resp.status, failure);
|
|
122
|
+
throw new MainAccountTokenRefreshError(failure.reason === "unknown" ? "transient" : "reauth");
|
|
123
|
+
}
|
|
124
|
+
let credentials: OAuthCredentials;
|
|
125
|
+
try {
|
|
126
|
+
credentials = credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
127
|
+
} catch {
|
|
128
|
+
if (options.signal.aborted) throw options.signal.reason;
|
|
129
|
+
noteNativeMainRefresh("transient", resp.status);
|
|
130
|
+
// Parsing errors may include provider body text; retain only the fixed verdict.
|
|
131
|
+
throw new MainAccountTokenRefreshError("transient");
|
|
132
|
+
}
|
|
133
|
+
noteNativeMainRefresh("ok", resp.status);
|
|
134
|
+
return credentials;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One line per native-main refresh verdict, so a dead grant is diagnosable from service.log.
|
|
139
|
+
*
|
|
140
|
+
* A revoked session is invisible from the outside: the grant is refused, nothing is written, and
|
|
141
|
+
* the request fails with a status that looks like every other 401. Carries the token endpoint's
|
|
142
|
+
* HTTP status and its structured `error` code and nothing else -- never a token, never a body.
|
|
143
|
+
*/
|
|
144
|
+
function noteNativeMainRefresh(outcome: string, status?: number, code?: string): void {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[codex] native main refresh: ${outcome}`
|
|
147
|
+
+ (status === undefined ? "" : ` status=${status}`)
|
|
148
|
+
+ ` code=${code && /^[A-Za-z0-9._-]{1,64}$/.test(code) ? code : "none"}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
81
152
|
function nonEmptyString(value: unknown): string | undefined {
|
|
82
153
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
83
154
|
}
|
|
@@ -147,13 +218,45 @@ function mainAccessTokenFresh(accessToken: string | undefined, now: number, skew
|
|
|
147
218
|
}
|
|
148
219
|
|
|
149
220
|
/** A refresh grant makes native main routeable even when the current access token is expired. */
|
|
150
|
-
|
|
221
|
+
function mainRefreshGrantKey(current: MainAuthJsonCredential): string | undefined {
|
|
222
|
+
return current.refreshToken
|
|
223
|
+
? `${current.path}:${refreshGrantFingerprintForToken(current.refreshToken)}` : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): boolean {
|
|
227
|
+
const key = current && mainRefreshGrantKey(current);
|
|
228
|
+
return !!key && isMainRefreshGrantRejected(key);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface MainAccountCredentialStatus {
|
|
232
|
+
refreshGrantRejected: boolean;
|
|
233
|
+
hasRefreshGrant: boolean;
|
|
234
|
+
usable: boolean;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One owned physical observation; no credential material escapes this view. */
|
|
238
|
+
export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus {
|
|
151
239
|
const current = readMainAuthJsonCredential();
|
|
152
|
-
|
|
240
|
+
const refreshGrantRejected = credentialRefreshGrantRejected(current);
|
|
241
|
+
const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
|
|
242
|
+
return {
|
|
243
|
+
refreshGrantRejected,
|
|
244
|
+
hasRefreshGrant,
|
|
245
|
+
usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Current owned profile only; callers must hold the native read/selection permission. */
|
|
250
|
+
export function isMainAccountRefreshGrantRejected(): boolean {
|
|
251
|
+
return getMainAccountCredentialStatus().refreshGrantRejected;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function isMainAccountCredentialUsable(now = Date.now()): boolean {
|
|
255
|
+
return getMainAccountCredentialStatus(now).usable;
|
|
153
256
|
}
|
|
154
257
|
|
|
155
258
|
export function hasMainAccountRefreshGrant(): boolean {
|
|
156
|
-
return
|
|
259
|
+
return getMainAccountCredentialStatus().hasRefreshGrant;
|
|
157
260
|
}
|
|
158
261
|
|
|
159
262
|
function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void {
|
|
@@ -342,14 +445,26 @@ async function resolveMainAccountToken(
|
|
|
342
445
|
dependencies: NativeMainRefreshDependencies = {},
|
|
343
446
|
rejectedAccessToken?: string,
|
|
344
447
|
): Promise<{ accessToken: string; chatgptAccountId: string } | null> {
|
|
448
|
+
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
345
449
|
const initial = readMainAuthJsonCredential();
|
|
346
|
-
|
|
450
|
+
// Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from
|
|
451
|
+
// the outside is indistinguishable from a refusal: same failed request, no write, no verdict.
|
|
452
|
+
// A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports.
|
|
453
|
+
if (!initial) {
|
|
454
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential");
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth");
|
|
347
458
|
const now = Date.now();
|
|
348
459
|
if (initial.accessToken !== rejectedAccessToken
|
|
349
460
|
&& mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
461
|
+
// The stored credential is no longer the one upstream rejected: somebody else replaced it
|
|
462
|
+
// while this request was in flight, so there is nothing here to refresh or to retire.
|
|
463
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-credential-replaced");
|
|
350
464
|
return { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId };
|
|
351
465
|
}
|
|
352
466
|
if (!initial.refreshToken) {
|
|
467
|
+
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-refresh-grant");
|
|
353
468
|
return initial.accessToken !== rejectedAccessToken
|
|
354
469
|
&& mainAccessTokenFresh(initial.accessToken, now, 0)
|
|
355
470
|
? { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId }
|
|
@@ -380,6 +495,7 @@ async function resolveMainAccountToken(
|
|
|
380
495
|
() => withCodexRefreshFileLock(lockKey, signal, async () => {
|
|
381
496
|
const locked = readMainAuthJsonCredential();
|
|
382
497
|
if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
|
|
498
|
+
if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth");
|
|
383
499
|
if (!locked.refreshToken
|
|
384
500
|
|| refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
|
|
385
501
|
if (locked.accessToken !== rejectedAccessToken
|
|
@@ -392,16 +508,31 @@ async function resolveMainAccountToken(
|
|
|
392
508
|
&& mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
|
|
393
509
|
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
|
|
394
510
|
}
|
|
395
|
-
const refresh = dependencies.refreshToken
|
|
396
|
-
?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
|
|
511
|
+
const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
|
|
397
512
|
let refreshed: OAuthCredentials;
|
|
398
513
|
try {
|
|
399
514
|
refreshed = await refresh(locked.refreshToken, { signal });
|
|
400
515
|
} catch (cause) {
|
|
516
|
+
// A refusal already classified at the endpoint keeps that verdict. An injected refresh
|
|
517
|
+
// threw an ordinary Error and has only its message, so prose still decides there --
|
|
518
|
+
// and only there, where there is no structured code to read.
|
|
401
519
|
const message = cause instanceof Error ? cause.message.toLowerCase() : "";
|
|
402
|
-
const reason =
|
|
403
|
-
?
|
|
404
|
-
:
|
|
520
|
+
const reason = cause instanceof MainAccountTokenRefreshError
|
|
521
|
+
? cause.reason
|
|
522
|
+
: dependencies.refreshToken && /invalid_grant|invalidated|revoked|expired/.test(message)
|
|
523
|
+
? "reauth" as const
|
|
524
|
+
: "transient" as const;
|
|
525
|
+
// The shared point every request path reaches a terminal native-main verdict through:
|
|
526
|
+
// the forced refresh after an upstream 401, the pre-send refresh of an expired bearer,
|
|
527
|
+
// and the compact twin all land here. Recording the quarantine at the callers instead
|
|
528
|
+
// left the native main slot alone unrecorded -- `refreshNativeMainForwardAuth` has no
|
|
529
|
+
// `quarantine` flag for its caller to act on the way the stored-pool twin does -- so a
|
|
530
|
+
// revoked session stayed `needsReauth: false` and every request re-earned its own 401.
|
|
531
|
+
if (signal.aborted) throw signal.reason;
|
|
532
|
+
if (reason === "reauth") {
|
|
533
|
+
assertMainAuthJsonSnapshotUnchanged(locked);
|
|
534
|
+
markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
|
|
535
|
+
}
|
|
405
536
|
throw new MainAccountTokenRefreshError(reason, { cause });
|
|
406
537
|
}
|
|
407
538
|
// The refresh may resolve after the caller went away (an implementation that does
|
|
@@ -411,6 +542,8 @@ async function resolveMainAccountToken(
|
|
|
411
542
|
// before its own commit above.
|
|
412
543
|
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
|
|
413
544
|
const result = persistRefreshedMainAuthJson(locked, refreshed);
|
|
545
|
+
// A permitted in-flight success speaks only for its own profile and grant.
|
|
546
|
+
clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
|
|
414
547
|
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
415
548
|
return result;
|
|
416
549
|
}),
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */
|
|
2
2
|
export type CodexQuotaRefreshOutcome =
|
|
3
|
-
|
|
3
|
+
/** `code` is set only for a provider code that requires a new sign-in (fixed vocabulary). */
|
|
4
|
+
| { status: "http_error"; httpStatus: number; code?: CodexTerminalAuthCode }
|
|
4
5
|
| { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" };
|
|
5
6
|
|
|
7
|
+
/** Provider auth codes that only a new sign-in can recover. */
|
|
8
|
+
export const CODEX_TERMINAL_AUTH_CODES = [
|
|
9
|
+
"invalid_workspace_selected",
|
|
10
|
+
"invalid_refresh_token",
|
|
11
|
+
// ChatGPT revokes every session of an account whose plan changes (for example Pro to Free),
|
|
12
|
+
// while the access token's `exp` still lies in the future.
|
|
13
|
+
"token_invalidated",
|
|
14
|
+
] as const;
|
|
15
|
+
export type CodexTerminalAuthCode = typeof CODEX_TERMINAL_AUTH_CODES[number];
|
|
16
|
+
export function isCodexTerminalAuthCode(code: unknown): code is CodexTerminalAuthCode {
|
|
17
|
+
return (CODEX_TERMINAL_AUTH_CODES as readonly unknown[]).includes(code);
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */
|
|
7
21
|
export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined {
|
|
8
22
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
@@ -10,7 +24,8 @@ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefre
|
|
|
10
24
|
if (row.status === "http_error") {
|
|
11
25
|
return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus)
|
|
12
26
|
&& row.httpStatus >= 100 && row.httpStatus <= 599
|
|
13
|
-
? { status: "http_error", httpStatus: row.httpStatus
|
|
27
|
+
? { status: "http_error", httpStatus: row.httpStatus,
|
|
28
|
+
...(isCodexTerminalAuthCode(row.code) ? { code: row.code } : {}) }
|
|
14
29
|
: undefined;
|
|
15
30
|
}
|
|
16
31
|
switch (row.status) {
|
package/src/codex/quota-types.ts
CHANGED
|
@@ -22,6 +22,31 @@ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
|
|
|
22
22
|
*/
|
|
23
23
|
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
24
24
|
|
|
25
|
+
/** Credits have a separate clock: ordinary usage headers do not re-observe the balance. */
|
|
26
|
+
export const CODEX_CREDITS_FRESHNESS_MS = 5 * 60_000;
|
|
27
|
+
|
|
28
|
+
export interface CodexSpendableCredits {
|
|
29
|
+
hasCredits?: boolean;
|
|
30
|
+
unlimited?: boolean;
|
|
31
|
+
balance?: number;
|
|
32
|
+
overageLimitReached?: boolean;
|
|
33
|
+
allowed?: boolean;
|
|
34
|
+
observedAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hasSpendableCodexCredits(
|
|
38
|
+
quota: Pick<StoredAccountQuota, "credits"> | null,
|
|
39
|
+
now = Date.now(),
|
|
40
|
+
): boolean {
|
|
41
|
+
const credits = quota?.credits;
|
|
42
|
+
if (!credits || !Number.isFinite(credits.observedAt)) return false;
|
|
43
|
+
const age = now - credits.observedAt;
|
|
44
|
+
if (age < 0 || age > CODEX_CREDITS_FRESHNESS_MS
|
|
45
|
+
|| credits.allowed === false || credits.overageLimitReached === true || credits.hasCredits === false) return false;
|
|
46
|
+
return credits.unlimited === true || (credits.hasCredits === true
|
|
47
|
+
&& typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance > 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
/**
|
|
26
51
|
* Above this a value is already milliseconds; at or below it, it is Unix seconds.
|
|
27
52
|
*
|
|
@@ -80,6 +105,8 @@ export type StoredAccountQuota = {
|
|
|
80
105
|
shortWindowSeconds?: number;
|
|
81
106
|
customWindows?: Array<{ label: string; percent: number; resetAt?: number }>;
|
|
82
107
|
resetCredits?: number;
|
|
108
|
+
/** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */
|
|
109
|
+
credits?: CodexSpendableCredits | null;
|
|
83
110
|
/** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */
|
|
84
111
|
monthlyIsPrimaryWindow?: boolean;
|
|
85
112
|
updatedAt: number;
|
|
@@ -114,7 +141,12 @@ export type WhamUsageResponse = {
|
|
|
114
141
|
secondary_window?: WhamUsageWindow | null;
|
|
115
142
|
tertiary_window?: WhamUsageWindow | null;
|
|
116
143
|
};
|
|
117
|
-
credits?:
|
|
144
|
+
credits?: {
|
|
145
|
+
has_credits?: unknown;
|
|
146
|
+
unlimited?: unknown;
|
|
147
|
+
balance?: unknown;
|
|
148
|
+
overage_limit_reached?: unknown;
|
|
149
|
+
} | null;
|
|
118
150
|
rate_limit_reset_credits?: { available_count: number } | null;
|
|
119
151
|
additional_rate_limits?: WhamAdditionalRateLimit[] | null;
|
|
120
152
|
};
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCodexCredits } from "./credits";
|
|
1
2
|
import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
|
|
2
3
|
import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -11,7 +12,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
|
|
|
11
12
|
|
|
12
13
|
import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
|
|
13
14
|
import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
|
|
14
|
-
import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types";
|
|
15
|
+
import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, hasSpendableCodexCredits, resetAtToMs } from "./quota-types";
|
|
15
16
|
import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
|
|
16
17
|
|
|
17
18
|
export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
|
|
@@ -83,10 +84,12 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
|
|
|
83
84
|
// actually exhausted account is still eligible for threshold rotation.
|
|
84
85
|
export const CODEX_UNKNOWN_USAGE_SCORE = 101;
|
|
85
86
|
export function isCodexQuotaExhausted(
|
|
86
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
|
|
87
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent" | "credits"> | null,
|
|
87
88
|
plan?: unknown,
|
|
89
|
+
allowCredits = false,
|
|
88
90
|
): boolean {
|
|
89
91
|
if (!quota) return false;
|
|
92
|
+
if (allowCredits && hasSpendableCodexCredits(quota)) return false;
|
|
90
93
|
// The burst window counts on EVERY plan. It is upstream-enforced independently, so an
|
|
91
94
|
// account at 100% there is blocked regardless of which longer window governs its plan;
|
|
92
95
|
// omitting it would route traffic straight into a 429 (#1791).
|
|
@@ -118,10 +121,11 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export function isCompleteCodexQuotaRecoverySnapshot(
|
|
121
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
|
|
124
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent" | "credits"> | null,
|
|
122
125
|
plan?: unknown,
|
|
126
|
+
allowCredits = false,
|
|
123
127
|
): boolean {
|
|
124
|
-
if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
|
|
128
|
+
if (!quota || isCodexQuotaExhausted(quota, plan, allowCredits)) return false;
|
|
125
129
|
// Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload
|
|
126
130
|
// carries no usage reading at all and must never clear a cooldown. What it does not do is
|
|
127
131
|
// fail closed on an unfamiliar plan NAME, which only ever meant "cooled forever".
|
|
@@ -277,6 +281,10 @@ function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolea
|
|
|
277
281
|
function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
278
282
|
return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
|
|
279
283
|
}
|
|
284
|
+
|
|
285
|
+
function snapshotHasCredits(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
286
|
+
return quota.resetCredits !== undefined || quota.credits !== undefined;
|
|
287
|
+
}
|
|
280
288
|
/**
|
|
281
289
|
* Publish parsed display quota and separately validated policy evidence after writer checks.
|
|
282
290
|
* A null policy observation retains only the matching main identity's previous evidence;
|
|
@@ -296,7 +304,7 @@ export function setAccountQuotaFromParsed(
|
|
|
296
304
|
): void {
|
|
297
305
|
quota = withoutRetiredCodexQuota(quota);
|
|
298
306
|
policyQuota = withoutRetiredCodexQuota(policyQuota);
|
|
299
|
-
if (!quota || (!snapshotHasUsage(quota) && quota
|
|
307
|
+
if (!quota || (!snapshotHasUsage(quota) && !snapshotHasCredits(quota))) return;
|
|
300
308
|
if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
|
|
301
309
|
const isMain = accountId === MAIN_CODEX_ACCOUNT_ID;
|
|
302
310
|
if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return;
|
|
@@ -343,7 +351,7 @@ export function setAccountQuotaFromParsed(
|
|
|
343
351
|
}
|
|
344
352
|
schedulePersistAccountQuotas();
|
|
345
353
|
// Credits carry the previous usage tuple; they must not refresh its observation clock.
|
|
346
|
-
if (!(quota
|
|
354
|
+
if (!(snapshotHasCredits(quota) && !snapshotHasUsage(quota))) {
|
|
347
355
|
if (!isMain && policyQuota && livePoolEvidence) observeCodexLowQuota(accountId, policyQuota);
|
|
348
356
|
notifyCodexQuotaSnapshot(accountId, next);
|
|
349
357
|
}
|
|
@@ -363,7 +371,11 @@ function mergeAccountQuota(
|
|
|
363
371
|
quota = withoutRetiredCodexQuota(quota) ?? {};
|
|
364
372
|
existing = withoutRetiredCodexQuota(existing ?? null) ?? undefined;
|
|
365
373
|
const next: StoredAccountQuota = { updatedAt };
|
|
366
|
-
const creditsOnly = quota
|
|
374
|
+
const creditsOnly = snapshotHasCredits(quota) && !snapshotHasUsage(quota);
|
|
375
|
+
// An explicit empty/invalid credits summary retracts the old balance. Partial headers
|
|
376
|
+
// retain the original observation clock instead of extending its spending authority.
|
|
377
|
+
if (quota.credits !== undefined) next.credits = quota.credits;
|
|
378
|
+
else if (existing?.credits !== undefined) next.credits = existing.credits;
|
|
367
379
|
|
|
368
380
|
if (creditsOnly) {
|
|
369
381
|
if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent;
|
|
@@ -373,7 +385,8 @@ function mergeAccountQuota(
|
|
|
373
385
|
if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
|
|
374
386
|
assignCarriedShort(next, existing, updatedAt, policyEvidence);
|
|
375
387
|
if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
|
|
376
|
-
next.resetCredits = quota.resetCredits;
|
|
388
|
+
if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
|
|
389
|
+
else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
|
|
377
390
|
return stampCodexQuotaUsageObservation(next, quota, existing);
|
|
378
391
|
}
|
|
379
392
|
|
|
@@ -525,7 +538,7 @@ export function withoutRetiredCodexQuota<T extends Omit<StoredAccountQuota, "upd
|
|
|
525
538
|
const next = { ...quota };
|
|
526
539
|
if (kept.length > 0) next.customWindows = kept;
|
|
527
540
|
else delete next.customWindows;
|
|
528
|
-
return snapshotHasUsage(next) || next
|
|
541
|
+
return snapshotHasUsage(next) || snapshotHasCredits(next) ? next as T : null;
|
|
529
542
|
}
|
|
530
543
|
|
|
531
544
|
export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
@@ -644,6 +657,7 @@ export function updateAccountQuota(
|
|
|
644
657
|
...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
|
|
645
658
|
...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}),
|
|
646
659
|
...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
|
|
660
|
+
...(existing?.credits !== undefined ? { credits: existing.credits } : {}),
|
|
647
661
|
updatedAt: Date.now(),
|
|
648
662
|
};
|
|
649
663
|
assignCarriedShort(quota, existing, quota.updatedAt);
|
|
@@ -702,6 +716,20 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null {
|
|
|
702
716
|
if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number;
|
|
703
717
|
}
|
|
704
718
|
if (quota.monthlyPercent !== undefined && raw.monthlyIsPrimaryWindow === true) quota.monthlyIsPrimaryWindow = true;
|
|
719
|
+
// Retain the independent credit timestamp on restart; it still expires at its original deadline.
|
|
720
|
+
if (raw.credits === null) quota.credits = null;
|
|
721
|
+
else if (raw.credits && typeof raw.credits === "object" && !Array.isArray(raw.credits)) {
|
|
722
|
+
const stored = raw.credits as Record<string, unknown>;
|
|
723
|
+
if (typeof stored.observedAt === "number" && Number.isFinite(stored.observedAt) && stored.observedAt >= 0) {
|
|
724
|
+
quota.credits = { observedAt: stored.observedAt };
|
|
725
|
+
for (const key of ["hasCredits", "unlimited", "overageLimitReached", "allowed"] as const) {
|
|
726
|
+
if (typeof stored[key] === "boolean") quota.credits[key] = stored[key];
|
|
727
|
+
}
|
|
728
|
+
if (typeof stored.balance === "number" && Number.isFinite(stored.balance) && stored.balance >= 0) {
|
|
729
|
+
quota.credits.balance = stored.balance;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
705
733
|
return { identityKey: entry.identityKey, quota };
|
|
706
734
|
}
|
|
707
735
|
|
|
@@ -845,7 +873,7 @@ function filterMainPolicyMonthlyQuota(
|
|
|
845
873
|
delete filtered.monthlyResetAt;
|
|
846
874
|
delete filtered.monthlyIsPrimaryWindow;
|
|
847
875
|
// Null retains the matching prior observation; an empty object would merge away evidence.
|
|
848
|
-
return hasKnownQuotaValue(filtered) || filtered
|
|
876
|
+
return hasKnownQuotaValue(filtered) || snapshotHasCredits(filtered) ? filtered : null;
|
|
849
877
|
}
|
|
850
878
|
|
|
851
879
|
/**
|
|
@@ -886,11 +914,24 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
886
914
|
? data.rate_limit_reset_credits.available_count
|
|
887
915
|
: undefined;
|
|
888
916
|
|
|
889
|
-
if (!data.rate_limit) {
|
|
890
|
-
return resetCredits !== undefined ? { resetCredits } : null;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
917
|
const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
|
|
918
|
+
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
919
|
+
if (data.credits !== undefined) {
|
|
920
|
+
quota.credits = null;
|
|
921
|
+
const parsed = parseCodexCredits(data.credits);
|
|
922
|
+
if (parsed) {
|
|
923
|
+
const balance = parsed.balance === undefined ? undefined : Number(parsed.balance);
|
|
924
|
+
quota.credits = {
|
|
925
|
+
observedAt: Date.now(),
|
|
926
|
+
...(parsed.hasCredits !== undefined ? { hasCredits: parsed.hasCredits } : {}),
|
|
927
|
+
...(parsed.unlimited !== undefined ? { unlimited: parsed.unlimited } : {}),
|
|
928
|
+
...(parsed.overageLimitReached !== undefined ? { overageLimitReached: parsed.overageLimitReached } : {}),
|
|
929
|
+
...(typeof data.rate_limit?.allowed === "boolean" ? { allowed: data.rate_limit.allowed } : {}),
|
|
930
|
+
...(balance !== undefined && Number.isFinite(balance) ? { balance } : {}),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
} else if (data.rate_limit?.allowed === false) quota.credits = null;
|
|
934
|
+
if (!data.rate_limit) return snapshotHasCredits(quota) ? quota : null;
|
|
894
935
|
const thirtyDayOnly = codexQuotaWindowForPlan(data.plan_type) === "monthly";
|
|
895
936
|
const primaryWindow = data.rate_limit.primary_window;
|
|
896
937
|
const secondaryWindow = data.rate_limit.secondary_window;
|
|
@@ -951,7 +992,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
951
992
|
|
|
952
993
|
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
953
994
|
|
|
954
|
-
return hasKnownQuotaValue(quota) ||
|
|
995
|
+
return hasKnownQuotaValue(quota) || snapshotHasCredits(quota) ? quota : null;
|
|
955
996
|
}
|
|
956
997
|
|
|
957
998
|
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
CODEX_UNKNOWN_USAGE_SCORE,
|
|
4
4
|
} from "../quota";
|
|
5
5
|
import { isThirtyDayOnlyCodexPlan } from "../plan";
|
|
6
|
-
import { isTerminalShortWindow } from "../quota-types";
|
|
6
|
+
import { hasSpendableCodexCredits, isTerminalShortWindow, type CodexSpendableCredits } from "../quota-types";
|
|
7
7
|
import type { CodexQuotaScope } from "./health-store";
|
|
8
8
|
import type { TransientProbeGrant } from "./thread-affinity";
|
|
9
9
|
|
|
@@ -107,7 +107,8 @@ export function computeCodexUsageScore(quota: {
|
|
|
107
107
|
shortPercent?: number;
|
|
108
108
|
shortResetAt?: number;
|
|
109
109
|
shortObservedAt?: number;
|
|
110
|
-
|
|
110
|
+
credits?: CodexSpendableCredits | null;
|
|
111
|
+
} | null, plan?: unknown, now: number = Date.now(), allowCredits = false): number {
|
|
111
112
|
if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
|
|
112
113
|
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
113
114
|
const longWindows = isThirtyDayOnlyCodexPlan(plan)
|
|
@@ -126,10 +127,15 @@ export function computeCodexUsageScore(quota: {
|
|
|
126
127
|
// correct for uncertainty and wrong for a measured refusal: the account stays selected,
|
|
127
128
|
// `applyQuotaAutoSwitch` never fires, and the pool wedges on an exhausted credential.
|
|
128
129
|
if (knownLong.length === 0) {
|
|
129
|
-
return isTerminalShortWindow(quota, now)
|
|
130
|
+
return isTerminalShortWindow(quota, now)
|
|
131
|
+
? (allowCredits && hasSpendableCodexCredits(quota, now)) ? CODEX_EXHAUSTED_USAGE_PERCENT - 1 : CODEX_EXHAUSTED_USAGE_PERCENT
|
|
132
|
+
: CODEX_UNKNOWN_USAGE_SCORE;
|
|
130
133
|
}
|
|
131
134
|
const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
|
|
132
|
-
|
|
135
|
+
const score = Math.max(...values);
|
|
136
|
+
// Keep included-capacity accounts ahead of a paid overage account, but do not
|
|
137
|
+
// classify the latter as exhausted solely from its subscription percentages.
|
|
138
|
+
return (allowCredits && hasSpendableCodexCredits(quota, now)) ? Math.min(score, CODEX_EXHAUSTED_USAGE_PERCENT - 1) : score;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
// `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
|
|
2
|
-
import { isCodexAccountHeldForCredits } from "../account-credit-use";
|
|
2
|
+
import { codexAccountUsesCreditsAfterLimit, isCodexAccountHeldForCredits } from "../account-credit-use";
|
|
3
3
|
import { isCodexAccountPaused } from "../account-pause";
|
|
4
4
|
import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
|
|
5
5
|
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../pool-rotation";
|
|
17
17
|
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "../quota";
|
|
18
18
|
import { codexPlanKey } from "../plan";
|
|
19
|
-
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan
|
|
19
|
+
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
|
|
20
20
|
import type { OcxConfig } from "../../types";
|
|
21
21
|
import { CODEX_FAILURE_WINDOW_MS, computeCodexUsageScore } from "./cooldown-math";
|
|
22
22
|
import {
|
|
@@ -220,7 +220,6 @@ export function getEligiblePoolAccounts(
|
|
|
220
220
|
if (
|
|
221
221
|
excludeId !== MAIN_CODEX_ACCOUNT_ID
|
|
222
222
|
&& !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
223
|
-
&& (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
|
|
224
223
|
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
225
224
|
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
226
225
|
// The main login is not in `config.codexAccounts`, so it never passes through the
|
|
@@ -293,6 +292,7 @@ export function hasCodexQuotaHeadroom(
|
|
|
293
292
|
getAccountQuota(accountId),
|
|
294
293
|
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
295
294
|
now,
|
|
295
|
+
codexAccountUsesCreditsAfterLimit(config, accountId),
|
|
296
296
|
);
|
|
297
297
|
if (isUnknownUsage(usage)) return true;
|
|
298
298
|
return usage < threshold;
|
|
@@ -344,6 +344,7 @@ export function hasCodexSharedStateQuotaHeadroom(
|
|
|
344
344
|
getAccountQuota(accountId),
|
|
345
345
|
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
346
346
|
now,
|
|
347
|
+
codexAccountUsesCreditsAfterLimit(config, accountId),
|
|
347
348
|
);
|
|
348
349
|
return isUnknownUsage(usage) || usage < 100;
|
|
349
350
|
}
|
|
@@ -537,7 +538,7 @@ export function sharedStateSelectionOptions(
|
|
|
537
538
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
538
539
|
): Pick<
|
|
539
540
|
CodexAccountUsabilityOptions,
|
|
540
|
-
"nativeMainSelectionOnly" | "isMainAccountTokenLive"
|
|
541
|
+
"nativeMainSelectionOnly" | "isMainAccountTokenLive" | "requestOwnedMainCredential"
|
|
541
542
|
> | undefined {
|
|
542
543
|
if (!selectionOptions) return undefined;
|
|
543
544
|
return {
|
|
@@ -547,6 +548,9 @@ export function sharedStateSelectionOptions(
|
|
|
547
548
|
...(selectionOptions.isMainAccountTokenLive
|
|
548
549
|
? { isMainAccountTokenLive: selectionOptions.isMainAccountTokenLive }
|
|
549
550
|
: {}),
|
|
551
|
+
...(selectionOptions.requestOwnedMainCredential !== undefined
|
|
552
|
+
? { requestOwnedMainCredential: selectionOptions.requestOwnedMainCredential }
|
|
553
|
+
: {}),
|
|
550
554
|
};
|
|
551
555
|
}
|
|
552
556
|
|
|
@@ -585,6 +589,7 @@ export function pickLowerUsageAccount(
|
|
|
585
589
|
getAccountQuota(id),
|
|
586
590
|
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
587
591
|
now,
|
|
592
|
+
codexAccountUsesCreditsAfterLimit(config, id),
|
|
588
593
|
);
|
|
589
594
|
if (usage < bestUsage) {
|
|
590
595
|
best = id;
|
|
@@ -608,6 +613,7 @@ export function pickLowestUsageAmong(
|
|
|
608
613
|
getAccountQuota(id),
|
|
609
614
|
getPoolAccountPlanForSelection(config, id, selectionOptions),
|
|
610
615
|
now,
|
|
616
|
+
codexAccountUsesCreditsAfterLimit(config, id),
|
|
611
617
|
);
|
|
612
618
|
if (usage < bestUsage) {
|
|
613
619
|
best = id;
|
|
@@ -785,6 +791,7 @@ export function applyQuotaAutoSwitch(
|
|
|
785
791
|
quota,
|
|
786
792
|
getPoolAccountPlanForSelection(config, active, selectionOptions),
|
|
787
793
|
now,
|
|
794
|
+
codexAccountUsesCreditsAfterLimit(config, active),
|
|
788
795
|
);
|
|
789
796
|
// Unknown usage is not evidence that a user's explicit selection crossed the
|
|
790
797
|
// threshold. Wait for quota priming instead of rotating among guesses.
|