@bitkyc08/opencodex 2.49.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.
Files changed (69) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/claude/inbound.ts +17 -5
  7. package/src/cli/account-api.ts +18 -3
  8. package/src/cli/account-auth.ts +8 -1
  9. package/src/cli/account-extended.ts +2 -1
  10. package/src/cli/account.ts +1 -0
  11. package/src/cli/capabilities.ts +15 -1
  12. package/src/cli/index.ts +5 -1
  13. package/src/cli/models-runtime.ts +8 -3
  14. package/src/cli/observe.ts +13 -3
  15. package/src/clients/config-export/zcode.ts +24 -0
  16. package/src/codex/account-runtime-state.ts +6 -1
  17. package/src/codex/account-store.ts +72 -9
  18. package/src/codex/account-usability.ts +3 -2
  19. package/src/codex/auth-api.ts +107 -23
  20. package/src/codex/auth-context.ts +21 -0
  21. package/src/codex/catalog/parsing.ts +23 -0
  22. package/src/codex/catalog/provider-fetch.ts +71 -2
  23. package/src/codex/catalog/sync.ts +14 -0
  24. package/src/codex/inject.ts +3 -2
  25. package/src/codex/quota-auto-refresh.ts +6 -1
  26. package/src/codex/quota.ts +54 -8
  27. package/src/combos/index.ts +2 -0
  28. package/src/combos/resolve.ts +52 -0
  29. package/src/config.ts +58 -0
  30. package/src/generated/compatibility-version.json +72 -60
  31. package/src/lib/errors.ts +8 -0
  32. package/src/lib/privacy.ts +25 -0
  33. package/src/oauth/health.ts +47 -12
  34. package/src/oauth/index.ts +46 -8
  35. package/src/oauth/token-guardian.ts +32 -6
  36. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  37. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  38. package/src/providers/quota.ts +15 -0
  39. package/src/providers/registry.ts +1 -1
  40. package/src/server/auth-cors.ts +6 -0
  41. package/src/server/chat-completions.ts +4 -4
  42. package/src/server/chat-native.ts +10 -1
  43. package/src/server/claude-messages.ts +5 -5
  44. package/src/server/images.ts +2 -2
  45. package/src/server/index.ts +25 -2
  46. package/src/server/management/logs-usage-routes.ts +4 -1
  47. package/src/server/management/model-rows.ts +16 -1
  48. package/src/server/management/oauth-account-routes.ts +6 -2
  49. package/src/server/management/provider-routes.ts +9 -2
  50. package/src/server/management/request-history-routes.ts +4 -2
  51. package/src/server/management/route-registry.ts +5 -4
  52. package/src/server/management/shared.ts +66 -3
  53. package/src/server/management-api.ts +1 -1
  54. package/src/server/request-decompress.ts +91 -3
  55. package/src/server/request-log.ts +10 -0
  56. package/src/server/responses/codex-ws-wire.ts +1 -1
  57. package/src/server/responses/compact.ts +8 -2
  58. package/src/server/responses/context-overflow.ts +11 -0
  59. package/src/server/responses/core.ts +144 -38
  60. package/src/server/responses/policy-fallback.ts +6 -2
  61. package/src/server/search.ts +2 -2
  62. package/src/service.ts +92 -7
  63. package/src/types/accounts.ts +18 -0
  64. package/src/types/config.ts +36 -0
  65. package/src/types/provider.ts +56 -0
  66. package/src/types.ts +4 -0
  67. package/src/web-search/ollama-executor.ts +127 -0
  68. package/src/web-search/passthrough-bridge.ts +761 -0
  69. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -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 { maskEmail } from "../lib/privacy";
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>();
@@ -368,6 +370,7 @@ function poolAccountDto(
368
370
  hasCredential: boolean,
369
371
  paused: boolean,
370
372
  priority: number,
373
+ maskEmails: boolean,
371
374
  ): CodexAuthAccountDto {
372
375
  const plan = codexPlanValue(account.plan);
373
376
  const quota = quotaForPlan(quotaResult.quota, plan);
@@ -375,7 +378,7 @@ function poolAccountDto(
375
378
  const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
376
379
  return {
377
380
  id: account.id,
378
- email: maskEmail(account.email) ?? account.email,
381
+ email: projectEmail(account.email, maskEmails) ?? account.email,
379
382
  ...(account.alias !== undefined ? { alias: account.alias } : {}),
380
383
  ...(plan !== undefined ? { plan } : {}),
381
384
  logLabel: codexAccountLogLabel(account),
@@ -383,7 +386,7 @@ function poolAccountDto(
383
386
  paused,
384
387
  priority,
385
388
  quota: quota ? { ...quota } : null,
386
- needsReauth,
389
+ needsReauth: needsReauth || health.status === "reauth_required",
387
390
  hasCredential,
388
391
  ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
389
392
  ...oauthAccountHealthFields("codex", account.id, health),
@@ -638,7 +641,7 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
638
641
 
639
642
  interface StagedNewCodexAccountState {
640
643
  credential: CodexAccountCredentials;
641
- validatedAt: number;
644
+ validatedAt?: number;
642
645
  }
643
646
 
644
647
  type PersistNewCodexAccountOutcome =
@@ -691,8 +694,10 @@ function persistNewCodexAccount(
691
694
  }
692
695
 
693
696
  try {
694
- saveCodexAccountCredential(addedAccount.id, staged.credential);
695
- markCodexAccountValidated(addedAccount.id, staged.validatedAt);
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);
696
701
  clearAccountNeedsReauth(addedAccount.id);
697
702
  } catch {
698
703
  // Config is already durable. Return the failure outcome through the coordinator so its
@@ -1101,6 +1106,7 @@ interface PoolQuotaRefreshFlight {
1101
1106
  superseded?: boolean;
1102
1107
  startCredentialGeneration?: number;
1103
1108
  resolvedCredentialGeneration?: number;
1109
+ validatePending?: boolean;
1104
1110
  };
1105
1111
  promise: Promise<PoolQuotaResult>;
1106
1112
  }
@@ -1480,11 +1486,12 @@ async function fetchFreshPoolAccountQuota(
1480
1486
  }
1481
1487
  }
1482
1488
 
1483
- async function fetchPoolAccountQuota(
1489
+ export async function fetchPoolAccountQuota(
1484
1490
  accountId: string,
1485
1491
  forceRefresh = false,
1486
1492
  configuredPlan?: string,
1487
1493
  getValidToken: typeof getValidCodexToken = getValidCodexToken,
1494
+ validatePending = false,
1488
1495
  afterDispatchSequence?: number,
1489
1496
  ): Promise<PoolQuotaResult> {
1490
1497
  const existing = getAccountQuota(accountId);
@@ -1507,7 +1514,11 @@ async function fetchPoolAccountQuota(
1507
1514
  && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence)
1508
1515
  && generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
1509
1516
  });
1510
- if (current) return current.promise;
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
+ }
1511
1522
  if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
1512
1523
 
1513
1524
  // A post-reset request must not let an older same-account response overwrite its evidence.
@@ -1517,6 +1528,7 @@ async function fetchPoolAccountQuota(
1517
1528
  }
1518
1529
  const state: PoolQuotaRefreshFlight["state"] = {
1519
1530
  startCredentialGeneration: record?.generation,
1531
+ validatePending,
1520
1532
  };
1521
1533
  const refresh = fetchFreshPoolAccountQuota(
1522
1534
  accountId,
@@ -1528,18 +1540,54 @@ async function fetchPoolAccountQuota(
1528
1540
  onDispatch: sequence => { state.dispatchSequence = sequence; },
1529
1541
  mayPublish: () => state.superseded !== true,
1530
1542
  },
1531
- );
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
+ });
1532
1577
  const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
