@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,685 @@
1
+ import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases";
2
+ import { initialModelSelectionPending } from "../../providers/initial-model-selection";
3
+ import { execFileSync } from "node:child_process";
4
+ import { createHash, createHmac, randomBytes } from "node:crypto";
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
6
+ import { delimiter, dirname, join, resolve } from "node:path";
7
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
8
+ import { resolveProviderApiKey } from "../../providers/key-store";
9
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
10
+ import {
11
+ clearModelCache,
12
+ clearProviderDiscoveryStatus,
13
+ captureModelCacheGeneration,
14
+ DEFAULT_MODEL_CACHE_TTL_MS,
15
+ getFreshCached,
16
+ getStaleCached,
17
+ isModelsFetchCoolingDown,
18
+ isModelCacheGenerationCurrent,
19
+ markModelsFetchFailure,
20
+ markProviderDiscoveryFailed,
21
+ markProviderDiscoveryOk,
22
+ shouldLogDiscoveryFailure,
23
+ setCached,
24
+ type ProviderModelDiscoveryFailure,
25
+ } from "../model-cache";
26
+ import {
27
+ buildModelsRequest,
28
+ getValidAccessTokenSnapshot,
29
+ observeActiveOAuthAccessToken,
30
+ resolveModelsAuthToken,
31
+ type OAuthActiveTokenObservation,
32
+ } from "../../oauth";
33
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
34
+ import { modelInList } from "../../types";
35
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
36
+ import { isModelVisionSidecarConsumer } from "../../vision/eligibility";
37
+ import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata";
38
+ import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
39
+ import {
40
+ captureFastPolicyAuthority,
41
+ fastPolicyForModel,
42
+ serviceTierSupportFromPolicy,
43
+ } from "../../providers/service-tier";
44
+ import type { FastPolicyAuthority } from "../../providers/fastwire";
45
+ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry";
46
+ import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
47
+ import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
48
+ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
49
+ import { effectiveModelAliases } from "../../providers/default-aliases";
50
+ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec";
51
+ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
52
+ import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
53
+ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
54
+ import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
55
+ import { fetchQoderModels } from "../../adapters/qoder/live-models";
56
+ import { resolveQoderProfile } from "../../adapters/qoder/profiles";
57
+ import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
58
+ import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
59
+ import {
60
+ COMBO_NAMESPACE,
61
+ comboModelId,
62
+ getCombo,
63
+ listComboIds,
64
+ quotaInactiveReason,
65
+ targetKey,
66
+ } from "../../combos";
67
+ import type { NormalizedComboConfig } from "../../combos/types";
68
+ import {
69
+ ProviderOutboundPolicyError,
70
+ providerOutboundGet,
71
+ providerOutboundPost,
72
+ providerRedirectError,
73
+ } from "../../lib/provider-outbound";
74
+ import { redactSecretString } from "../../lib/redact";
75
+ import {
76
+ extractProviderModelItems,
77
+ isRegistryModelDiscoveryUrl,
78
+ readBoundedDiscoveryJson,
79
+ resolveProviderModelDiscovery,
80
+ type ModelDiscoveryResponseFailure,
81
+ type ProviderModelsApiItem,
82
+ type ResolvedProviderModelDiscovery,
83
+ } from "../../providers/model-discovery";
84
+ import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
85
+ import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
86
+ import upstreamModelsSnapshot from "../data/upstream-models.json";
87
+ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
88
+ import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
89
+ import type { CatalogModel } from "./parsing";
90
+ import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
91
+ import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
92
+ import type { ComboCatalogOmission } from "./aggregation";
93
+ import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
94
+ import type {
95
+ CatalogAdmissionSnapshot,
96
+ CatalogDiscoveryPolicyField,
97
+ CatalogGatherAuthorityIdentity,
98
+ CatalogProviderDiscoveryPolicySnapshot,
99
+ CatalogProcessLocalEvidence,
100
+ CatalogSourceEvidence,
101
+ CatalogTrustedOpenAiApiPolicySnapshot,
102
+ } from "../convergence-types";
103
+ import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture";
104
+ import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints";
105
+ import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility";
106
+ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
107
+
108
+ export interface ProviderModelsResult {
109
+ readonly models: CatalogModel[];
110
+ readonly outcome: CatalogGatherProviderModelOutcome;
111
+ }
112
+ export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" };
113
+
114
+ export function observedModelsAuthResolver(
115
+ authStoreBuffer: Uint8Array | null,
116
+ outcomes: CatalogGatherProviderAuthOutcome[],
117
+ ): ModelsAuthResolver {
118
+ return {
119
+ kind: "observed",
120
+ resolve(name, provider) {
121
+ if (provider.authMode === "forward") return { apiKey: undefined, observed: true };
122
+ if (provider.authMode !== "oauth") {
123
+ return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true };
124
+ }
125
+
126
+ const observation = observeActiveOAuthAccessToken(name, authStoreBuffer);
127
+ outcomes.push({ provider: name, state: observation.kind });
128
+ if (observation.kind !== "available") return { apiKey: undefined, observed: true };
129
+ return {
130
+ apiKey: observation.snapshot.accessToken,
131
+ observed: true,
132
+ ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
133
+ ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
134
+ };
135
+ },
136
+ };
137
+ }
138
+ export async function fetchProviderModelsWithAuth(
139
+ captured: CapturedProviderGather,
140
+ ttlMs: number,
141
+ contextCap: number | undefined,
142
+ resolveAuth: ModelsAuthResolver,
143
+ ): Promise<ProviderModelsResult> {
144
+ const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured;
145
+ const observed = (
146
+ models: CatalogModel[],
147
+ state: CatalogGatherProviderModelOutcome["state"],
148
+ ): ProviderModelsResult => ({ models, outcome: { provider: name, state } });
149
+ // Capture before any credential refresh or outbound await. OAuth account changes clear this
150
+ // generation, so a request started with the former account cannot later publish its result.
151
+ const cacheGeneration = captureModelCacheGeneration(name);
152
+ const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration);
153
+ if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models
154
+ const seedVertexDefault = prov.adapter === "google"
155
+ && prov.googleMode === "vertex"
156
+ && (prov.models?.length ?? 0) === 0
157
+ && Boolean(prov.defaultModel);
158
+ const seedStaticDefault = prov.liveModels === false
159
+ && (prov.models?.length ?? 0) === 0
160
+ && Boolean(prov.defaultModel);
161
+ // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the
162
+ // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints,
163
+ // so a retain-only id must enter here or it never exists to be retained (#1690).
164
+ const configuredIds = [...new Set([
165
+ ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []),
166
+ ...(prov.models ?? []),
167
+ ...(prov.retainModels ?? []),
168
+ ])];
169
+ const configured: CatalogModel[] = configuredIds.map(id => ({
170
+ id,
171
+ provider: name,
172
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
173
+ }));
174
+ const withConfiguredRetention = (
175
+ models: CatalogModel[],
176
+ options?: { retainComboTargets?: boolean; warnDrops?: boolean },
177
+ ): CatalogModel[] => {
178
+ const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({
179
+ name,
180
+ provider: prov,
181
+ models,
182
+ configured,
183
+ retainConfiguredModelIds: captured.retainConfiguredModelIds,
184
+ contextCap,
185
+ seedVertexDefault,
186
+ retainComboTargets: options?.retainComboTargets,
187
+ metadataModelIdCaseFold,
188
+ });
189
+ if (
190
+ options?.warnDrops === true
191
+ && droppedConfiguredIds.length > 0
192
+ && name !== OPENAI_API_PROVIDER_ID
193
+ && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)
194
+ ) {
195
+ warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
196
+ }
197
+ return merged;
198
+ };
199
+ // Static catalogs never need an OAuth refresh or an upstream model request. Clear any
200
+ // discovery failure left by an older live configuration even when the account is logged out.
201
+ if (prov.liveModels === false) {
202
+ clearProviderDiscoveryStatus(name);
203
+ return observed(configured, "authoritative");
204
+ }
205
+ const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing"
206
+ ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist"
207
+ ? await getValidAccessTokenSnapshot(name)
208
+ .then(snapshot => ({
209
+ apiKey: snapshot.accessToken,
210
+ observed: false,
211
+ ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
212
+ }))
213
+ .catch(() => ({ apiKey: undefined, observed: false }))
214
+ : { apiKey: await resolveModelsAuthToken(name, prov), observed: false }
215
+ : resolveAuth.resolve(name, prov));
216
+ const apiKey = auth.apiKey;
217
+ // A configured default is a real callable selector and must remain discoverable when a
218
+ // compatible provider's live /models request fails (issue #308). Static providers already seed
219
+ // their default selector above when no explicit model list exists.
220
+ const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
221
+ ? configured
222
+ : [{
223
+ id: prov.defaultModel,
224
+ provider: name,
225
+ ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
226
+ }];
227
+ const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
228
+ const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
229
+ vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
230
+ ? [...models, vertexDefaultSeed]
231
+ : models
232
+ );
233
+ if (prov.adapter === "qoder") {
234
+ if (!apiKey) return observed(configured, "degraded");
235
+ const profile = resolveQoderProfile(prov.baseUrl);
236
+ if (!profile) return observed(configured, "degraded");
237
+ // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT
238
+ // fingerprint so an account switch cannot observe another account's roster, even if a caller
239
+ // bypasses the normal config mutation path that clears provider caches.
240
+ const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
241
+ const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
242
+ if (fresh) {
243
+ return observed(withConfiguredRetention(
244
+ applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
245
+ ), "authoritative");
246
+ }
247
+ const scopedStale = getStaleCached(name, authorityIdentity);
248
+ if (isModelsFetchCoolingDown(name) && scopedStale) {
249
+ return observed(withConfiguredRetention(
250
+ applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
251
+ ), "degraded");
252
+ }
253
+ const live = await fetchQoderModels(profile, apiKey);
254
+ if (live.ok) {
255
+ const discovered = live.models.map(id => ({
256
+ id,
257
+ provider: name,
258
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
259
+ }));
260
+ const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
261
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
262
+ return observed(withConfiguredRetention(configured), "degraded");
263
+ }
264
+ markProviderDiscoveryOk(name, live.models.length);
265
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
266
+ }
267
+ if (isCurrentCacheGeneration()) {
268
+ markModelsFetchFailure(name);
269
+ markProviderDiscoveryFailed(name, { reason: "provider" });
270
+ console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`);
271
+ }
272
+ const stale = getStaleCached(name, authorityIdentity);
273
+ return observed(withConfiguredRetention(
274
+ stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
275
+ ), "degraded");
276
+ }
277
+ if (prov.adapter === "devin") {
278
+ if (!apiKey) return observed(configured, "degraded");
279
+ const cachedDevin = getFreshCached(name, ttlMs);
280
+ if (cachedDevin) {
281
+ return observed(
282
+ withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)),
283
+ "authoritative",
284
+ );
285
+ }
286
+ if (isModelsFetchCoolingDown(name)) {
287
+ const cooling = getStaleCached(name);
288
+ return observed(
289
+ withConfiguredRetention(
290
+ cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured,
291
+ ),
292
+ "degraded",
293
+ );
294
+ }
295
+ const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl });
296
+ if (liveResult.ok) {
297
+ // Live catalog is the source of truth — use the discovered base models
298
+ // directly, not a filtered subset of the static seed.
299
+ //
300
+ // That extends to the context window. Cognition publishes no window
301
+ // anywhere, so the per-account catalog is the only first-party number,
302
+ // and the shipped static table is a degraded-mode guess that was wrong
303
+ // for nine of its eleven rows. The live value is applied first and the
304
+ // config hints run after it, so an explicit per-model override and an
305
+ // enabled Context cap still win — this only replaces the number nobody
306
+ // chose.
307
+ const result = liveResult.models.map((id) => {
308
+ const liveWindow = liveResult.contextWindows[id];
309
+ return {
310
+ id,
311
+ provider: name,
312
+ ...(liveWindow ? { contextWindow: liveWindow } : {}),
313
+ // The account catalog names the effort variants each base model has, so
314
+ // its ladder is measured rather than assumed. Without this the entry
315
+ // inherits the generic routed ladder and offers rungs the model rounds
316
+ // away, and every client that keys an effort control off this field —
317
+ // the Pi-shaped exports — renders no control at all.
318
+ ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}),
319
+ // The account catalog's per-base supportsImages vote collapses to one
320
+ // modalities value. It spreads before the hints so exact
321
+ // modelCapabilities declarations, the legacy modelInputModalities
322
+ // record and the vision-sidecar rewrite keep winning — the live
323
+ // value survives only when none of them applies.
324
+ ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}),
325
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
326
+ } as CatalogModel;
327
+ });
328
+ const forCache = withConfiguredRetention(result, { retainComboTargets: false });
329
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
330
+ return observed(withConfiguredRetention(configured), "degraded");
331
+ }
332
+ markProviderDiscoveryOk(name, liveResult.models.length);
333
+ return observed(withConfiguredRetention(forCache), "authoritative");
334
+ }
335
+ if (isCurrentCacheGeneration()) {
336
+ markModelsFetchFailure(name);
337
+ markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" });
338
+ }
339
+ const stale = getStaleCached(name);
340
+ return observed(
341
+ withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured),
342
+ "degraded",
343
+ );
344
+ }
345
+ if (prov.adapter === "cursor") {
346
+ if (!apiKey) return observed(configured, "degraded");
347
+ // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
348
+ // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
349
+ // suffix) but filter the static seed to the bases the account actually has — so models not on the
350
+ // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
351
+ const cachedCursor = getFreshCached(name, ttlMs);
352
+ if (cachedCursor) {
353
+ return observed(
354
+ withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)),
355
+ "authoritative",
356
+ );
357
+ }
358
+ if (isModelsFetchCoolingDown(name)) {
359
+ const cooling = getStaleCached(name);
360
+ return observed(
361
+ withConfiguredRetention(
362
+ cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
363
+ ),
364
+ "degraded",
365
+ );
366
+ }
367
+ const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
368
+ const liveResult = await fetchCursorUsableModels({
369
+ apiKey,
370
+ baseUrl: prov.baseUrl,
371
+ upstreamHttpVersion: prov.upstreamHttpVersion,
372
+ ...(cursorFetch ? { fetch: cursorFetch } : {}),
373
+ });
374
+ if (liveResult.ok) {
375
+ const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
376
+ const result = available.length > 0 ? available : configured;
377
+ // Cache the discovery-filtered roster without combo retention so a later
378
+ // gather can re-apply the current capture's retain set on read.
379
+ const forCache = withConfiguredRetention(result, { retainComboTargets: false });
380
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
381
+ return observed(withConfiguredRetention(configured), "degraded");
382
+ }
383
+ // Publish roster-derived state only for a discovery the cache accepted: a stale
384
+ // in-flight capture (generation revoked by a credential/config change) must not
385
+ // overwrite the spelling or Max-Mode evidence of the newer one.
386
+ recordLiveCursorClaudeModels(liveResult.models);
387
+ // Live Max-Mode evidence feeds the umbrella resolver's ultra gate
388
+ // (devlog 260828_cursor_umbrella_catalog; union with static evidence).
389
+ recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []);
390
+ markProviderDiscoveryOk(name, liveResult.models.length);
391
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
392
+ }
393
+ if (isCurrentCacheGeneration()) {
394
+ markModelsFetchFailure(name);
395
+ markProviderDiscoveryFailed(name, { reason: "provider" });
396
+ console.warn(
397
+ `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
398
+ );
399
+ }
400
+ const staleCursor = getStaleCached(name);
401
+ return observed(
402
+ withConfiguredRetention(
403
+ staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
404
+ ),
405
+ "degraded",
406
+ );
407
+ }
408
+ if (prov.authMode === "oauth" && !apiKey) {
409
+ // No usable token (logged out, or account marked needsReauth). Still surface the
410
+ // configured static catalog so the GUI Models tab / rail counts are not empty —
411
+ // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
412
+ return observed(configured, "degraded");
413
+ }
414
+ const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist";
415
+ const project = prov.project ?? auth.oauthProjectId;
416
+ if (cloudCodeAssist && !project) return observed(configured, "degraded");
417
+ const fresh = getFreshCached(name, ttlMs);
418
+ if (fresh) {
419
+ return observed(
420
+ withConfiguredRetention(
421
+ withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
422
+ ),
423
+ "authoritative",
424
+ ); // dedups Codex's frequent /v1/models polling within the TTL
425
+ }
426
+ if (isModelsFetchCoolingDown(name)) {
427
+ // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
428
+ // fetch timeout on every catalog poll — the dashboard polls this path per page load.
429
+ const stale = getStaleCached(name);
430
+ return observed(
431
+ withConfiguredRetention(
432
+ stale
433
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias))
434
+ : failedDiscoveryConfigured,
435
+ ),
436
+ "degraded",
437
+ );
438
+ }
439
+ const url = request.url;
440
+ let headers = materializeCapturedHeaders(request, apiKey);
441
+ // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery
442
+ // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the
443
+ // SAME effective credential/header authority. buildModelsRequest's generic tail writes the
444
+ // generated Bearer AFTER configured headers, but the native inference adapter applies
445
+ // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured
446
+ // provider headers here so the whole Ollama request family shares that one authority.
447
+ if (ollamaShowEnrichable(name, prov)) {
448
+ headers = applyConfiguredHeadersLast(headers, prov.headers);
449
+ }
450
+ const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
451
+ ? "vertex-aiplatform"
452
+ : "provider-models";
453
+ const failedDiscoveryFallback = (
454
+ failure: ProviderModelDiscoveryFailure,
455
+ ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
456
+ if (!isCurrentCacheGeneration()) {
457
+ return {
458
+ models: withConfiguredRetention(failedDiscoveryConfigured),
459
+ fallback: "configured",
460
+ shouldLog: false,
461
+ };
462
+ }
463
+ // Decide logging BEFORE recording the new status, so we can compare against the prior one and
464
+ // suppress an identical repeated failure (#395 log flood). The failure stays observable via the
465
+ // discovery-status API regardless.
466
+ const shouldLog = shouldLogDiscoveryFailure(name, failure);
467
+ markModelsFetchFailure(name);
468
+ markProviderDiscoveryFailed(name, failure);
469
+ const stale = getStaleCached(name);
470
+ return {
471
+ models: withConfiguredRetention(
472
+ stale
473
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias))
474
+ : failedDiscoveryConfigured,
475
+ ),
476
+ fallback: stale ? "stale" : "configured",
477
+ shouldLog,
478
+ };
479
+ };
480
+ try {
481
+ // Canonical-URL TUN transparency for Clash/Surge/Mihomo fake-IP DNS:
482
+ // `isRegistryModelDiscoveryUrl` proves the FINAL request URL is the
483
+ // registry's own fixed discovery URL, so a purely-benchmark DNS answer may
484
+ // be pin-connected through the intercepting TUN without proxy env. The
485
+ // proof is on the URL — not the provider name — because an OAuth/forward
486
+ // name matches any baseUrl by design. Retargeted or renamed custom rows
487
+ // fetch a different URL and keep the rejection.
488
+ const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl };
489
+ const res = request.method === "POST"
490
+ ? await providerOutboundPost(name, prov, url, {
491
+ headers,
492
+ body: JSON.stringify({ project }),
493
+ signal: AbortSignal.timeout(8000),
494
+ }, outboundDependencies)
495
+ : await providerOutboundGet(name, prov, url, {
496
+ headers,
497
+ signal: AbortSignal.timeout(8000),
498
+ }, outboundDependencies);
499
+ const redirectError = await providerRedirectError(res, url);
500
+ if (redirectError) {
501
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
502
+ if (shouldLog) {
503
+ console.warn(
504
+ `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
505
+ );
506
+ }
507
+ return observed(models, "degraded");
508
+ }
509
+ if (!res.ok) {
510
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
511
+ if (shouldLog) {
512
+ console.warn(
513
+ `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
514
+ );
515
+ }
516
+ return observed(models, "degraded");
517
+ }
518
+
519
+ const contentType = (
520
+ res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
521
+ ).slice(0, 80);
522
+ const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
523
+ if (!bounded.ok) {
524
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
525
+ const diagnostic = bounded.reason === "response_too_large"
526
+ ? `exceeded the ${discovery.maxResponseBytes}-byte response limit`
527
+ : contentType === "application/json" || contentType.endsWith("+json")
528
+ ? "returned invalid JSON in a 2xx response"
529
+ : "returned a non-JSON 2xx response";
530
+ if (shouldLog) {
531
+ console.warn(
532
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
533
+ );
534
+ }
535
+ return observed(models, "degraded");
536
+ }
537
+ const antigravity = cloudCodeAssist
538
+ ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels)
539
+ : undefined;
540
+ if (cloudCodeAssist && !antigravity) {
541
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
542
+ if (shouldLog) {
543
+ console.warn(
544
+ `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
545
+ );
546
+ }
547
+ return observed(models, "degraded");
548
+ }
549
+ if (antigravity) {
550
+ const live = antigravity.map(model => applyProviderConfigHints(name, prov, {
551
+ id: model.id,
552
+ provider: name,
553
+ // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex
554
+ // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder.
555
+ reasoningEfforts: [],
556
+ ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
557
+ ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}),
558
+ }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias));
559
+ const forCache = withConfiguredRetention(live, { retainComboTargets: false });
560
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
561
+ return observed(withConfiguredRetention(configured), "degraded");
562
+ }
563
+ registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, {
564
+ provider: name,
565
+ cacheGeneration,
566
+ });
567
+ markProviderDiscoveryOk(name, live.length);
568
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
569
+ }
570
+ const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"
571
+ ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
572
+ : undefined;
573
+ // Native /v1beta/models wins; a google row served by an OpenAI-compatible
574
+ // gateway keeps the generic data[] / top-level-array contract.
575
+ const extracted = googleAiStudio?.ok
576
+ ? googleAiStudio
577
+ : extractProviderModelItems(bounded.value, discovery);
578
+ if (!extracted.ok) {
579
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
580
+ const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
581
+ response_too_large: "returned an oversized 2xx response",
582
+ invalid_json: "returned invalid JSON in a 2xx response",
583
+ invalid_shape: "returned malformed 2xx data",
584
+ too_many_models: `exceeded the ${discovery.maxModels}-row model limit`,
585
+ };
586
+ if (shouldLog) {
587
+ console.warn(
588
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
589
+ );
590
+ }
591
+ return observed(models, "degraded");
592
+ }
593
+ const items = extracted.items;
594
+ // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata,
595
+ // so a newly announced id would otherwise publish generic defaults. /api/show fills that
596
+ // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured
597
+ // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered
598
+ // window only where exact config is absent, and the provider context cap still caps it).
599
+ const showEnrichment = ollamaShowEnrichable(name, prov)
600
+ ? await fetchOllamaShowEnrichment({
601
+ headers,
602
+ discoveryUrl: request.url,
603
+ modelIds: items.map(m => m.id),
604
+ provider: prov,
605
+ }).catch(() => undefined)
606
+ : undefined;
607
+ const live = items.map(m => {
608
+ const ownedBy = boundedOwnedBy(m.owned_by);
609
+ // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the
610
+ // models-API row does not carry. applyProviderConfigHints then applies explicit
611
+ // configured metadata over both, and the provider context cap still caps the result.
612
+ const modelsApiHints = catalogHintsFromModelsApiItem(name, m);
613
+ const show = showEnrichment?.metadata.get(m.id);
614
+ const discoveredHints = {
615
+ ...modelsApiHints,
616
+ ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined
617
+ ? { contextWindow: show.contextWindow }
618
+ : {}),
619
+ ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true
620
+ ? { inputModalities: ["text", "image"] as string[] }
621
+ : {}),
622
+ };
623
+ return applyProviderConfigHints(name, prov, {
624
+ id: m.id,
625
+ provider: name,
626
+ ...(ownedBy ? { owned_by: ownedBy } : {}),
627
+ ...discoveredHints,
628
+ }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias);
629
+ })
630
+ .filter(m => shouldExposeProviderModel(name, m.id));
631
+ // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
632
+ // `live`; otherwise configured entries would be reported as discovered ones.
633
+ const liveModelCount = live.length;
634
+ // Dated-release aliases + configured retention (compat allow-list, combo targets,
635
+ // Vertex default). Cache without combo retention so a later gather re-applies the
636
+ // current capture's retain set on read (warm-cache OCX-111 / #1308).
637
+ const forCache = withConfiguredRetention(live, { retainComboTargets: false });
638
+ const returned = withConfiguredRetention(forCache, { warnDrops: true });
639
+ const droppedConfiguredIds = configured
640
+ .map(model => model.id)
641
+ .filter(id => !returned.some(model => model.id === id));
642
+ if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
643
+ console.warn(
644
+ `[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"}.`,
645
+ );
646
+ }
647
+ if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
648
+ return observed(withConfiguredRetention(configured), "degraded");
649
+ }
650
+ markProviderDiscoveryOk(name, liveModelCount);
651
+ return observed(returned, "authoritative");
652
+ } catch (error) {
653
+ if (error instanceof ProviderOutboundPolicyError) {
654
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
655
+ if (shouldLog) {
656
+ console.warn(
657
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
658
+ );
659
+ }
660
+ return observed(models, "degraded");
661
+ }
662
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
663
+ if (shouldLog) {
664
+ console.warn(
665
+ `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
666
+ );
667
+ }
668
+ return observed(models, "degraded");
669
+ }
670
+ }
671
+
672
+ export async function fetchProviderModels(
673
+ name: string,
674
+ prov: OcxProviderConfig,
675
+ ttlMs: number,
676
+ contextCap?: number,
677
+ ): Promise<CatalogModel[]> {
678
+ const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver);
679
+ return (await fetchProviderModelsWithAuth(
680
+ captured,
681
+ ttlMs,
682
+ contextCap,
683
+ refreshingModelsAuthResolver,
684
+ )).models;
685
+ }