@bitkyc08/opencodex 2.11.0 → 2.11.1

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 (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -1,4 +1,10 @@
1
- import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
1
+ import {
2
+ ConfigMutationLockError,
3
+ loadConfig,
4
+ mutatePersistedConfig,
5
+ saveConfigPreservingClaudeCode,
6
+ withConfigMutationLockSync,
7
+ } from "../config";
2
8
  import { withCodexAccountLogLabel } from "./account-label";
3
9
  import {
4
10
  getCodexAccountCredential,
@@ -14,6 +20,14 @@ import {
14
20
  TokenRefreshError,
15
21
  } from "./account-store";
16
22
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
23
+ import {
24
+ appendDefaultCodexAccountNamespace,
25
+ codexAccountPickerEnabled,
26
+ } from "./account-namespaces";
27
+ import {
28
+ catalogRefreshIsPending,
29
+ normalizeCatalogDisposition,
30
+ } from "./catalog-refresh-status";
17
31
  import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
18
32
  import {
19
33
  clearCodexAccountPin,
@@ -85,7 +99,8 @@ export { clearMainAccountInfoCache } from "./main-account-cache";
85
99
  import { maskEmail } from "../lib/privacy";
86
100
  import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
87
101
  export { maskEmail } from "../lib/privacy";
88
- import type { CodexAccount, OcxConfig } from "../types";
102
+ import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
103
+ import type { CatalogDisposition } from "./convergence-types";
89
104
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
90
105
  import { providerCodexAccountMode } from "../providers/registry";
91
106
  import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -131,10 +146,22 @@ function nativeMainProfileBusyResponse(): Response {
131
146
  }
132
147
 
133
148
  const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
149
+ const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
150
+ const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
134
151
 
135
152
  const MAX_CODEX_LOGIN_STATE_ROWS = 32;
136
153
  const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
137
- interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number }
154
+ interface CodexLoginStateRow {
155
+ status: string;
156
+ startedAt: number;
157
+ accountId?: string;
158
+ email?: string;
159
+ error?: string;
160
+ code?: string;
161
+ needsReauth?: boolean;
162
+ catalogRefreshPending?: boolean;
163
+ doneAt?: number;
164
+ }
138
165
  const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
139
166
  export class CodexLoginStateBusyError extends ResourceAdmissionError {
140
167
  constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
@@ -391,6 +418,100 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
391
418
  Object.assign(sourceConfig, nextConfig);
392
419
  }
393
420
 
421
+ interface StagedNewCodexAccountState {
422
+ credential: CodexAccountCredentials;
423
+ validatedAt: number;
424
+ }
425
+
426
+ type PersistNewCodexAccountOutcome =
427
+ | { status: "committed"; pickerVisibilityChanged: boolean }
428
+ | { status: "publication-failed"; pickerVisibilityChanged: boolean };
429
+
430
+ function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
431
+ return {
432
+ error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
433
+ code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
434
+ accountId,
435
+ needsReauth: true as const,
436
+ ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
437
+ };
438
+ }
439
+
440
+ /** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
441
+ function persistNewCodexAccount(
442
+ sourceConfig: OcxConfig,
443
+ runtimeConfig: OcxConfig,
444
+ addedAccount: CodexAccount,
445
+ staged: StagedNewCodexAccountState,
446
+ ): PersistNewCodexAccountOutcome {
447
+ return withConfigMutationLockSync(() => {
448
+ const previousConfig = { ...runtimeConfig };
449
+ let pickerVisibilityChanged: boolean;
450
+ try {
451
+ const accounts = [...(runtimeConfig.codexAccounts ?? [])];
452
+ const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
453
+ && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
454
+ accounts.push(addedAccount);
455
+ runtimeConfig.codexAccounts = accounts;
456
+
457
+ // Presence of the explicit flag distinguishes a dashboard-managed map from
458
+ // a hand-authored legacy map. Preserve manual maps exactly.
459
+ const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
460
+ if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
461
+ runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
462
+ }
463
+ const namespaceAdded = tracksPickerNamespaces
464
+ && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
465
+ pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
466
+ saveRuntimeConfig(sourceConfig, runtimeConfig);
467
+ } catch (error) {
468
+ for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
469
+ delete runtimeConfig[key];
470
+ }
471
+ Object.assign(runtimeConfig, previousConfig);
472
+ throw error;
473
+ }
474
+
475
+ try {
476
+ saveCodexAccountCredential(addedAccount.id, staged.credential);
477
+ markCodexAccountValidated(addedAccount.id, staged.validatedAt);
478
+ clearAccountNeedsReauth(addedAccount.id);
479
+ } catch {
480
+ // Config is already durable. Return the failure outcome through the coordinator so its
481
+ // generation commit is not rolled back while config.json remains changed.
482
+ return { status: "publication-failed" as const, pickerVisibilityChanged };
483
+ }
484
+ return { status: "committed" as const, pickerVisibilityChanged };
485
+ });
486
+ }
487
+
488
+ /** Bounded catalog-convergence callback supplied by the management dispatcher. */
489
+ export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
490
+
491
+ interface AccountNamespaceCatalogRefresh {
492
+ catalogRefreshPending: boolean;
493
+ }
494
+
495
+ /** Collapse post-persistence convergence into the one public recovery bit. */
496
+ async function convergeAccountNamespaceCatalog(
497
+ config: OcxConfig,
498
+ changed: boolean,
499
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
500
+ ): Promise<AccountNamespaceCatalogRefresh> {
501
+ if (!changed || !codexAccountPickerEnabled(config)) {
502
+ return { catalogRefreshPending: false };
503
+ }
504
+ if (!convergeCodexCatalog) return { catalogRefreshPending: true };
505
+
506
+ try {
507
+ const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
508
+ if (!catalogRefresh) return { catalogRefreshPending: true };
509
+ return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
510
+ } catch {
511
+ return { catalogRefreshPending: true };
512
+ }
513
+ }
514
+
394
515
  async function mapWithConcurrency<T, R>(
395
516
  items: T[],
396
517
  concurrency: number,
@@ -1202,6 +1323,7 @@ export async function handleCodexAuthAPI(
1202
1323
  req: Request,
1203
1324
  url: URL,
1204
1325
  config: OcxConfig,
1326
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
1205
1327
  ): Promise<Response | null> {
1206
1328
 
1207
1329
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
@@ -1240,20 +1362,38 @@ export async function handleCodexAuthAPI(
1240
1362
  const latestConfig = getRuntimeConfig(config);
1241
1363
  const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
1242
1364
  if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
1243
- saveCodexAccountCredential(body.id, {
1244
- accessToken: body.accessToken,
1245
- refreshToken: body.refreshToken,
1246
- expiresAt: exp,
1247
- chatgptAccountId: derivedAccountId,
1248
- });
1249
- markCodexAccountValidated(body.id, warmup.validatedAt);
1250
- clearAccountNeedsReauth(body.id);
1251
- const accounts = latestConfig.codexAccounts ?? [];
1252
- accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
1253
- latestConfig.codexAccounts = accounts;
1254
- saveRuntimeConfig(config, latestConfig);
1365
+ const addedAccount = withCodexAccountLogLabel(
1366
+ { id: body.id, email: body.email, plan: body.plan, isMain: false },
1367
+ latestConfig.codexAccounts ?? [],
1368
+ );
1369
+ const persistence = persistNewCodexAccount(
1370
+ config,
1371
+ latestConfig,
1372
+ addedAccount,
1373
+ {
1374
+ credential: {
1375
+ accessToken: body.accessToken,
1376
+ refreshToken: body.refreshToken,
1377
+ expiresAt: exp,
1378
+ chatgptAccountId: derivedAccountId,
1379
+ },
1380
+ validatedAt: warmup.validatedAt,
1381
+ },
1382
+ );
1255
1383
  reconcileLiveStateStores();
1256
- return jsonResponse({ ok: true });
1384
+ if (persistence.status === "publication-failed") markAccountNeedsReauth(body.id);
1385
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
1386
+ latestConfig,
1387
+ persistence.pickerVisibilityChanged,
1388
+ convergeCodexCatalog,
1389
+ );
1390
+ if (persistence.status === "publication-failed") {
1391
+ return jsonResponse({
1392
+ ok: false,
1393
+ ...codexCredentialPersistenceFailure(body.id, catalogRefresh.catalogRefreshPending === true),
1394
+ }, 500);
1395
+ }
1396
+ return jsonResponse({ ok: true, ...catalogRefresh });
1257
1397
  }
1258
1398
 
1259
1399
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
@@ -1265,10 +1405,15 @@ export async function handleCodexAuthAPI(
1265
1405
  if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
1266
1406
  return jsonResponse({ error: "Invalid account id format" }, 400);
1267
1407
  }
1268
- deleteCodexAccount(runtimeConfig, id);
1408
+ const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
1269
1409
  saveRuntimeConfig(config, runtimeConfig);
1270
1410
  reconcileLiveStateStores();
1271
- return jsonResponse({ ok: true });
1411
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
1412
+ runtimeConfig,
1413
+ pickerVisibilityChanged,
1414
+ convergeCodexCatalog,
1415
+ );
1416
+ return jsonResponse({ ok: true, ...catalogRefresh });
1272
1417
  }