1533
1578
  const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
1534
1579
  activeFlights.add(flight);
1535
1580
  if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
1536
- try {
1537
- return await refresh;
1538
- } finally {
1581
+ const releaseFlight = () => {
1539
1582
  activeFlights.delete(flight);
1540
1583
  if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
1541
1584
  poolQuotaRefreshInFlight.delete(accountId);
1542
1585
  }
1586
+ };
1587
+ try {
1588
+ return await refresh;
1589
+ } finally {
1590
+ releaseFlight();
1543
1591
  }
1544
1592
  }
1545
1593
 
@@ -1594,8 +1642,10 @@ async function refreshAfterManualReset(
1594
1642
  }
1595
1643
  return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration };
1596
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.
1597
1647
  const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken,
1598
- didReset ? afterDispatchSequence : undefined);
1648
+ false, didReset ? afterDispatchSequence : undefined);
1599
1649
  const record = readCodexAccountRecord(accountId);
1600
1650
  const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
1601
1651
  && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
@@ -1876,9 +1926,12 @@ export interface CodexAuthAccountsSnapshot {
1876
1926
  export async function listCodexAuthAccountsSnapshot(
1877
1927
  config: OcxConfig,
1878
1928
  forceRefresh = false,
1929
+ options: { validatePending?: boolean } = {},
1879
1930
  ): Promise<CodexAuthAccountsSnapshot> {
1880
1931
  const runtimeConfig = getRuntimeConfig(config);
1881
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);
1882
1935
  const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
1883
1936
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1884
1937
  const cred = getCodexAccountCredential(account.id);
@@ -1887,7 +1940,7 @@ export async function listCodexAuthAccountsSnapshot(
1887
1940
  quotaResult = { quota: null, needsReauth: true };
1888
1941
  } else {
1889
1942
  try {
1890
- quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
1943
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
1891
1944
  } catch (error) {
1892
1945
  if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
1893
1946
  quotaResult = {
@@ -1923,6 +1976,7 @@ export async function listCodexAuthAccountsSnapshot(
1923
1976
  false,
1924
1977
  isCodexAccountPaused(runtimeConfig, accountId),
1925
1978
  getCodexAccountPriority(runtimeConfig, accountId),
1979
+ maskEmails,
1926
1980
  )];
1927
1981
  }
1928
1982
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -1942,6 +1996,7 @@ export async function listCodexAuthAccountsSnapshot(
1942
1996
  true,
1943
1997
  isCodexAccountPaused(runtimeConfig, accountId),
1944
1998
  getCodexAccountPriority(runtimeConfig, accountId),
1999
+ maskEmails,
1945
2000
  )];
1946
2001
  });
