@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
@@ -1,2944 +1,54 @@
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
-
89
-
90
- import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
91
- import type { CatalogModel } from "./parsing";
92
- import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
93
- import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
94
- import type { ComboCatalogOmission } from "./aggregation";
95
- import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
96
- import type {
97
- CatalogAdmissionSnapshot,
98
- CatalogDiscoveryPolicyField,
99
- CatalogGatherAuthorityIdentity,
100
- CatalogProviderDiscoveryPolicySnapshot,
101
- CatalogProcessLocalEvidence,
102
- CatalogSourceEvidence,
103
- CatalogTrustedOpenAiApiPolicySnapshot,
104
- } from "../convergence-types";
105
-
106
1
  export type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
107
2
 
108
- /** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
109
- * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
110
- export interface CatalogGatherProviderAuthOutcome {
111
- readonly provider: string;
112
- readonly state: OAuthActiveTokenObservation["kind"];
113
- }
114
-
115
- export interface CatalogGatherProviderModelOutcome {
116
- readonly provider: string;
117
- readonly state: "authoritative" | "degraded";
118
- }
119
-
120
- export interface GatherRoutedModelsOptions {
121
- comboOmissions?: ComboCatalogOmission[];
122
- providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[];
123
- /** Flight-local authority of each provider's returned model rows. */
124
- providerModelOutcomes?: CatalogGatherProviderModelOutcome[];
125
- /** Internal convergence sink for the immutable policy that produced the returned rows. */
126
- discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[];
127
- }
128
-
129
- interface GatherFlightResult {
130
- models: CatalogModel[];
131
- comboOmissions: ComboCatalogOmission[];
132
- providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
133
- providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
134
- discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
135
- }
136
-
137
- interface ProviderModelsResult {
138
- readonly models: CatalogModel[];
139
- readonly outcome: CatalogGatherProviderModelOutcome;
140
- }
141
-
142
- interface ModelsAuthResolution {
143
- readonly apiKey: string | undefined;
144
- readonly observed: boolean;
145
- readonly oauthApiBaseUrl?: string;
146
- readonly oauthProjectId?: string;
147
- }
148
-
149
- type ModelsAuthResolver =
150
- | { readonly kind: "refreshing" }
151
- | {
152
- readonly kind: "observed";
153
- readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution;
154
- };
155
-
156
- type ModelsAuthResolverFactory = (
157
- outcomes: CatalogGatherProviderAuthOutcome[],
158
- ) => ModelsAuthResolver;
159
-
160
- interface CapturedModelsRequest {
161
- readonly method: "GET" | "POST";
162
- readonly url: string;
163
- readonly headersWithoutCredential: Readonly<Record<string, string>>;
164
- readonly headersWithCredential: Readonly<Record<string, string>>;
165
- }
166
-
167
- interface CapturedProviderGather {
168
- readonly name: string;
169
- readonly provider: OcxProviderConfig;
170
- readonly discovery: ResolvedProviderModelDiscovery;
171
- readonly policy: CatalogProviderDiscoveryPolicySnapshot;
172
- readonly request: CapturedModelsRequest;
173
- readonly fastPolicyAuthority: FastPolicyAuthority;
174
- readonly metadataModelIdCaseFold: boolean;
175
- readonly effectiveAlias?: string | null;
176
- readonly observedAuth?: ModelsAuthResolution;
177
- /**
178
- * Configured model ids this provider must keep even when live discovery omits
179
- * them — combo targets that are also listed in providers.*.models (OCX-111).
180
- * Combo-only ids (not in models[]) stay out of the public catalog and are
181
- * synthesized for combo derivation instead (#1305).
182
- */
183
- readonly retainConfiguredModelIds?: ReadonlySet<string>;
184
- }
185
-
186
- interface GatherFlightCapture {
187
- readonly discoveryPolicyIdentity: string;
188
- readonly authIdentity: string;
189
- readonly providerGraphIdentity: string;
190
- readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[];
191
- readonly providers: readonly CapturedProviderGather[];
192
- readonly authResolver: ModelsAuthResolver;
193
- readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[];
194
- readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot;
195
- }
196
-
197
- interface GatherInflightEntry {
198
- readonly discoveryPolicyIdentity: string;
199
- /**
200
- * The credential half of the join decision.
201
- *
202
- * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no
203
- * `authMode`, key or headers, and discovery policy does not carry them either.
204
- * Two admissions differing ONLY in credential therefore produced the same key
205
- * and the same policy, so the second joined the first and published rows the
206
- * old key had fetched — reproduced against the real routes by rotating a key
207
- * through `/api/providers/keys` mid-flight.
208
- *
209
- * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider
210
- * row and therefore covers `apiKey` too: removing this term alone leaves the
211
- * credential regression green. It is kept deliberately, for two reasons. It
212
- * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the
213
- * final materialized headers, which are derived rather than stored, so an
214
- * OAuth token that changes while the row is byte-identical still separates
215
- * admissions. And it states the credential rule where a reader looks for it,
216
- * instead of leaving it as an emergent property of hashing everything.
217
- */
218
- readonly authIdentity: string;
219
- /**
220
- * The whole admitted provider graph, not a chosen subset.
221
- *
222
- * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was
223
- * silently treated as equivalence: credentials leaked a flight until
224
- * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both
225
- * reproduced against real routes. Enumerating fields cannot converge, because
226
- * the next field added to a provider row inherits the same defect. This
227
- * identity therefore covers the enriched, frozen provider objects the flight
228
- * actually gathered from, so a join is refused unless the admissions agree on
229
- * everything rather than on everything somebody remembered to list.
230
- */
231
- readonly providerGraphIdentity: string;
232
- readonly promise: Promise<GatherFlightResult>;
233
- }
234
-
235
- function withCanonicalOpenAiForwardAuthDefault(
236
- name: string,
237
- provider: OcxProviderConfig,
238
- ): OcxProviderConfig {
239
- if (name !== OPENAI_CODEX_PROVIDER_ID || provider.authMode !== undefined) return provider;
240
- const candidate = { ...provider, authMode: "forward" as const };
241
- return isCanonicalOpenAiForwardProvider(candidate) ? candidate : provider;
242
- }
243
-
244
- const gatherInflight = new Map<string, GatherInflightEntry[]>();
245
- const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32);
246
- const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`;
247
- const MAX_CONCURRENT_CATALOG_GATHERS = 8;
248
- const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
249
-
250
- export class CatalogGatherBusyError extends ResourceAdmissionError {
251
- override readonly code = "catalog_busy";
252
- readonly retryAfterSeconds = 1;
253
- constructor() {
254
- super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
255
- this.name = "CatalogGatherBusyError";
256
- }
257
- }
258
-
259
- export function catalogGatherAdmissionMetrics(): AdmissionMetrics {
260
- return gatherGate.metrics();
261
- }
262
-
263
- function stableJson(value: unknown): string {
264
- return JSON.stringify(value, (_key, nested) => {
265
- if (nested && typeof nested === "object" && !Array.isArray(nested)) {
266
- return Object.fromEntries(Object.entries(nested as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
267
- }
268
- return nested;
269
- });
270
- }
271
-
272
- function framed(value: string): string {
273
- return `${Buffer.byteLength(value, "utf8")}:${value}`;
274
- }
275
-
276
- function canonicalAuthorityEncoding(value: unknown): string {
277
- if (value === null) return "null";
278
- if (value === undefined) return "undefined";
279
- if (typeof value === "string") return `string${framed(value)}`;
280
- if (typeof value === "boolean") return value ? "boolean1" : "boolean0";
281
- if (typeof value === "number") {
282
- if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number.");
283
- const encoded = Object.is(value, -0) ? "-0" : String(value);
284
- return `number${framed(encoded)}`;
285
- }
286
- if (Array.isArray(value)) {
287
- return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`;
288
- }
289
- if (typeof value === "object") {
290
- const record = value as Record<string, unknown>;
291
- const keys = Object.keys(record).sort((left, right) => left.localeCompare(right));
292
- return `object${keys.length}:${keys.map(key => (
293
- `${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}`
294
- )).join("")}`;
295
- }
296
- throw new TypeError(`Catalog authority cannot encode ${typeof value}.`);
297
- }
298
-
299
- function keyedGatherIdentity(domain: string, value: unknown): string {
300
- return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
301
- .update(framed(domain))
302
- .update(framed(canonicalAuthorityEncoding(value)))
303
- .digest("hex");
304
- }
305
-
306
- function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string {
307
- return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY)
308
- .update(framed(domain))
309
- .update(`${value.byteLength}:`)
310
- .update(value)
311
- .digest("hex");
312
- }
313
-
314
- export function createCatalogGatherAuthorityIdentity(
315
- snapshot: CatalogAdmissionSnapshot,
316
- sourceEvidence: CatalogSourceEvidence,
317
- processLocal: CatalogProcessLocalEvidence,
318
- discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[],
319
- ): CatalogGatherAuthorityIdentity {
320
- const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence);
321
- const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal);
322
- const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies);
323
- return Object.freeze({
324
- version: 1 as const,
325
- authorityId: keyedGatherIdentity("catalog-authority-v1", {
326
- admittedConfig: snapshot.configIdentity,
327
- discoveryPolicyIdentity,
328
- sourceEvidenceIdentity,
329
- processLocalEvidenceIdentity,
330
- }),
331
- admittedConfig: Object.freeze({
332
- ...snapshot.configIdentity,
333
- generation: Object.freeze({ ...snapshot.configIdentity.generation }),
334
- }),
335
- authSnapshotIdentity: keyedGatherIdentity(
336
- "catalog-auth-v1",
337
- sourceEvidence.conditional["provider-auth-selection"],
338
- ),
339
- discoveryPolicyIdentity,
340
- nativeCatalogSourceIdentity: keyedGatherIdentity(
341
- "catalog-native-v1",
342
- sourceEvidence.conditional["native-catalog-selection"],
343
- ),
344
- sourceEvidenceIdentity,
345
- processLocalEvidenceIdentity,
346
- });
347
- }
348
-
349
- function detachedClone<T>(value: T): T {
350
- if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T;
351
- if (value && typeof value === "object") {
352
- const clone: Record<string, unknown> = {};
353
- for (const key of Object.keys(value)) {
354
- clone[key] = detachedClone((value as Record<string, unknown>)[key]);
355
- }
356
- return clone as T;
357
- }
358
- return value;
359
- }
360
-
361
- function recursivelyFreeze<T>(value: T): T {
362
- if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
363
- for (const nested of Object.values(value as Record<string, unknown>)) recursivelyFreeze(nested);
364
- return Object.freeze(value);
365
- }
366
-
367
- function detachedFrozen<T>(value: T): T {
368
- return recursivelyFreeze(detachedClone(value));
369
- }
370
-
371
- function capturedField<T extends object, K extends keyof T>(
372
- value: T | undefined,
373
- key: K,
374
- ): CatalogDiscoveryPolicyField<T[K]> {
375
- if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" });
376
- return detachedFrozen({ state: "present" as const, value: value[key] });
377
- }
378
-
379
- function captureTrustedOpenAiApiPolicy(
380
- name: string,
381
- registryTransportMatch: boolean,
382
- ): CatalogTrustedOpenAiApiPolicySnapshot {
383
- if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" });
384
- if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" });
385
- const entry = getProviderRegistryEntry(name);
386
- if (!entry?.models) return Object.freeze({ state: "registry-models-absent" });
387
- return detachedFrozen({
388
- state: "captured" as const,
389
- models: entry.models,
390
- ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}),
391
- ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}),
392
- ...(entry.modelMaxOutputTokens ? { modelMaxOutputTokens: entry.modelMaxOutputTokens } : {}),
393
- ...(entry.virtualModels ? { virtualModels: entry.virtualModels } : {}),
394
- ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}),
395
- ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}),
396
- });
397
- }
398
-
399
- function captureModelsRequest(
400
- name: string,
401
- provider: OcxProviderConfig,
402
- observedAuth: ModelsAuthResolution | undefined,
403
- ): CapturedModelsRequest {
404
- const observed = observedAuth
405
- ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl }
406
- : undefined;
407
- const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
408
- const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
409
- const method = withoutCredential.method ?? "GET";
410
- if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) {
411
- throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`);
412
- }
413
- return detachedFrozen({
414
- method,
415
- url: withoutCredential.url,
416
- headersWithoutCredential: withoutCredential.headers,
417
- headersWithCredential: withCredential.headers,
418
- });
419
- }
420
-
421
- /**
422
- * Fill the registry seed's per-model numeric capability maps beneath the provider's own
423
- * values, mutating `prov` in place. The merge is per key — an operator's entry always
424
- * wins; a model the persisted map never mentions picks up its seed value — matching
425
- * `mergeRecordFill` in src/router.ts exactly.
426
- *
427
- * Routing already performs this fill at resolve time (routedProviderConfig in
428
- * src/router.ts) and the catalog did not, and that divergence is #4570:
429
- * zhipu-bigmodel-coding/glm-5.3-flash reached the live catalog with correct modalities
430
- * but no context window, because an install persisted before Flash joined the seed map
431
- * held a truthy partial `modelContextWindows` that shadowed the whole seed.
432
- *
433
- * This lives here and not in enrichProviderFromRegistry because enrichment output is
434
- * persisted on a management POST, and #1409 (pinned by
435
- * tests/server/management-provider-validation.test.ts) requires that a save never write
436
- * registry seed keys into the operator's config. The gather clone is detached and
437
- * frozen, never saved, so the catalog can see the seed without the config gaining it.
438
- */
439
- export function applyRegistryCapabilitySeedFill(name: string, prov: OcxProviderConfig): void {
440
- // router.ts resolves the canonical OpenAI API provider's token maps with
441
- // mergePositiveNumberCaps (user values cap the seed rather than replace it), so a
442
- // plain fill here would give that one provider catalog semantics routing never has.
443
- if (name === OPENAI_API_PROVIDER_ID) return;
444
- if (!providerMatchesRegistryTransport(name, prov)) return;
445
- const entry = getProviderRegistryEntry(name);
446
- if (!entry) return;
447
- if (entry.modelContextWindows || prov.modelContextWindows) {
448
- prov.modelContextWindows = { ...(entry.modelContextWindows ?? {}), ...(prov.modelContextWindows ?? {}) };
449
- }
450
- if (entry.modelMaxOutputTokens || prov.modelMaxOutputTokens) {
451
- prov.modelMaxOutputTokens = { ...(entry.modelMaxOutputTokens ?? {}), ...(prov.modelMaxOutputTokens ?? {}) };
452
- }
453
- }
454
-
455
- function captureProviderGather(
456
- name: string,
457
- configured: OcxProviderConfig,
458
- authResolver: ModelsAuthResolver,
459
- retainConfiguredModelIds?: ReadonlySet<string>,
460
- config?: Pick<OcxConfig, "providers">,
461
- ): CapturedProviderGather {
462
- const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
463
- enrichProviderFromRegistry(name, enriched);
464
- applyRegistryCapabilitySeedFill(name, enriched);
465
- const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
466
- const provider = recursivelyFreeze(enriched);
467
- const fastPolicyAuthority = captureFastPolicyAuthority(
468
- name,
469
- provider,
470
- registryTransportMatch,
471
- configured,
472
- );
473
- const metadataModelIdCaseFold = shouldCaseFoldMetadataModelId(name);
474
- const observedAuth = authResolver.kind === "observed"
475
- && provider.authMode !== "forward"
476
- && provider.liveModels !== false
477
- ? authResolver.resolve(name, provider)
478
- : undefined;
479
- const request = captureModelsRequest(name, provider, observedAuth);
480
- const resolved = resolveProviderModelDiscovery(name, provider);
481
- const discovery = detachedFrozen({
482
- ...(resolved.spec ? { spec: resolved.spec } : {}),
483
- maxResponseBytes: resolved.maxResponseBytes,
484
- maxModels: resolved.maxModels,
485
- });
486
- const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch);
487
- const policy = detachedFrozen({
488
- provider: name,
489
- registryTransportMatch,
490
- location: {
491
- spec: discovery.spec ? "present" as const : "absent" as const,
492
- url: capturedField(discovery.spec, "url"),
493
- path: capturedField(discovery.spec, "path"),
494
- query: capturedField(discovery.spec, "query"),
495
- },
496
- finalMethod: request.method,
497
- finalUrl: request.url,
498
- filter: capturedField(discovery.spec, "filter"),
499
- maxResponseBytes: discovery.maxResponseBytes,
500
- maxModels: discovery.maxModels,
501
- trustedOpenAiApi,
502
- });
503
- const effectiveAlias = effectiveProviderAliasDecision(name, configured, config);
504
- return Object.freeze({
505
- name,
506
- provider,
507
- discovery,
508
- policy,
509
- request,
510
- fastPolicyAuthority,
511
- metadataModelIdCaseFold,
512
- effectiveAlias,
513
- ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
514
- ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0
515
- ? { retainConfiguredModelIds }
516
- : {}),
517
- });
518
- }
519
-
520
- /** Model ids each provider must retain for combo catalog derivation (OCX-111). */
521
- export function configuredComboTargetModelsByProvider(
522
- config: Pick<OcxConfig, "combos">,
523
- ): Map<string, ReadonlySet<string>> {
524
- const byProvider = new Map<string, Set<string>>();
525
- for (const id of listComboIds(config)) {
526
- const combo = getCombo(config, id);
527
- if (!combo) continue;
528
- for (const target of combo.targets) {
529
- let models = byProvider.get(target.provider);
530
- if (!models) {
531
- models = new Set();
532
- byProvider.set(target.provider, models);
533
- }
534
- models.add(target.model);
535
- }
536
- }
537
- return byProvider;
538
- }
539
-
540
- function captureGatherFlight(
541
- config: OcxConfig,
542
- createAuthResolver: ModelsAuthResolverFactory,
543
- ): GatherFlightCapture {
544
- const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = [];
545
- const authResolver = createAuthResolver(providerAuthOutcomes);
546
- const comboTargetsByProvider = configuredComboTargetModelsByProvider(config);
547
- const providers = Object.entries(config.providers)
548
- .filter(([, provider]) => provider.disabled !== true)
549
- .map(([name, provider]) => captureProviderGather(
550
- name,
551
- provider,
552
- authResolver,
553
- comboTargetsByProvider.get(name),
554
- config,
555
- ));
556
- const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy));
557
- return Object.freeze({
558
- discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots),
559
- // Credentials are hashed under the same unexported per-process key, never
560
- // stored or compared in the clear: this value can reach a map key and must
561
- // not disclose a token. The final headers are included because a static
562
- // header can carry authority just as an `apiKey` can.
563
- authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({
564
- name: provider.name,
565
- authMode: provider.provider.authMode ?? null,
566
- liveModels: provider.provider.liveModels ?? null,
567
- credential: provider.provider.apiKey ?? null,
568
- observedAuth: provider.observedAuth ?? null,
569
- headers: provider.request.headersWithCredential,
570
- url: provider.request.url,
571
- }))),
572
- // Every enriched provider row the flight will gather from, in admission order.
573
- // Anything that can change a catalog row lives in here by construction.
574
- providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1",
575
- providers.map(provider => ({
576
- name: provider.name,
577
- // `fetch` is a caller-owned transport executor, not admitted state: the
578
- // outbound transport honors it so a caller can supply its own HTTP path.
579
- // It is the one member of a provider row that is legitimately a function,
580
- // so it is dropped here rather than allowed to break every encode.
581
- provider: omitProviderTransportExecutor(provider.provider),
582
- fastPolicyAuthority: provider.fastPolicyAuthority,
583
- // Combo retention is capture-time state, not a provider-row field. Two
584
- // gathers that share providers but differ in combo targets must not join.
585
- retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(),
586
- }))),
587
- discoveryPolicySnapshots,
588
- providers: Object.freeze(providers),
589
- authResolver,
590
- providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]),
591
- openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi
592
- ?? Object.freeze({ state: "unused" as const }),
593
- });
594
- }
595
-
596
- /**
597
- * Drop the caller-owned transport executor before hashing a provider row.
598
- *
599
- * Fails closed on anything ELSE that cannot be encoded: the point of hashing the
600
- * whole row is that no field escapes the comparison, so a second function member
601
- * must surface as an encode error rather than being quietly skipped here.
602
- */
603
- function omitProviderTransportExecutor(provider: OcxProviderConfig): Record<string, unknown> {
604
- const entries = Object.entries(provider).filter(([key]) => key !== "fetch");
605
- return Object.fromEntries(entries);
606
- }
607
-
608
- function materializeCapturedHeaders(
609
- request: CapturedModelsRequest,
610
- apiKey: string | undefined,
611
- ): Record<string, string> {
612
- const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential;
613
- return Object.fromEntries(Object.entries(source).map(([name, value]) => [
614
- name,
615
- apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value,
616
- ]));
617
- }
618
-
619
- function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
620
- return {
621
- n: name,
622
- // Preserve the persisted tri-state. Registry enrichment may turn an omitted value into
623
- // `false` while an explicit `true` stays live, so those callers must not share a flight.
624
- live: prov.liveModels ?? null,
625
- base: prov.baseUrl ?? "",
626
- adapter: prov.adapter ?? "",
627
- models: [...(prov.models ?? [])].sort(),
628
- retain: [...(prov.retainModels ?? [])].sort(),
629
- selected: [...(prov.selectedModels ?? [])].sort(),
630
- displayNames: prov.modelDisplayNames ?? null,
631
- defaultModel: prov.defaultModel ?? null,
632
- ctx: prov.contextWindow ?? null,
633
- ctxW: prov.modelContextWindows ?? null,
634
- maxIn: prov.modelMaxInputTokens ?? null,
635
- maxOut: prov.modelMaxOutputTokens ?? null,
636
- autoCompact: prov.modelAutoCompactTokenLimits ?? null,
637
- inMod: prov.modelInputModalities ?? null,
638
- capabilities: prov.modelCapabilities ?? null,
639
- re: prov.modelReasoningEfforts ?? null,
640
- defRe: prov.modelDefaultReasoningEfforts ?? null,
641
- rsSum: prov.modelSupportsReasoningSummaries ?? null,
642
- verbosity: prov.modelSupportsVerbosity ?? null,
643
- rsDel: prov.modelReasoningSummaryDelivery ?? null,
644
- serviceTier: prov.modelSupportsServiceTier ?? null,
645
- noVis: [...(prov.noVisionModels ?? [])].sort(),
646
- ptc: prov.parallelToolCalls ?? null,
647
- gMode: prov.googleMode ?? null,
648
- };
649
- }
650
-
651
- function gatherFlightKey(config: OcxConfig): string {
652
- const providers = Object.entries(config.providers)
653
- .filter(([, prov]) => prov.disabled !== true)
654
- .map(([name, prov]) => providerCatalogFingerprint(name, prov))
655
- .sort((a, b) => String(a.n).localeCompare(String(b.n)));
656
- const assembly = stableJson({
657
- providers,
658
- disabledModels: [...(config.disabledModels ?? [])].sort(),
659
- combos: config.combos ?? {},
660
- customModels: (config.customModels ?? []).map((cm) => ({
661
- p: cm.provider,
662
- m: cm.modelId,
663
- d: cm.displayName ?? null,
664
- cw: cm.contextWindow ?? null,
665
- im: cm.inputModalities ?? null,
666
- })),
667
- caps: config.providerContextCaps ?? null,
668
- });
669
- const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16);
670
- return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`;
671
- }
672
-
673
- /** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */
674
- export function clearGatherRoutedModelsInflight(): void {
675
- gatherInflight.clear();
676
- }
677
-
678
- const NUMERIC_MODEL_ID_SEGMENT = /^\d+$/;
679
-
680
- /**
681
- * Resolve an unknown Claude point release or date pin from the nearest configured
682
- * family row. Only numeric tail segments are removed so unrelated model families
683
- * cannot inherit one another's limits.
684
- */
685
- function anthropicFamilyContextWindow(
686
- record: Record<string, number> | undefined,
687
- id: string,
688
- ): number | undefined {
689
- if (!record || !id.toLowerCase().startsWith("claude-")) return undefined;
690
- let candidate = id;
691
- while (true) {
692
- const cut = candidate.lastIndexOf("-");
693
- if (cut <= 0 || !NUMERIC_MODEL_ID_SEGMENT.test(candidate.slice(cut + 1))) return undefined;
694
- candidate = candidate.slice(0, cut);
695
- const value = modelRecordValue(record, candidate);
696
- if (typeof value === "number" && value > 0) return value;
697
- }
698
- }
699
-
700
- /**
701
- * Resolve the configured context window in exact-model, Anthropic numeric-family,
702
- * then provider-wide order. Return undefined when the selected value is not positive.
703
- */
704
- export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
705
- const configured = modelRecordValue(prov.modelContextWindows, id)
706
- ?? (prov.adapter === "anthropic" ? anthropicFamilyContextWindow(prov.modelContextWindows, id) : undefined)
707
- ?? prov.contextWindow;
708
- return typeof configured === "number" && configured > 0 ? configured : undefined;
709
- }
710
-
711
- export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined {
712
- const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id)
713
- ? prov.modelCapabilities?.[id]?.inputModalities : undefined;
714
- const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id);
715
- return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
716
- }
717
-
718
- /** Exact display-only override for one provider-native model id. */
719
- export function configuredModelDisplayName(
720
- prov: OcxProviderConfig,
721
- id: string,
722
- ): string | undefined {
723
- if (!prov.modelDisplayNames || !Object.hasOwn(prov.modelDisplayNames, id)) return undefined;
724
- const value = prov.modelDisplayNames[id];
725
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
726
- }
727
-
728
- export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
729
- const configured = modelRecordValue(prov.modelMaxInputTokens, id);
730
- return typeof configured === "number" && configured > 0 ? configured : undefined;
731
- }
732
-
733
- function generatedMaxOutputTokens(
734
- providerName: string,
735
- id: string,
736
- metadataId = id,
737
- metadataModelIdCaseFold?: boolean,
738
- ): number | undefined {
739
- const metadataProvider = providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID
740
- ? "openai"
741
- : resolveMetadataProvider(providerName);
742
- if (!metadataProvider) return undefined;
743
- const metadata = getModelMetadata(metadataProvider, metadataId)
744
- ?? ((metadataModelIdCaseFold ?? (providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID
745
- ? false
746
- : shouldCaseFoldMetadataModelId(providerName)))
747
- ? getModelMetadataCaseInsensitive(metadataProvider, metadataId)
748
- : undefined);
749
- return positiveSafeInteger(metadata?.maxTokens);
750
- }
751
-
752
- function routedMaxOutputTokens(
753
- providerName: string,
754
- provider: OcxProviderConfig,
755
- model: CatalogModel,
756
- metadataId = model.id,
757
- metadataModelIdCaseFold?: boolean,
758
- ): number | undefined {
759
- const discovered = positiveSafeInteger(model.maxOutputTokens);
760
- const generated = generatedMaxOutputTokens(providerName, model.id, metadataId, metadataModelIdCaseFold);
761
- const configured = positiveSafeInteger(
762
- modelRecordValue(provider.modelMaxOutputTokens, model.id),
763
- );
764
- const authoritative = discovered ?? generated;
765
- if (configured === undefined) return authoritative;
766
- return authoritative === undefined
767
- ? configured
768
- : Math.min(authoritative, configured);
769
- }
770
-
771
- export function configuredAutoCompactTokenLimit(
772
- prov: OcxProviderConfig | undefined,
773
- id: string,
774
- ): number | undefined {
775
- if (!prov) return undefined;
776
- const configured = modelRecordValue(prov.modelAutoCompactTokenLimits, id);
777
- return typeof configured === "number" && Number.isSafeInteger(configured) && configured > 0
778
- ? configured
779
- : undefined;
780
- }
781
-
782
- function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined {
783
- if (!prov) return undefined;
784
- const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id);
785
- if (explicit !== undefined) return explicit;
786
- return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined;
787
- }
788
-
789
- function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined {
790
- const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined;
791
- if (explicit !== undefined) return explicit;
792
- if (!prov) return undefined;
793
- void name;
794
- // Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered
795
- // model would otherwise re-advertise a control the upstream accepts and ignores.
796
- //
797
- // Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its
798
- // registry authority up front and forbids any later registry read, so consulting the registry
799
- // here made a custom-destination flight fall back to "configured" instead of serving its own
800
- // discovery result (tests/codex-integration/codex-gather-authority.test.ts). `applyVerbosityDefaults` in
801
- // providers/derive.ts materializes the registry default into the config at seed/enrich time.
802
- return prov.supportsVerbosity;
803
- }
804
-
805
- export function applyProviderConfigHints(
806
- name: string,
807
- prov: OcxProviderConfig,
808
- model: CatalogModel,
809
- providerCap?: number,
810
- metadataModelIdCaseFold?: boolean,
811
- effectiveAlias?: string | null,
812
- ): CatalogModel {
813
- const displayName = configuredModelDisplayName(prov, model.id);
814
- // The alias decision is resolved once at flight admission (captureProviderGather) and threaded
815
- // through as `effectiveAlias`. Re-deriving it here would read PROVIDER_REGISTRY after admission,
816
- // which is exactly the authority leak tests/codex-integration/codex-gather-authority.test.ts
817
- // forbids: a flight must not consult the live registry once its transport has been captured.
818
- // When no decision was threaded in, carry whatever the row already resolved to instead.
819
- const providerAlias = typeof effectiveAlias === "string" || effectiveAlias === null
820
- ? effectiveAlias
821
- : model.providerAlias;
822
- const configuredCap = configuredContextWindow(prov, model.id);
823
- const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
824
- const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold);
825
- const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
826
- let inputModalities = configuredInputModalities(prov, model.id);
827
- // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time
828
- // planning aligned. The catalog must still advertise image input — the Codex app
829
- // gates attachments client-side on input_modalities, and a text-only entry would block images
830
- // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived
831
- // text-only rows stay untouched: the runtime predicate only reads these two config sources, so
832
- // it would not convert those.
833
- const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id);
834
- if (sidecarCovered) {
835
- const base = inputModalities ?? model.inputModalities ?? ["text"];
836
- inputModalities = base.includes("image") ? [...base] : [...base, "image"];
837
- }
838
- const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
839
- const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
840
- const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
841
- const supportsVerbosity = configuredVerbositySupport(name, prov, model.id);
842
- const fastPolicy = fastPolicyForModel(prov, model.id, name);
843
- const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy);
844
- const {
845
- supportsServiceTier: _staleServiceTier,
846
- fastTierDescription: _staleFastTierDescription,
847
- providerAlias: _staleProviderAlias,
848
- ...modelWithoutServiceTier
849
- } = model;
850
- // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
851
- const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
852
- ? model.contextWindow
853
- : undefined;
854
- const hintedWindow = discoveredWindow !== undefined
855
- ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow)
856
- : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
857
- const hinted = {
858
- ...modelWithoutServiceTier,
859
- ...(displayName !== undefined ? { displayName } : {}),
860
- ...(providerAlias !== undefined ? { providerAlias } : {}),
861
- ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
862
- ...(inputModalities ? { inputModalities } : {}),
863
- ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
864
- ...(configuredMaxInput !== undefined
865
- ? {
866
- maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
867
- ? Math.min(model.maxInputTokens, configuredMaxInput)
868
- : configuredMaxInput,
869
- }
870
- : {}),
871
- ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
872
- ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
873
- ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
874
- ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}),
875
- ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
876
- ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined
877
- ? { fastTierDescription: fastPolicy.fastTierDescription }
878
- : {}),
879
- // Default-on for openai-chat providers (explicit false opts out); other adapters
880
- // advertise only on explicit opt-in.
881
- ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
882
- ? { parallelToolCalls: true }
883
- : {}),
884
- ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}),
885
- };
886
- const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
887
- const withCap = providerCap !== undefined
888
- ? capped !== hinted.contextWindow
889
- ? { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true }
890
- : { ...hinted, contextCap: providerCap, contextCapped: false }
891
- : hinted;
892
- const contextWindow = typeof withCap.contextWindow === "number" && withCap.contextWindow > 0
893
- ? withCap.contextWindow
894
- : undefined;
895
- const boundedMaxInput = typeof withCap.maxInputTokens === "number" && withCap.maxInputTokens > 0
896
- ? (contextWindow !== undefined ? Math.min(withCap.maxInputTokens, contextWindow) : withCap.maxInputTokens)
897
- : undefined;
898
- const withHardBounds = boundedMaxInput !== undefined && boundedMaxInput !== withCap.maxInputTokens
899
- ? { ...withCap, maxInputTokens: boundedMaxInput }
900
- : withCap;
901
- const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact]
902
- .filter((value): value is number => typeof value === "number" && value > 0);
903
- if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds;
904
- return {
905
- ...withHardBounds,
906
- autoCompactTokenLimit: clampAutoCompactTokenLimit(
907
- contextWindow,
908
- boundedMaxInput,
909
- Math.min(...softCandidates),
910
- ),
911
- };
912
- }
913
-
914
- export function catalogHintsFromProviderConfig(
915
- name: string,
916
- prov: OcxProviderConfig,
917
- id: string,
918
- contextCap?: number,
919
- metadataModelIdCaseFold?: boolean,
920
- effectiveAlias?: string | null,
921
- ): Partial<CatalogModel> {
922
- const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias);
923
- const { provider: _provider, id: _id, ...hints } = hinted;
924
- return hints;
925
- }
926
-
927
- export function applyConfigHintsToCachedModels(
928
- name: string,
929
- prov: OcxProviderConfig,
930
- models: CatalogModel[],
931
- contextCap?: number,
932
- metadataModelIdCaseFold?: boolean,
933
- effectiveAlias?: string | null,
934
- ): CatalogModel[] {
935
- return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias));
936
- }
937
-
938
-
939
- /**
940
- * Last-resort context window for combo member synthesis when discovery,
941
- * provider config, and an enabled Context cap all omit one. Matches the
942
- * catalog entry default in `normalizeRoutedCatalogEntry` so incomplete live
943
- * rows still catalog. An enabled Context cap is the operator-facing window,
944
- * not a clamp on this placeholder.
945
- */
946
- const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000;
947
-
948
- interface ComboCatalogMemberFallback {
949
- readonly contextWindow?: number;
950
- /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */
951
- readonly maxInputTokens?: number;
952
- readonly maxOutputTokens?: number;
953
- readonly autoCompactTokenLimit?: number;
954
- readonly inputModalities?: readonly string[];
955
- readonly reasoningEfforts?: readonly string[];
956
- }
957
-
958
- /**
959
- * Ladder advertised for a combo member whose vendor metadata says it reasons but
960
- * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show
961
- * the effort control; the routed adapters clamp to the real upstream top rung.
962
- */
963
- const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
964
-
965
- /**
966
- * Vendor-table lookup tolerant of point releases and date pins. Configured combo
967
- * targets often name a variant the table does not carry (`claude-fable-5-1`,
968
- * `claude-opus-4-5-20251101`); the base family row still describes its modality
969
- * and reasoning capability, so fall back to it before giving up.
970
- */
971
- function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined {
972
- const exact = getModelMetadataCaseInsensitive(provider, modelId);
973
- if (exact) return exact;
974
- let candidate = modelId.replace(/\[[^\]]*\]$/, "");
975
- while (true) {
976
- const trimmed = candidate.replace(/-\d+$/, "");
977
- if (trimmed === candidate || !trimmed.includes("-")) return undefined;
978
- const hit = getModelMetadataCaseInsensitive(provider, trimmed);
979
- if (hit) return hit;
980
- candidate = trimmed;
981
- }
982
- }
983
-
984
- /**
985
- * Combo members are usually thin discovery rows (id + context window). Without a
986
- * capability source the combo intersection collapses to text-only / no effort ladder,
987
- * and the Codex app then refuses image attachments and hides the effort picker for
988
- * every Claude combo. The generated vendor table knows both, so use it as the
989
- * last-resort fallback when the caller supplied none.
990
- *
991
- * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`.
992
- * Mapping it onto `maxInputTokens` would be read by the combo intersection
993
- * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and
994
- * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it.
995
- */
996
- function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined {
997
- const metadataProvider = resolveMetadataProvider(target.provider);
998
- // Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id
999
- // while using a provider name that has no metadata alias. Reuse only its effort
1000
- // ladder below; context/modality rows remain provider-owned.
1001
- const metadata = metadataProvider
1002
- ? comboMemberVendorMetadata(metadataProvider, target.model)
1003
- : comboMemberVendorMetadata("openai", target.model);
1004
- if (!metadata) return undefined;
1005
- return {
1006
- ...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
1007
- ? { contextWindow: metadata.contextWindow }
1008
- : {}),
1009
- ...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
1010
- ? { maxOutputTokens: metadata.maxTokens }
1011
- : {}),
1012
- ...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0
1013
- ? { inputModalities: [...metadata.input] }
1014
- : {}),
1015
- ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}),
1016
- };
1017
- }
1018
-
1019
- /**
1020
- * Resolve a combo target to a catalog member for derivation.
1021
- * Prefer discovery metadata; when the target is missing from the gather map or
1022
- * lacks a positive contextWindow, synthesize from the (registry-enriched)
1023
- * provider config so combos remain catalogued when targets are configured but
1024
- * discovery metadata is incomplete. Disabled providers stay unresolved.
1025
- * When hints still omit contextWindow, prefer known maxInputTokens, else the
1026
- * enabled Context cap, else COMBO_MEMBER_CONTEXT_FALLBACK so a live row
1027
- * without ctx does not drop the whole combo from the public catalog.
1028
- */
1029
- export function resolveComboCatalogMember(
1030
- target: { provider: string; model: string },
1031
- memberByKey: ReadonlyMap<string, CatalogModel>,
1032
- providers: ReadonlyMap<string, OcxProviderConfig>,
1033
- contextCap?: number,
1034
- callerFallback?: ComboCatalogMemberFallback,
1035
- metadataModelIdCaseFold?: boolean,
1036
- ): CatalogModel | undefined {
1037
- const existing = memberByKey.get(targetKey(target));
1038
- const prov = providers.get(target.provider);
1039
- const fallback = callerFallback ?? vendorMetadataComboFallback(target);
1040
- // Disabled providers never contribute members — even a complete discovery row
1041
- // is unusable for catalog derivation while the provider is off.
1042
- if (prov?.disabled === true) return undefined;
1043
-
1044
- const withFallbackMetadata = (member: CatalogModel): CatalogModel => {
1045
- const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0
1046
- ? member.contextWindow
1047
- : undefined;
1048
- const addMaxInput = fallback !== undefined && contextWindow !== undefined
1049
- && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0);
1050
- const addMaxOutput = fallback !== undefined
1051
- && typeof fallback.maxOutputTokens === "number"
1052
- && fallback.maxOutputTokens > 0
1053
- && !(typeof member.maxOutputTokens === "number" && member.maxOutputTokens > 0);
1054
- const effectiveMaxInput = addMaxInput
1055
- ? Math.min(fallback?.maxInputTokens ?? contextWindow!, contextWindow!)
1056
- : member.maxInputTokens;
1057
- const softCandidates = [member.autoCompactTokenLimit, fallback?.autoCompactTokenLimit]
1058
- .filter((value): value is number => typeof value === "number" && value > 0);
1059
- const autoCompactTokenLimit = contextWindow !== undefined && softCandidates.length > 0
1060
- ? clampAutoCompactTokenLimit(contextWindow, effectiveMaxInput, Math.min(...softCandidates))
1061
- : member.autoCompactTokenLimit;
1062
- const adjustAutoCompact = autoCompactTokenLimit !== member.autoCompactTokenLimit;
1063
- const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0)
1064
- && fallback?.inputModalities !== undefined;
1065
- const addReasoning = member.reasoningEfforts === undefined
1066
- && fallback?.reasoningEfforts !== undefined;
1067
- if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning) return member;
1068
- return {
1069
- ...member,
1070
- // Never claim a larger input budget than the window, and prefer the model's own
1071
- // measured ceiling when the fallback carries one.
1072
- ...(addMaxInput ? { maxInputTokens: effectiveMaxInput } : {}),
1073
- ...(addMaxOutput ? { maxOutputTokens: fallback!.maxOutputTokens } : {}),
1074
- ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
1075
- ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}),
1076
- ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}),
1077
- };
1078
- };
1079
-
1080
- // Complete live/configured rows still honour providerContextCaps so a high
1081
- // discovery window cannot outrun an operator-configured cap. Native-alias
1082
- // fallback metadata may fill only capability gaps; it never raises an explicit
1083
- // discovered/configured context window.
1084
- if (
1085
- existing
1086
- && typeof existing.contextWindow === "number"
1087
- && existing.contextWindow > 0
1088
- ) {
1089
- // Live discovery can explicitly say text-only even when configured routing
1090
- // supplies a vision sidecar. Apply the same provider hints used for thin
1091
- // rows before deriving a combo from this complete row.
1092
- const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id)
1093
- ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold)
1094
- : existing;
1095
- const capped = applyProviderContextCap(hinted.contextWindow, contextCap);
1096
- if (capped === undefined || capped === existing.contextWindow) {
1097
- return withFallbackMetadata(hinted);
1098
- }
1099
- const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0
1100
- ? Math.min(hinted.maxInputTokens, capped)
1101
- : Math.min(fallback?.maxInputTokens ?? capped, capped);
1102
- return withFallbackMetadata({
1103
- ...hinted,
1104
- contextWindow: capped,
1105
- maxInputTokens: maxInput,
1106
- contextCap,
1107
- contextCapped: true as const,
1108
- });
1109
- }
1110
-
1111
- const base: CatalogModel = existing ?? {
1112
- id: target.model,
1113
- provider: target.provider,
1114
- };
1115
- const hinted = prov
1116
- ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold)
1117
- : base;
1118
- const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0
1119
- ? hinted.contextWindow
1120
- : undefined;
1121
- const knownMaxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0
1122
- ? hinted.maxInputTokens
1123
- : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0
1124
- ? base.maxInputTokens
1125
- : undefined);
1126
- // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback
1127
- // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M)
1128
- // would otherwise shrink the advertised window to the input limit.
1129
- const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined;
1130
- // Real discovery/config values win. A native alias is the next fallback tier.
1131
- // The generic 128k/text synthesis from #1305 remains the final fallback.
1132
- const fallbackContext = existing || prov ? fallback?.contextWindow : undefined;
1133
- const uncappedContext = hintedContext
1134
- ?? knownMaxInput
1135
- ?? fallbackContext
1136
- ?? (existing || prov ? resolveUnknownRoutedContextWindow(contextCap) : undefined);
1137
- if (uncappedContext === undefined) return undefined;
1138
- // 真发现值才压低。resolveUnknownRoutedContextWindow 已经把 cap 当成窗口填进去了,不能再 min 一次。
1139
- const usedDiscoveredWindow = hintedContext !== undefined || knownMaxInput !== undefined || fallbackContext !== undefined;
1140
- const cappedContext = usedDiscoveredWindow
1141
- ? applyProviderContextCap(uncappedContext, contextCap)
1142
- : uncappedContext;
1143
- const contextWindow = cappedContext ?? uncappedContext;
1144
- const fallbackCapped = usedDiscoveredWindow
1145
- && contextCap !== undefined
1146
- && cappedContext !== undefined
1147
- && cappedContext !== uncappedContext;
1148
-
1149
- const inputModalities = hinted.inputModalities
1150
- ?? base.inputModalities
1151
- ?? (fallback?.inputModalities ? [...fallback.inputModalities] : undefined)
1152
- ?? ["text"];
1153
- const reasoningEfforts = hinted.reasoningEfforts
1154
- ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined)
1155
- ?? base.reasoningEfforts
1156
- ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined);
1157
- const maxOutputTokens = positiveSafeInteger(hinted.maxOutputTokens, base.maxOutputTokens)
1158
- ?? (existing || prov ? positiveSafeInteger(fallback?.maxOutputTokens) : undefined);
1159
- // The model's own measured input ceiling still applies when discovery gave us nothing:
1160
- // GPT-5.6 advertises a 1.05M window but refuses input past 922k.
1161
- const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput;
1162
- const maxInputTokens = effectiveMaxInput !== undefined
1163
- ? Math.min(effectiveMaxInput, contextWindow)
1164
- : contextWindow;
1165
- const softCandidates = [
1166
- hinted.autoCompactTokenLimit,
1167
- base.autoCompactTokenLimit,
1168
- fallback?.autoCompactTokenLimit,
1169
- configuredAutoCompactTokenLimit(prov, target.model),
1170
- ].filter((value): value is number => typeof value === "number" && value > 0);
1171
- // A generic 128k synthesis is a catalog compatibility fallback, not evidence
1172
- // that a configured soft policy has an authoritative window to clamp against.
1173
- const hasAuthoritativeAutoCompactBasis = hintedContext !== undefined
1174
- || fallbackContext !== undefined
1175
- || contextCap !== undefined;
1176
- const autoCompactTokenLimit = hasAuthoritativeAutoCompactBasis && softCandidates.length > 0
1177
- ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...softCandidates))
1178
- : undefined;
1179
-
1180
- return {
1181
- ...hinted,
1182
- inputModalities,
1183
- ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1184
- contextWindow,
1185
- maxInputTokens,
1186
- ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
1187
- ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
1188
- ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}),
1189
- };
1190
- }
1191
-
1192
- const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/;
1193
- const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/;
1194
- const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/;
1195
-
1196
- /** Whether a Gregorian year contains February 29th. */
1197
- function isLeapYear(year: number): boolean {
1198
- return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
1199
- }
1200
-
1201
- /**
1202
- * Whether a month/day pair exists in the given year. Without a year, February 29th is
1203
- * accepted because it occurs in at least one calendar year.
1204
- */
1205
- function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean {
1206
- if (year !== undefined && (year < 1 || year > 9999)) return false;
1207
- if (month < 1 || month > 12 || day < 1) return false;
1208
- const daysInMonth = [
1209
- 31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30,
1210
- 31, 31, 30, 31, 30, 31,
1211
- ];
1212
- return day <= daysInMonth[month - 1]!;
1213
- }
1214
-
1215
- /**
1216
- * Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD`
1217
- * (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of
1218
- * the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and
1219
- * Solar aliases all fell through to `droppedConfiguredIds` (#3024).
1220
- *
1221
- * Calendar validation rejects impossible month-end and leap-day values as well as ordinary
1222
- * numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible
1223
- * collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known,
1224
- * accepted cost; the test table pins it so it cannot become a surprise later.
1225
- *
1226
- * Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a
1227
- * hyphenated suffix is ambiguous against ordinary name segments and needs its own call.
1228
- */
1229
- function isDatedVariantSuffix(suffix: string): boolean {
1230
- const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix);
1231
- if (yyyyMmDd) {
1232
- return isValidCalendarDate(
1233
- Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]),
1234
- );
1235
- }
1236
-
1237
- const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix);
1238
- if (yyMmDd) {
1239
- return isValidCalendarDate(
1240
- 2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]),
1241
- );
1242
- }
1243
-
1244
- const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix);
1245
- if (!mmDdOrYyMm) return false;
1246
- const first = Number(mmDdOrYyMm[1]);
1247
- const second = Number(mmDdOrYyMm[2]);
1248
- return isValidCalendarDate(undefined, first, second)
1249
- || (first >= 20 && first <= 29 && second >= 1 && second <= 12);
1250
- }
1251
-
1252
- /** Whether `liveId` is a supported dated release of the configured base id. */
1253
- export function isDatedVariantId(liveId: string, configuredId: string): boolean {
1254
- if (!liveId.startsWith(`${configuredId}-`)) return false;
1255
- return isDatedVariantSuffix(liveId.slice(configuredId.length + 1));
1256
- }
1257
-
1258
- export const lastDropWarnSignature = new Map<string, string>();
1259
- let lastWarningReconciledGeneration = 0;
1260
-
1261
- export function reconcileProviderFetchWarnings(generation: number): number {
1262
- if (generation <= lastWarningReconciledGeneration) return 0;
1263
- const removed = lastDropWarnSignature.size;
1264
- lastDropWarnSignature.clear();
1265
- lastWarningReconciledGeneration = generation;
1266
- return removed;
1267
- }
1268
-
1269
- export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
1270
-
1271
- export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
1272
- kimi: new Set([
1273
- "k3[1m]",
1274
- "kimi-k2.7-code",
1275
- "kimi-k2.7-code-highspeed",
1276
- "kimi-k2.6",
1277
- "kimi-k2.5",
1278
- ]),
1279
- xai: new Set([
1280
- "grok-4.3",
1281
- "grok-4.20-multi-agent-0309",
1282
- "grok-4.20-0309-reasoning",
1283
- "grok-4.20-0309-non-reasoning",
1284
- "grok-build-0.1",
1285
- "grok-composer-2.5-fast",
1286
- ]),
1287
- };
1288
-
1289
- export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
1290
- const signature = [...droppedConfiguredIds].sort().join(",");
1291
- if (lastDropWarnSignature.get(name) === signature) return;
1292
- lastDropWarnSignature.set(name, signature);
1293
- console.warn(
1294
- `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1295
- );
1296
- }
1297
-
1298
- /**
1299
- * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise
1300
- * collapse to the four-tier default ladder that omits `max`. These two helpers name the
1301
- * ladder each GLM generation actually honours on the wire.
1302
- */
1303
- /** GLM-5.2 and its 1M alias: the full five-tier ladder including `max`. */
1304
- export function isGlm52ModelId(id: string): boolean {
1305
- const normalized = id.trim().toLowerCase();
1306
- return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
1307
- }
1308
- /**
1309
- * GLM-5.3 and its 1M alias. 260814: docs.z.ai/devpack/latest-model folds every incoming
1310
- * effort into three effective tiers (low/minimal/light -> low, medium/high -> high,
1311
- * xhigh/max/ultra -> max), so a boolean capability must not be expanded to five rows.
1312
- */
1313
- export function isGlm53ModelId(id: string): boolean {
1314
- const normalized = id.trim().toLowerCase();
1315
- return normalized === "glm-5.3" || normalized === "glm-5.3[1m]";
1316
- }
1317
-
1318
- function plainRecord(value: unknown): Record<string, unknown> | undefined {
1319
- return value !== null && typeof value === "object" && !Array.isArray(value)
1320
- ? value as Record<string, unknown>
1321
- : undefined;
1322
- }
1323
-
1324
- const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
1325
-
1326
- function positiveSafeInteger(...values: unknown[]): number | undefined {
1327
- return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined;
1328
- }
1329
-
1330
- function normalizedMetadataString(raw: string, maxLength: number): string | undefined {
1331
- if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined;
1332
- const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength);
1333
- return normalized || undefined;
1334
- }
1335
-
1336
- function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined {
1337
- if (!Array.isArray(value)) return undefined;
1338
- const out: string[] = [];
1339
- const maxInspectedItems = Math.max(maxItems * 8, maxItems);
1340
- for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) {
1341
- const raw = value[i];
1342
- if (typeof raw !== "string") continue;
1343
- const normalized = normalizedMetadataString(raw, maxLength);
1344
- if (normalized && !out.includes(normalized)) out.push(normalized);
1345
- if (out.length >= maxItems) break;
1346
- }
1347
- return out.length > 0 ? out : undefined;
1348
- }
1349
-
1350
- function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined {
1351
- const metadata = plainRecord(item.metadata);
1352
- const metadataCapabilities = metadata?.capabilities;
1353
- const capabilityRecord = plainRecord(metadataCapabilities)
1354
- ?? plainRecord(item.capabilities)
1355
- ?? plainRecord(item.features);
1356
- const out = new Set<string>();
1357
- for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) {
1358
- for (const capability of normalizedStringList(list) ?? []) out.add(capability);
1359
- }
1360
- const capabilityFields = capabilityRecord ?? {};
1361
- let inspectedCapabilityFields = 0;
1362
- for (const key in capabilityFields) {
1363
- if (!Object.hasOwn(capabilityFields, key)) continue;
1364
- inspectedCapabilityFields += 1;
1365
- if (inspectedCapabilityFields > 256 || out.size >= 32) break;
1366
- if (capabilityFields[key] === true) {
1367
- const normalized = normalizedMetadataString(key, 64);
1368
- if (normalized) out.add(normalized);
1369
- }
1370
- }
1371
- for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) {
1372
- if (item[field] === true) out.add("tools");
1373
- }
1374
- for (const field of ["supports_reasoning", "reasoning"] as const) {
1375
- if (item[field] === true) out.add("reasoning");
1376
- }
1377
- return out.size > 0 ? [...out].filter(Boolean).slice(0, 32) : undefined;
1378
- }
1379
-
1380
- function modelInputModalities(
1381
- item: ProviderModelsApiItem,
1382
- capabilities: readonly string[] | undefined,
1383
- ): string[] | undefined {
1384
- const metadata = plainRecord(item.metadata);
1385
- const capabilityRecord = plainRecord(metadata?.capabilities)
1386
- ?? plainRecord(item.capabilities)
1387
- ?? plainRecord(item.features);
1388
- const explicit = normalizedStringList(
1389
- item.input_modalities
1390
- ?? item.modalities
1391
- ?? metadata?.input_modalities
1392
- ?? capabilityRecord?.input_modalities
1393
- ?? plainRecord(item.architecture)?.input_modalities,
1394
- 8,
1395
- 24,
1396
- )?.filter(value => (
1397
- // Codex parses `input_modalities` as a closed enum of text | image | audio. A provider that
1398
- // advertises anything else (zenmux reports "video") must not reach the catalog: Codex rejects
1399
- // the whole file, so plugins, apps and MCP servers all stop loading over one model's metadata.
1400
- value === "text" || value === "image" || value === "audio"
1401
- ));
1402
- if (explicit && explicit.length > 0) return explicit;
1403
- const architecture = plainRecord(item.architecture);
1404
- const architectureModality = typeof architecture?.modality === "string"
1405
- ? normalizedMetadataString(architecture.modality, 64)
1406
- : undefined;
1407
- if (architectureModality?.includes("->")) {
1408
- const [rawInput = ""] = architectureModality.split("->");
1409
- const inferred = rawInput
1410
- .split("+")
1411
- .filter(value => value === "text" || value === "image" || value === "audio");
1412
- if (inferred.length > 0) return [...new Set(inferred)];
1413
- }
1414
- // GitHub Copilot nests vision support one level down as `capabilities.supports.vision`, so the
1415
- // flat read alone finds nothing and every Copilot model falls through to `["text"]` — Codex then
1416
- // refuses image attachments on models that accept them (#2941). Precedence is by specificity:
1417
- // a flat boolean is authoritative when present, the nested boolean is consulted only otherwise,
1418
- // and a non-boolean at either level decides NOTHING so the signals below still apply. Two things
1419
- // this ordering deliberately avoids: a deny-wins rule across both levels would flip a provider
1420
- // reporting flat `true` with nested `false` from image-capable to text-only, changing behaviour
1421
- // that predates Copilot support; and a truthy test would let the string `"no"` advertise image
1422
- // input. The payload also carries a SECOND `vision` key under `limits` holding an image count,
1423
- // which is why this reads one exact path instead of searching `capabilities` for a vision-ish key.
1424
- const nestedSupports = plainRecord(capabilityRecord?.supports);
1425
- const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean"
1426
- ? capabilityRecord.vision
1427
- : typeof nestedSupports?.vision === "boolean"
1428
- ? nestedSupports.vision
1429
- : undefined;
1430
- if (explicitVisionSupport === false) return ["text"];
1431
- if (explicitVisionSupport === true || capabilities?.some(value => (
1432
- value === "vision" || value === "image-input" || value === "image_input"
1433
- // llama.cpp and Ollama-compatible servers report vision as "multimodal" —
1434
- // it is the only image signal those servers emit (#1797). Mapped to the
1435
- // closed `text|image` enum rather than passed through: an out-of-enum
1436
- // modality makes Codex reject the entire catalog file.
1437
- || value === "multimodal"
1438
- ))) {
1439
- return ["text", "image"];
1440
- }
1441
- return undefined;
1442
- }
1443
-
1444
- /**
1445
- * A per-token rate exactly as a /models row publishes it, or undefined when the value is not a
1446
- * usable non-negative number. Providers ship these both as JSON numbers and as decimal strings —
1447
- * OpenRouter encodes free as the string `"0.00000000"` — so both shapes are accepted and nothing
1448
- * else is. The explicit numeric-shape test has to run BEFORE any coercion: `Number("")` and
1449
- * `Number(" ")` are both 0 and `Number(true)` is 1, so a bare `Number(value)` would classify a
1450
- * row with an empty price string as free.
1451
- */
1452
- const DISCOVERED_PRICING_RATE_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
1453
-
1454
- function discoveredPricingRate(value: unknown): number | undefined {
1455
- const numeric = typeof value === "number"
1456
- ? value
1457
- : typeof value === "string" && DISCOVERED_PRICING_RATE_PATTERN.test(value.trim())
1458
- ? Number(value.trim())
1459
- : undefined;
1460
- if (numeric === undefined || !Number.isFinite(numeric) || numeric < 0) return undefined;
1461
- return numeric;
1462
- }
1463
-
1464
- /**
1465
- * Cost class for one discovered row, read from the provider's own `pricing` object (#3666).
1466
- *
1467
- * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing,
1468
- * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only
1469
- * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free
1470
- * one costs a click.
1471
- *
1472
- * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming
1473
- * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on
1474
- * purpose. And the operator's own `modelCosts` overlay is an estimate they typed, not something
1475
- * the provider published, so a zeroed overlay never reaches this field either.
1476
- *
1477
- * Classification is on numeric zero and never on a unit conversion: OpenRouter quotes USD per
1478
- * token while the cost overlays and the jawcode bundle quote per 1M, and zero is zero in both.
1479
- */
1480
- export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" {
1481
- const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing);
1482
- if (!pricing) return "unknown";
1483
- const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input);
1484
- const completion = discoveredPricingRate(pricing.completion ?? pricing.output);
1485
- if (prompt === undefined || completion === undefined) return "unknown";
1486
- return prompt === 0 && completion === 0 ? "free" : "paid";
1487
- }
1488
-
1489
- export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
1490
- const metadata = plainRecord(item.metadata);
1491
- const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
1492
- const limits = plainRecord(metadata?.limits);
1493
- const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits);
1494
- const contextWindow =
1495
- positiveSafeInteger(
1496
- limits?.max_context_length,
1497
- // GitHub Copilot reports the live context window here instead of in the metadata or
1498
- // top-level fields used by other OpenAI-compatible catalogs (#3156). Keep the existing
1499
- // metadata field authoritative when both are present: adding this provider-specific
1500
- // fallback must not change previously recognized providers.
1501
- capabilityLimits?.max_context_window_tokens,
1502
- metadata?.context_length,
1503
- item.context_length,
1504
- item.context_size,
1505
- item.max_model_len,
1506
- item.max_context_length,
1507
- // llama.cpp reports the served context under `meta`: `n_ctx` is what the
1508
- // server was actually started with, `n_ctx_train` the model's trained
1509
- // maximum. Prefer the served value — routing must not promise a window the
1510
- // running server will refuse. Both come LAST so no provider already
1511
- // supplying a recognized field changes behavior (#1797).
1512
- plainRecord(item.meta)?.n_ctx,
1513
- plainRecord(item.meta)?.n_ctx_train,
1514
- // A chained OpenCodex hub (and other re-serving gateways) reports the per-model
1515
- // window on the same capability record this function already reads for
1516
- // `max_output_tokens` below (#4032). Without it every routed row fell through to
1517
- // the 128k compatibility floor in parsing.ts while local forward rows kept their
1518
- // real values. Appended after the recognized fields for the same reason as the
1519
- // llama.cpp entries above: no provider that already resolves changes behavior.
1520
- capabilityRecord?.context_length,
1521
- );
1522
- const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
1523
- const maxOutputTokens = positiveSafeInteger(
1524
- capabilityRecord?.max_output_tokens,
1525
- limits?.max_output_tokens,
1526
- metadata?.max_output_tokens,
1527
- item.max_output_tokens,
1528
- );
1529
- // Some OpenAI-compatible catalogs expose the selectable ladder under
1530
- // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key.
1531
- // Treat both as model metadata: otherwise a valid upstream capability disappears
1532
- // before client exporters (including omp) can advertise it.
1533
- const reasoningParameters = plainRecord(item.reasoning_parameters)
1534
- ?? plainRecord(metadata?.reasoning_parameters)
1535
- ?? plainRecord(capabilityRecord?.reasoning_parameters);
1536
- const rawReasoningEfforts = capabilityRecord?.reasoning_effort
1537
- ?? item.reasoning_efforts
1538
- ?? reasoningParameters?.efforts;
1539
- const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
1540
- const reasoningEfforts = listedReasoningEfforts
1541
- ? sanitizeCodexReasoningEfforts(listedReasoningEfforts)
1542
- : typeof rawReasoningEfforts === "boolean"
1543
- ? (rawReasoningEfforts
1544
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm53ModelId(item.id)
1545
- ? ["low", "high", "max"]
1546
- : (providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
1547
- ? ["low", "medium", "high", "xhigh", "max"]
1548
- : ["low", "medium", "high", "xhigh"])
1549
- : [])
1550
- : undefined;
1551
- const capabilities = modelCapabilities(item);
1552
- const inputModalities = modelInputModalities(item, capabilities);
1553
- const pricingStatus = discoveredPricingStatus(item);
1554
- return {
1555
- ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
1556
- ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
1557
- ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
1558
- ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1559
- ...(inputModalities ? { inputModalities } : {}),
1560
- ...(capabilities ? { capabilities } : {}),
1561
- // Omitted when the classification is "unknown", following this function's existing
1562
- // contract that an unknown property is absent rather than present-and-empty. Callers
1563
- // that need to tell "provider published no prices" from "this build does not classify"
1564
- // call discoveredPricingStatus directly.
1565
- ...(pricingStatus !== "unknown" ? { pricingStatus } : {}),
1566
- };
1567
- }
1568
-
1569
- function boundedOwnedBy(value: unknown): string | undefined {
1570
- if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined;
1571
- if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined;
1572
- return value;
1573
- }
1574
-
1575
- const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
1576
-
1577
- function observedModelsAuthResolver(
1578
- authStoreBuffer: Uint8Array | null,
1579
- outcomes: CatalogGatherProviderAuthOutcome[],
1580
- ): ModelsAuthResolver {
1581
- return {
1582
- kind: "observed",
1583
- resolve(name, provider) {
1584
- if (provider.authMode === "forward") return { apiKey: undefined, observed: true };
1585
- if (provider.authMode !== "oauth") {
1586
- return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true };
1587
- }
1588
-
1589
- const observation = observeActiveOAuthAccessToken(name, authStoreBuffer);
1590
- outcomes.push({ provider: name, state: observation.kind });
1591
- if (observation.kind !== "available") return { apiKey: undefined, observed: true };
1592
- return {
1593
- apiKey: observation.snapshot.accessToken,
1594
- observed: true,
1595
- ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
1596
- ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
1597
- };
1598
- },
1599
- };
1600
- }
1601
-
1602
- async function fetchProviderModelsWithAuth(
1603
- captured: CapturedProviderGather,
1604
- ttlMs: number,
1605
- contextCap: number | undefined,
1606
- resolveAuth: ModelsAuthResolver,
1607
- ): Promise<ProviderModelsResult> {
1608
- const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured;
1609
- const observed = (
1610
- models: CatalogModel[],
1611
- state: CatalogGatherProviderModelOutcome["state"],
1612
- ): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
1613
- // Capture before any credential refresh or outbound await. OAuth account changes clear this
1614
- // generation, so a request started with the former account cannot later publish its result.
1615
- const cacheGeneration = captureModelCacheGeneration(name);
1616
- const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration);
1617
- if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models
1618
- const seedVertexDefault = prov.adapter === "google"
1619
- && prov.googleMode === "vertex"
1620
- && (prov.models?.length ?? 0) === 0
1621
- && Boolean(prov.defaultModel);
1622
- const seedStaticDefault = prov.liveModels === false
1623
- && (prov.models?.length ?? 0) === 0
1624
- && Boolean(prov.defaultModel);
1625
- // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the
1626
- // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints,
1627
- // so a retain-only id must enter here or it never exists to be retained (#1690).
1628
- const configuredIds = [...new Set([
1629
- ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []),
1630
- ...(prov.models ?? []),
1631
- ...(prov.retainModels ?? []),
1632
- ])];
1633
- const configured: CatalogModel[] = configuredIds.map(id => ({
1634
- id,
1635
- provider: name,
1636
- ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1637
- }));
1638
- const withConfiguredRetention = (
1639
- models: CatalogModel[],
1640
- options?: { retainComboTargets?: boolean; warnDrops?: boolean },
1641
- ): CatalogModel[] => {
1642
- const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({
1643
- name,
1644
- provider: prov,
1645
- models,
1646
- configured,
1647
- retainConfiguredModelIds: captured.retainConfiguredModelIds,
1648
- contextCap,
1649
- seedVertexDefault,
1650
- retainComboTargets: options?.retainComboTargets,
1651
- metadataModelIdCaseFold,
1652
- });
1653
- if (
1654
- options?.warnDrops === true
1655
- && droppedConfiguredIds.length > 0
1656
- && name !== OPENAI_API_PROVIDER_ID
1657
- && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)
1658
- ) {
1659
- warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1660
- }
1661
- return merged;
1662
- };
1663
- // Static catalogs never need an OAuth refresh or an upstream model request. Clear any
1664
- // discovery failure left by an older live configuration even when the account is logged out.
1665
- if (prov.liveModels === false) {
1666
- clearProviderDiscoveryStatus(name);
1667
- return observed(configured, "authoritative");
1668
- }
1669
- const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
1670
- ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist"
1671
- ? await getValidAccessTokenSnapshot(name)
1672
- .then(snapshot => ({
1673
- apiKey: snapshot.accessToken,
1674
- observed: false,
1675
- ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
1676
- }))
1677
- .catch(() => ({ apiKey: undefined, observed: false }))
1678
- : { apiKey: await resolveModelsAuthToken(name, prov), observed: false }
1679
- : resolveAuth.resolve(name, prov));
1680
- const apiKey = auth.apiKey;
1681
- // A configured default is a real callable selector and must remain discoverable when a
1682
- // compatible provider's live /models request fails (issue #308). Static providers already seed
1683
- // their default selector above when no explicit model list exists.
1684
- const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
1685
- ? configured
1686
- : [{
1687
- id: prov.defaultModel,
1688
- provider: name,
1689
- ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1690
- }];
1691
- const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
1692
- const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
1693
- vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
1694
- ? [...models, vertexDefaultSeed]
1695
- : models
1696
- );
1697
- if (prov.adapter === "qoder") {
1698
- if (!apiKey) return observed(configured, "degraded");
1699
- const profile = resolveQoderProfile(prov.baseUrl);
1700
- if (!profile) return observed(configured, "degraded");
1701
- // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT
1702
- // fingerprint so an account switch cannot observe another account's roster, even if a caller
1703
- // bypasses the normal config mutation path that clears provider caches.
1704
- const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
1705
- const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
1706
- if (fresh) {
1707
- return observed(withConfiguredRetention(
1708
- applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1709
- ), "authoritative");
1710
- }
1711
- const scopedStale = getStaleCached(name, authorityIdentity);
1712
- if (isModelsFetchCoolingDown(name) && scopedStale) {
1713
- return observed(withConfiguredRetention(
1714
- applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1715
- ), "degraded");
1716
- }
1717
- const live = await fetchQoderModels(profile, apiKey);
1718
- if (live.ok) {
1719
- const discovered = live.models.map(id => ({
1720
- id,
1721
- provider: name,
1722
- ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1723
- }));
1724
- const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
1725
- if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
1726
- return observed(withConfiguredRetention(configured), "degraded");
1727
- }
1728
- markProviderDiscoveryOk(name, live.models.length);
1729
- return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1730
- }
1731
- if (isCurrentCacheGeneration()) {
1732
- markModelsFetchFailure(name);
1733
- markProviderDiscoveryFailed(name, { reason: "provider" });
1734
- console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`);
1735
- }
1736
- const stale = getStaleCached(name, authorityIdentity);
1737
- return observed(withConfiguredRetention(
1738
- stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
1739
- ), "degraded");
1740
- }
1741
- if (prov.adapter === "devin") {
1742
- if (!apiKey) return observed(configured, "degraded");
1743
- const cachedDevin = getFreshCached(name, ttlMs);
1744
- if (cachedDevin) {
1745
- return observed(
1746
- withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)),
1747
- "authoritative",
1748
- );
1749
- }
1750
- if (isModelsFetchCoolingDown(name)) {
1751
- const cooling = getStaleCached(name);
1752
- return observed(
1753
- withConfiguredRetention(
1754
- cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
1755
- ),
1756
- "degraded",
1757
- );
1758
- }
1759
- const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl });
1760
- if (liveResult.ok) {
1761
- // Live catalog is the source of truth — use the discovered base models
1762
- // directly, not a filtered subset of the static seed.
1763
- //
1764
- // That extends to the context window. Cognition publishes no window
1765
- // anywhere, so the per-account catalog is the only first-party number,
1766
- // and the shipped static table is a degraded-mode guess that was wrong
1767
- // for nine of its eleven rows. The live value is applied first and the
1768
- // config hints run after it, so an explicit per-model override and an
1769
- // enabled Context cap still win — this only replaces the number nobody
1770
- // chose.
1771
- const result = liveResult.models.map((id) => {
1772
- const liveWindow = liveResult.contextWindows[id];
1773
- return {
1774
- id,
1775
- provider: name,
1776
- ...(liveWindow ? { contextWindow: liveWindow } : {}),
1777
- // The account catalog names the effort variants each base model has, so
1778
- // its ladder is measured rather than assumed. Without this the entry
1779
- // inherits the generic routed ladder and offers rungs the model rounds
1780
- // away, and every client that keys an effort control off this field —
1781
- // the Pi-shaped exports — renders no control at all.
1782
- ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}),
1783
- // The account catalog's per-base supportsImages vote collapses to one
1784
- // modalities value. It spreads before the hints so exact
1785
- // modelCapabilities declarations, the legacy modelInputModalities
1786
- // record and the vision-sidecar rewrite keep winning — the live
1787
- // value survives only when none of them applies.
1788
- ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}),
1789
- ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1790
- } as CatalogModel;
1791
- });
1792
- const forCache = withConfiguredRetention(result, { retainComboTargets: false });
1793
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1794
- return observed(withConfiguredRetention(configured), "degraded");
1795
- }
1796
- markProviderDiscoveryOk(name, liveResult.models.length);
1797
- return observed(withConfiguredRetention(forCache), "authoritative");
1798
- }
1799
- if (isCurrentCacheGeneration()) {
1800
- markModelsFetchFailure(name);
1801
- markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" });
1802
- }
1803
- const stale = getStaleCached(name);
1804
- return observed(
1805
- withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured),
1806
- "degraded",
1807
- );
1808
- }
1809
- if (prov.adapter === "cursor") {
1810
- if (!apiKey) return observed(configured, "degraded");
1811
- // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
1812
- // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
1813
- // suffix) but filter the static seed to the bases the account actually has — so models not on the
1814
- // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
1815
- const cachedCursor = getFreshCached(name, ttlMs);
1816
- if (cachedCursor) {
1817
- return observed(
1818
- withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)),
1819
- "authoritative",
1820
- );
1821
- }
1822
- if (isModelsFetchCoolingDown(name)) {
1823
- const cooling = getStaleCached(name);
1824
- return observed(
1825
- withConfiguredRetention(
1826
- cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
1827
- ),
1828
- "degraded",
1829
- );
1830
- }
1831
- const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
1832
- const liveResult = await fetchCursorUsableModels({
1833
- apiKey,
1834
- baseUrl: prov.baseUrl,
1835
- upstreamHttpVersion: prov.upstreamHttpVersion,
1836
- ...(cursorFetch ? { fetch: cursorFetch } : {}),
1837
- });
1838
- if (liveResult.ok) {
1839
- const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
1840
- const result = available.length > 0 ? available : configured;
1841
- // Cache the discovery-filtered roster without combo retention so a later
1842
- // gather can re-apply the current capture's retain set on read.
1843
- const forCache = withConfiguredRetention(result, { retainComboTargets: false });
1844
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1845
- return observed(withConfiguredRetention(configured), "degraded");
1846
- }
1847
- // Publish roster-derived state only for a discovery the cache accepted: a stale
1848
- // in-flight capture (generation revoked by a credential/config change) must not
1849
- // overwrite the spelling or Max-Mode evidence of the newer one.
1850
- recordLiveCursorClaudeModels(liveResult.models);
1851
- // Live Max-Mode evidence feeds the umbrella resolver's ultra gate
1852
- // (devlog 260828_cursor_umbrella_catalog; union with static evidence).
1853
- recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []);
1854
- markProviderDiscoveryOk(name, liveResult.models.length);
1855
- return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1856
- }
1857
- if (isCurrentCacheGeneration()) {
1858
- markModelsFetchFailure(name);
1859
- markProviderDiscoveryFailed(name, { reason: "provider" });
1860
- console.warn(
1861
- `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
1862
- );
1863
- }
1864
- const staleCursor = getStaleCached(name);
1865
- return observed(
1866
- withConfiguredRetention(
1867
- staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
1868
- ),
1869
- "degraded",
1870
- );
1871
- }
1872
- if (prov.authMode === "oauth" && !apiKey) {
1873
- // No usable token (logged out, or account marked needsReauth). Still surface the
1874
- // configured static catalog so the GUI Models tab / rail counts are not empty —
1875
- // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
1876
- return observed(configured, "degraded");
1877
- }
1878
- const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist";
1879
- const project = prov.project ?? auth.oauthProjectId;
1880
- if (cloudCodeAssist && !project) return observed(configured, "degraded");
1881
- const fresh = getFreshCached(name, ttlMs);
1882
- if (fresh) {
1883
- return observed(
1884
- withConfiguredRetention(
1885
- withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
1886
- ),
1887
- "authoritative",
1888
- ); // dedups Codex's frequent /v1/models polling within the TTL
1889
- }
1890
- if (isModelsFetchCoolingDown(name)) {
1891
- // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
1892
- // fetch timeout on every catalog poll — the dashboard polls this path per page load.
1893
- const stale = getStaleCached(name);
1894
- return observed(
1895
- withConfiguredRetention(
1896
- stale
1897
- ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias))
1898
- : failedDiscoveryConfigured,
1899
- ),
1900
- "degraded",
1901
- );
1902
- }
1903
- const url = request.url;
1904
- let headers = materializeCapturedHeaders(request, apiKey);
1905
- // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery
1906
- // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the
1907
- // SAME effective credential/header authority. buildModelsRequest's generic tail writes the
1908
- // generated Bearer AFTER configured headers, but the native inference adapter applies
1909
- // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured
1910
- // provider headers here so the whole Ollama request family shares that one authority.
1911
- if (ollamaShowEnrichable(name, prov)) {
1912
- headers = applyConfiguredHeadersLast(headers, prov.headers);
1913
- }
1914
- const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
1915
- ? "vertex-aiplatform"
1916
- : "provider-models";
1917
- const failedDiscoveryFallback = (
1918
- failure: ProviderModelDiscoveryFailure,
1919
- ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
1920
- if (!isCurrentCacheGeneration()) {
1921
- return {
1922
- models: withConfiguredRetention(failedDiscoveryConfigured),
1923
- fallback: "configured",
1924
- shouldLog: false,
1925
- };
1926
- }
1927
- // Decide logging BEFORE recording the new status, so we can compare against the prior one and
1928
- // suppress an identical repeated failure (#395 log flood). The failure stays observable via the
1929
- // discovery-status API regardless.
1930
- const shouldLog = shouldLogDiscoveryFailure(name, failure);
1931
- markModelsFetchFailure(name);
1932
- markProviderDiscoveryFailed(name, failure);
1933
- const stale = getStaleCached(name);
1934
- return {
1935
- models: withConfiguredRetention(
1936
- stale
1937
- ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias))
1938
- : failedDiscoveryConfigured,
1939
- ),
1940
- fallback: stale ? "stale" : "configured",
1941
- shouldLog,
1942
- };
1943
- };
1944
- try {
1945
- // Canonical-URL TUN transparency for Clash/Surge/Mihomo fake-IP DNS:
1946
- // `isRegistryModelDiscoveryUrl` proves the FINAL request URL is the
1947
- // registry's own fixed discovery URL, so a purely-benchmark DNS answer may
1948
- // be pin-connected through the intercepting TUN without proxy env. The
1949
- // proof is on the URL — not the provider name — because an OAuth/forward
1950
- // name matches any baseUrl by design. Retargeted or renamed custom rows
1951
- // fetch a different URL and keep the rejection.
1952
- const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl };
1953
- const res = request.method === "POST"
1954
- ? await providerOutboundPost(name, prov, url, {
1955
- headers,
1956
- body: JSON.stringify({ project }),
1957
- signal: AbortSignal.timeout(8000),
1958
- }, outboundDependencies)
1959
- : await providerOutboundGet(name, prov, url, {
1960
- headers,
1961
- signal: AbortSignal.timeout(8000),
1962
- }, outboundDependencies);
1963
- const redirectError = await providerRedirectError(res, url);
1964
- if (redirectError) {
1965
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
1966
- if (shouldLog) {
1967
- console.warn(
1968
- `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
1969
- );
1970
- }
1971
- return observed(models, "degraded");
1972
- }
1973
- if (!res.ok) {
1974
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
1975
- if (shouldLog) {
1976
- console.warn(
1977
- `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
1978
- );
1979
- }
1980
- return observed(models, "degraded");
1981
- }
1982
-
1983
- const contentType = (
1984
- res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
1985
- ).slice(0, 80);
1986
- const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
1987
- if (!bounded.ok) {
1988
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
1989
- const diagnostic = bounded.reason === "response_too_large"
1990
- ? `exceeded the ${discovery.maxResponseBytes}-byte response limit`
1991
- : contentType === "application/json" || contentType.endsWith("+json")
1992
- ? "returned invalid JSON in a 2xx response"
1993
- : "returned a non-JSON 2xx response";
1994
- if (shouldLog) {
1995
- console.warn(
1996
- `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1997
- );
1998
- }
1999
- return observed(models, "degraded");
2000
- }
2001
- const antigravity = cloudCodeAssist
2002
- ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels)
2003
- : undefined;
2004
- if (cloudCodeAssist && !antigravity) {
2005
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
2006
- if (shouldLog) {
2007
- console.warn(
2008
- `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
2009
- );
2010
- }
2011
- return observed(models, "degraded");
2012
- }
2013
- if (antigravity) {
2014
- const live = antigravity.map(model => applyProviderConfigHints(name, prov, {
2015
- id: model.id,
2016
- provider: name,
2017
- // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
2018
- // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
2019
- reasoningEfforts: [],
2020
- ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
2021
- ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
2022
- }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias));
2023
- const forCache = withConfiguredRetention(live, { retainComboTargets: false });
2024
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
2025
- return observed(withConfiguredRetention(configured), "degraded");
2026
- }
2027
- registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, {
2028
- provider: name,
2029
- cacheGeneration,
2030
- });
2031
- markProviderDiscoveryOk(name, live.length);
2032
- return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
2033
- }
2034
- const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"
2035
- ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
2036
- : undefined;
2037
- // Native /v1beta/models wins; a google row served by an OpenAI-compatible
2038
- // gateway keeps the generic data[] / top-level-array contract.
2039
- const extracted = googleAiStudio?.ok
2040
- ? googleAiStudio
2041
- : extractProviderModelItems(bounded.value, discovery);
2042
- if (!extracted.ok) {
2043
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
2044
- const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
2045
- response_too_large: "returned an oversized 2xx response",
2046
- invalid_json: "returned invalid JSON in a 2xx response",
2047
- invalid_shape: "returned malformed 2xx data",
2048
- too_many_models: `exceeded the ${discovery.maxModels}-row model limit`,
2049
- };
2050
- if (shouldLog) {
2051
- console.warn(
2052
- `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
2053
- );
2054
- }
2055
- return observed(models, "degraded");
2056
- }
2057
- const items = extracted.items;
2058
- // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata,
2059
- // so a newly announced id would otherwise publish generic defaults. /api/show fills that
2060
- // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured
2061
- // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered
2062
- // window only where exact config is absent, and the provider context cap still caps it).
2063
- const showEnrichment = ollamaShowEnrichable(name, prov)
2064
- ? await fetchOllamaShowEnrichment({
2065
- headers,
2066
- discoveryUrl: request.url,
2067
- modelIds: items.map(m => m.id),
2068
- provider: prov,
2069
- }).catch(() => undefined)
2070
- : undefined;
2071
- const live = items.map(m => {
2072
- const ownedBy = boundedOwnedBy(m.owned_by);
2073
- // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the
2074
- // models-API row does not carry. applyProviderConfigHints then applies explicit
2075
- // configured metadata over both, and the provider context cap still caps the result.
2076
- const modelsApiHints = catalogHintsFromModelsApiItem(name, m);
2077
- const show = showEnrichment?.metadata.get(m.id);
2078
- const discoveredHints = {
2079
- ...modelsApiHints,
2080
- ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined
2081
- ? { contextWindow: show.contextWindow }
2082
- : {}),
2083
- ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true
2084
- ? { inputModalities: ["text", "image"] as string[] }
2085
- : {}),
2086
- };
2087
- return applyProviderConfigHints(name, prov, {
2088
- id: m.id,
2089
- provider: name,
2090
- ...(ownedBy ? { owned_by: ownedBy } : {}),
2091
- ...discoveredHints,
2092
- }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias);
2093
- })
2094
- .filter(m => shouldExposeProviderModel(name, m.id));
2095
- // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
2096
- // `live`; otherwise configured entries would be reported as discovered ones.
2097
- const liveModelCount = live.length;
2098
- // Dated-release aliases + configured retention (compat allow-list, combo targets,
2099
- // Vertex default). Cache without combo retention so a later gather re-applies the
2100
- // current capture's retain set on read (warm-cache OCX-111 / #1308).
2101
- const forCache = withConfiguredRetention(live, { retainComboTargets: false });
2102
- const returned = withConfiguredRetention(forCache, { warnDrops: true });
2103
- const droppedConfiguredIds = configured
2104
- .map(model => model.id)
2105
- .filter(id => !returned.some(model => model.id === id));
2106
- if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
2107
- console.warn(
2108
- `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
2109
- );
2110
- }
2111
- if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
2112
- return observed(withConfiguredRetention(configured), "degraded");
2113
- }
2114
- markProviderDiscoveryOk(name, liveModelCount);
2115
- return observed(returned, "authoritative");
2116
- } catch (error) {
2117
- if (error instanceof ProviderOutboundPolicyError) {
2118
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
2119
- if (shouldLog) {
2120
- console.warn(
2121
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
2122
- );
2123
- }
2124
- return observed(models, "degraded");
2125
- }
2126
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
2127
- if (shouldLog) {
2128
- console.warn(
2129
- `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
2130
- );
2131
- }
2132
- return observed(models, "degraded");
2133
- }
2134
- }
2135
-
2136
- export async function fetchProviderModels(
2137
- name: string,
2138
- prov: OcxProviderConfig,
2139
- ttlMs: number,
2140
- contextCap?: number,
2141
- ): Promise<CatalogModel[]> {
2142
- const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver);
2143
- return (await fetchProviderModelsWithAuth(
2144
- captured,
2145
- ttlMs,
2146
- contextCap,
2147
- refreshingModelsAuthResolver,
2148
- )).models;
2149
- }
2150
-
2151
- export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
2152
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
2153
- // xAI /models advertises both the dated deployment and this floating alias.
2154
- // Keep only grok-4.20-multi-agent-0309; the alias is the same server-side id.
2155
- if (providerName === "xai" && modelId === "grok-4.20-multi-agent-beta-latest") return false;
2156
- return true;
2157
- }
2158
-
2159
- export function shouldRetainConfiguredProviderModel(
2160
- providerName: string,
2161
- modelId: string,
2162
- prov?: OcxProviderConfig,
2163
- ): boolean {
2164
- if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
2165
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
2166
- if (modelInList(prov?.retainModels, modelId)) return true;
2167
- return false;
2168
- }
2169
-
2170
- /**
2171
- * Fold dated-release aliases and retain configured rows that must survive an
2172
- * authoritative live roster (compatibility allow-list, combo targets, Vertex
2173
- * default). Used on every discovery return — live, fresh cache, stale, and
2174
- * failure fallback — so a warm cache captured before a combo existed still
2175
- * surfaces the configured target (OCX-111 / #1308).
2176
- *
2177
- * Cache writes should pass `retainComboTargets: false` so combo retention is
2178
- * re-applied on read against the current capture, not frozen into the TTL entry.
2179
- */
2180
- export function mergeConfiguredModelsIntoLiveCatalog(opts: {
2181
- name: string;
2182
- provider: OcxProviderConfig;
2183
- models: readonly CatalogModel[];
2184
- configured: readonly CatalogModel[];
2185
- retainConfiguredModelIds?: ReadonlySet<string>;
2186
- contextCap?: number;
2187
- seedVertexDefault?: boolean;
2188
- retainComboTargets?: boolean;
2189
- metadataModelIdCaseFold?: boolean;
2190
- }): { models: CatalogModel[]; droppedConfiguredIds: string[] } {
2191
- const {
2192
- name,
2193
- provider: prov,
2194
- configured,
2195
- retainConfiguredModelIds,
2196
- contextCap,
2197
- seedVertexDefault,
2198
- retainComboTargets = true,
2199
- metadataModelIdCaseFold,
2200
- } = opts;
2201
- const out = [...opts.models];
2202
- const present = new Set(out.map(model => model.id));
2203
- const droppedConfiguredIds: string[] = [];
2204
- for (const candidate of configured) {
2205
- if (present.has(candidate.id)) continue;
2206
- const dated = out.find(live => isDatedVariantId(live.id, candidate.id));
2207
- if (dated) {
2208
- out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold));
2209
- present.add(candidate.id);
2210
- continue;
2211
- }
2212
- if (
2213
- seedVertexDefault === true
2214
- || shouldRetainConfiguredProviderModel(name, candidate.id, prov)
2215
- || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true)
2216
- ) {
2217
- out.push(candidate);
2218
- present.add(candidate.id);
2219
- continue;
2220
- }
2221
- droppedConfiguredIds.push(candidate.id);
2222
- }
2223
- return { models: out, droppedConfiguredIds };
2224
- }
2225
-
2226
- export function filterCatalogVisibleModels(
2227
- models: CatalogModel[],
2228
- config: Pick<OcxConfig, "disabledModels" | "providers">,
2229
- ): CatalogModel[] {
2230
- const disabled = new Set(config.disabledModels ?? []);
2231
- const allowByProvider = new Map<string, Set<string>>();
2232
- for (const [name, prov] of Object.entries(config.providers)) {
2233
- const sel = prov.selectedModels;
2234
- // Keyed the way `sync.ts` keys the same list, so a slash-bearing native id and
2235
- // the encoded slug the Codex picker displays are one entry rather than two. A
2236
- // bare `Set(sel)` matched only the native form, so an allowlist written from the
2237
- // displayed slug — which `ocx models remove` also accepts — hid every model it
2238
- // was meant to keep.
2239
- //
2240
- // The key is deliberately lossy: `p/a/b` and `p/a-b` collapse to one entry, so a
2241
- // provider publishing both spellings has them selected together. That is a real
2242
- // limitation, pinned by the tests below and tracked as a follow-up; it is NOT
2243
- // fixed here. Resolving selections against the current roster instead was tried
2244
- // and rejected — the roster is an incomplete dictionary (live discovery can omit
2245
- // a published id), so it produces the same over-grant while additionally
2246
- // disagreeing with the `slugEquivalenceKey` contract `sync.ts` uses at merge time.
2247
- // Two catalog stages with different equivalence relations is the exact bug class
2248
- // this change exists to remove.
2249
- if (Array.isArray(sel) && sel.length > 0) {
2250
- allowByProvider.set(name, new Set(sel.map(model => slugEquivalenceKey(routedSlug(name, model)))));
2251
- }
2252
- }
2253
- return models.filter(m => {
2254
- if (initialModelSelectionPending(config.providers[m.provider])) return false;
2255
- const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
2256
- // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
2257
- for (const stored of disabled) {
2258
- // Combo management stores the public alias, while canonical `combo/<id>` references
2259
- // remain valid for backward compatibility through slugEquals below.
2260
- if (m.alias !== undefined && stored === catalogModelSlug(m) && !nativeAlias) return false;
2261
- if (slugEquals(stored, m.provider, m.id)) return false;
2262
- }
2263
- const allow = allowByProvider.get(m.provider);
2264
- return !allow || allow.has(slugEquivalenceKey(routedSlug(m.provider, m.id)));
2265
- });
2266
- }
2267
-
2268
- export async function gatherRoutedModels(
2269
- config: OcxConfig,
2270
- options?: GatherRoutedModelsOptions,
2271
- ): Promise<CatalogModel[]> {
2272
- return gatherRoutedModelsWithAuth(
2273
- config,
2274
- `refreshing:${gatherFlightKey(config)}`,
2275
- () => refreshingModelsAuthResolver,
2276
- options,
2277
- );
2278
- }
2279
-
2280
- /**
2281
- * Catalog-gather model discovery using only auth-store bytes already captured by the
2282
- * filesystem-evidence owner. This entry point never reaches the refreshing resolver.
2283
- */
2284
- export async function gatherRoutedModelsForCatalogGather(
2285
- config: OcxConfig,
2286
- evidence: CatalogGatherProviderAuthEvidence,
2287
- options?: GatherRoutedModelsOptions,
2288
- ): Promise<CatalogModel[]> {
2289
- const authStoreBuffer = evidence.authStoreBuffer === null
2290
- ? null
2291
- : Uint8Array.from(evidence.authStoreBuffer);
2292
- const authIdentity = authStoreBuffer === null
2293
- ? "absent"
2294
- : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer);
2295
- return gatherRoutedModelsWithAuth(
2296
- config,
2297
- `observed:${authIdentity}:${gatherFlightKey(config)}`,
2298
- outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes),
2299
- options,
2300
- );
2301
- }
2302
-
2303
- async function gatherRoutedModelsWithAuth(
2304
- config: OcxConfig,
2305
- key: string,
2306
- createAuthResolver: ModelsAuthResolverFactory,
2307
- options?: GatherRoutedModelsOptions,
2308
- ): Promise<CatalogModel[]> {
2309
- const capture = captureGatherFlight(config, createAuthResolver);
2310
- const bucket = gatherInflight.get(key) ?? [];
2311
- let entry = bucket.find(candidate => (
2312
- candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity
2313
- && candidate.authIdentity === capture.authIdentity
2314
- && candidate.providerGraphIdentity === capture.providerGraphIdentity
2315
- ));
2316
- if (!entry) {
2317
- const lease = gatherGate.tryAcquire();
2318
- if (!lease) throw new CatalogGatherBusyError();
2319
- // Claim the slot synchronously before any await so same-key callers join this flight.
2320
- // Distinct authorities retain separate entries even when their legacy bucket matches.
2321
- let ownedEntry!: GatherInflightEntry;
2322
- const flight = gatherRoutedModelsUncached(config, capture).finally(() => {
2323
- const current = gatherInflight.get(key);
2324
- const index = current?.indexOf(ownedEntry) ?? -1;
2325
- if (current && index >= 0) current.splice(index, 1);
2326
- if (current?.length === 0) gatherInflight.delete(key);
2327
- lease.release();
2328
- });
2329
- ownedEntry = Object.freeze({
2330
- discoveryPolicyIdentity: capture.discoveryPolicyIdentity,
2331
- authIdentity: capture.authIdentity,
2332
- providerGraphIdentity: capture.providerGraphIdentity,
2333
- promise: flight,
2334
- });
2335
- bucket.push(ownedEntry);
2336
- gatherInflight.set(key, bucket);
2337
- entry = ownedEntry;
2338
- }
2339
- const {
2340
- models,
2341
- comboOmissions,
2342
- providerAuthOutcomes,
2343
- providerModelOutcomes,
2344
- discoveryPolicySnapshots,
2345
- } = await entry.promise;
2346
- if (options?.comboOmissions) {
2347
- options.comboOmissions.length = 0;
2348
- options.comboOmissions.push(...comboOmissions);
2349
- }
2350
- if (options?.providerAuthOutcomes) {
2351
- options.providerAuthOutcomes.length = 0;
2352
- options.providerAuthOutcomes.push(...providerAuthOutcomes);
2353
- }
2354
- if (options?.providerModelOutcomes) {
2355
- options.providerModelOutcomes.length = 0;
2356
- options.providerModelOutcomes.push(...providerModelOutcomes);
2357
- }
2358
- if (options?.discoveryPolicySnapshots) {
2359
- options.discoveryPolicySnapshots.length = 0;
2360
- options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots);
2361
- }
2362
- return models;
2363
- }
2364
-
2365
- /** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */
2366
- function boundCustomNativeReasoning(
2367
- model: CatalogModel,
2368
- allowed: readonly string[],
2369
- nativeDefault: string | undefined,
2370
- ): CatalogModel {
2371
- if (allowed.length === 0 || model.reasoningEfforts === undefined) return model;
2372
- const bounded = { ...model };
2373
- if (model.reasoningEfforts.length === 0) {
2374
- bounded.reasoningEfforts = [];
2375
- delete bounded.defaultReasoningEffort;
2376
- return bounded;
2377
- }
2378
- const declared = new Set(model.reasoningEfforts);
2379
- const surviving = [...new Set(allowed)].filter(effort => declared.has(effort));
2380
- const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!;
2381
- // A nonempty but incompatible declaration is not an explicit no-reasoning setting.
2382
- bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback];
2383
- bounded.defaultReasoningEffort = model.defaultReasoningEffort
2384
- && bounded.reasoningEfforts.includes(model.defaultReasoningEffort)
2385
- ? model.defaultReasoningEffort
2386
- : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!;
2387
- return bounded;
2388
- }
2389
-
2390
- async function gatherRoutedModelsUncached(
2391
- config: OcxConfig,
2392
- capture: GatherFlightCapture,
2393
- ): Promise<GatherFlightResult> {
2394
- // Flight-local list: joiners copy from the resolved promise, not a process-global last write.
2395
- const localOmissions: ComboCatalogOmission[] = [];
2396
- const localProviderAuthOutcomes = capture.providerAuthOutcomes;
2397
- const resolveAuth = capture.authResolver;
2398
- const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
2399
- // Persisted provider entries can predate newer registry fields (noVisionModels,
2400
- // modelInputModalities, ...). The ROUTER merges registry seeds at request time
2401
- // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
2402
- // same merged view or its advertisements drift from actual proxy behavior (e.g. a
2403
- // vision-sidecar model advertised text-only, blocking image attachments app-side).
2404
- // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
2405
- const activeProviders = capture.providers;
2406
- const providerResults = await Promise.all(
2407
- activeProviders.map(provider => fetchProviderModelsWithAuth(
2408
- provider,
2409
- ttlMs,
2410
- providerContextCap(config, provider.name),
2411
- resolveAuth,
2412
- )),
2413
- );
2414
- const lists = providerResults.map(result => result.models);
2415
- const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows(
2416
- lists.flat(),
2417
- config,
2418
- capture.openAiApiPolicy,
2419
- );
2420
- const apiProvider = activeProviders.find(provider => provider.name === OPENAI_API_PROVIDER_ID);
2421
- // Trusted reconstruction replaces whole rows, including the earlier Fast hints.
2422
- // Restore only that capability from the same captured authority used by discovery.
2423
- if (apiProvider) {
2424
- for (const model of apiAugmented) {
2425
- if (model.provider !== OPENAI_API_PROVIDER_ID) continue;
2426
- const policy = fastPolicyForModel(apiProvider.provider, model.id, apiProvider.name);
2427
- const supported = serviceTierSupportFromPolicy(policy);
2428
- if (supported !== undefined) model.supportsServiceTier = supported;
2429
- if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription;
2430
- }
2431
- }
2432
- const metadataModelIdCaseFoldByProvider = new Map(
2433
- activeProviders.map(provider => [provider.name, provider.metadataModelIdCaseFold]),
2434
- );
2435
- const all = augmentRoutedModelsWithMetadata(
2436
- apiAugmented,
2437
- activeProviders.map(provider => provider.name),
2438
- config.providers,
2439
- config,
2440
- metadataModelIdCaseFoldByProvider,
2441
- )
2442
- // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
2443
- // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
2444
- // exposure decision goes through shouldExposeRoutedModel (single choke point).
2445
- .filter(shouldExposeRoutedModel);
2446
- const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
2447
- // [Decision Log]
2448
- // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
2449
- // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
2450
- // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
2451
- // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
2452
- // openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
2453
- // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
2454
- // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
2455
- // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
2456
- // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
2457
- // 노출 경로에 영향 없음.
2458
- // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
2459
- // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
2460
- // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
2461
- // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
2462
- // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
2463
- // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
2464
- // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
2465
- // 우선시하므로 실제 충돌 가능성은 낮음.
2466
- if (!hasComboTargets(config)) {
2467
- // Skip the native slug injection entirely when no combos are configured — avoids
2468
- // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
2469
- // configs that will never need it.
2470
- } else {
2471
- const disabled = disabledNativeSlugs(config);
2472
- const openaiContextCap = nativeContextLimits(config);
2473
- const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => {
2474
- const combo = getCombo(config, id);
2475
- return combo?.targets.flatMap(target => (
2476
- target.provider === "openai" ? [target.model] : []
2477
- )) ?? [];
2478
- }));
2479
- for (const slug of nativeOpenAiSlugs()) {
2480
- // A bare native disable key hides the native row, not a combo that targets it.
2481
- // Keep synthetic native metadata available to those combos.
2482
- if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue;
2483
- const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap);
2484
- if (contextWindow === undefined) continue;
2485
- const synthetic: CatalogModel = {
2486
- provider: "openai",
2487
- id: slug,
2488
- owned_by: "openai",
2489
- contextWindow,
2490
- // Input limit, not the total window. These coincide for native GPT-5.6 today (the
2491
- // advertised 922,000 window is already capped at its measured ceiling), but the two
2492
- // stay separate fields because routed/API rows of the same family run a wider window.
2493
- // Falls back to the window for slugs with no separate ceiling.
2494
- maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow),
2495
- ...(nativeOpenAiMaxOutputTokens(slug) !== undefined
2496
- ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(slug) }
2497
- : {}),
2498
- autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap),
2499
- inputModalities: nativeInputModalities(slug),
2500
- reasoningEfforts: nativeReasoningEfforts(slug),
2501
- ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
2502
- };
2503
- const key = `openai/${slug}`;
2504
- // Only inject when not already present from a routed provider (an API-key
2505
- // "openai" provider could shadow the native one).
2506
- if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
2507
- }
2508
- }
2509
- // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
2510
- // custom-model vision-sidecar inheritance so both see the same merged registry view.
2511
- const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
2512
- for (const id of listComboIds(config)) {
2513
- const combo = getCombo(config, id);
2514
- if (!combo) continue;
2515
- const comboNativeLimits = nativeContextLimits(config);
2516
- const nativeContextWindow = combo.nativeAlias && combo.alias
2517
- ? nativeOpenAiContextWindow(combo.alias, comboNativeLimits)
2518
- : undefined;
2519
- const nativeAliasMaxInput = combo.nativeAlias && combo.alias
2520
- ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak")
2521
- ? NATIVE_GPT56_MAX_INPUT_TOKENS
2522
- : nativeOpenAiMaxInputTokens(combo.alias, comboNativeLimits) ?? nativeOpenAiContextWindow(combo.alias, comboNativeLimits))
2523
- : undefined;
2524
- const nativeAliasAutoCompact = combo.nativeAlias && combo.alias
2525
- ? nativeOpenAiAutoCompactTokenLimit(combo.alias, comboNativeLimits)
2526
- : undefined;
2527
- const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined
2528
- ? {
2529
- contextWindow: nativeContextWindow,
2530
- ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}),
2531
- ...(nativeOpenAiMaxOutputTokens(combo.alias) !== undefined
2532
- ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(combo.alias) }
2533
- : {}),
2534
- ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}),
2535
- inputModalities: nativeInputModalities(combo.alias),
2536
- reasoningEfforts: nativeReasoningEfforts(combo.alias),
2537
- }
2538
- : undefined;
2539
- const members = combo.targets
2540
- .map(target => resolveComboCatalogMember(
2541
- target,
2542
- memberByKey,
2543
- enrichedByName,
2544
- providerContextCap(config, target.provider),
2545
- nativeAliasFallback,
2546
- metadataModelIdCaseFoldByProvider.get(target.provider),
2547
- ))
2548
- .filter((member): member is CatalogModel => member !== undefined);
2549
- const derived = deriveComboCatalogModel(id, combo, members);
2550
- if (derived) {
2551
- const nativeDefault = combo.nativeAlias && combo.alias
2552
- ? nativeDefaultReasoningEffort(combo.alias)
2553
- : undefined;
2554
- if (combo.defaultEffort === null
2555
- && nativeDefault
2556
- && derived.reasoningEfforts?.includes(nativeDefault)) {
2557
- derived.defaultReasoningEffort = nativeDefault;
2558
- }
2559
- all.push(derived);
2560
- }
2561
- else warnUncataloguedComboOnce(id, combo, members, localOmissions);
2562
- }
2563
- replaceLastComboCatalogOmissions(localOmissions);
2564
- all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
2565
- // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
2566
- // with the same slug below, so that row's provider capability metadata is the inheritance source.
2567
- const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
2568
- const customModels = (config.customModels ?? []).map(cm => {
2569
- const rawProvider = config.providers[cm.provider];
2570
- const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
2571
- // Registry routing backfills an omitted authMode on the built-in OpenAI provider to
2572
- // forward. Keep the catalog projection on the same contract while still failing closed
2573
- // for every explicit non-forward mode and every non-canonical endpoint.
2574
- const providerForCanonicalCheck = rawProvider
2575
- ? withCanonicalOpenAiForwardAuthDefault(cm.provider, rawProvider)
2576
- : undefined;
2577
- const codexForwardNativeCapabilityAlias = cm.provider === OPENAI_CODEX_PROVIDER_ID
2578
- && providerForCanonicalCheck !== undefined
2579
- && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)
2580
- && hasNativeOpenAiCapabilityMetadata(cm.modelId);
2581
- const customNativeLimits = {
2582
- ...nativeContextLimits(config),
2583
- ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0
2584
- ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } }
2585
- : {}),
2586
- };
2587
- const nativeAliasContextWindow = codexForwardNativeCapabilityAlias
2588
- ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits)
2589
- : undefined;
2590
- const customContextWindow = cm.contextWindow
2591
- ? nativeAliasContextWindow !== undefined
2592
- ? nativeAliasContextWindow
2593
- : cm.contextWindow
2594
- : nativeAliasContextWindow;
2595
- const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias
2596
- ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits)
2597
- : undefined;
2598
- const nativeAliasMaxOutputTokens = codexForwardNativeCapabilityAlias
2599
- ? nativeOpenAiMaxOutputTokens(cm.modelId)
2600
- : undefined;
2601
- const configuredMaxInput = rawProvider
2602
- ? configuredMaxInputTokens(rawProvider, cm.modelId)
2603
- : undefined;
2604
- const hardMaxCandidates = [nativeAliasMaxInputTokens, configuredMaxInput]
2605
- .filter((value): value is number => typeof value === "number" && value > 0);
2606
- const customMaxInputTokens = hardMaxCandidates.length > 0
2607
- ? Math.min(
2608
- ...hardMaxCandidates,
2609
- ...(customContextWindow !== undefined ? [customContextWindow] : []),
2610
- )
2611
- : undefined;
2612
- const customMaxOutputTokens = rawProvider
2613
- ? routedMaxOutputTokens(cm.provider, rawProvider, {
2614
- id: cm.modelId,
2615
- provider: cm.provider,
2616
- ...(nativeAliasMaxOutputTokens !== undefined ? { maxOutputTokens: nativeAliasMaxOutputTokens } : {}),
2617
- }, cm.modelId, metadataModelIdCaseFoldByProvider.get(cm.provider))
2618
- : nativeAliasMaxOutputTokens;
2619
- const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId);
2620
- const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias
2621
- ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits)
2622
- : customContextWindow !== undefined && configuredAutoCompact !== undefined
2623
- ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, configuredAutoCompact)
2624
- : undefined;
2625
- const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias
2626
- ? nativeDefaultReasoningEffort(cm.modelId)
2627
- : undefined;
2628
- const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
2629
- const fastPolicy = effectiveProvider
2630
- ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider)
2631
- : undefined;
2632
- const supportsServiceTier = fastPolicy
2633
- ? serviceTierSupportFromPolicy(fastPolicy)
2634
- : undefined;
2635
- const base: CatalogModel = {
2636
- id: cm.modelId,
2637
- provider: cm.provider,
2638
- catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
2639
- // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
2640
- ...(cm.displayName
2641
- ? { displayName: cm.displayName }
2642
- : codexForwardNativeCapabilityAlias
2643
- ? { displayName: nativeOpenAiCapabilityDisplayName(cm.modelId) ?? cm.modelId } : {}),
2644
- ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}),
2645
- ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}),
2646
- ...(customMaxOutputTokens !== undefined ? { maxOutputTokens: customMaxOutputTokens } : {}),
2647
- ...(customAutoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: customAutoCompactTokenLimit } : {}),
2648
- ...(cm.inputModalities
2649
- ? { inputModalities: cm.inputModalities }
2650
- : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}),
2651
- ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
2652
- // Native-alias defaults apply only where the custom row declares nothing: the explicit
2653
- // spreads below must win (later in object order), so a stored `[]` stays empty and a
2654
- // declared ladder is narrowed to proven native capabilities after the merge below.
2655
- ...(codexForwardNativeCapabilityAlias
2656
- ? {
2657
- codexForwardNativeCapabilityAlias: true,
2658
- parallelToolCalls: nativeParallelToolCalls(cm.modelId),
2659
- ...(Array.isArray(cm.reasoningEfforts)
2660
- ? {}
2661
- : {
2662
- reasoningEfforts: nativeReasoningEfforts(cm.modelId),
2663
- ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}),
2664
- }),
2665
- }
2666
- : {}),
2667
- // Explicit custom-row ladder wins over the inherited provider row below: the merge only
2668
- // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept
2669
- // instead of being replaced by that row's metadata. Capability-backed native model ids
2670
- // are bounded against their own pinned ladder after the merge, including gateways.
2671
- ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
2672
- ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
2673
- ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
2674
- ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined
2675
- ? { fastTierDescription: fastPolicy.fastTierDescription }
2676
- : {}),
2677
- ...(cm.codexToolMode !== undefined
2678
- ? { codexToolMode: cm.codexToolMode }
2679
- : effectiveProvider?.codexToolMode !== undefined
2680
- ? { codexToolMode: effectiveProvider.codexToolMode }
2681
- : {}),
2682
- };
2683
- // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
2684
- // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
2685
- // context, ...) so the generated catalog keeps advertising what the router actually provides.
2686
- // Explicit custom fields win by construction; this only fills gaps. Without it a
2687
- // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one,
2688
- // which Codex then rejects for spawn_agent with effort "none".
2689
- const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId));
2690
- // The final ladder is what the catalog will advertise; the inherited default only rides
2691
- // along when it is actually a member — otherwise a provider default like "xhigh" would
2692
- // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels.
2693
- const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts;
2694
- const mergedMaxInputCandidates = [base.maxInputTokens, replaced?.maxInputTokens]
2695
- .filter((value): value is number => typeof value === "number" && value > 0);
2696
- const mergedMaxInput = mergedMaxInputCandidates.length > 0
2697
- ? Math.min(...mergedMaxInputCandidates)
2698
- : undefined;
2699
- const mergedMaxOutputCandidates = [base.maxOutputTokens, replaced?.maxOutputTokens]
2700
- .filter((value): value is number => typeof value === "number" && value > 0);
2701
- const mergedMaxOutput = mergedMaxOutputCandidates.length > 0
2702
- ? Math.min(...mergedMaxOutputCandidates)
2703
- : undefined;
2704
- const merged: CatalogModel = replaced ? {
2705
- ...base,
2706
- ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}),
2707
- ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}),
2708
- ...(mergedMaxOutput !== undefined ? { maxOutputTokens: mergedMaxOutput } : {}),
2709
- ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined
2710
- ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit }
2711
- : {}),
2712
- ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}),
2713
- ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}),
2714
- ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined
2715
- && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort)
2716
- ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}),
2717
- ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
2718
- ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
2719
- ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
2720
- ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
2721
- ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
2722
- } : base;
2723
- // Catalog-advertised efforts are bounded whenever the model id is a pinned native
2724
- // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot
2725
- // advertise none/minimal. Full native identity stays behind the alias predicate.
2726
- const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId);
2727
- const reasoningBounded = nativeEffortSource
2728
- ? boundCustomNativeReasoning(
2729
- merged,
2730
- nativeReasoningEfforts(cm.modelId),
2731
- nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId),
2732
- )
2733
- : merged;
2734
- // Vision-sidecar coverage only: when the enriched provider's shared predicate matches
2735
- // noVisionModels or text-without-image modelInputModalities, advertise image input so the
2736
- // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full
2737
- // applyProviderConfigHints pass — custom rows are a
2738
- // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
2739
- // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
2740
- const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0
2741
- ? reasoningBounded.contextWindow
2742
- : undefined;
2743
- const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0
2744
- ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens)
2745
- : undefined;
2746
- const mergedWithHardBounds = boundedMergedMaxInput !== undefined
2747
- && boundedMergedMaxInput !== reasoningBounded.maxInputTokens
2748
- ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput }
2749
- : reasoningBounded;
2750
- const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact]
2751
- .filter((value): value is number => typeof value === "number" && value > 0);
2752
- const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0
2753
- ? {
2754
- ...mergedWithHardBounds,
2755
- autoCompactTokenLimit: clampAutoCompactTokenLimit(
2756
- mergedContext,
2757
- boundedMergedMaxInput,
2758
- Math.min(...mergedSoftCandidates),
2759
- ),
2760
- }
2761
- : mergedWithHardBounds;
2762
- const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
2763
- // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints.
2764
- if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) {
2765
- const current = mergedWithAutoCompact.inputModalities ?? ["text"];
2766
- if (!current.includes("image")) {
2767
- return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] };
2768
- }
2769
- }
2770
- return mergedWithAutoCompact;
2771
- });
2772
- // Custom rows override discovered rows that encode to the same Codex-facing slug.
2773
- const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
2774
- const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
2775
- const models = [...deduped, ...customModels];
2776
- // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
2777
- const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
2778
- const providerModels = models.filter(model => model.provider === name);
2779
- const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))];
2780
- return aliases.flatMap(([id, { alias }]) => {
2781
- const exact = providerModels.filter(model => model.id === id);
2782
- const matches = exact.length > 0
2783
- ? exact
2784
- : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase());
2785
- return matches.length === 1
2786
- ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const]
2787
- : [];
2788
- });
2789
- }));
2790
- const providerModelOutcomes = providerResults.map(result => (
2791
- result.outcome.provider === OPENAI_API_PROVIDER_ID
2792
- && capture.openAiApiPolicy.state === "captured"
2793
- && capture.openAiApiPolicy.models !== undefined
2794
- ? { provider: result.outcome.provider, state: "authoritative" as const }
2795
- : result.outcome
2796
- ));
2797
- return {
2798
- models: models.map(model => {
2799
- const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
2800
- // #1711: one stamping point for every row this gather produces — routed, combo, and custom
2801
- // alike — because it is the only place that has both the finished list and the config the
2802
- // quota rules need. A combo votes over its own targets; anything else votes over the single
2803
- // provider that would serve it.
2804
- const targets = model.provider === COMBO_NAMESPACE
2805
- ? config.combos?.[model.id]?.targets ?? []
2806
- : [{ provider: model.provider }];
2807
- const inactive = quotaInactiveReason(config, targets);
2808
- const named = displayName && !model.displayName ? { ...model, displayName } : model;
2809
- return inactive ? { ...named, quotaInactiveReason: inactive } : named;
2810
- }),
2811
- comboOmissions: localOmissions,
2812
- providerAuthOutcomes: localProviderAuthOutcomes,
2813
- providerModelOutcomes,
2814
- discoveryPolicySnapshots: capture.discoveryPolicySnapshots,
2815
- };
2816
- }
2817
-
2818
- export function augmentRoutedModelsWithRegistryOpenAiApiRows(
2819
- models: CatalogModel[],
2820
- config: OcxConfig,
2821
- ): CatalogModel[] {
2822
- const configured = config.providers[OPENAI_API_PROVIDER_ID];
2823
- if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
2824
- return augmentRoutedModelsWithCapturedOpenAiApiRows(
2825
- models,
2826
- config,
2827
- captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true),
2828
- );
2829
- }
2830
-
2831
- function augmentRoutedModelsWithCapturedOpenAiApiRows(
2832
- models: CatalogModel[],
2833
- config: OcxConfig,
2834
- policy: CatalogTrustedOpenAiApiPolicySnapshot,
2835
- ): CatalogModel[] {
2836
- if (policy.state !== "captured" || !policy.models) return models;
2837
- const configured = config.providers[OPENAI_API_PROVIDER_ID];
2838
- if (!configured || configured.disabled === true) return models;
2839
-
2840
- const existingById = new Map(
2841
- models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
2842
- );
2843
- const trustedRows = policy.models.map((id): CatalogModel => {
2844
- const officialContext = policy.modelContextWindows?.[id];
2845
- const officialMaxInput = policy.modelMaxInputTokens?.[id];
2846
- const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
2847
- const userMaxInput = configured.modelMaxInputTokens?.[id];
2848
- const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
2849
- const contextWindow = typeof officialContext === "number"
2850
- ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
2851
- : undefined;
2852
- const maxInputTokens = typeof officialMaxInput === "number"
2853
- ? Math.min(
2854
- officialMaxInput,
2855
- userMaxInput ?? officialMaxInput,
2856
- contextWindow ?? officialMaxInput,
2857
- )
2858
- : undefined;
2859
- const configuredAutoCompact = configuredAutoCompactTokenLimit(configured, id);
2860
- const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined
2861
- ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact)
2862
- : undefined;
2863
- const maxOutputTokens = routedMaxOutputTokens(
2864
- OPENAI_API_PROVIDER_ID,
2865
- configured,
2866
- policy.modelMaxOutputTokens?.[id] !== undefined
2867
- ? { provider: OPENAI_API_PROVIDER_ID, id, maxOutputTokens: policy.modelMaxOutputTokens[id] }
2868
- : existingById.get(id) ?? { provider: OPENAI_API_PROVIDER_ID, id },
2869
- policy.virtualModels?.[id]?.wireModelId ?? id,
2870
- );
2871
- return {
2872
- provider: OPENAI_API_PROVIDER_ID,
2873
- id,
2874
- owned_by: OPENAI_API_PROVIDER_ID,
2875
- ...(contextWindow ? { contextWindow } : {}),
2876
- ...(maxInputTokens ? { maxInputTokens } : {}),
2877
- ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
2878
- ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}),
2879
- ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}),
2880
- ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}),
2881
- };
2882
- });
2883
-
2884
- for (const trusted of trustedRows) {
2885
- const live = existingById.get(trusted.id);
2886
- if (!live) continue;
2887
- const liveSignature = normalizedOpenAiApiSignature(live);
2888
- const trustedSignature = normalizedOpenAiApiSignature(trusted);
2889
- if (liveSignature === trustedSignature) continue;
2890
- const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
2891
- if (openAiApiCollisionWarnings.has(warningKey)) continue;
2892
- openAiApiCollisionWarnings.add(warningKey);
2893
- console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
2894
- }
2895
-
2896
- return [
2897
- ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
2898
- ...trustedRows,
2899
- ];
2900
- }
2901
-
2902
- export function augmentRoutedModelsWithMetadata(
2903
- models: CatalogModel[],
2904
- providerNames: string[],
2905
- providers?: Record<string, OcxProviderConfig>,
2906
- caps?: Pick<OcxConfig, "providerContextCaps">,
2907
- metadataModelIdCaseFoldByProvider?: ReadonlyMap<string, boolean>,
2908
- ): CatalogModel[] {
2909
- const out = [...models];
2910
- const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
2911
- for (const provider of providerNames) {
2912
- if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
2913
- if (providers?.[provider]?.liveModels === false) continue;
2914
- const jawcodeProvider = resolveMetadataProvider(provider);
2915
- if (!jawcodeProvider) continue;
2916
- for (const meta of listModelMetadata(jawcodeProvider)) {
2917
- const key = `${provider}/${meta.id}`;
2918
- if (seen.has(key)) continue;
2919
- seen.add(key);
2920
- const contextCap = caps ? providerContextCap(caps, provider) : undefined;
2921
- const model: CatalogModel = {
2922
- provider,
2923
- id: meta.id,
2924
- owned_by: provider,
2925
- ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
2926
- ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}),
2927
- ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
2928
- };
2929
- out.push({
2930
- ...model,
2931
- ...(providers?.[provider]
2932
- ? applyProviderConfigHints(
2933
- provider,
2934
- providers[provider],
2935
- model,
2936
- contextCap,
2937
- metadataModelIdCaseFoldByProvider?.get(provider),
2938
- )
2939
- : {}),
2940
- });
2941
- }
2942
- }
2943
- return out;
2944
- }
3
+ export type {
4
+ CatalogGatherProviderAuthOutcome,
5
+ CatalogGatherProviderModelOutcome,
6
+ } from "./gather-capture";
7
+ export { createCatalogGatherAuthorityIdentity } from "./gather-capture";
8
+
9
+ export {
10
+ applyConfigHintsToCachedModels,
11
+ applyProviderConfigHints,
12
+ applyRegistryCapabilitySeedFill,
13
+ CALLABLE_CONFIGURED_COMPATIBILITY_MODELS,
14
+ catalogHintsFromModelsApiItem,
15
+ catalogHintsFromProviderConfig,
16
+ configuredAutoCompactTokenLimit,
17
+ configuredContextWindow,
18
+ configuredInputModalities,
19
+ configuredMaxInputTokens,
20
+ configuredModelDisplayName,
21
+ discoveredPricingStatus,
22
+ isGlm52ModelId,
23
+ isGlm53ModelId,
24
+ QUIET_AUTHORITATIVE_CATALOG_PROVIDERS,
25
+ } from "./model-hints";
26
+
27
+ export {
28
+ configuredComboTargetModelsByProvider,
29
+ resolveComboCatalogMember,
30
+ } from "./combo-member";
31
+
32
+ export {
33
+ filterCatalogVisibleModels,
34
+ isDatedVariantId,
35
+ lastDropWarnSignature,
36
+ mergeConfiguredModelsIntoLiveCatalog,
37
+ reconcileProviderFetchWarnings,
38
+ shouldExposeProviderModel,
39
+ shouldRetainConfiguredProviderModel,
40
+ warnDroppedConfiguredIdsOnce,
41
+ } from "./model-visibility";
42
+
43
+ export { fetchProviderModels } from "./provider-models";
44
+
45
+ export type { GatherRoutedModelsOptions } from "./routed-gather";
46
+ export {
47
+ augmentRoutedModelsWithMetadata,
48
+ augmentRoutedModelsWithRegistryOpenAiApiRows,
49
+ CatalogGatherBusyError,
50
+ catalogGatherAdmissionMetrics,
51
+ clearGatherRoutedModelsInflight,
52
+ gatherRoutedModels,
53
+ gatherRoutedModelsForCatalogGather,
54
+ } from "./routed-gather";