@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -10,6 +10,7 @@ import {
10
10
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
11
11
  import { isCodexAccountPaused } from "./account-pause";
12
12
  import { ConfigMutationLockError } from "../config";
13
+ import { NativeProfileError } from "./native-profile-types";
13
14
  import { isCodexAccountUsable } from "./account-usability";
14
15
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
15
16
  import {
@@ -25,7 +26,9 @@ import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./nat
25
26
  import type { NativeMainStartupBlockReason } from "./native-profile-startup";
26
27
  import {
27
28
  codexQuotaScopeForModel,
29
+ computeCodexUsageScore,
28
30
  getCodexQuotaHealthSnapshot,
31
+ isEffectiveCodexAccountPinned,
29
32
  releaseCodexQuotaProbeLease,
30
33
  releaseCodexQuotaScopeProbeLease,
31
34
  tryAcquireCodexQuotaProbeLease,
@@ -42,7 +45,7 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
42
45
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
43
46
  import { maskAccountId } from "../lib/privacy";
44
47
  import { formatErrorResponse } from "../bridge";
45
- import { getAccountQuota } from "./quota";
48
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
46
49
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
47
50
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
48
51
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
@@ -52,6 +55,21 @@ import { extractAccountId } from "../oauth/chatgpt";
52
55
  const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
53
56
  const CODEX_APP_AFFINITY_KEY = randomBytes(32);
54
57
 
58
+ /**
59
+ * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
60
+ * WHAM usage is still valid routing evidence for the same logical main account. Score it with
61
+ * the conservative unknown-plan rule: an unobserved governing window preserves the pin, while
62
+ * any known weekly/monthly/short value at the threshold releases it through the ordinary Pool
63
+ * path. This keeps the keyring boundary intact instead of reading auth.json just to classify a
64
+ * request that already brought its own credential (#3157).
65
+ */
66
+ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
67
+ const threshold = config.autoSwitchThreshold ?? 80;
68
+ if (threshold <= 0) return true;
69
+ const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID));
70
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
71
+ }
72
+
55
73
  function boundedCodexAffinityComponent(value: string | null): string | undefined {
56
74
  const normalized = value?.trim();
57
75
  if (!normalized) return undefined;
@@ -328,6 +346,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
328
346
  && !(cause instanceof CodexCredentialRefreshStaleError)
329
347
  && !(cause instanceof MainAuthJsonChangedDuringRefreshError)
330
348
  && !(cause instanceof MainAccountTokenRefreshError && cause.reason === "transient")
349
+ && !(cause instanceof NativeProfileError && cause.retryable)
350
+ && !(cause instanceof DOMException && cause.name === "AbortError")
331
351
  && !(cause instanceof ConfigMutationLockError);
332
352
  }
333
353
 
@@ -372,6 +392,12 @@ export async function resolveCodexAuthContext(
372
392
  const requestScopedMainCredential = options.requestScopedMainCredential === true
373
393
  && hasCallerCodexBearer(headers);
374
394
  const fixedAccountId = options.accountId;
395
+ const preserveRequestOwnedMainPin = requestScopedMainCredential
396
+ && fixedAccountId === undefined
397
+ && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
398
+ && isEffectiveCodexAccountPinned(config)
399
+ && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
400
+ && requestOwnedMainPinHasQuotaHeadroom(config);
375
401
  if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
376
402
  throw new Error("Codex auth context cannot select and exclude an account simultaneously");
377
403
  }
@@ -425,6 +451,19 @@ export async function resolveCodexAuthContext(
425
451
  directSelectionAdmission.release();
426
452
  }
427
453
  };