1947
2002
  const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
@@ -1958,7 +2013,7 @@ export async function listCodexAuthAccountsSnapshot(
1958
2013
  });
1959
2014
  const main: CodexAuthAccountDto = {
1960
2015
  id: MAIN_CODEX_ACCOUNT_ID,
1961
- email: maskEmail(mainInfo.email) ?? "Codex App login",
2016
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
1962
2017
  plan: mainInfo.plan,
1963
2018
  ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
1964
2019
  && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
@@ -2019,8 +2074,12 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
2019
2074
  }
2020
2075
  }
2021
2076
 
2022
- export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
2023
- return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
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;
2024
2083
  }
2025
2084
 
2026
2085
  interface PauseExhaustedResult {
@@ -2129,6 +2188,7 @@ export async function handleCodexAuthAPI(
2129
2188
  url: URL,
2130
2189
  config: OcxConfig,
2131
2190
  convergeCodexCatalog?: CodexAuthCatalogConvergence,
2191
+ principal?: import("../server/management-auth").ManagementPrincipal,
2132
2192
  ): Promise<Response | null> {
2133
2193
 
2134
2194
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
@@ -2136,6 +2196,14 @@ export async function handleCodexAuthAPI(
2136
2196
  return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
2137
2197
  }
2138
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
+
2139
2207
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
2140
2208
  return manualImportDisabledResponse();
2141
2209
  }
@@ -2313,6 +2381,9 @@ export async function handleCodexAuthAPI(
2313
2381
  const exists = (runtimeConfig.codexAccounts ?? [])
2314
2382
  .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
2315
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
+ }
2316
2387
  }
2317
2388
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
2318
2389
  // "Use this account now" outranks selection order until the account is spent:
@@ -2757,7 +2828,12 @@ export async function handleCodexAuthAPI(
2757
2828
  break;
2758
2829
  }
2759
2830
 
2760
- const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
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);
2761
2837
  if (!warmup.ok) {
2762
2838
  const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
2763
2839
  setCodexLoginState(flowId, {
@@ -2797,11 +2873,13 @@ export async function handleCodexAuthAPI(
2797
2873
  };
2798
2874
 
2799
2875
  if (existingIdx >= 0) {
2800
- saveCodexAccountCredential(accountId, credential);
2876
+ const generation = saveCodexAccountCredential(accountId, credential, {
2877
+ validationPending: warmup.validatedAt === undefined,
2878
+ });
2801
2879
  // A successful reauthentication replaces the credential generation. Do not let a
2802
2880
  // failed optional WHAM probe make the replacement inherit quota from the old record.
2803
2881
  if (reauth) clearAccountQuota(accountId);
2804
- markCodexAccountValidated(accountId, warmup.validatedAt);
2882
+ if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation);
2805
2883
  clearAccountNeedsReauth(accountId);
2806
2884
  if (quota) setAccountQuotaFromParsed(accountId, quota);
2807
2885
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
@@ -2852,6 +2930,7 @@ export async function handleCodexAuthAPI(
2852
2930
  status: "done",
2853
2931
  accountId,
2854
2932
  email,
2933
+ ...(warmup.validatedAt === undefined ? { validationPending: true } : {}),
2855
2934
  ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
2856
2935
  doneAt: Date.now(),
2857
2936
  });
@@ -2952,6 +3031,9 @@ export async function handleCodexAuthAPI(
2952
3031
  if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
2953
3032
  const flowId = url.searchParams.get("flowId");
2954
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);
2955
3037
  // Reauth always has a pre-existing credential; never treat "credential exists" as success
2956
3038
  // when the flow map entry is gone (would false-complete on lost/expired flow state).
2957
3039
  const reauthStatus = url.searchParams.get("reauth") === "1";
@@ -2964,13 +3046,15 @@ export async function handleCodexAuthAPI(
2964
3046
  && !isAccountNeedsReauth(accountId)
2965
3047
  && getCodexAccountCredential(accountId)
2966
3048
  ) {
2967
- return jsonResponse({ status: "done", accountId });
3049
+ return jsonResponse({ status: "done", accountId,
3050
+ ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}),
3051
+ });
2968
3052
  }
2969
- return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
3053
+ return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" });
2970
3054
  }
2971
3055
  // Legacy fallback: return latest pending flow
2972
3056
  for (const [, st] of codexAuthLoginState) {
2973
- if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
3057
+ if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined });
2974
3058
  }
2975
3059
  return jsonResponse({ status: "idle" });
2976
3060
  }
@@ -7,6 +7,7 @@ import {
7
7
  getCodexAccountCredential,
8
8
  getValidCodexToken,
9
9
  isCodexAccountGenerationLive,
10
+ readCodexAccountRecord,
10
11
  } from "./account-store";
11
12
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
12
13
  import { ConfigMutationLockError } from "../config";
@@ -200,6 +201,19 @@ export class CodexPoolAuthenticationError extends Error {
200
201
  }
201
202
  }
202
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
+
203
217
  export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
204
218
  "OpenCodex local native-main profile maintenance is active; retry this request";
205
219
 
