@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,858 @@
1
+ import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases";
2
+ import { initialModelSelectionPending } from "../../providers/initial-model-selection";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createHash, createHmac, randomBytes } from "node:crypto";
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
6
+ import { delimiter, dirname, join, resolve } from "node:path";
7
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
8
+ import { resolveProviderApiKey } from "../../providers/key-store";
9
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
10
+ import {
11
+ clearModelCache,
12
+ clearProviderDiscoveryStatus,
13
+ captureModelCacheGeneration,
14
+ DEFAULT_MODEL_CACHE_TTL_MS,
15
+ getFreshCached,
16
+ getStaleCached,
17
+ isModelsFetchCoolingDown,
18
+ isModelCacheGenerationCurrent,
19
+ markModelsFetchFailure,
20
+ markProviderDiscoveryFailed,
21
+ markProviderDiscoveryOk,
22
+ shouldLogDiscoveryFailure,
23
+ setCached,
24
+ type ProviderModelDiscoveryFailure,
25
+ } from "../model-cache";
26
+ import {
27
+ buildModelsRequest,
28
+ getValidAccessTokenSnapshot,
29
+ observeActiveOAuthAccessToken,
30
+ resolveModelsAuthToken,
31
+ type OAuthActiveTokenObservation,
32
+ } from "../../oauth";
33
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
34
+ import { modelInList } from "../../types";
35
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
36
+ import { isModelVisionSidecarConsumer } from "../../vision/eligibility";
37
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata";
38
+ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
39
+ import {
40
+ captureFastPolicyAuthority,
41
+ fastPolicyForModel,
42
+ serviceTierSupportFromPolicy,
43
+ } from "../../providers/service-tier";
44
+ import type { FastPolicyAuthority } from "../../providers/fastwire";
45
+ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry";
46
+ import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
47
+ import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
48
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
49
+ import { effectiveModelAliases } from "../../providers/default-aliases";
50
+ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
51
+ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
52
+ import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
53
+ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
54
+ import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
55
+ import { fetchQoderModels } from "../../adapters/qoder/live-models";
56
+ import { resolveQoderProfile } from "../../adapters/qoder/profiles";
57
+ import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
58
+ import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
59
+ import {
60
+ COMBO_NAMESPACE,
61
+ comboModelId,
62
+ getCombo,
63
+ listComboIds,
64
+ quotaInactiveReason,
65
+ targetKey,
66
+ } from "../../combos";
67
+ import type { NormalizedComboConfig } from "../../combos/types";
68
+ import {
69
+ ProviderOutboundPolicyError,
70
+ providerOutboundGet,
71
+ providerOutboundPost,
72
+ providerRedirectError,
73
+ } from "../../lib/provider-outbound";
74
+ import { redactSecretString } from "../../lib/redact";
75
+ import {
76
+ extractProviderModelItems,
77
+ isRegistryModelDiscoveryUrl,
78
+ readBoundedDiscoveryJson,
79
+ resolveProviderModelDiscovery,
80
+ type ModelDiscoveryResponseFailure,
81
+ type ProviderModelsApiItem,
82
+ type ResolvedProviderModelDiscovery,
83
+ } from "../../providers/model-discovery";
84
+ import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
85
+ import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
86
+ import upstreamModelsSnapshot from "../data/upstream-models.json";
87
+ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
88
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
89
+ import type { CatalogModel } from "./parsing";
90
+ import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
91
+ import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
92
+ import type { ComboCatalogOmission } from "./aggregation";
93
+ import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
94
+ import type {
95
+ CatalogAdmissionSnapshot,
96
+ CatalogDiscoveryPolicyField,
97
+ CatalogGatherAuthorityIdentity,
98
+ CatalogProviderDiscoveryPolicySnapshot,
99
+ CatalogProcessLocalEvidence,
100
+ CatalogSourceEvidence,
101
+ CatalogTrustedOpenAiApiPolicySnapshot,
102
+ } from "../convergence-types";
103
+ import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture";
104
+ import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens } from "./model-hints";
105
+ import { resolveComboCatalogMember } from "./combo-member";
106
+ import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture";
107
+ import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models";
108
+
109
+ export interface GatherRoutedModelsOptions {
110
+ comboOmissions?: ComboCatalogOmission[];
111
+ providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[];
112
+ /** Flight-local authority of each provider's returned model rows. */
113
+ providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
114
+ /** Internal convergence sink for the immutable policy that produced the returned rows. */
115
+ discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
116
+ }
117
+
118
+ interface GatherFlightResult {
119
+ models: CatalogModel[];
120
+ comboOmissions: ComboCatalogOmission[];
121
+ providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
122
+ providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
123
+ discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
124
+ }
125
+ interface GatherInflightEntry {
126
+ readonly discoveryPolicyIdentity: string;
127
+ /**
128
+ * The credential half of the join decision.
129
+ *
130
+ * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no
131
+ * `authMode`, key or headers, and discovery policy does not carry them either.
132
+ * Two admissions differing ONLY in credential therefore produced the same key
133
+ * and the same policy, so the second joined the first and published rows the
134
+ * old key had fetched — reproduced against the real routes by rotating a key
135
+ * through `/api/providers/keys` mid-flight.
136
+ *
137
+ * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider
138
+ * row and therefore covers `apiKey` too: removing this term alone leaves the
139
+ * credential regression green. It is kept deliberately, for two reasons. It
140
+ * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the
141
+ * final materialized headers, which are derived rather than stored, so an
142
+ * OAuth token that changes while the row is byte-identical still separates
143
+ * admissions. And it states the credential rule where a reader looks for it,
144
+ * instead of leaving it as an emergent property of hashing everything.
145
+ */
146
+ readonly authIdentity: string;
147
+ /**
148
+ * The whole admitted provider graph, not a chosen subset.
149
+ *
150
+ * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was
151
+ * silently treated as equivalence: credentials leaked a flight until
152
+ * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both
153
+ * reproduced against real routes. Enumerating fields cannot converge, because
154
+ * the next field added to a provider row inherits the same defect. This
155
+ * identity therefore covers the enriched, frozen provider objects the flight
156
+ * actually gathered from, so a join is refused unless the admissions agree on
157
+ * everything rather than on everything somebody remembered to list.
158
+ */
159
+ readonly providerGraphIdentity: string;
160
+ readonly promise: Promise<GatherFlightResult>;
161
+ }
162
+ const gatherInflight = new Map<string, GatherInflightEntry[]>();
163
+ const MAX_CONCURRENT_CATALOG_GATHERS = 8;
164
+ const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
165
+
166
+ export class CatalogGatherBusyError extends ResourceAdmissionError {
167
+ override readonly code = "catalog_busy";
168
+ readonly retryAfterSeconds = 1;
169
+ constructor() {
170
+ super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
171
+ this.name = "CatalogGatherBusyError";
172
+ }
173
+ }
174
+
175
+ export function catalogGatherAdmissionMetrics(): AdmissionMetrics {
176
+ return gatherGate.metrics();
177
+ }
178
+ /** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */
179
+ export function clearGatherRoutedModelsInflight(): void {
180
+ gatherInflight.clear();
181
+ }
182
+ export async function gatherRoutedModels(
183
+ config: OcxConfig,
184
+ options?: GatherRoutedModelsOptions,
185
+ ): Promise<CatalogModel[]> {
186
+ return gatherRoutedModelsWithAuth(
187
+ config,
188
+ `refreshing:${gatherFlightKey(config)}`,
189
+ () => refreshingModelsAuthResolver,
190
+ options,
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Catalog-gather model discovery using only auth-store bytes already captured by the
196
+ * filesystem-evidence owner. This entry point never reaches the refreshing resolver.
197
+ */
198
+ export async function gatherRoutedModelsForCatalogGather(
199
+ config: OcxConfig,
200
+ evidence: CatalogGatherProviderAuthEvidence,
201
+ options?: GatherRoutedModelsOptions,
202
+ ): Promise<CatalogModel[]> {
203
+ const authStoreBuffer = evidence.authStoreBuffer === null
204
+ ? null
205
+ : Uint8Array.from(evidence.authStoreBuffer);
206
+ const authIdentity = authStoreBuffer === null
207
+ ? "absent"
208
+ : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer);
209
+ return gatherRoutedModelsWithAuth(
210
+ config,
211
+ `observed:${authIdentity}:${gatherFlightKey(config)}`,
212
+ outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes),
213
+ options,
214
+ );
215
+ }
216
+
217
+ async function gatherRoutedModelsWithAuth(
218
+ config: OcxConfig,
219
+ key: string,
220
+ createAuthResolver: ModelsAuthResolverFactory,
221
+ options?: GatherRoutedModelsOptions,
222
+ ): Promise<CatalogModel[]> {
223
+ const capture = captureGatherFlight(config, createAuthResolver);
224
+ const bucket = gatherInflight.get(key) ?? [];
225
+ let entry = bucket.find(candidate => (
226
+ candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity
227
+ && candidate.authIdentity === capture.authIdentity
228
+ && candidate.providerGraphIdentity === capture.providerGraphIdentity
229
+ ));
230
+ if (!entry) {
231
+ const lease = gatherGate.tryAcquire();
232
+ if (!lease) throw new CatalogGatherBusyError();
233
+ // Claim the slot synchronously before any await so same-key callers join this flight.
234
+ // Distinct authorities retain separate entries even when their legacy bucket matches.
235
+ let ownedEntry!: GatherInflightEntry;
236
+ const flight = gatherRoutedModelsUncached(config, capture).finally(() => {
237
+ const current = gatherInflight.get(key);
238
+ const index = current?.indexOf(ownedEntry) ?? -1;
239
+ if (current && index >= 0) current.splice(index, 1);
240
+ if (current?.length === 0) gatherInflight.delete(key);
241
+ lease.release();
242
+ });
243
+ ownedEntry = Object.freeze({
244
+ discoveryPolicyIdentity: capture.discoveryPolicyIdentity,
245
+ authIdentity: capture.authIdentity,
246
+ providerGraphIdentity: capture.providerGraphIdentity,
247
+ promise: flight,
248
+ });
249
+ bucket.push(ownedEntry);
250
+ gatherInflight.set(key, bucket);
251
+ entry = ownedEntry;
252
+ }
253
+ const {
254
+ models,
255
+ comboOmissions,
256
+ providerAuthOutcomes,
257
+ providerModelOutcomes,
258
+ discoveryPolicySnapshots,
259
+ } = await entry.promise;
260
+ if (options?.comboOmissions) {
261
+ options.comboOmissions.length = 0;
262
+ options.comboOmissions.push(...comboOmissions);
263
+ }
264
+ if (options?.providerAuthOutcomes) {
265
+ options.providerAuthOutcomes.length = 0;
266
+ options.providerAuthOutcomes.push(...providerAuthOutcomes);
267
+ }
268
+ if (options?.providerModelOutcomes) {
269
+ options.providerModelOutcomes.length = 0;
270
+ options.providerModelOutcomes.push(...providerModelOutcomes);
271
+ }
272
+ if (options?.discoveryPolicySnapshots) {
273
+ options.discoveryPolicySnapshots.length = 0;
274
+ options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
275
+ }
276
+ return models;
277
+ }
278
+
279
+ /** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */
280
+ function boundCustomNativeReasoning(
281
+ model: CatalogModel,
282
+ allowed: readonly string[],
283
+ nativeDefault: string | undefined,
284
+ ): CatalogModel {
285
+ if (allowed.length === 0 || model.reasoningEfforts === undefined) return model;
286
+ const bounded = { ...model };
287
+ if (model.reasoningEfforts.length === 0) {
288
+ bounded.reasoningEfforts = [];
289
+ delete bounded.defaultReasoningEffort;
290
+ return bounded;
291
+ }
292
+ const declared = new Set(model.reasoningEfforts);
293
+ const surviving = [...new Set(allowed)].filter(effort => declared.has(effort));
294
+ const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!;
295
+ // A nonempty but incompatible declaration is not an explicit no-reasoning setting.
296
+ bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback];
297
+ bounded.defaultReasoningEffort = model.defaultReasoningEffort
298
+ && bounded.reasoningEfforts.includes(model.defaultReasoningEffort)
299
+ ? model.defaultReasoningEffort
300
+ : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!;
301
+ return bounded;
302
+ }
303
+
304
+ async function gatherRoutedModelsUncached(
305
+ config: OcxConfig,
306
+ capture: GatherFlightCapture,
307
+ ): Promise<GatherFlightResult> {
308
+ // Flight-local list: joiners copy from the resolved promise, not a process-global last write.
309
+ const localOmissions: ComboCatalogOmission[] = [];
310
+ const localProviderAuthOutcomes = capture.providerAuthOutcomes;
311
+ const resolveAuth = capture.authResolver;
312
+ const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
313
+ // Persisted provider entries can predate newer registry fields (noVisionModels,
314
+ // modelInputModalities, ...). The ROUTER merges registry seeds at request time
315
+ // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
316
+ // same merged view or its advertisements drift from actual proxy behavior (e.g. a
317
+ // vision-sidecar model advertised text-only, blocking image attachments app-side).
318
+ // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
319
+ const activeProviders = capture.providers;
320
+ const providerResults = await Promise.all(
321
+ activeProviders.map(provider => fetchProviderModelsWithAuth(
322
+ provider,
323
+ ttlMs,
324
+ providerContextCap(config, provider.name),
325
+ resolveAuth,
326
+ )),
327
+ );
328
+ const lists = providerResults.map(result => result.models);
329
+ const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows(
330
+ lists.flat(),
331
+ config,
332
+ capture.openAiApiPolicy,
333
+ );
334
+ const apiProvider = activeProviders.find(provider => provider.name === OPENAI_API_PROVIDER_ID);
335
+ // Trusted reconstruction replaces whole rows, including the earlier Fast hints.
336
+ // Restore only that capability from the same captured authority used by discovery.
337
+ if (apiProvider) {
338
+ for (const model of apiAugmented) {
339
+ if (model.provider !== OPENAI_API_PROVIDER_ID) continue;
340
+ const policy = fastPolicyForModel(apiProvider.provider, model.id, apiProvider.name);
341
+ const supported = serviceTierSupportFromPolicy(policy);
342
+ if (supported !== undefined) model.supportsServiceTier = supported;
343
+ if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription;
344
+ }
345
+ }
346
+ const metadataModelIdCaseFoldByProvider = new Map(
347
+ activeProviders.map(provider => [provider.name, provider.metadataModelIdCaseFold]),
348
+ );
349
+ const all = augmentRoutedModelsWithMetadata(
350
+ apiAugmented,
351
+ activeProviders.map(provider => provider.name),
352
+ config.providers,
353
+ config,
354
+ metadataModelIdCaseFoldByProvider,
355
+ )
356
+ // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
357
+ // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
358
+ // exposure decision goes through shouldExposeRoutedModel (single choke point).
359
+ .filter(shouldExposeRoutedModel);
360
+ const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
361
+ // [Decision Log]
362
+ // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
363
+ // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
364
+ // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
365
+ // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
366
+ // openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
367
+ // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
368
+ // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
369
+ // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
370
+ // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
371
+ // 노출 경로에 영향 없음.
372
+ // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
373
+ // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
374
+ // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
375
+ // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
376
+ // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
377
+ // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
378
+ // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
379
+ // 우선시하므로 실제 충돌 가능성은 낮음.
380
+ if (!hasComboTargets(config)) {
381
+ // Skip the native slug injection entirely when no combos are configured — avoids
382
+ // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
383
+ // configs that will never need it.
384
+ } else {
385
+ const disabled = disabledNativeSlugs(config);
386
+ const openaiContextCap = nativeContextLimits(config);
387
+ const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => {
388
+ const combo = getCombo(config, id);
389
+ return combo?.targets.flatMap(target => (
390
+ target.provider === "openai" ? [target.model] : []
391
+ )) ?? [];
392
+ }));
393
+ for (const slug of nativeOpenAiSlugs()) {
394
+ // A bare native disable key hides the native row, not a combo that targets it.
395
+ // Keep synthetic native metadata available to those combos.
396
+ if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue;
397
+ const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap);
398
+ if (contextWindow === undefined) continue;
399
+ const synthetic: CatalogModel = {
400
+ provider: "openai",
401
+ id: slug,
402
+ owned_by: "openai",
403
+ contextWindow,
404
+ // Input limit, not the total window. These coincide for native GPT-5.6 today (the
405
+ // advertised 922,000 window is already capped at its measured ceiling), but the two
406
+ // stay separate fields because routed/API rows of the same family run a wider window.
407
+ // Falls back to the window for slugs with no separate ceiling.
408
+ maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow),
409
+ ...(nativeOpenAiMaxOutputTokens(slug) !== undefined
410
+ ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(slug) }
411
+ : {}),
412
+ autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap),
413
+ inputModalities: nativeInputModalities(slug),
414
+ reasoningEfforts: nativeReasoningEfforts(slug),
415
+ ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
416
+ };
417
+ const key = `openai/${slug}`;
418
+ // Only inject when not already present from a routed provider (an API-key
419
+ // "openai" provider could shadow the native one).
420
+ if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
421
+ }
422
+ }
423
+ // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
424
+ // custom-model vision-sidecar inheritance so both see the same merged registry view.
425
+ const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
426
+ for (const id of listComboIds(config)) {
427
+ const combo = getCombo(config, id);
428
+ if (!combo) continue;
429
+ const comboNativeLimits = nativeContextLimits(config);
430
+ const nativeContextWindow = combo.nativeAlias && combo.alias
431
+ ? nativeOpenAiContextWindow(combo.alias, comboNativeLimits)
432
+ : undefined;
433
+ const nativeAliasMaxInput = combo.nativeAlias && combo.alias
434
+ ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak")
435
+ ? NATIVE_GPT56_MAX_INPUT_TOKENS
436
+ : nativeOpenAiMaxInputTokens(combo.alias, comboNativeLimits) ?? nativeOpenAiContextWindow(combo.alias, comboNativeLimits))
437
+ : undefined;
438
+ const nativeAliasAutoCompact = combo.nativeAlias && combo.alias
439
+ ? nativeOpenAiAutoCompactTokenLimit(combo.alias, comboNativeLimits)
440
+ : undefined;
441
+ const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined
442
+ ? {
443
+ contextWindow: nativeContextWindow,
444
+ ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}),
445
+ ...(nativeOpenAiMaxOutputTokens(combo.alias) !== undefined
446
+ ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(combo.alias) }
447
+ : {}),
448
+ ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}),
449
+ inputModalities: nativeInputModalities(combo.alias),
450
+ reasoningEfforts: nativeReasoningEfforts(combo.alias),
451
+ }
452
+ : undefined;
453
+ const members = combo.targets
454
+ .map(target => resolveComboCatalogMember(
455
+ target,
456
+ memberByKey,
457
+ enrichedByName,
458
+ providerContextCap(config, target.provider),
459
+ nativeAliasFallback,
460
+ metadataModelIdCaseFoldByProvider.get(target.provider),
461
+ ))
462
+ .filter((member): member is CatalogModel => member !== undefined);
463
+ const derived = deriveComboCatalogModel(id, combo, members);
464
+ if (derived) {
465
+ const nativeDefault = combo.nativeAlias && combo.alias
466
+ ? nativeDefaultReasoningEffort(combo.alias)
467
+ : undefined;
468
+ if (combo.defaultEffort === null
469
+ && nativeDefault
470
+ && derived.reasoningEfforts?.includes(nativeDefault)) {
471
+ derived.defaultReasoningEffort = nativeDefault;
472
+ }
473
+ all.push(derived);
474
+ }
475
+ else warnUncataloguedComboOnce(id, combo, members, localOmissions);
476
+ }
477
+ replaceLastComboCatalogOmissions(localOmissions);
478
+ all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
479
+ // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
480
+ // with the same slug below, so that row's provider capability metadata is the inheritance source.
481
+ const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
482
+ const customModels = (config.customModels ?? []).map(cm => {
483
+ const rawProvider = config.providers[cm.provider];
484
+ const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
485
+ // Registry routing backfills an omitted authMode on the built-in OpenAI provider to
486
+ // forward. Keep the catalog projection on the same contract while still failing closed
487
+ // for every explicit non-forward mode and every non-canonical endpoint.
488
+ const providerForCanonicalCheck = rawProvider
489
+ ? withCanonicalOpenAiForwardAuthDefault(cm.provider, rawProvider)
490
+ : undefined;
491
+ const codexForwardNativeCapabilityAlias = cm.provider === OPENAI_CODEX_PROVIDER_ID
492
+ && providerForCanonicalCheck !== undefined
493
+ && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)
494
+ && hasNativeOpenAiCapabilityMetadata(cm.modelId);
495
+ const customNativeLimits = {
496
+ ...nativeContextLimits(config),
497
+ ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0
498
+ ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } }
499
+ : {}),
500
+ };
501
+ const nativeAliasContextWindow = codexForwardNativeCapabilityAlias
502
+ ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits)
503
+ : undefined;
504
+ const customContextWindow = cm.contextWindow
505
+ ? nativeAliasContextWindow !== undefined
506
+ ? nativeAliasContextWindow
507
+ : cm.contextWindow
508
+ : nativeAliasContextWindow;
509
+ const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias
510
+ ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits)
511
+ : undefined;
512
+ const nativeAliasMaxOutputTokens = codexForwardNativeCapabilityAlias
513
+ ? nativeOpenAiMaxOutputTokens(cm.modelId)
514
+ : undefined;
515
+ const configuredMaxInput = rawProvider
516
+ ? configuredMaxInputTokens(rawProvider, cm.modelId)
517
+ : undefined;
518
+ const hardMaxCandidates = [nativeAliasMaxInputTokens, configuredMaxInput]
519
+ .filter((value): value is number => typeof value === "number" && value > 0);
520
+ const customMaxInputTokens = hardMaxCandidates.length > 0
521
+ ? Math.min(
522
+ ...hardMaxCandidates,
523
+ ...(customContextWindow !== undefined ? [customContextWindow] : []),
524
+ )
525
+ : undefined;
526
+ const customMaxOutputTokens = rawProvider
527
+ ? routedMaxOutputTokens(cm.provider, rawProvider, {
528
+ id: cm.modelId,
529
+ provider: cm.provider,
530
+ ...(nativeAliasMaxOutputTokens !== undefined ? { maxOutputTokens: nativeAliasMaxOutputTokens } : {}),
531
+ }, cm.modelId, metadataModelIdCaseFoldByProvider.get(cm.provider))
532
+ : nativeAliasMaxOutputTokens;
533
+ const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId);
534
+ const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias
535
+ ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits)
536
+ : customContextWindow !== undefined && configuredAutoCompact !== undefined
537
+ ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, configuredAutoCompact)
538
+ : undefined;
539
+ const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias
540
+ ? nativeDefaultReasoningEffort(cm.modelId)
541
+ : undefined;
542
+ const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
543
+ const fastPolicy = effectiveProvider
544
+ ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider)
545
+ : undefined;
546
+ const supportsServiceTier = fastPolicy
547
+ ? serviceTierSupportFromPolicy(fastPolicy)
548
+ : undefined;
549
+ const base: CatalogModel = {
550
+ id: cm.modelId,
551
+ provider: cm.provider,
552
+ catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
553
+ // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
554
+ ...(cm.displayName
555
+ ? { displayName: cm.displayName }
556
+ : codexForwardNativeCapabilityAlias
557
+ ? { displayName: nativeOpenAiCapabilityDisplayName(cm.modelId) ?? cm.modelId } : {}),
558
+ ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}),
559
+ ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}),
560
+ ...(customMaxOutputTokens !== undefined ? { maxOutputTokens: customMaxOutputTokens } : {}),
561
+ ...(customAutoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: customAutoCompactTokenLimit } : {}),
562
+ ...(cm.inputModalities
563
+ ? { inputModalities: cm.inputModalities }
564
+ : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}),
565
+ ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
566
+ // Native-alias defaults apply only where the custom row declares nothing: the explicit
567
+ // spreads below must win (later in object order), so a stored `[]` stays empty and a
568
+ // declared ladder is narrowed to proven native capabilities after the merge below.
569
+ ...(codexForwardNativeCapabilityAlias
570
+ ? {
571
+ codexForwardNativeCapabilityAlias: true,
572
+ parallelToolCalls: nativeParallelToolCalls(cm.modelId),
573
+ ...(Array.isArray(cm.reasoningEfforts)
574
+ ? {}
575
+ : {
576
+ reasoningEfforts: nativeReasoningEfforts(cm.modelId),
577
+ ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}),
578
+ }),
579
+ }
580
+ : {}),
581
+ // Explicit custom-row ladder wins over the inherited provider row below: the merge only
582
+ // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
583
+ // instead of being replaced by that row's metadata. Capability-backed native model ids
584
+ // are bounded against their own pinned ladder after the merge, including gateways.
585
+ ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
586
+ ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
587
+ ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
588
+ ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined
589
+ ? { fastTierDescription: fastPolicy.fastTierDescription }
590
+ : {}),
591
+ ...(cm.codexToolMode !== undefined
592
+ ? { codexToolMode: cm.codexToolMode }
593
+ : effectiveProvider?.codexToolMode !== undefined
594
+ ? { codexToolMode: effectiveProvider.codexToolMode }
595
+ : {}),
596
+ };
597
+ // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
598
+ // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
599
+ // context, ...) so the generated catalog keeps advertising what the router actually provides.
600
+ // Explicit custom fields win by construction; this only fills gaps. Without it a
601
+ // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one,
602
+ // which Codex then rejects for spawn_agent with effort "none".
603
+ const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId));
604
+ // The final ladder is what the catalog will advertise; the inherited default only rides
605
+ // along when it is actually a member — otherwise a provider default like "xhigh" would
606
+ // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels.
607
+ const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts;
608
+ const mergedMaxInputCandidates = [base.maxInputTokens, replaced?.maxInputTokens]
609
+ .filter((value): value is number => typeof value === "number" && value > 0);
610
+ const mergedMaxInput = mergedMaxInputCandidates.length > 0
611
+ ? Math.min(...mergedMaxInputCandidates)
612
+ : undefined;
613
+ const mergedMaxOutputCandidates = [base.maxOutputTokens, replaced?.maxOutputTokens]
614
+ .filter((value): value is number => typeof value === "number" && value > 0);
615
+ const mergedMaxOutput = mergedMaxOutputCandidates.length > 0
616
+ ? Math.min(...mergedMaxOutputCandidates)
617
+ : undefined;
618
+ const merged: CatalogModel = replaced ? {
619
+ ...base,
620
+ ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}),
621
+ ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}),
622
+ ...(mergedMaxOutput !== undefined ? { maxOutputTokens: mergedMaxOutput } : {}),
623
+ ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined
624
+ ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit }
625
+ : {}),
626
+ ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}),
627
+ ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}),
628
+ ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined
629
+ && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort)
630
+ ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}),
631
+ ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
632
+ ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
633
+ ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
634
+ ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
635
+ ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
636
+ } : base;
637
+ // Catalog-advertised efforts are bounded whenever the model id is a pinned native
638
+ // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot
639
+ // advertise none/minimal. Full native identity stays behind the alias predicate.
640
+ const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId);
641
+ const reasoningBounded = nativeEffortSource
642
+ ? boundCustomNativeReasoning(
643
+ merged,
644
+ nativeReasoningEfforts(cm.modelId),
645
+ nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId),
646
+ )
647
+ : merged;
648
+ // Vision-sidecar coverage only: when the enriched provider's shared predicate matches
649
+ // noVisionModels or text-without-image modelInputModalities, advertise image input so the
650
+ // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full
651
+ // applyProviderConfigHints pass — custom rows are a
652
+ // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
653
+ // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
654
+ const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0
655
+ ? reasoningBounded.contextWindow
656
+ : undefined;
657
+ const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0
658
+ ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens)
659
+ : undefined;
660
+ const mergedWithHardBounds = boundedMergedMaxInput !== undefined
661
+ && boundedMergedMaxInput !== reasoningBounded.maxInputTokens
662
+ ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput }
663
+ : reasoningBounded;
664
+ const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact]
665
+ .filter((value): value is number => typeof value === "number" && value > 0);
666
+ const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0
667
+ ? {
668
+ ...mergedWithHardBounds,
669
+ autoCompactTokenLimit: clampAutoCompactTokenLimit(
670
+ mergedContext,
671
+ boundedMergedMaxInput,
672
+ Math.min(...mergedSoftCandidates),
673
+ ),
674
+ }
675
+ : mergedWithHardBounds;
676
+ const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
677
+ // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints.
678
+ if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) {
679
+ const current = mergedWithAutoCompact.inputModalities ?? ["text"];
680
+ if (!current.includes("image")) {
681
+ return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] };
682
+ }
683
+ }
684
+ return mergedWithAutoCompact;
685
+ });
686
+ // Custom rows override discovered rows that encode to the same Codex-facing slug.
687
+ const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
688
+ const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
689
+ const models = [...deduped, ...customModels];
690
+ // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
691
+ const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
692
+ const providerModels = models.filter(model => model.provider === name);
693
+ const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))];
694
+ return aliases.flatMap(([id, { alias }]) => {
695
+ const exact = providerModels.filter(model => model.id === id);
696
+ const matches = exact.length > 0
697
+ ? exact
698
+ : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase());
699
+ return matches.length === 1
700
+ ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const]
701
+ : [];
702
+ });
703
+ }));
704
+ const providerModelOutcomes = providerResults.map(result => (
705
+ result.outcome.provider === OPENAI_API_PROVIDER_ID
706
+ && capture.openAiApiPolicy.state === "captured"
707
+ && capture.openAiApiPolicy.models !== undefined
708
+ ? { provider: result.outcome.provider, state: "authoritative" as const }
709
+ : result.outcome
710
+ ));
711
+ return {
712
+ models: models.map(model => {
713
+ const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
714
+ // #1711: one stamping point for every row this gather produces — routed, combo, and custom
715
+ // alike — because it is the only place that has both the finished list and the config the
716
+ // quota rules need. A combo votes over its own targets; anything else votes over the single
717
+ // provider that would serve it.
718
+ const targets = model.provider === COMBO_NAMESPACE
719
+ ? config.combos?.[model.id]?.targets ?? []
720
+ : [{ provider: model.provider }];
721
+ const inactive = quotaInactiveReason(config, targets);
722
+ const named = displayName && !model.displayName ? { ...model, displayName } : model;
723
+ return inactive ? { ...named, quotaInactiveReason: inactive } : named;
724
+ }),
725
+ comboOmissions: localOmissions,
726
+ providerAuthOutcomes: localProviderAuthOutcomes,
727
+ providerModelOutcomes,
728
+ discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
729
+ };
730
+ }
731
+
732
+ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
733
+ models: CatalogModel[],
734
+ config: OcxConfig,
735
+ ): CatalogModel[] {
736
+ const configured = config.providers[OPENAI_API_PROVIDER_ID];
737
+ if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
738
+ return augmentRoutedModelsWithCapturedOpenAiApiRows(
739
+ models,
740
+ config,
741
+ captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true),
742
+ );
743
+ }
744
+
745
+ function augmentRoutedModelsWithCapturedOpenAiApiRows(
746
+ models: CatalogModel[],
747
+ config: OcxConfig,
748
+ policy: CatalogTrustedOpenAiApiPolicySnapshot,
749
+ ): CatalogModel[] {
750
+ if (policy.state !== "captured" || !policy.models) return models;
751
+ const configured = config.providers[OPENAI_API_PROVIDER_ID];
752
+ if (!configured || configured.disabled === true) return models;
753
+
754
+ const existingById = new Map(
755
+ models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
756
+ );
757
+ const trustedRows = policy.models.map((id): CatalogModel => {
758
+ const officialContext = policy.modelContextWindows?.[id];
759
+ const officialMaxInput = policy.modelMaxInputTokens?.[id];
760
+ const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
761
+ const userMaxInput = configured.modelMaxInputTokens?.[id];
762
+ const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
763
+ const contextWindow = typeof officialContext === "number"
764
+ ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
765
+ : undefined;
766
+ const maxInputTokens = typeof officialMaxInput === "number"
767
+ ? Math.min(
768
+ officialMaxInput,
769
+ userMaxInput ?? officialMaxInput,
770
+ contextWindow ?? officialMaxInput,
771
+ )
772
+ : undefined;
773
+ const configuredAutoCompact = configuredAutoCompactTokenLimit(configured, id);
774
+ const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined
775
+ ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact)
776
+ : undefined;
777
+ const maxOutputTokens = routedMaxOutputTokens(
778
+ OPENAI_API_PROVIDER_ID,
779
+ configured,
780
+ policy.modelMaxOutputTokens?.[id] !== undefined
781
+ ? { provider: OPENAI_API_PROVIDER_ID, id, maxOutputTokens: policy.modelMaxOutputTokens[id] }
782
+ : existingById.get(id) ?? { provider: OPENAI_API_PROVIDER_ID, id },
783
+ policy.virtualModels?.[id]?.wireModelId ?? id,
784
+ );
785
+ return {
786
+ provider: OPENAI_API_PROVIDER_ID,
787
+ id,
788
+ owned_by: OPENAI_API_PROVIDER_ID,
789
+ ...(contextWindow ? { contextWindow } : {}),
790
+ ...(maxInputTokens ? { maxInputTokens } : {}),
791
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
792
+ ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
793
+ ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}),
794
+ ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}),
795
+ };
796
+ });
797
+
798
+ for (const trusted of trustedRows) {
799
+ const live = existingById.get(trusted.id);
800
+ if (!live) continue;
801
+ const liveSignature = normalizedOpenAiApiSignature(live);
802
+ const trustedSignature = normalizedOpenAiApiSignature(trusted);
803
+ if (liveSignature === trustedSignature) continue;
804
+ const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
805
+ if (openAiApiCollisionWarnings.has(warningKey)) continue;
806
+ openAiApiCollisionWarnings.add(warningKey);
807
+ console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
808
+ }
809
+
810
+ return [
811
+ ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
812
+ ...trustedRows,
813
+ ];
814
+ }
815
+
816
+ export function augmentRoutedModelsWithMetadata(
817
+ models: CatalogModel[],
818
+ providerNames: string[],
819
+ providers?: Record<string, OcxProviderConfig>,
820
+ caps?: Pick<OcxConfig, "providerContextCaps">,
821
+ metadataModelIdCaseFoldByProvider?: ReadonlyMap<string, boolean>,
822
+ ): CatalogModel[] {
823
+ const out = [...models];
824
+ const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
825
+ for (const provider of providerNames) {
826
+ if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
827
+ if (providers?.[provider]?.liveModels === false) continue;
828
+ const jawcodeProvider = resolveMetadataProvider(provider);
829
+ if (!jawcodeProvider) continue;
830
+ for (const meta of listModelMetadata(jawcodeProvider)) {
831
+ const key = `${provider}/${meta.id}`;
832
+ if (seen.has(key)) continue;
833
+ seen.add(key);
834
+ const contextCap = caps ? providerContextCap(caps, provider) : undefined;
835
+ const model: CatalogModel = {
836
+ provider,
837
+ id: meta.id,
838
+ owned_by: provider,
839
+ ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
840
+ ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}),
841
+ ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
842
+ };
843
+ out.push({
844
+ ...model,
845
+ ...(providers?.[provider]
846
+ ? applyProviderConfigHints(
847
+ provider,
848
+ providers[provider],
849
+ model,
850
+ contextCap,
851
+ metadataModelIdCaseFoldByProvider?.get(provider),
852
+ )
853
+ : {}),
854
+ });
855
+ }
856
+ }
857
+ return out;
858
+ }