@bitkyc08/opencodex 2.48.0 → 2.50.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/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
package/src/codex/auth-api.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isCodexAccountGenerationLive,
|
|
13
13
|
forceRefreshCodexPoolToken,
|
|
14
14
|
markCodexAccountValidated,
|
|
15
|
+
markCodexAccountValidationFailed,
|
|
15
16
|
readCodexAccountRecord,
|
|
16
17
|
saveCodexAccountCredential,
|
|
17
18
|
CodexCredentialGenerationConflictError,
|
|
@@ -114,7 +115,7 @@ export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
|
114
115
|
import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
|
|
115
116
|
import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
|
|
116
117
|
import { observeMainReserveRevocation } from "./reserve-availability";
|
|
117
|
-
import {
|
|
118
|
+
import { emailMaskingEnabled, projectEmail } from "../lib/privacy";
|
|
118
119
|
import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
|
|
119
120
|
export { maskEmail } from "../lib/privacy";
|
|
120
121
|
import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
|
|
@@ -192,6 +193,7 @@ interface CodexLoginStateRow {
|
|
|
192
193
|
code?: string;
|
|
193
194
|
needsReauth?: boolean;
|
|
194
195
|
catalogRefreshPending?: boolean;
|
|
196
|
+
validationPending?: boolean;
|
|
195
197
|
doneAt?: number;
|
|
196
198
|
}
|
|
197
199
|
const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
|
|
@@ -241,12 +243,15 @@ function codexAccountPersistenceConflict(
|
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
/**
|
|
244
|
-
* The exact
|
|
246
|
+
* The exact labels `parseUsageQuota` emits for the Codex Spark windows (quota.ts).
|
|
245
247
|
* Matching on the label rather than on "is a custom window" is load-bearing: the same array
|
|
246
248
|
* carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
|
|
247
249
|
* Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
|
|
248
250
|
*/
|
|
249
|
-
const
|
|
251
|
+
const CODEX_SPARK_WINDOW_LABELS = new Set([
|
|
252
|
+
"GPT-5.3-Codex-Spark 5h",
|
|
253
|
+
"GPT-5.3-Codex-Spark Weekly",
|
|
254
|
+
]);
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
257
|
* Drop the Spark window unless the operator asked for it (default hidden).
|
|
@@ -264,7 +269,7 @@ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedA
|
|
|
264
269
|
): T {
|
|
265
270
|
if (!quota?.customWindows?.length) return quota;
|
|
266
271
|
if (loadConfig().showCodexSparkQuota === true) return quota;
|
|
267
|
-
const kept = quota.customWindows.filter(window => window.label
|
|
272
|
+
const kept = quota.customWindows.filter(window => !CODEX_SPARK_WINDOW_LABELS.has(window.label));
|
|
268
273
|
if (kept.length === quota.customWindows.length) return quota;
|
|
269
274
|
// An empty list is dropped rather than serialized: an absent field and an empty array should
|
|
270
275
|
// not be two different ways of saying "no custom windows" on the wire.
|
|
@@ -365,6 +370,7 @@ function poolAccountDto(
|
|
|
365
370
|
hasCredential: boolean,
|
|
366
371
|
paused: boolean,
|
|
367
372
|
priority: number,
|
|
373
|
+
maskEmails: boolean,
|
|
368
374
|
): CodexAuthAccountDto {
|
|
369
375
|
const plan = codexPlanValue(account.plan);
|
|
370
376
|
const quota = quotaForPlan(quotaResult.quota, plan);
|
|
@@ -372,7 +378,7 @@ function poolAccountDto(
|
|
|
372
378
|
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
373
379
|
return {
|
|
374
380
|
id: account.id,
|
|
375
|
-
email:
|
|
381
|
+
email: projectEmail(account.email, maskEmails) ?? account.email,
|
|
376
382
|
...(account.alias !== undefined ? { alias: account.alias } : {}),
|
|
377
383
|
...(plan !== undefined ? { plan } : {}),
|
|
378
384
|
logLabel: codexAccountLogLabel(account),
|
|
@@ -380,7 +386,7 @@ function poolAccountDto(
|
|
|
380
386
|
paused,
|
|
381
387
|
priority,
|
|
382
388
|
quota: quota ? { ...quota } : null,
|
|
383
|
-
needsReauth,
|
|
389
|
+
needsReauth: needsReauth || health.status === "reauth_required",
|
|
384
390
|
hasCredential,
|
|
385
391
|
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
386
392
|
...oauthAccountHealthFields("codex", account.id, health),
|
|
@@ -635,7 +641,7 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
|
|
|
635
641
|
|
|
636
642
|
interface StagedNewCodexAccountState {
|
|
637
643
|
credential: CodexAccountCredentials;
|
|
638
|
-
validatedAt
|
|
644
|
+
validatedAt?: number;
|
|
639
645
|
}
|
|
640
646
|
|
|
641
647
|
type PersistNewCodexAccountOutcome =
|
|
@@ -688,8 +694,10 @@ function persistNewCodexAccount(
|
|
|
688
694
|
}
|
|
689
695
|
|
|
690
696
|
try {
|
|
691
|
-
saveCodexAccountCredential(addedAccount.id, staged.credential
|
|
692
|
-
|
|
697
|
+
const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, {
|
|
698
|
+
validationPending: staged.validatedAt === undefined,
|
|
699
|
+
});
|
|
700
|
+
if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation);
|
|
693
701
|
clearAccountNeedsReauth(addedAccount.id);
|
|
694
702
|
} catch {
|
|
695
703
|
// Config is already durable. Return the failure outcome through the coordinator so its
|
|
@@ -1098,6 +1106,7 @@ interface PoolQuotaRefreshFlight {
|
|
|
1098
1106
|
superseded?: boolean;
|
|
1099
1107
|
startCredentialGeneration?: number;
|
|
1100
1108
|
resolvedCredentialGeneration?: number;
|
|
1109
|
+
validatePending?: boolean;
|
|
1101
1110
|
};
|
|
1102
1111
|
promise: Promise<PoolQuotaResult>;
|
|
1103
1112
|
}
|
|
@@ -1477,11 +1486,12 @@ async function fetchFreshPoolAccountQuota(
|
|
|
1477
1486
|
}
|
|
1478
1487
|
}
|
|
1479
1488
|
|
|
1480
|
-
async function fetchPoolAccountQuota(
|
|
1489
|
+
export async function fetchPoolAccountQuota(
|
|
1481
1490
|
accountId: string,
|
|
1482
1491
|
forceRefresh = false,
|
|
1483
1492
|
configuredPlan?: string,
|
|
1484
1493
|
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
1494
|
+
validatePending = false,
|
|
1485
1495
|
afterDispatchSequence?: number,
|
|
1486
1496
|
): Promise<PoolQuotaResult> {
|
|
1487
1497
|
const existing = getAccountQuota(accountId);
|
|
@@ -1504,7 +1514,11 @@ async function fetchPoolAccountQuota(
|
|
|
1504
1514
|
&& (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
|
|
1505
1515
|
&& generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
1506
1516
|
});
|
|
1507
|
-
if (current)
|
|
1517
|
+
if (current) {
|
|
1518
|
+
// A manual refresh joining a passive read must not lose its validation intent.
|
|
1519
|
+
current.state.validatePending ||= validatePending;
|
|
1520
|
+
return current.promise;
|
|
1521
|
+
}
|
|
1508
1522
|
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
1509
1523
|
|
|
1510
1524
|
// A post-reset request must not let an older same-account response overwrite its evidence.
|
|
@@ -1514,6 +1528,7 @@ async function fetchPoolAccountQuota(
|
|
|
1514
1528
|
}
|
|
1515
1529
|
const state: PoolQuotaRefreshFlight["state"] = {
|
|
1516
1530
|
startCredentialGeneration: record?.generation,
|
|
1531
|
+
validatePending,
|
|
1517
1532
|
};
|
|
1518
1533
|
const refresh = fetchFreshPoolAccountQuota(
|
|
1519
1534
|
accountId,
|
|
@@ -1525,18 +1540,54 @@ async function fetchPoolAccountQuota(
|
|
|
1525
1540
|
onDispatch: sequence => { state.dispatchSequence = sequence; },
|
|
1526
1541
|
mayPublish: () => state.superseded !== true,
|
|
1527
1542
|
},
|
|
1528
|
-
)
|
|
1543
|
+
).then(async result => {
|
|
1544
|
+
// A passive flight has consumed its validation decision. Remove it before
|
|
1545
|
+
// promise settlement queues other continuations, so a late explicit caller
|
|
1546
|
+
// starts fresh work instead of setting an intent nobody will read again.
|
|
1547
|
+
if (!state.validatePending) {
|
|
1548
|
+
releaseFlight();
|
|
1549
|
+
return result;
|
|
1550
|
+
}
|
|
1551
|
+
// Only an explicit account-list refresh finishes deferred registration. Passive quota
|
|
1552
|
+
// polls and startup priming remain read-only with respect to inference spending.
|
|
1553
|
+
const generation = result.freshCredentialGeneration;
|
|
1554
|
+
const record = state.validatePending ? readCodexAccountRecord(accountId) : null;
|
|
1555
|
+
if (record?.codexValidationPending && record.credential && record.deletedAt == null
|
|
1556
|
+
&& generation !== undefined && record.generation === generation
|
|
1557
|
+
&& isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
|
|
1558
|
+
try {
|
|
1559
|
+
await warmCodexAccount({
|
|
1560
|
+
accessToken: record.credential.accessToken,
|
|
1561
|
+
chatgptAccountId: record.credential.chatgptAccountId,
|
|
1562
|
+
});
|
|
1563
|
+
markCodexAccountValidated(accountId, Date.now(), generation);
|
|
1564
|
+
clearAccountNeedsReauth(accountId, generation);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
// Keep the durable restriction on any failed/partial inference response, even
|
|
1567
|
+
// when WHAM just reported headroom. No raw upstream text enters diagnostics.
|
|
1568
|
+
const reason = codexWarmupFailureReason(error);
|
|
1569
|
+
if (reason === "http_status:401" || reason === "http_status:403") {
|
|
1570
|
+
markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation });
|
|
1571
|
+
markAccountNeedsReauth(accountId, captureConfigGeneration(), generation);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
return result;
|
|
1576
|
+
});
|
|
1529
1577
|
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
1530
1578
|
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
1531
1579
|
activeFlights.add(flight);
|
|
1532
1580
|
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
1533
|
-
|
|
1534
|
-
return await refresh;
|
|
1535
|
-
} finally {
|
|
1581
|
+
const releaseFlight = () => {
|
|
1536
1582
|
activeFlights.delete(flight);
|
|
1537
1583
|
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
1538
1584
|
poolQuotaRefreshInFlight.delete(accountId);
|
|
1539
1585
|
}
|
|
1586
|
+
};
|
|
1587
|
+
try {
|
|
1588
|
+
return await refresh;
|
|
1589
|
+
} finally {
|
|
1590
|
+
releaseFlight();
|
|
1540
1591
|
}
|
|
1541
1592
|
}
|
|
1542
1593
|
|
|
@@ -1591,8 +1642,10 @@ async function refreshAfterManualReset(
|
|
|
1591
1642
|
}
|
|
1592
1643
|
return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
|
|
1593
1644
|
};
|
|
1645
|
+
// `validatePending` is false here: a manual reset settles cooldown, and finishing deferred
|
|
1646
|
+
// registration stays reserved for an explicit dashboard account-list refresh.
|
|
1594
1647
|
const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
|
|
1595
|
-
didReset ? afterDispatchSequence : undefined);
|
|
1648
|
+
false, didReset ? afterDispatchSequence : undefined);
|
|
1596
1649
|
const record = readCodexAccountRecord(accountId);
|
|
1597
1650
|
const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
|
|
1598
1651
|
&& (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
|
|
@@ -1873,9 +1926,12 @@ export interface CodexAuthAccountsSnapshot {
|
|
|
1873
1926
|
export async function listCodexAuthAccountsSnapshot(
|
|
1874
1927
|
config: OcxConfig,
|
|
1875
1928
|
forceRefresh = false,
|
|
1929
|
+
options: { validatePending?: boolean } = {},
|
|
1876
1930
|
): Promise<CodexAuthAccountsSnapshot> {
|
|
1877
1931
|
const runtimeConfig = getRuntimeConfig(config);
|
|
1878
1932
|
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1933
|
+
// One redaction decision for the whole snapshot, read once from the operator's config (#3859).
|
|
1934
|
+
const maskEmails = emailMaskingEnabled(runtimeConfig);
|
|
1879
1935
|
const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
1880
1936
|
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
1881
1937
|
const cred = getCodexAccountCredential(account.id);
|
|
@@ -1884,7 +1940,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1884
1940
|
quotaResult = { quota: null, needsReauth: true };
|
|
1885
1941
|
} else {
|
|
1886
1942
|
try {
|
|
1887
|
-
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
|
|
1943
|
+
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
|
|
1888
1944
|
} catch (error) {
|
|
1889
1945
|
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
1890
1946
|
quotaResult = {
|
|
@@ -1920,6 +1976,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1920
1976
|
false,
|
|
1921
1977
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1922
1978
|
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1979
|
+
maskEmails,
|
|
1923
1980
|
)];
|
|
1924
1981
|
}
|
|
1925
1982
|
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
@@ -1939,6 +1996,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1939
1996
|
true,
|
|
1940
1997
|
isCodexAccountPaused(runtimeConfig, accountId),
|
|
1941
1998
|
getCodexAccountPriority(runtimeConfig, accountId),
|
|
1999
|
+
maskEmails,
|
|
1942
2000
|
)];
|
|
1943
2001
|
});
|
|
1944
2002
|
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
|
|
@@ -1955,7 +2013,7 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
1955
2013
|
});
|
|
1956
2014
|
const main: CodexAuthAccountDto = {
|
|
1957
2015
|
id: MAIN_CODEX_ACCOUNT_ID,
|
|
1958
|
-
email:
|
|
2016
|
+
email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
|
|
1959
2017
|
plan: mainInfo.plan,
|
|
1960
2018
|
...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
|
|
1961
2019
|
&& isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
|
|
@@ -2016,8 +2074,12 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
|
|
|
2016
2074
|
}
|
|
2017
2075
|
}
|
|
2018
2076
|
|
|
2019
|
-
export async function listCodexAuthAccounts(
|
|
2020
|
-
|
|
2077
|
+
export async function listCodexAuthAccounts(
|
|
2078
|
+
config: OcxConfig,
|
|
2079
|
+
forceRefresh = false,
|
|
2080
|
+
options: { validatePending?: boolean } = {},
|
|
2081
|
+
): Promise<CodexAuthAccountDto[]> {
|
|
2082
|
+
return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
|
|
2021
2083
|
}
|
|
2022
2084
|
|
|
2023
2085
|
interface PauseExhaustedResult {
|
|
@@ -2126,6 +2188,7 @@ export async function handleCodexAuthAPI(
|
|
|
2126
2188
|
url: URL,
|
|
2127
2189
|
config: OcxConfig,
|
|
2128
2190
|
convergeCodexCatalog?: CodexAuthCatalogConvergence,
|
|
2191
|
+
principal?: import("../server/management-auth").ManagementPrincipal,
|
|
2129
2192
|
): Promise<Response | null> {
|
|
2130
2193
|
|
|
2131
2194
|
if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
|
|
@@ -2133,6 +2196,14 @@ export async function handleCodexAuthAPI(
|
|
|
2133
2196
|
return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
|
|
2134
2197
|
}
|
|
2135
2198
|
|
|
2199
|
+
if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") {
|
|
2200
|
+
// Inference spends quota: only a dashboard session carries the consent
|
|
2201
|
+
// required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational.
|
|
2202
|
+
return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, {
|
|
2203
|
+
validatePending: principal === "gui-session",
|
|
2204
|
+
}) });
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2136
2207
|
if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
|
|
2137
2208
|
return manualImportDisabledResponse();
|
|
2138
2209
|
}
|
|
@@ -2310,6 +2381,9 @@ export async function handleCodexAuthAPI(
|
|
|
2310
2381
|
const exists = (runtimeConfig.codexAccounts ?? [])
|
|
2311
2382
|
.some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
|
|
2312
2383
|
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
2384
|
+
if (readCodexAccountRecord(body.accountId)?.codexValidationPending) {
|
|
2385
|
+
return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409);
|
|
2386
|
+
}
|
|
2313
2387
|
}
|
|
2314
2388
|
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
2315
2389
|
// "Use this account now" outranks selection order until the account is spent:
|
|
@@ -2754,7 +2828,12 @@ export async function handleCodexAuthAPI(
|
|
|
2754
2828
|
break;
|
|
2755
2829
|
}
|
|
2756
2830
|
|
|
2757
|
-
|
|
2831
|
+
// A successful authenticated WHAM read can prove quota is exhausted without
|
|
2832
|
+
// spending an inference request. Store the account, but defer inference validation
|
|
2833
|
+
// and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
|
|
2834
|
+
const warmup = isCodexQuotaExhausted(quota, plan)
|
|
2835
|
+
? { ok: true as const, validatedAt: undefined }
|
|
2836
|
+
: await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
2758
2837
|
if (!warmup.ok) {
|
|
2759
2838
|
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
2760
2839
|
setCodexLoginState(flowId, {
|
|
@@ -2794,11 +2873,13 @@ export async function handleCodexAuthAPI(
|
|
|
2794
2873
|
};
|
|
2795
2874
|
|
|
2796
2875
|
if (existingIdx >= 0) {
|
|
2797
|
-
saveCodexAccountCredential(accountId, credential
|
|
2876
|
+
const generation = saveCodexAccountCredential(accountId, credential, {
|
|
2877
|
+
validationPending: warmup.validatedAt === undefined,
|
|
2878
|
+
});
|
|
2798
2879
|
// A successful reauthentication replaces the credential generation. Do not let a
|
|
2799
2880
|
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
2800
2881
|
if (reauth) clearAccountQuota(accountId);
|
|
2801
|
-
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
2882
|
+
if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
|
|
2802
2883
|
clearAccountNeedsReauth(accountId);
|
|
2803
2884
|
if (quota) setAccountQuotaFromParsed(accountId, quota);
|
|
2804
2885
|
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
@@ -2849,6 +2930,7 @@ export async function handleCodexAuthAPI(
|
|
|
2849
2930
|
status: "done",
|
|
2850
2931
|
accountId,
|
|
2851
2932
|
email,
|
|
2933
|
+
...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
|
|
2852
2934
|
...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
|
|
2853
2935
|
doneAt: Date.now(),
|
|
2854
2936
|
});
|
|
@@ -2949,6 +3031,9 @@ export async function handleCodexAuthAPI(
|
|
|
2949
3031
|
if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
|
|
2950
3032
|
const flowId = url.searchParams.get("flowId");
|
|
2951
3033
|
const accountId = url.searchParams.get("accountId")?.trim();
|
|
3034
|
+
// Transient flow state carries the address of the account being added, so it follows the
|
|
3035
|
+
// same operator policy as the stored accounts it is about to become.
|
|
3036
|
+
const maskFlowEmails = emailMaskingEnabled(config);
|
|
2952
3037
|
// Reauth always has a pre-existing credential; never treat "credential exists" as success
|
|
2953
3038
|
// when the flow map entry is gone (would false-complete on lost/expired flow state).
|
|
2954
3039
|
const reauthStatus = url.searchParams.get("reauth") === "1";
|
|
@@ -2961,13 +3046,15 @@ export async function handleCodexAuthAPI(
|
|
|
2961
3046
|
&& !isAccountNeedsReauth(accountId)
|
|
2962
3047
|
&& getCodexAccountCredential(accountId)
|
|
2963
3048
|
) {
|
|
2964
|
-
return jsonResponse({ status: "done", accountId
|
|
3049
|
+
return jsonResponse({ status: "done", accountId,
|
|
3050
|
+
...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
|
|
3051
|
+
});
|
|
2965
3052
|
}
|
|
2966
|
-
return jsonResponse(st ? { ...st, email:
|
|
3053
|
+
return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
|
|
2967
3054
|
}
|
|
2968
3055
|
// Legacy fallback: return latest pending flow
|
|
2969
3056
|
for (const [, st] of codexAuthLoginState) {
|
|
2970
|
-
if (st.status === "pending") return jsonResponse({ ...st, email:
|
|
3057
|
+
if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
|
|
2971
3058
|
}
|
|
2972
3059
|
return jsonResponse({ status: "idle" });
|
|
2973
3060
|
}
|
|
@@ -6,6 +6,7 @@ import { resolveCodexHomeDir } from "./home";
|
|
|
6
6
|
import { extractAccountId } from "../oauth/chatgpt";
|
|
7
7
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
8
8
|
import { codexPlanKey } from "./plan";
|
|
9
|
+
import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
|
|
9
10
|
|
|
10
11
|
export interface CodexTokens {
|
|
11
12
|
access_token: string;
|
|
@@ -31,12 +32,21 @@ function hasErrnoCode(error: unknown, code: string): boolean {
|
|
|
31
32
|
/**
|
|
32
33
|
* Reads the Codex CLI credential file and classifies the outcome. Reads once instead of doing an
|
|
33
34
|
* `existsSync` pre-check, so a file replaced between check and read cannot be misread as absent.
|
|
35
|
+
* An already-owned lifecycle may supply its pinned auth path instead of resolving ambient home.
|
|
36
|
+
* `bounded` opts into the native-profile bounded reader (regular file, size-capped, no-follow,
|
|
37
|
+
* non-blocking) for startup observation paths that run inside the owner claim; bounded violations
|
|
38
|
+
* classify as `unreadable`. Legacy callers keep the unbounded read.
|
|
34
39
|
* Never returns or logs the raw error or any token material.
|
|
35
40
|
*/
|
|
36
|
-
export function readCodexTokensResult(
|
|
41
|
+
export function readCodexTokensResult(
|
|
42
|
+
authPath = join(resolveCodexHomeDir(), "auth.json"),
|
|
43
|
+
options?: { bounded?: boolean },
|
|
44
|
+
): CodexTokenReadResult {
|
|
37
45
|
let raw: string;
|
|
38
46
|
try {
|
|
39
|
-
raw =
|
|
47
|
+
raw = options?.bounded === true
|
|
48
|
+
? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")
|
|
49
|
+
: readFileSync(authPath, "utf-8");
|
|
40
50
|
} catch (error) {
|
|
41
51
|
return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" };
|
|
42
52
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { createHmac, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
CodexCredentialGenerationConflictError,
|
|
4
4
|
CodexCredentialRefreshLockTimeoutError,
|
|
5
5
|
CodexCredentialRefreshBusyError,
|
|
6
6
|
CodexCredentialRefreshStaleError,
|
|
7
|
+
getCodexAccountCredential,
|
|
7
8
|
getValidCodexToken,
|
|
8
9
|
isCodexAccountGenerationLive,
|
|
10
|
+
readCodexAccountRecord,
|
|
9
11
|
} from "./account-store";
|
|
10
12
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
11
13
|
import { ConfigMutationLockError } from "../config";
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
isMainAccountTokenLive,
|
|
22
24
|
type NativeMainRefreshDependencies,
|
|
23
25
|
} from "./main-account";
|
|
24
|
-
import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
|
|
26
|
+
import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
|
|
25
27
|
import type { NativeMainStartupBlockReason } from "./native-profile-startup";
|
|
26
28
|
import {
|
|
27
29
|
codexQuotaScopeForModel,
|
|
@@ -49,7 +51,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
|
49
51
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
50
52
|
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
51
53
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
52
|
-
import { extractAccountId } from "../oauth/chatgpt";
|
|
54
|
+
import { extractAccountId, extractEmail } from "../oauth/chatgpt";
|
|
53
55
|
import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
54
56
|
import {
|
|
55
57
|
captureMainAccountIdentityGeneration,
|
|
@@ -62,7 +64,7 @@ import {
|
|
|
62
64
|
} from "./main-account-cache";
|
|
63
65
|
import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target";
|
|
64
66
|
import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
65
|
-
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability";
|
|
67
|
+
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
|
|
66
68
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
67
69
|
|
|
68
70
|
const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
|
|
@@ -199,6 +201,19 @@ export class CodexPoolAuthenticationError extends Error {
|
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
class CodexAccountValidationPendingError extends CodexPoolAuthenticationError {
|
|
205
|
+
constructor() {
|
|
206
|
+
super("Codex account validation is pending; refresh quota after recovery to validate it");
|
|
207
|
+
this.name = "CodexAccountValidationPendingError";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function assertCodexAccountValidationReady(accountId: string): void {
|
|
212
|
+
if (accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) {
|
|
213
|
+
throw new CodexAccountValidationPendingError();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
202
217
|
export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
|
|
203
218
|
"OpenCodex local native-main profile maintenance is active; retry this request";
|
|
204
219
|
|
|
@@ -455,6 +470,54 @@ function callerMatchesObservedMain(headers: Headers): boolean {
|
|
|
455
470
|
return matchesMainQuotaCredential(bearer, effectiveAccountId);
|
|
456
471
|
}
|
|
457
472
|
|
|
473
|
+
/** Constant-time digest comparison so bearer bytes never drive branch timing. */
|
|
474
|
+
function sameCredentialMaterial(a: string, b: string): boolean {
|
|
475
|
+
return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The early-cooldown caller-main fallback must not resurrect the subscription that is cooling
|
|
480
|
+
* down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the
|
|
481
|
+
* cooled account. A distinct workspace account id is always safe; an exact materialized
|
|
482
|
+
* bearer + account tuple marks the same subscription. Beyond that, the stable native user id is
|
|
483
|
+
* the strongest available evidence: it survives an email change and a token rotation, and it
|
|
484
|
+
* separates members who share one workspace account id even when neither credential carries an
|
|
485
|
+
* email. Email remains the fallback when no comparable user id exists on both sides. Coexisting
|
|
486
|
+
* personal/business registrations with the same email and account id over-deny during the
|
|
487
|
+
* cooldown — the safe direction.
|
|
488
|
+
*/
|
|
489
|
+
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
|
|
490
|
+
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
491
|
+
if (!bearer) return true;
|
|
492
|
+
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
493
|
+
if (callerAccountId === undefined) return true;
|
|
494
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
495
|
+
// No physical-main read to identify the caller: the observed-main equality tag suffices.
|
|
496
|
+
return callerMatchesObservedMain(headers);
|
|
497
|
+
}
|
|
498
|
+
const stored = getCodexAccountCredential(accountId);
|
|
499
|
+
const entry = config.codexAccounts?.find(account => account.id === accountId);
|
|
500
|
+
const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId;
|
|
501
|
+
if (!cooledAccountId) return true;
|
|
502
|
+
if (cooledAccountId !== callerAccountId) return false;
|
|
503
|
+
if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true;
|
|
504
|
+
// Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve
|
|
505
|
+
// path already trusts. A credential whose own two encodings of it disagree cannot identify
|
|
506
|
+
// anyone, so it fails closed even when the disagreement is on the stored side.
|
|
507
|
+
const callerUser = nativeUserIdClaims(bearer);
|
|
508
|
+
const cooledUser = stored?.accessToken
|
|
509
|
+
? nativeUserIdClaims(stored.accessToken)
|
|
510
|
+
: { userId: undefined, conflict: false };
|
|
511
|
+
if (callerUser.conflict || cooledUser.conflict) return true;
|
|
512
|
+
if (callerUser.userId !== undefined && cooledUser.userId !== undefined) {
|
|
513
|
+
return callerUser.userId === cooledUser.userId;
|
|
514
|
+
}
|
|
515
|
+
const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined;
|
|
516
|
+
const cooledEmail = entry?.email?.trim().toLowerCase() || undefined;
|
|
517
|
+
if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail;
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
458
521
|
function captureObservedMainWriter(): MainQuotaWriter | undefined {
|
|
459
522
|
const identityKey = getObservedMainQuotaIdentityKey();
|
|
460
523
|
return identityKey === undefined ? undefined : {
|
|
@@ -536,6 +599,7 @@ export class CodexThreadAffinityExpiredError extends Error {
|
|
|
536
599
|
|
|
537
600
|
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
538
601
|
return !(cause instanceof CodexMainAccountHardLockError)
|
|
602
|
+
&& !(cause instanceof CodexAccountValidationPendingError)
|
|
539
603
|
&& !(cause instanceof CodexReserveUnavailableError)
|
|
540
604
|
&& !(cause instanceof CodexCredentialGenerationConflictError)
|
|
541
605
|
&& !(cause instanceof CodexCredentialRefreshLockTimeoutError)
|
|
@@ -598,20 +662,31 @@ export async function resolveCodexAuthContext(
|
|
|
598
662
|
throw new CodexReserveUnavailableError();
|
|
599
663
|
}
|
|
600
664
|
const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
|
|
601
|
-
const
|
|
665
|
+
const requestOwnedMainPinCandidate = requestScopedMainCredential
|
|
602
666
|
&& fixedAccountId === undefined
|
|
603
667
|
&& config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
|
|
604
668
|
&& isEffectiveCodexAccountPinned(config)
|
|
605
669
|
&& !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
|
|
606
|
-
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))
|
|
607
670
|
&& requestOwnedMainPinHasQuotaHeadroom(config);
|
|
671
|
+
// During an owned startup, equality cannot be established until recovery and the
|
|
672
|
+
// memory-only policy binding finish. This read-only fence never probes a foreign home.
|
|
673
|
+
if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
|
|
674
|
+
throw new CodexMainProfileDrainingError();
|
|
675
|
+
}
|
|
676
|
+
const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
|
|
677
|
+
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
|
|
608
678
|
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
|
|
609
679
|
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
|
|
610
680
|
}
|
|
611
681
|
const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
|
|
612
|
-
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
613
682
|
const substituteStoredMain = options.substituteMainCredentialForDirect === true;
|
|
683
|
+
// An internal route change can strip the admission bearer before this point.
|
|
684
|
+
// Trusted substitution still has to claim and validate stored main below.
|
|
685
|
+
if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
614
686
|
if (!substituteStoredMain) {
|
|
687
|
+
if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) {
|
|
688
|
+
throw new CodexMainProfileDrainingError();
|
|
689
|
+
}
|
|
615
690
|
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
|
|
616
691
|
if (reserve) {
|
|
617
692
|
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
|
|
@@ -851,6 +926,9 @@ export async function resolveCodexAuthContext(
|
|
|
851
926
|
} finally {
|
|
852
927
|
selectionAdmission?.release();
|
|
853
928
|
}
|
|
929
|
+
// Legacy selectors may retain an unusable account for actionable errors. A
|
|
930
|
+
// deferred credential must never become request auth through that fallback.
|
|
931
|
+
assertCodexAccountValidationReady(accountId);
|
|
854
932
|
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
855
933
|
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
856
934
|
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
@@ -885,6 +963,14 @@ export async function resolveCodexAuthContext(
|
|
|
885
963
|
? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
|
|
886
964
|
: tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
|
|
887
965
|
if (!probeLeaseId) {
|
|
966
|
+
// The selector can retain the configured Pool account when no stored
|
|
967
|
+
// alternate is eligible. A validated caller may still serve this request,
|
|
968
|
+
// just as it can after an upstream rejection, without changing Pool state.
|
|
969
|
+
if (requestScopedMainCredential && fixedAccountId === undefined
|
|
970
|
+
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
971
|
+
&& !callerIsCooledPoolAccount(headers, config, accountId)) {
|
|
972
|
+
return await resolveCallerOwnedMainContext();
|
|
973
|
+
}
|
|
888
974
|
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
|
|
889
975
|
}
|
|
890
976
|
}
|
|
@@ -938,6 +1024,7 @@ export async function resolveCodexAuthContext(
|
|
|
938
1024
|
|
|
939
1025
|
try {
|
|
940
1026
|
const token = await getValidCodexToken(accountId);
|
|
1027
|
+
assertCodexAccountValidationReady(accountId);
|
|
941
1028
|
return {
|
|
942
1029
|
kind: "pool",
|
|
943
1030
|
accountId,
|
|
@@ -977,6 +1064,7 @@ export function applyCodexAuthContextToProvider(
|
|
|
977
1064
|
mode: CodexAccountMode | undefined,
|
|
978
1065
|
): OcxRuntimeProviderConfig {
|
|
979
1066
|
if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
|
|
1067
|
+
assertCodexAccountValidationReady(ctx.accountId);
|
|
980
1068
|
return {
|
|
981
1069
|
...provider,
|
|
982
1070
|
_codexAccountOverride: {
|
|
@@ -1019,6 +1107,7 @@ export function materializeCodexUpstreamAuth(
|
|
|
1019
1107
|
if (value) selected.set(name, value);
|
|
1020
1108
|
}
|
|
1021
1109
|
if (ctx.kind === "pool" || ctx.kind === "main-pool") {
|
|
1110
|
+
assertCodexAccountValidationReady(ctx.accountId);
|
|
1022
1111
|
selected.set("authorization", `Bearer ${ctx.accessToken}`);
|
|
1023
1112
|
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
|
|
1024
1113
|
if (ctx.kind === "main-pool") {
|
|
@@ -144,6 +144,29 @@ export interface CatalogModel {
|
|
|
144
144
|
codexToolMode?: "code_mode_only" | "shell";
|
|
145
145
|
/** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
|
|
146
146
|
capabilities?: string[];
|
|
147
|
+
/**
|
|
148
|
+
* This row is listed but cannot currently serve a request (#1711). Today the only value is
|
|
149
|
+
* "no_credit", set when every usable target has positive quota-exhaustion evidence.
|
|
150
|
+
*
|
|
151
|
+
* It is NOT visibility. The row stays `visibility: "list"` on purpose: the issue explicitly
|
|
152
|
+
* rejects hiding, and Codex Desktop only understands "list" and "hide" anyway, so hiding would
|
|
153
|
+
* be the one outcome the reporter asked not to have. An OpenCodex-aware consumer greys the
|
|
154
|
+
* entry; the native picker ignores the field, which is the honest limit of what a custom
|
|
155
|
+
* catalog field can do.
|
|
156
|
+
*/
|
|
157
|
+
quotaInactiveReason?: "no_credit";
|
|
158
|
+
/**
|
|
159
|
+
* Discovered per-token cost class for this routed model (#3666). "free" means the provider's
|
|
160
|
+
* own /models row reported a numeric zero for BOTH the prompt and the completion rate;
|
|
161
|
+
* "paid" means at least one rate is above zero. ABSENT means unknown — the provider published
|
|
162
|
+
* no usable pair, or this row never came from a models API at all.
|
|
163
|
+
*
|
|
164
|
+
* Fail closed: a partial, non-numeric, or negative rate leaves the field absent, never "free",
|
|
165
|
+
* because showing a paid model under a Free filter costs the user money while hiding a free
|
|
166
|
+
* one costs a click. This is a management/Dashboard projection only — deriveEntry never
|
|
167
|
+
* serializes it into the Codex catalog, and it never affects routing or visibility.
|
|
168
|
+
*/
|
|
169
|
+
pricingStatus?: "free" | "paid";
|
|
147
170
|
/** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
|
|
148
171
|
catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND | typeof CODEX_PROVIDER_MODEL_CATALOG_KIND;
|
|
149
172
|
}
|