@@ -585,6 +599,7 @@ export class CodexThreadAffinityExpiredError extends Error {
585
599
 
586
600
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
587
601
  return !(cause instanceof CodexMainAccountHardLockError)
602
+ && !(cause instanceof CodexAccountValidationPendingError)
588
603
  && !(cause instanceof CodexReserveUnavailableError)
589
604
  && !(cause instanceof CodexCredentialGenerationConflictError)
590
605
  && !(cause instanceof CodexCredentialRefreshLockTimeoutError)
@@ -911,6 +926,9 @@ export async function resolveCodexAuthContext(
911
926
  } finally {
912
927
  selectionAdmission?.release();
913
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);
914
932
  // Lazy prime: if the selected account has no quota yet, the pool is likely
915
933
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
916
934
  // best-effort prime so the NEXT routing decision has real scores. This never
@@ -1006,6 +1024,7 @@ export async function resolveCodexAuthContext(
1006
1024
 
1007
1025
  try {
1008
1026
  const token = await getValidCodexToken(accountId);
1027
+ assertCodexAccountValidationReady(accountId);
1009
1028
  return {
1010
1029
  kind: "pool",
1011
1030
  accountId,
@@ -1045,6 +1064,7 @@ export function applyCodexAuthContextToProvider(
1045
1064
  mode: CodexAccountMode | undefined,
1046
1065
  ): OcxRuntimeProviderConfig {
1047
1066
  if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
1067
+ assertCodexAccountValidationReady(ctx.accountId);
1048
1068
  return {
1049
1069
  ...provider,
1050
1070
  _codexAccountOverride: {
@@ -1087,6 +1107,7 @@ export function materializeCodexUpstreamAuth(
1087
1107
  if (value) selected.set(name, value);
1088
1108
  }
1089
1109
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
1110
+ assertCodexAccountValidationReady(ctx.accountId);
1090
1111
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
1091
1112
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
1092
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
  }
@@ -60,6 +60,7 @@ import {
60
60
  comboModelId,
61
61
  getCombo,
62
62
  listComboIds,
63
+ quotaInactiveReason,
63
64
  targetKey,
64
65
  } from "../../combos";
65
66
  import type { NormalizedComboConfig } from "../../combos/types";
@@ -79,6 +80,7 @@ import {
79
80
  type ProviderModelsApiItem,
80
81
  type ResolvedProviderModelDiscovery,
81
82
  } from "../../providers/model-discovery";
83
+ import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
82
84
  import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
83
85
  import upstreamModelsSnapshot from "../data/upstream-models.json";
84
86
  import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
@@ -1400,6 +1402,51 @@ function modelInputModalities(
1400
1402
  return undefined;
1401
1403
  }
1402
1404
 
1405
+ /**
1406
+ * A per-token rate exactly as a /models row publishes it, or undefined when the value is not a
1407
+ * usable non-negative number. Providers ship these both as JSON numbers and as decimal strings —
1408
+ * OpenRouter encodes free as the string `"0.00000000"` — so both shapes are accepted and nothing
1409
+ * else is. The explicit numeric-shape test has to run BEFORE any coercion: `Number("")` and
1410
+ * `Number(" ")` are both 0 and `Number(true)` is 1, so a bare `Number(value)` would classify a
1411
+ * row with an empty price string as free.
1412
+ */
1413
+ const DISCOVERED_PRICING_RATE_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
1414
+
1415
+ function discoveredPricingRate(value: unknown): number | undefined {
1416
+ const numeric = typeof value === "number"
1417
+ ? value
1418
+ : typeof value === "string" && DISCOVERED_PRICING_RATE_PATTERN.test(value.trim())
1419
+ ? Number(value.trim())
1420
+ : undefined;
1421
+ if (numeric === undefined || !Number.isFinite(numeric) || numeric < 0) return undefined;
1422
+ return numeric;
1423
+ }
1424
+
1425
+ /**
1426
+ * Cost class for one discovered row, read from the provider's own `pricing` object (#3666).
1427
+ *
1428
+ * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing,
1429
+ * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only
1430
+ * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free
1431
+ * one costs a click.
1432
+ *
1433
+ * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming
1434
+ * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on
1435
+ * purpose. And the operator's own `modelCosts` overlay is an estimate they typed, not something
1436
+ * the provider published, so a zeroed overlay never reaches this field either.
1437
+ *
1438
+ * Classification is on numeric zero and never on a unit conversion: OpenRouter quotes USD per
1439
+ * token while the cost overlays and the jawcode bundle quote per 1M, and zero is zero in both.
1440
+ */
1441
+ export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" {
1442
+ const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing);
1443
+ if (!pricing) return "unknown";
1444
+ const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input);
1445
+ const completion = discoveredPricingRate(pricing.completion ?? pricing.output);
1446
+ if (prompt === undefined || completion === undefined) return "unknown";
1447
+ return prompt === 0 && completion === 0 ? "free" : "paid";
1448
+ }
1449
+
1403
1450
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
1404
1451
  const metadata = plainRecord(item.metadata);
1405
1452
  const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
@@ -1464,6 +1511,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1464
1511
  : undefined;
1465
1512
  const capabilities = modelCapabilities(item);
1466
1513
  const inputModalities = modelInputModalities(item, capabilities);
1514
+ const pricingStatus = discoveredPricingStatus(item);
1467
1515
  return {
1468
1516
  ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
1469
1517
  ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
@@ -1471,6 +1519,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1471
1519
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1472
1520
  ...(inputModalities ? { inputModalities } : {}),
1473
1521
  ...(capabilities ? { capabilities } : {}),
1522
+ // Omitted when the classification is "unknown", following this function's existing
1523
+ // contract that an unknown property is absent rather than present-and-empty. Callers
1524
+ // that need to tell "provider published no prices" from "this build does not classify"
1525
+ // call discoveredPricingStatus directly.
1526
+ ...(pricingStatus !== "unknown" ? { pricingStatus } : {}),
1474
1527
  };
1475
1528
  }
1476
1529
 
@@ -1871,7 +1924,14 @@ async function fetchProviderModelsWithAuth(
1871
1924
  markProviderDiscoveryOk(name, live.length);
1872
1925
  return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1873
1926
  }
1874
- const extracted = extractProviderModelItems(bounded.value, discovery);
1927
+ const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"
1928
+ ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
1929
+ : undefined;
1930
+ // Native /v1beta/models wins; a google row served by an OpenAI-compatible
1931
+ // gateway keeps the generic data[] / top-level-array contract.
1932
+ const extracted = googleAiStudio?.ok
1933
+ ? googleAiStudio
1934
+ : extractProviderModelItems(bounded.value, discovery);
1875
1935
  if (!extracted.ok) {
1876
1936
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
1877
1937
  const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
@@ -2622,7 +2682,16 @@ async function gatherRoutedModelsUncached(
2622
2682
  return {
2623
2683
  models: models.map(model => {
2624
2684
  const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
2625
- return displayName && !model.displayName ? { ...model, displayName } : model;
2685
+ // #1711: one stamping point for every row this gather produces — routed, combo, and custom
2686
+ // alike — because it is the only place that has both the finished list and the config the
2687
+ // quota rules need. A combo votes over its own targets; anything else votes over the single
2688
+ // provider that would serve it.
2689
+ const targets = model.provider === COMBO_NAMESPACE
2690
+ ? config.combos?.[model.id]?.targets ?? []
2691
+ : [{ provider: model.provider }];
2692
+ const inactive = quotaInactiveReason(config, targets);
2693
+ const named = displayName && !model.displayName ? { ...model, displayName } : model;
2694
+ return inactive ? { ...named, quotaInactiveReason: inactive } : named;
2626
2695
  }),
2627
2696
  comboOmissions: localOmissions,
2628
2697
  providerAuthOutcomes: localProviderAuthOutcomes,
@@ -99,6 +99,12 @@ export const PICKER_ORDER_PRIORITY_BASE = 1_000;
99
99
  // independent of display order. It does not freeze native advertisements. Absent on unmoved rows.
100
100
  export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority";
101
101
 
102
+ // OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711).
103
+ // Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority
104
+ // above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native
105
+ // picker and cannot change what Codex offers. It never touches `visibility`.
106
+ export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason";
107
+
102
108
  export type SpawnAgentSurface = "v1" | "v2";
103
109
 
104
110
  export type SubagentRosterExclusionReason =
@@ -383,6 +389,10 @@ export function deriveEntry(
383
389
  if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
384
390
  applyCatalogModelMetadata(e, model);
385
391
  if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
392
+ // Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is
393
+ // the whole point of #1711 — operator disable is what removes rows, and it stays a separate
394
+ // path from this one.
395
+ if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
386
396
  } else {
387
397
  applyNativeOpenAiContextOverride(e, contextCap);
388
398
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
@@ -430,6 +440,10 @@ export function deriveEntry(
430
440
  if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
431
441
  applyCatalogModelMetadata(entry, model);
432
442
  if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
443
+ // Same additive stamp as the templated path above. A routed row that reaches the no-template
444
+ // fallback is still a served row, so omitting it here would make the field depend on whether a
445
+ // template happened to be cached — which is exactly what the regression test caught.
446
+ if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
433
447
  if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap);
434
448
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
435
449
  preserveExactInputModalities: preserveExact,
@@ -1430,7 +1430,7 @@ export async function injectCodexConfig(
1430
1430
  const historyMessage =
1431
1431
  keepRootOverrideAlongsideTable
1432
1432
  ? (keptUserBaseUrl
1433
- ? ` Codex resume history: left unchanged; threads already tagged openai follow your own root openai_base_url, not the proxy.\n`
1433
+ ? ` Codex resume history: left unchanged; threads already tagged openai follow your configured root openai_base_url.\n`
1434
1434
  : ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`)
1435
1435
  : config?.syncResumeHistory === false
1436
1436
  ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
@@ -1447,6 +1447,7 @@ export async function injectCodexConfig(
1447
1447
  // The client-compaction form writes a provider table as well, so "nothing was injected" would
1448
1448
  // misdescribe the file it just produced: new threads do use the injected table. Report that
1449
1449
  // mixed result on its own terms, and never tell the operator to delete a setting of theirs.
1450
+ // Ownership alone says nothing about destination: their line may already target this proxy.
1450
1451
  if (keptUserBaseUrl && keepRootOverrideAlongsideTable) {
1451
1452
  return {
1452
1453
  success: true,
@@ -1459,7 +1460,7 @@ export async function injectCodexConfig(
1459
1460
  managedDefaultsMessage +
1460
1461
  ` New threads use the injected opencodex provider and route through the proxy.\n` +
1461
1462
  ` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` +
1462
- ` Remove that line and rerun 'ocx start' only if you want those threads on the proxy too.\n` +
1463
+ ` No root URL change is required to enable client-side compaction for new threads.\n` +
1463
1464
  ` Fallback: codex --profile opencodex (same behavior)`,
1464
1465
  };
1465
1466
  }
@@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id";
8
8
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
9
9
  import { isCodexAccountPaused } from "./account-pause";
10
10
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
11
- import { getValidCodexToken, isCodexAccountGenerationLive } from "./account-store";
11
+ import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
12
12
  import { codexAccountLogLabel } from "./account-label";
13
13
  import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
14
14
  import { isMainAccountHardLocked } from "./main-account-hard-lock";
@@ -164,7 +164,11 @@ function mainWarmupRestricted(config: OcxConfig): boolean {
164
164
  async function warmAccount(config: OcxConfig, accountId: string): Promise<void | false> {
165
165
  const writerGeneration = captureConfigGeneration();
166
166
  if (accountId !== MAIN_CODEX_ACCOUNT_ID) {
167
+ if (readCodexAccountRecord(accountId)?.codexValidationPending) return false;
167
168
  const token = await getValidCodexToken(accountId);
169
+ const record = readCodexAccountRecord(accountId);
170
+ if (!record?.credential || record.deletedAt != null || record.codexValidationPending
171
+ || record.generation !== token.generation) return false;
168
172
  if (isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
169
173
  try {
170
174
  await warmCodexAccount({ ...token, onCompleted: headers => {
@@ -294,6 +298,7 @@ export async function runCodexQuotaAutoRefresh(
294
298
  && (accountId === MAIN_CODEX_ACCOUNT_ID || config.codexAccounts?.some(
295
299
  account => account.id === accountId && isSelectableCodexPoolAccount(account)))
296
300
  && (setting?.fiveHour === true || setting?.weekly === true)
301
+ && !(accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending)
297
302
  && !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId)
298
303
  && !(accountId === MAIN_CODEX_ACCOUNT_ID && isMainAccountHardLocked(config));
299
304
  };