1273
1418
 
1274
1419
  if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
@@ -1761,6 +1906,8 @@ export async function handleCodexAuthAPI(
1761
1906
  const latestConfig = getRuntimeConfig(config);
1762
1907
  const accounts = latestConfig.codexAccounts ?? [];
1763
1908
  const existingIdx = accounts.findIndex(account => account.id === accountId);
1909
+ let pickerVisibilityChanged = false;
1910
+ let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
1764
1911
  const commitConflict = codexAccountPersistenceConflict(
1765
1912
  latestConfig,
1766
1913
  accountId,
@@ -1776,22 +1923,21 @@ export async function handleCodexAuthAPI(
1776
1923
  break;
1777
1924
  }
1778
1925
 
1779
- saveCodexAccountCredential(accountId, {
1926
+ const credential: CodexAccountCredentials = {
1780
1927
  accessToken: cred.access,
1781
1928
  refreshToken: cred.refresh,
1782
1929
  expiresAt: cred.expires,
1783
1930
  chatgptAccountId: oauthAccountId,
1784
- });
1785
- // A successful reauthentication replaces the credential generation. Do not let a
1786
- // failed optional WHAM probe make the replacement inherit quota from the old record.
1787
- if (reauth) clearAccountQuota(accountId);
1788
- markCodexAccountValidated(accountId, warmup.validatedAt);
1789
- clearAccountNeedsReauth(accountId);
1790
- if (quota) {
1791
- setAccountQuotaFromParsed(accountId, quota);
1792
- }
1931
+ };
1793
1932
 
1794
1933
  if (existingIdx >= 0) {
1934
+ saveCodexAccountCredential(accountId, credential);
1935
+ // A successful reauthentication replaces the credential generation. Do not let a
1936
+ // failed optional WHAM probe make the replacement inherit quota from the old record.
1937
+ if (reauth) clearAccountQuota(accountId);
1938
+ markCodexAccountValidated(accountId, warmup.validatedAt);
1939
+ clearAccountNeedsReauth(accountId);
1940
+ if (quota) setAccountQuotaFromParsed(accountId, quota);
1795
1941
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1796
1942
  accounts[existingIdx] = withCodexAccountLogLabel({
1797
1943
  ...accounts[existingIdx],
@@ -1802,13 +1948,49 @@ export async function handleCodexAuthAPI(
1802
1948
  latestConfig.codexAccounts = accounts;
1803
1949
  saveRuntimeConfig(config, latestConfig);
1804
1950
  } else {
1805
- accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
1806
- latestConfig.codexAccounts = accounts;
1807
- saveRuntimeConfig(config, latestConfig);
1951
+ const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
1952
+ newAccountPersistence = persistNewCodexAccount(
1953
+ config,
1954
+ latestConfig,
1955
+ addedAccount,
1956
+ {
1957
+ credential,
1958
+ validatedAt: warmup.validatedAt,
1959
+ },
1960
+ );
1961
+ pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
1808
1962
  }
1809
1963
  reconcileLiveStateStores();
1810
- setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
1811
- completed = true;
1964
+ if (newAccountPersistence?.status === "publication-failed") {
1965
+ markAccountNeedsReauth(accountId);
1966
+ }
1967
+ // A new quota row is generation-gated by live account ownership. Reconcile the
1968
+ // durable config owner first so a partial prior sweep cannot reject this write.
1969
+ if (newAccountPersistence?.status === "committed" && quota) {
1970
+ setAccountQuotaFromParsed(accountId, quota);
1971
+ }
1972
+ const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
1973
+ latestConfig,
1974
+ pickerVisibilityChanged,
1975
+ convergeCodexCatalog,
1976
+ );
1977
+ if (newAccountPersistence?.status === "publication-failed") {
1978
+ setCodexLoginState(flowId, {
1979
+ status: "error",
1980
+ ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
1981
+ doneAt: Date.now(),
1982
+ });
1983
+ completed = true;
1984
+ } else {
1985
+ setCodexLoginState(flowId, {
1986
+ status: "done",
1987
+ accountId,
1988
+ email,
1989
+ ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
1990
+ doneAt: Date.now(),
1991
+ });
1992
+ completed = true;
1993
+ }
1812
1994
  }
1813
1995
  break;
1814
1996
  }
@@ -1894,7 +2076,13 @@ export async function handleCodexAuthAPI(
1894
2076
  const reauthStatus = url.searchParams.get("reauth") === "1";
1895
2077
  if (flowId) {
1896
2078
  const st = codexAuthLoginState.get(flowId);
1897
- if (!st && accountId && !reauthStatus && getCodexAccountCredential(accountId)) {
2079
+ if (
2080
+ !st
2081
+ && accountId
2082
+ && !reauthStatus
2083
+ && !isAccountNeedsReauth(accountId)
2084
+ && getCodexAccountCredential(accountId)
2085
+ ) {
1898
2086
  return jsonResponse({ status: "done", accountId });
1899
2087
  }
1900
2088
  return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
@@ -1,5 +1,9 @@
1
1
  import type { OcxConfig } from "../../types";
2
- import { isMainCodexAccountTarget } from "../account-namespaces";
2
+ import {
3
+ codexAccountNamespaceEntries,
4
+ codexAccountPickerEnabled,
5
+ isMainCodexAccountTarget,
6
+ } from "../account-namespaces";
3
7
  import type { RawEntry } from "./parsing";
4
8
 
5
9
  /** Stable nonsemantic marker used to distinguish generated rows from provider-owned rows. */
@@ -14,14 +18,15 @@ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1";
14
18
  * namespace validation. Only those public keys leave this boundary; private account ids do not.
15
19
  */
16
20
  export function visibleCodexAccountSelectors(
17
- config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces">,
21
+ config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled">,
18
22
  ): string[] {
23
+ if (!codexAccountPickerEnabled(config)) return [];
19
24
  const storedPoolAccounts = new Set(
20
25
  (config.codexAccounts ?? [])
21
26
  .filter(account => !account.isMain)
22
27
  .map(account => account.id),
23
28
  );
24
- return Object.entries(config.codexAccountNamespaces ?? {})
29
+ return codexAccountNamespaceEntries(config)
25
30
  .filter(([, accountId]) =>
26
31
  isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId)
27
32
  )
@@ -53,7 +58,7 @@ export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string |
53
58
  }
54
59
 
55
60
  export function accountBoundNativeModelSlugs(
56
- config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces">,
61
+ config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled">,
57
62
  nativeSlugs: Iterable<string>,
58
63
  ): string[] {
59
64
  const natives = [...nativeSlugs];
@@ -9,17 +9,18 @@ import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
9
  import type { OcxConfig, OcxProviderConfig } from "../../types";
10
10
  import { modelInList } from "../../types";
11
11
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
12
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
12
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
16
- import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
16
+ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
17
17
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
18
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
19
19
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
20
20
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
21
21
  import {
22
22
  COMBO_NAMESPACE,
23
+ comboDisabledModelSelectors,
23
24
  comboModelId,
24
25
  getCombo,
25
26
  listComboIds,
@@ -131,10 +132,23 @@ export function deriveComboCatalogModel(
131
132
  const inputModalities = intersectStrings(
132
133
  members.map(member => member.inputModalities ?? ["text"]),
133
134
  );
134
- const reasoningEfforts = intersectStrings(
135
- members.map(member => member.reasoningEfforts ?? []),
136
- );
135
+ // Unknown ladders (`undefined`) are wildcards for catalog derivation — same
136
+ // boundary as the GUI picker. An explicit empty ladder still constrains.
137
+ const advertisedLadders = members
138
+ .map(member => member.reasoningEfforts)
139
+ .filter((ladder): ladder is string[] => ladder !== undefined);
140
+ const reasoningEfforts = advertisedLadders.length === 0
141
+ ? []
142
+ : intersectStrings(advertisedLadders);
137
143
  const contextWindow = Math.min(...members.map(member => member.contextWindow!));
144
+ const limitingMembers = members.filter(member => member.contextWindow === contextWindow);
145
+ const hasLimitingContextCapMetadata = limitingMembers.some(
146
+ member => typeof member.contextCapped === "boolean",
147
+ );
148
+ // A combo is cap-limited only when every member defining its effective minimum was
149
+ // itself reduced by a provider cap. An uncapped member at the same minimum means the
150
+ // combo would have the same window even without the cap.
151
+ const contextCapped = limitingMembers.every(member => member.contextCapped === true);
138
152
  const maxInputTokens = Math.min(
139
153
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
140
154
  );
@@ -149,9 +163,12 @@ export function deriveComboCatalogModel(
149
163
  owned_by: COMBO_NAMESPACE,
150
164
  contextWindow,
151
165
  maxInputTokens,
166
+ ...(hasLimitingContextCapMetadata ? { contextCapped } : {}),
152
167
  inputModalities,
153
168
  reasoningEfforts,
154
169
  ...(combo.alias ? { alias: combo.alias } : {}),
170
+ ...(combo.nativeAlias ? { nativeAlias: true } : {}),
171
+ ...(combo.displayName ? { displayName: combo.displayName } : {}),
155
172
  ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
156
173
  ...(members.every(member => member.parallelToolCalls === true)
157
174
  ? { parallelToolCalls: true }
@@ -252,12 +269,15 @@ export function exactComboCatalogSlugs(
252
269
  ): Set<string> {
253
270
  const disabled = new Set(config.disabledModels ?? []);
254
271
  return new Set(listComboIds(config).flatMap(id => {
255
- const alias = typeof config.combos?.[id]?.alias === "string"
256
- ? config.combos[id]!.alias!.trim()
272
+ const raw = config.combos?.[id];
273
+ const alias = typeof raw?.alias === "string"
274
+ ? raw.alias.trim()
257
275
  : "";
258
276
  const canonical = comboModelId(id);
259
277
  const publicSlug = alias || canonical;
260
- return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug];
278
+ const comboDisabled = comboDisabledModelSelectors(id, raw ?? {})
279
+ .some(selector => disabled.has(selector));
280
+ return comboDisabled ? [] : [publicSlug];
261
281
  }));
262
282
  }
263
283
 
@@ -282,6 +302,8 @@ export const slugAliasCollisionWarnings = new Set<string>();
282
302
 
283
303
  export const comboMasqueradeCollisionWarnings = new Set<string>();
284
304
 
305
+ export const comboUnrestorableShadowWarnings = new Set<string>();
306
+
285
307
  export const accountSelectorShadowCollisionWarnings = new Set<string>();
286
308
  let lastWarningReconciledGeneration = 0;
287
309
 
@@ -291,11 +313,13 @@ export function reconcileCatalogWarningMemos(generation: number): number {
291
313
  + comboCatalogWarningSignatures.size
292
314
  + slugAliasCollisionWarnings.size
293
315
  + comboMasqueradeCollisionWarnings.size
316
+ + comboUnrestorableShadowWarnings.size
294
317
  + accountSelectorShadowCollisionWarnings.size;
295
318
  openAiApiCollisionWarnings.clear();
296
319
  comboCatalogWarningSignatures.clear();
297
320
  slugAliasCollisionWarnings.clear();
298
321
  comboMasqueradeCollisionWarnings.clear();
322
+ comboUnrestorableShadowWarnings.clear();
299
323
  accountSelectorShadowCollisionWarnings.clear();
300
324
  lastWarningReconciledGeneration = generation;
301
325
  return removed;
@@ -309,6 +333,15 @@ export function warnComboMasqueradeCollisionOnce(slug: string): void {
309
333
  );
310
334
  }
311
335
 
336
+ export function warnComboUnrestorableShadowOnce(slug: string): void {
337
+ const key = slugEquivalenceKey(slug);
338
+ if (comboUnrestorableShadowWarnings.has(key)) return;
339
+ comboUnrestorableShadowWarnings.add(key);
340
+ console.warn(
341
+ `[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the existing user-managed or foreign catalog row is retained because no pristine backup can restore it. Rename the combo alias to expose both models.`,
342
+ );
343
+ }
344
+
312
345
  /** Warn once when a live provider row loses its reserved slug to an account selector. */
313
346
  export function warnAccountSelectorShadowedProviderOnce(slug: string): void {
314
347
  if (accountSelectorShadowCollisionWarnings.has(slug)) return;
@@ -9,7 +9,7 @@ import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
9
  import type { OcxConfig, OcxProviderConfig } from "../../types";
10
10
  import { modelInList } from "../../types";
11
11
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
12
- import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
12
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
@@ -328,6 +328,9 @@ export type CatalogSourceForGather =
328
328
  | "legacy-backup-fallback"
329
329
  | "models-cache-fallback";
330
330
  catalog: ReadonlyRawCatalog;
331
+ runtimeSupport:
332
+ | Readonly<{ kind: "available"; catalog: ReadonlyRawCatalog }>
333
+ | Readonly<{ kind: "unavailable" }>;
331
334
  processLocal: Readonly<{
332
335
  runtime: CatalogGatherProcessLocalObservation;
333
336
  bundledCatalog: CatalogGatherProcessLocalObservation;
@@ -341,6 +344,8 @@ export type CatalogSourceForGather =
341
344
  }>;
342
345
  }>;
343
346
 
347
+ export type CatalogGatherPathKind = "default" | "custom";
348
+
344
349
  const UNUSED_PROCESS_LOCAL = Object.freeze({ state: "unused" as const });
345
350
 
346
351
  function sameRuntimeIdentity(
@@ -407,46 +412,73 @@ export function peekCodexRuntimeForCatalogGather(
407
412
  */
408
413
  export function resolveCatalogSourceForGather(
409
414
  evidenceSession: CatalogGatherEvidenceSession,
415
+ pathKind: CatalogGatherPathKind,
410
416
  ): CatalogSourceForGather {
411
417
  const bundledMemo = bundledCatalogCache;
418
+ let runtimeSupport:
419
+ | Readonly<{ kind: "available"; catalog: ReadonlyRawCatalog }>
420
+ | Readonly<{ kind: "unavailable" }> = Object.freeze({ kind: "unavailable" });
421
+ let processLocal: Readonly<{
422
+ runtime: CatalogGatherProcessLocalObservation;
423
+ bundledCatalog: CatalogGatherProcessLocalObservation;
424
+ }> = {
425
+ runtime: UNUSED_PROCESS_LOCAL,
426
+ bundledCatalog: UNUSED_PROCESS_LOCAL,
427
+ };
412
428
  if (bundledMemo?.value && bundledMemo.expiresAt > Date.now()) {
413
429
  const runtime = peekCodexRuntimeForCatalogGather(evidenceSession);
414
430
  if (runtime.kind === "available" && bundledMemo.key === bundledRuntimeKey(runtime.runtime)) {
415
- return cloneAndDeepFreeze({
431
+ runtimeSupport = Object.freeze({
416
432
  kind: "available" as const,
417
- source: "bundled-catalog-template" as const,
418
433
  catalog: bundledMemo.value,
419
- processLocal: {
420
- runtime: runtime.processLocal,
421
- bundledCatalog: {
422
- state: "used" as const,
423
- epoch: bundledMemo.epoch,
424
- valueIdentity: bundledMemo.valueIdentity,
425
- },
426
- },
427
434
  });
435
+ processLocal = {
436
+ runtime: runtime.processLocal,
437
+ bundledCatalog: {
438
+ state: "used" as const,
439
+ epoch: bundledMemo.epoch,
440
+ valueIdentity: bundledMemo.valueIdentity,
441
+ },
442
+ };
443
+ if (pathKind === "default") {
444
+ return cloneAndDeepFreeze({
445
+ kind: "available" as const,
446
+ source: "bundled-catalog-template" as const,
447
+ catalog: bundledMemo.value,
448
+ runtimeSupport,
449
+ processLocal,
450
+ });
451
+ }
428
452
  }
429
453
  }
430
454
 
431
- const roles = [
432
- "active-catalog-merge",
433
- "hashed-backup-fallback",
434
- "legacy-backup-fallback",
435
- "models-cache-fallback",
436
- ] as const;
455
+ const roles = pathKind === "default"
456
+ ? [
457
+ "active-catalog-merge",
458
+ "hashed-backup-fallback",
459
+ "legacy-backup-fallback",
460
+ "models-cache-fallback",
461
+ ] as const
462
+ : [
463
+ "active-catalog-merge",
464
+ "hashed-backup-fallback",
465
+ "models-cache-fallback",
466
+ ] as const;
437
467
  for (const role of roles) {
438
468
  const bytes = evidenceSession.readSource(role);
439
469
  if (bytes === null) continue;
440
470
  const catalog = parseCatalogJson(Buffer.from(bytes).toString("utf8"));
441
- if (!catalog || !findNativeTemplate(catalog)) continue;
471
+ if (!catalog) continue;
472
+ // Custom catalogs may intentionally contain only routed rows. Keep their existing source
473
+ // priority (active, then backup/cache) without imposing the default catalog's native-template
474
+ // requirement; a valid active custom file therefore remains authoritative over stale fallbacks.
475
+ if (pathKind === "default" && !findNativeTemplate(catalog)) continue;
442
476
  return cloneAndDeepFreeze({
443
477
  kind: "available" as const,
444
478
  source: role,
445
479
  catalog,
446
- processLocal: {
447
- runtime: UNUSED_PROCESS_LOCAL,
448
- bundledCatalog: UNUSED_PROCESS_LOCAL,
449
- },
480
+ runtimeSupport,
481
+ processLocal,
450
482
  });
451
483
  }
452
484