454
+ // Pool discovery excludes request-owned main credentials by design: they must never be folded
455
+ // into stored-account entitlement, affinity, or persistence state. An effective manual main pin
456
+ // is the one exception where that exclusion is selection evidence in the opposite direction.
457
+ // Validate the caller's own gated-model roster before using it, and fall through to a Pool model
458
+ // detour when it lacks the grant. This branch performs no physical-main credential read.
459
+ if (preserveRequestOwnedMainPin) {
460
+ const callerEntitled = !options.modelId
461
+ || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
462
+ || await (
463
+ options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
464
+ )(headers, options.modelId);
465
+ if (callerEntitled) return { kind: "main", accountId: null };
466
+ }
428
467
  // An explicit namespace binding is stronger than the provider's default mode. It must use the
429
468
  // selected stored credential even while the canonical OpenAI provider is globally Direct.
430
469
  // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own
@@ -455,6 +494,7 @@ export async function resolveCodexAuthContext(
455
494
  const excludeAccountIds = nativeMainReadsForbidden
456
495
  ? new Set([MAIN_CODEX_ACCOUNT_ID])
457
496
  : undefined;
497
+ const mainModelGrantUnobserved = excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID) === true;
458
498
  const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)
459
499
  ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, {
460
500
  excludeAccountIds,
@@ -473,7 +513,10 @@ export async function resolveCodexAuthContext(
473
513
  // it. Retained recovery makes main wholly ineligible so pool routing continues.
474
514
  nativeMainSelectionOnly,
475
515
  isMainAccountTokenLive: requestScopedMainCredential
476
- ? () => false
516
+ // Main stays excluded from this request's model roster below. This synthetic liveness is
517
+ // consulted only by shared-state preservation, so a caller-owned pin survives a model
518
+ // detour without reading or selecting the physical main credential.
519
+ ? () => preserveRequestOwnedMainPin
477
520
  : options.isMainAccountTokenLive,
478
521
  modelEligibleAccountIds,
479
522
  };
@@ -507,7 +550,15 @@ export async function resolveCodexAuthContext(
507
550
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
508
551
  const selected = resolution.status === "selected" ? resolution.accountId : null;
509
552
  if (!selected) {
510
- if (requestScopedMainCredential && fixedAccountId === undefined && !options.excludeAccountId) {
553
+ // A retry that excluded a failed Pool account may still use the validated caller-owned
554
+ // main credential. Treating every exclusion as if main itself had failed strands a healthy
555
+ // native bearer after the first Pool attempt. Preserve the exactly-once boundary by refusing
556
+ // this fallback only when the excluded credential is main.
557
+ if (
558
+ requestScopedMainCredential
559
+ && fixedAccountId === undefined
560
+ && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
561
+ ) {
511
562
  return await resolveCallerOwnedMainContext();
512
563
  }
513
564
  if (fixedAccountId !== undefined) {
@@ -527,7 +578,11 @@ export async function resolveCodexAuthContext(
527
578
  throw new CodexMainProfileDrainingError();
528
579
  }
529
580
  throw new CodexPoolAuthenticationError(
530
- modelEligibleAccountIds ? "No eligible Codex account supports this model" : undefined,
581
+ modelEligibleAccountIds === undefined
582
+ ? undefined
583
+ : entitledAccountIds?.size === 0 && !mainModelGrantUnobserved
584
+ ? "No eligible Codex account supports this model"
585
+ : "Codex accounts that support this model are currently unavailable",
531
586
  );
532
587
  }
533
588
  accountId = selected;
@@ -615,7 +670,7 @@ export async function resolveCodexAuthContext(
615
670
  } catch (cause) {
616
671
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
617
672
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
618
- if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
673
+ if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
619
674
  markAccountNeedsReauth(accountId, writerGeneration);
620
675
  }
621
676
  throw new CodexAuthContextError(accountId, cause);
@@ -660,7 +715,7 @@ export async function resolveCodexAuthContext(
660
715
  } catch (cause) {
661
716
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
662
717
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
663
- if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
718
+ if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
664
719
  markAccountNeedsReauth(accountId, writerGeneration);
665
720
  }
666
721
  throw new CodexAuthContextError(accountId, cause);
@@ -8,7 +8,7 @@ import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCa
8
8
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
9
  import type { OcxConfig, OcxProviderConfig } from "../../types";
10
10
  import { modelInList } from "../../types";
11
- import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
11
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, resolveEffortAtOrBelow, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
12
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";
@@ -77,20 +77,16 @@ export function intersectStrings(values: readonly string[][]): string[] {
77
77
  return [...new Set(values[0])].filter(value => rest.every(set => set.has(value)));
78
78
  }
79
79
 
80
+ /**
81
+ * The catalog's view of a combo's default effort. Delegates to the shared leaf
82
+ * resolver so the request path (src/combos/request.ts) cannot drift from what the
83
+ * catalog advertised (#3108).
84
+ */
80
85
  export function effectiveComboDefault(
81
86
  configured: string | null | undefined,
82
87
  common: readonly string[],
83
88
  ): string | undefined {
84
- if (!configured) return undefined;
85
- if (configured && common.includes(configured)) return configured;
86
- const requestedRank = codexEffortRank(configured);
87
- const ranked = common
88
- .map(effort => ({ effort, rank: codexEffortRank(effort) }))
89
- .filter(item => item.rank >= 0)
90
- .sort((a, b) => a.rank - b.rank);
91
- if (ranked.length === 0) return undefined;
92
- const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
93
- return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
89
+ return resolveEffortAtOrBelow(configured, common);
94
90
  }
95
91
 
96
92
  /**
@@ -138,10 +134,18 @@ export function deriveComboCatalogModel(
138
134
  : derivedInputModalities;
139
135
  if (inputModalities.length === 0) return null;
140
136
  // Unknown ladders (`undefined`) are wildcards for catalog derivation — same
141
- // boundary as the GUI picker. An explicit empty ladder still constrains.
142
- const advertisedLadders = members
137
+ // boundary as the GUI picker. Under the default `strict` mode an explicit empty
138
+ // ladder still constrains, so one target that advertises no effort control empties
139
+ // the whole combo's picker. `adaptive` is the opt-in for mixed-capability groups:
140
+ // empty ladders drop out of the published intersection while non-empty ladders
141
+ // still define it. Dispatch is unaffected either way — each concrete target
142
+ // resolves its own effort at request time.
143
+ const knownLadders = members
143
144
  .map(member => member.reasoningEfforts)
144
145
  .filter((ladder): ladder is string[] => ladder !== undefined);
146
+ const advertisedLadders = combo.reasoningEffortMode === "adaptive"
147
+ ? knownLadders.filter(ladder => ladder.length > 0)
148
+ : knownLadders;
145
149
  const reasoningEfforts = advertisedLadders.length === 0
146
150
  ? []
147
151
  : intersectStrings(advertisedLadders);
@@ -271,6 +271,34 @@ export function nativeOpenAiContextWindow(slug: string, limits?: NativeContextLi
271
271
  return narrowToLimits(raw, slug, limits);
272
272
  }
273
273
 
274
+ /**
275
+ * Long-context tier for a native slug as a (default, long) pair, for clients that let the user
276
+ * pick a window per request (Cursor's local-agent "Context" selector). The pair is the family's
277
+ * pinned default window and its opt-in ceiling, independent of whether the operator has
278
+ * already opted the proxy into the long window: the selector exists so the client can choose.
279
+ * Any user lever below the long window removes the tier: a per-model window override, the
280
+ * provider-level window override, or a provider context cap. A lever at or above it leaves the
281
+ * tier intact (the 922k/1050k opt-in values are the levers, not a request to shrink). Undefined
282
+ * when the family has no separate tier or when the two windows coincide.
283
+ */
284
+ export function nativeOpenAiContextTier(
285
+ slug: string,
286
+ limits?: NativeContextLimitsInput,
287
+ ): { defaultWindow: number; longWindow: number } | undefined {
288
+ const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[slug];
289
+ const defaultWindow = positiveInt(override?.contextWindow);
290
+ const longWindow = positiveInt(override?.maxContextWindow);
291
+ if (defaultWindow === undefined || longWindow === undefined || longWindow <= defaultWindow) return undefined;
292
+ const resolved = asLimits(limits);
293
+ const levers = [
294
+ positiveInt(resolved.modelWindows?.[slug]),
295
+ positiveInt(resolved.providerWindow),
296
+ positiveInt(resolved.cap),
297
+ ];
298
+ if (levers.some(lever => lever !== undefined && lever < longWindow)) return undefined;
299
+ return { defaultWindow, longWindow };
300
+ }
301
+
274
302
  /**
275
303
  * Largest input a native slug accepts, or undefined when no separate limit is known
276
304
  * (the caller then falls back to the context window).
@@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash, createHmac, randomBytes } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
- import { atomicWriteFile, expandUserPath, getConfigDir, resolveEnvValue, websocketsEnabled } from "../../config";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
+ import { resolveProviderApiKey } from "../../providers/key-store";
6
7
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
8
  import {
8
9
  clearModelCache,
@@ -571,7 +572,9 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
571
572
  base: prov.baseUrl ?? "",
572
573
  adapter: prov.adapter ?? "",
573
574
  models: [...(prov.models ?? [])].sort(),
575
+ retain: [...(prov.retainModels ?? [])].sort(),
574
576
  selected: [...(prov.selectedModels ?? [])].sort(),
577
+ displayNames: prov.modelDisplayNames ?? null,
575
578
  defaultModel: prov.defaultModel ?? null,
576
579
  ctx: prov.contextWindow ?? null,
577
580
  ctxW: prov.modelContextWindows ?? null,
@@ -627,6 +630,16 @@ export function configuredInputModalities(prov: OcxProviderConfig, id: string):
627
630
  return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
628
631
  }
629
632
 
633
+ /** Exact display-only override for one provider-native model id. */
634
+ export function configuredModelDisplayName(
635
+ prov: OcxProviderConfig,
636
+ id: string,
637
+ ): string | undefined {
638
+ if (!prov.modelDisplayNames || !Object.hasOwn(prov.modelDisplayNames, id)) return undefined;
639
+ const value = prov.modelDisplayNames[id];
640
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
641
+ }
642
+
630
643
  export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
631
644
  const configured = modelRecordValue(prov.modelMaxInputTokens, id);
632
645
  return typeof configured === "number" && configured > 0 ? configured : undefined;
@@ -667,6 +680,7 @@ function configuredVerbositySupport(name: string, prov: OcxProviderConfig | unde
667
680
  }
668
681
 
669
682
  export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
683
+ const displayName = configuredModelDisplayName(prov, model.id);
670
684
  const configuredCap = configuredContextWindow(prov, model.id);
671
685
  const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
672
686
  const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
@@ -702,6 +716,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
702
716
  : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
703
717
  const hinted = {
704
718
  ...modelWithoutServiceTier,
719
+ ...(displayName !== undefined ? { displayName } : {}),
705
720
  ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
706
721
  ...(inputModalities ? { inputModalities } : {}),
707
722
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
@@ -1195,9 +1210,15 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1195
1210
  const metadata = plainRecord(item.metadata);
1196
1211
  const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
1197
1212
  const limits = plainRecord(metadata?.limits);
1213
+ const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits);
1198
1214
  const contextWindow =
1199
1215
  positiveSafeInteger(
1200
1216
  limits?.max_context_length,
1217
+ // GitHub Copilot reports the live context window here instead of in the metadata or
1218
+ // top-level fields used by other OpenAI-compatible catalogs (#3156). Keep the existing
1219
+ // metadata field authoritative when both are present: adding this provider-specific
1220
+ // fallback must not change previously recognized providers.
1221
+ capabilityLimits?.max_context_window_tokens,
1201
1222
  metadata?.context_length,
1202
1223
  item.context_length,
1203
1224
  item.context_size,
@@ -1262,7 +1283,7 @@ function observedModelsAuthResolver(
1262
1283
  resolve(name, provider) {
1263
1284
  if (provider.authMode === "forward") return { apiKey: undefined, observed: true };
1264
1285
  if (provider.authMode !== "oauth") {
1265
- return { apiKey: resolveEnvValue(provider.apiKey), observed: true };
1286
+ return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true };
1266
1287
  }
1267
1288
 
1268
1289
  const observation = observeActiveOAuthAccessToken(name, authStoreBuffer);
@@ -1298,7 +1319,14 @@ async function fetchProviderModelsWithAuth(
1298
1319
  && prov.googleMode === "vertex"
1299
1320
  && (prov.models?.length ?? 0) === 0
1300
1321
  && Boolean(prov.defaultModel);
1301
- const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
1322
+ // Ordered dedupe union: Vertex seed, then `models`, then `retainModels`. `configured` is the
1323
+ // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints,
1324
+ // so a retain-only id must enter here or it never exists to be retained (#1690).
1325
+ const configuredIds = [...new Set([
1326
+ ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []),
1327
+ ...(prov.models ?? []),
1328
+ ...(prov.retainModels ?? []),
1329
+ ])];
1302
1330
  const configured: CatalogModel[] = configuredIds.map(id => ({
1303
1331
  id,
1304
1332
  provider: name,
@@ -1694,9 +1722,14 @@ export function shouldExposeProviderModel(providerName: string, modelId: string)
1694
1722
  return true;
1695
1723
  }
1696
1724
 
1697
- export function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
1725
+ export function shouldRetainConfiguredProviderModel(
1726
+ providerName: string,
1727
+ modelId: string,
1728
+ prov?: OcxProviderConfig,
1729
+ ): boolean {
1698
1730
  if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
1699
1731
  if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
1732
+ if (modelInList(prov?.retainModels, modelId)) return true;
1700
1733
  return false;
1701
1734
  }
1702
1735
 
@@ -1742,7 +1775,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: {
1742
1775
  }
1743
1776
  if (
1744
1777
  seedVertexDefault === true
1745
- || shouldRetainConfiguredProviderModel(name, candidate.id)
1778
+ || shouldRetainConfiguredProviderModel(name, candidate.id, prov)
1746
1779
  || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true)
1747
1780
  ) {
1748
1781
  out.push(candidate);
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata";
5
+ export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiContextTier, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
@@ -71,7 +71,14 @@ export function codexIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrati
71
71
  }
72
72
 
73
73
  /** Whether a Codex sync is permitted for this admitted config snapshot. */
74
- export function shouldSyncCodexOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
74
+ export function shouldSyncCodexOnStart(config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">): boolean {
75
+ // A hub is a server for OTHER machines: it must not rewrite its own host's
76
+ // Codex/Claude/Grok client configs on startup (interview decision Q6, and the
77
+ // first clisu-oracle dogfood boot proved the failure mode — the hub marked
78
+ // /readyz failed because it tried to run the full local client sync).
79
+ // "Hub is also a client" stays possible by explicitly enabling integrations
80
+ // later; the ROLE alone never injects.
81
+ if (config.runtimeRole === "hub") return false;
75
82
  return codexIntegrationEnabled(config);
76
83
  }
77
84
 
@@ -182,7 +189,7 @@ export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesir
182
189
  */
183
190
  export async function syncCodexOnStartIfEnabled(
184
191
  port: number,
185
- config: Pick<OcxConfig, "clientIntegrations">,
192
+ config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">,
186
193
  sync: CodexStartupSync = defaultStartupSync,
187
194
  readinessGate?: ReadinessGate,
188
195
  ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> {
@@ -225,6 +232,9 @@ async function defaultStartupSync(port: number): Promise<CodexStartupSyncOutcome
225
232
  * startup and its diagnostic is worth printing. This only answers whether to
226
233
  * attempt the sync at all.
227
234
  */
228
- export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
235
+ export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations" | "runtimeRole">): boolean {
236
+ // Same hub rule as shouldSyncCodexOnStart: the hub role never rewrites its
237
+ // host's client configs on startup.
238
+ if (config.runtimeRole === "hub") return false;
229
239
  return grokIntegrationEnabled(config);
230
240
  }