@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -24,7 +24,7 @@ import { modelInList } from "../../types";
24
24
  import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
25
25
  import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata";
26
26
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
27
- import { getProviderRegistryEntry } from "../../providers/registry";
27
+ import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
28
28
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
29
29
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
30
30
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
@@ -39,8 +39,19 @@ import {
39
39
  targetKey,
40
40
  } from "../../combos";
41
41
  import type { NormalizedComboConfig } from "../../combos/types";
42
- import { providerDestinationResolvedError } from "../../lib/destination-policy";
42
+ import {
43
+ ProviderOutboundPolicyError,
44
+ providerOutboundGet,
45
+ providerRedirectError,
46
+ } from "../../lib/provider-outbound";
43
47
  import { redactSecretString } from "../../lib/redact";
48
+ import {
49
+ extractProviderModelItems,
50
+ readBoundedDiscoveryJson,
51
+ resolveProviderModelDiscovery,
52
+ type ModelDiscoveryResponseFailure,
53
+ type ProviderModelsApiItem,
54
+ } from "../../providers/model-discovery";
44
55
  import upstreamModelsSnapshot from "../data/upstream-models.json";
45
56
 
46
57
 
@@ -50,25 +61,72 @@ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpen
50
61
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
51
62
  import type { ComboCatalogOmission } from "./aggregation";
52
63
 
53
- export type ProviderModelsApiItem = {
54
- id: string;
55
- owned_by?: string;
56
- context_length?: number;
57
- max_model_len?: number;
58
- metadata?: {
59
- capabilities?: Record<string, unknown>;
60
- limits?: Record<string, unknown>;
64
+ /** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
65
+ * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
66
+ interface GatherFlightResult {
67
+ models: CatalogModel[];
68
+ comboOmissions: ComboCatalogOmission[];
69
+ }
70
+
71
+ const gatherInflight = new Map<string, Promise<GatherFlightResult>>();
72
+
73
+ function stableJson(value: unknown): string {
74
+ return JSON.stringify(value, (_key, nested) => {
75
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
76
+ return Object.fromEntries(Object.entries(nested as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
77
+ }
78
+ return nested;
79
+ });
80
+ }
81
+
82
+ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
83
+ return {
84
+ n: name,
85
+ live: prov.liveModels !== false,
86
+ base: prov.baseUrl ?? "",
87
+ adapter: prov.adapter ?? "",
88
+ models: [...(prov.models ?? [])].sort(),
89
+ selected: [...(prov.selectedModels ?? [])].sort(),
90
+ defaultModel: prov.defaultModel ?? null,
91
+ ctx: prov.contextWindow ?? null,
92
+ ctxW: prov.modelContextWindows ?? null,
93
+ maxIn: prov.modelMaxInputTokens ?? null,
94
+ inMod: prov.modelInputModalities ?? null,
95
+ re: prov.modelReasoningEfforts ?? null,
96
+ defRe: prov.modelDefaultReasoningEfforts ?? null,
97
+ rsSum: prov.modelSupportsReasoningSummaries ?? null,
98
+ rsDel: prov.modelReasoningSummaryDelivery ?? null,
99
+ noVis: [...(prov.noVisionModels ?? [])].sort(),
100
+ ptc: prov.parallelToolCalls ?? null,
101
+ gMode: prov.googleMode ?? null,
61
102
  };
62
- };
103
+ }
63
104
 
64
- export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
65
- return Array.isArray(value) && value.every(item =>
66
- item !== null
67
- && typeof item === "object"
68
- && !Array.isArray(item)
69
- && typeof (item as { id?: unknown }).id === "string"
70
- && (item as { id: string }).id.trim().length > 0
71
- );
105
+ function gatherFlightKey(config: OcxConfig): string {
106
+ const providers = Object.entries(config.providers)
107
+ .filter(([, prov]) => prov.disabled !== true)
108
+ .map(([name, prov]) => providerCatalogFingerprint(name, prov))
109
+ .sort((a, b) => String(a.n).localeCompare(String(b.n)));
110
+ const assembly = stableJson({
111
+ providers,
112
+ disabledModels: [...(config.disabledModels ?? [])].sort(),
113
+ combos: config.combos ?? {},
114
+ customModels: (config.customModels ?? []).map((cm) => ({
115
+ p: cm.provider,
116
+ m: cm.modelId,
117
+ d: cm.displayName ?? null,
118
+ cw: cm.contextWindow ?? null,
119
+ im: cm.inputModalities ?? null,
120
+ })),
121
+ caps: config.providerContextCaps ?? null,
122
+ });
123
+ const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16);
124
+ return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`;
125
+ }
126
+
127
+ /** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */
128
+ export function clearGatherRoutedModelsInflight(): void {
129
+ gatherInflight.clear();
72
130
  }
73
131
 
74
132
  export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
@@ -193,31 +251,134 @@ export function isGlm52ModelId(id: string): boolean {
193
251
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
194
252
  }
195
253
 
254
+ function plainRecord(value: unknown): Record<string, unknown> | undefined {
255
+ return value !== null && typeof value === "object" && !Array.isArray(value)
256
+ ? value as Record<string, unknown>
257
+ : undefined;
258
+ }
259
+
260
+ const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
261
+
262
+ function positiveSafeInteger(...values: unknown[]): number | undefined {
263
+ return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined;
264
+ }
265
+
266
+ function normalizedMetadataString(raw: string, maxLength: number): string | undefined {
267
+ if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined;
268
+ const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength);
269
+ return normalized || undefined;
270
+ }
271
+
272
+ function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined {
273
+ if (!Array.isArray(value)) return undefined;
274
+ const out: string[] = [];
275
+ const maxInspectedItems = Math.max(maxItems * 8, maxItems);
276
+ for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) {
277
+ const raw = value[i];
278
+ if (typeof raw !== "string") continue;
279
+ const normalized = normalizedMetadataString(raw, maxLength);
280
+ if (normalized && !out.includes(normalized)) out.push(normalized);
281
+ if (out.length >= maxItems) break;
282
+ }
283
+ return out.length > 0 ? out : undefined;
284
+ }
285
+
286
+ function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined {
287
+ const metadata = plainRecord(item.metadata);
288
+ const metadataCapabilities = metadata?.capabilities;
289
+ const capabilityRecord = plainRecord(metadataCapabilities) ?? plainRecord(item.capabilities);
290
+ const out = new Set<string>();
291
+ for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) {
292
+ for (const capability of normalizedStringList(list) ?? []) out.add(capability);
293
+ }
294
+ const capabilityFields = capabilityRecord ?? {};
295
+ let inspectedCapabilityFields = 0;
296
+ for (const key in capabilityFields) {
297
+ if (!Object.hasOwn(capabilityFields, key)) continue;
298
+ inspectedCapabilityFields += 1;
299
+ if (inspectedCapabilityFields > 256 || out.size >= 32) break;
300
+ if (capabilityFields[key] === true) {
301
+ const normalized = normalizedMetadataString(key, 64);
302
+ if (normalized) out.add(normalized);
303
+ }
304
+ }
305
+ for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) {
306
+ if (item[field] === true) out.add("tools");
307
+ }
308
+ for (const field of ["supports_reasoning", "reasoning"] as const) {
309
+ if (item[field] === true) out.add("reasoning");
310
+ }
311
+ return out.size > 0 ? [...out].filter(Boolean).slice(0, 32) : undefined;
312
+ }
313
+
314
+ function modelInputModalities(
315
+ item: ProviderModelsApiItem,
316
+ capabilities: readonly string[] | undefined,
317
+ ): string[] | undefined {
318
+ const metadata = plainRecord(item.metadata);
319
+ const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
320
+ const explicit = normalizedStringList(
321
+ item.input_modalities
322
+ ?? item.modalities
323
+ ?? metadata?.input_modalities
324
+ ?? capabilityRecord?.input_modalities,
325
+ 8,
326
+ 24,
327
+ )?.filter(value => (
328
+ // Codex parses `input_modalities` as a closed enum of text | image | audio. A provider that
329
+ // advertises anything else (zenmux reports "video") must not reach the catalog: Codex rejects
330
+ // the whole file, so plugins, apps and MCP servers all stop loading over one model's metadata.
331
+ value === "text" || value === "image" || value === "audio"
332
+ ));
333
+ if (explicit && explicit.length > 0) return explicit;
334
+ if (capabilityRecord?.vision === false) return ["text"];
335
+ if (capabilityRecord?.vision === true || capabilities?.some(value => value === "vision" || value === "image-input")) {
336
+ return ["text", "image"];
337
+ }
338
+ return undefined;
339
+ }
340
+
196
341
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
197
- const capabilities = item.metadata?.capabilities;
198
- const limits = item.metadata?.limits;
342
+ const metadata = plainRecord(item.metadata);
343
+ const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
344
+ const limits = plainRecord(metadata?.limits);
199
345
  const contextWindow =
200
- typeof limits?.max_context_length === "number" ? limits.max_context_length
201
- : typeof item.context_length === "number" ? item.context_length
202
- : typeof item.max_model_len === "number" ? item.max_model_len
203
- : undefined;
204
- const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
205
- ? (capabilities.reasoning_effort
206
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
207
- ? ["low", "medium", "high", "xhigh", "max"]
208
- : ["low", "medium", "high", "xhigh"])
209
- : [])
210
- : undefined;
211
- const inputModalities = capabilities && typeof capabilities.vision === "boolean"
212
- ? (capabilities.vision ? ["text", "image"] : ["text"])
213
- : undefined;
346
+ positiveSafeInteger(
347
+ limits?.max_context_length,
348
+ item.context_length,
349
+ item.context_size,
350
+ item.max_model_len,
351
+ item.max_context_length,
352
+ );
353
+ const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
354
+ const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts;
355
+ const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
356
+ const reasoningEfforts = listedReasoningEfforts
357
+ ? sanitizeCodexReasoningEfforts(listedReasoningEfforts)
358
+ : typeof rawReasoningEfforts === "boolean"
359
+ ? (rawReasoningEfforts
360
+ ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
361
+ ? ["low", "medium", "high", "xhigh", "max"]
362
+ : ["low", "medium", "high", "xhigh"])
363
+ : [])
364
+ : undefined;
365
+ const capabilities = modelCapabilities(item);
366
+ const inputModalities = modelInputModalities(item, capabilities);
214
367
  return {
215
368
  ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
369
+ ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
216
370
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
217
371
  ...(inputModalities ? { inputModalities } : {}),
372
+ ...(capabilities ? { capabilities } : {}),
218
373
  };
219
374
  }
220
375
 
376
+ function boundedOwnedBy(value: unknown): string | undefined {
377
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined;
378
+ if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined;
379
+ return value;
380
+ }
381
+
221
382
  export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
222
383
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
223
384
  const apiKey = await resolveModelsAuthToken(name, prov);
@@ -299,6 +460,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
299
460
  const stale = getStaleCached(name);
300
461
  return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
301
462
  }
463
+ const discovery = resolveProviderModelDiscovery(name, prov);
302
464
  const { url, headers } = buildModelsRequest(prov, apiKey, name);
303
465
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
304
466
  ? "vertex-aiplatform"
@@ -322,21 +484,20 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
322
484
  };
323
485
  };
324
486
  try {
325
- const destinationError = await providerDestinationResolvedError(name, {
326
- baseUrl: url,
327
- allowPrivateNetwork: prov.allowPrivateNetwork,
487
+ const res = await providerOutboundGet(name, prov, url, {
488
+ headers,
489
+ signal: AbortSignal.timeout(8000),
328
490
  });
329
- if (destinationError) {
330
- const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
491
+ const redirectError = await providerRedirectError(res, url);
492
+ if (redirectError) {
493
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
331
494
  if (shouldLog) {
332
495
  console.warn(
333
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
496
+ `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
334
497
  );
335
498
  }
336
499
  return models;
337
500
  }
338
-
339
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
340
501
  if (!res.ok) {
341
502
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
342
503
  if (shouldLog) {
@@ -350,15 +511,14 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
350
511
  const contentType = (
351
512
  res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
352
513
  ).slice(0, 80);
353
- const body = await res.text();
354
- let json: unknown;
355
- try {
356
- json = JSON.parse(body) as unknown;
357
- } catch {
514
+ const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
515
+ if (!bounded.ok) {
358
516
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
359
- const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
360
- ? "returned invalid JSON in a 2xx response"
361
- : "returned a non-JSON 2xx response";
517
+ const diagnostic = bounded.reason === "response_too_large"
518
+ ? `exceeded the ${discovery.maxResponseBytes}-byte response limit`
519
+ : contentType === "application/json" || contentType.endsWith("+json")
520
+ ? "returned invalid JSON in a 2xx response"
521
+ : "returned a non-JSON 2xx response";
362
522
  if (shouldLog) {
363
523
  console.warn(
364
524
  `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
@@ -366,25 +526,32 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
366
526
  }
367
527
  return models;
368
528
  }
369
- const data = json !== null && typeof json === "object" && !Array.isArray(json)
370
- ? (json as { data?: unknown }).data
371
- : undefined;
372
- if (!isProviderModelsApiItems(data)) {
529
+ const extracted = extractProviderModelItems(bounded.value, discovery);
530
+ if (!extracted.ok) {
373
531
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
532
+ const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
533
+ response_too_large: "returned an oversized 2xx response",
534
+ invalid_json: "returned invalid JSON in a 2xx response",
535
+ invalid_shape: "returned malformed 2xx data",
536
+ too_many_models: `exceeded the ${discovery.maxModels}-row model limit`,
537
+ };
374
538
  if (shouldLog) {
375
539
  console.warn(
376
- `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
540
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
377
541
  );
378
542
  }
379
543
  return models;
380
544
  }
381
- const items = data;
382
- const live = items.map(m => applyProviderConfigHints(name, prov, {
383
- id: m.id,
384
- provider: name,
385
- owned_by: m.owned_by,
386
- ...catalogHintsFromModelsApiItem(name, m),
387
- }, contextCap))
545
+ const items = extracted.items;
546
+ const live = items.map(m => {
547
+ const ownedBy = boundedOwnedBy(m.owned_by);
548
+ return applyProviderConfigHints(name, prov, {
549
+ id: m.id,
550
+ provider: name,
551
+ ...(ownedBy ? { owned_by: ownedBy } : {}),
552
+ ...catalogHintsFromModelsApiItem(name, m),
553
+ }, contextCap);
554
+ })
388
555
  .filter(m => shouldExposeProviderModel(name, m.id));
389
556
  // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
390
557
  // `live`; otherwise configured entries would be reported as discovered ones.
@@ -421,6 +588,15 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
421
588
  setCached(name, live);
422
589
  return live;
423
590
  } catch (error) {
591
+ if (error instanceof ProviderOutboundPolicyError) {
592
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
593
+ if (shouldLog) {
594
+ console.warn(
595
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
596
+ );
597
+ }
598
+ return models;
599
+ }
424
600
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
425
601
  if (shouldLog) {
426
602
  console.warn(
@@ -469,7 +645,29 @@ export async function gatherRoutedModels(
469
645
  config: OcxConfig,
470
646
  options?: { comboOmissions?: ComboCatalogOmission[] },
471
647
  ): Promise<CatalogModel[]> {
472
- // Per-invocation list: sync passes `comboOmissions` so overlapping gathers cannot race.
648
+ const key = gatherFlightKey(config);
649
+ let promise = gatherInflight.get(key);
650
+ if (!promise) {
651
+ // Claim the slot synchronously before any await so same-key callers join this flight.
652
+ // Distinct keys keep their own entries — a second config must not evict the first.
653
+ const flight = gatherRoutedModelsUncached(config).finally(() => {
654
+ if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
655
+ });
656
+ gatherInflight.set(key, flight);
657
+ promise = flight;
658
+ }
659
+ const { models, comboOmissions } = await promise;
660
+ if (options?.comboOmissions) {
661
+ options.comboOmissions.length = 0;
662
+ options.comboOmissions.push(...comboOmissions);
663
+ }
664
+ return models;
665
+ }
666
+
667
+ async function gatherRoutedModelsUncached(
668
+ config: OcxConfig,
669
+ ): Promise<GatherFlightResult> {
670
+ // Flight-local list: joiners copy from the resolved promise, not a process-global last write.
473
671
  const localOmissions: ComboCatalogOmission[] = [];
474
672
  const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
475
673
  // Persisted provider entries can predate newer registry fields (noVisionModels,
@@ -551,10 +749,6 @@ export async function gatherRoutedModels(
551
749
  else warnUncataloguedComboOnce(id, combo, members, localOmissions);
552
750
  }
553
751
  replaceLastComboCatalogOmissions(localOmissions);
554
- if (options?.comboOmissions) {
555
- options.comboOmissions.length = 0;
556
- options.comboOmissions.push(...localOmissions);
557
- }
558
752
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
559
753
  // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
560
754
  // custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
@@ -588,7 +782,7 @@ export async function gatherRoutedModels(
588
782
  // Custom rows override discovered rows that encode to the same Codex-facing slug.
589
783
  const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
590
784
  const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
591
- return [...deduped, ...customModels];
785
+ return { models: [...deduped, ...customModels], comboOmissions: localOmissions };
592
786
  }
593
787
 
594
788
  export function augmentRoutedModelsWithRegistryOpenAiApiRows(
@@ -596,7 +790,7 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
596
790
  config: OcxConfig,
597
791
  ): CatalogModel[] {
598
792
  const configured = config.providers[OPENAI_API_PROVIDER_ID];
599
- if (!configured || configured.disabled === true) return models;
793
+ if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
600
794
  const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
601
795
  if (!entry?.models) return models;
602
796
 
@@ -35,8 +35,9 @@ import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgen
35
35
  import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing";
36
36
  import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata";
37
37
  import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
38
+ import { isMultiAgentV2Enabled } from "../features";
38
39
  import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
39
- import { filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
40
+ import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
40
41
  import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
41
42
  import type { ComboCatalogOmission } from "./aggregation";
42
43
 
@@ -50,6 +51,26 @@ export type SubagentRosterExclusionReason =
50
51
  | "surface_incompatible"
51
52
  | "outside_display_limit";
52
53
 
54
+ /**
55
+ * Whether a catalog entry may be offered as a V2 subagent model.
56
+ *
57
+ * Upstream (codex-rs 92938d880) requires `multi_agent_version === "v2"` exactly,
58
+ * because upstream assumes a single backend serves every model. opencodex routes
59
+ * many providers, so that equality would reject the cross-provider spawns this
60
+ * proxy exists to enable.
61
+ *
62
+ * Decision (option B, devlog 260730_codex_rs_upstream_v2_live_handoff/060): any
63
+ * model opencodex actually routes is eligible. An entry pinned to a DIFFERENT
64
+ * multi-agent backend (`v1`) stays excluded, because that pin is a real capability
65
+ * statement rather than an absence of information. An unpinned entry (null or
66
+ * absent) is a routed or unpinned-native model and is allowed. The three-way
67
+ * distinction is the substance; do not flatten it into a truthiness check.
68
+ */
69
+ export function isEligibleV2SubagentEntry(entry: RawEntry): boolean {
70
+ const pinned = entry.multi_agent_version;
71
+ return pinned === "v2" || pinned === null || pinned === undefined;
72
+ }
73
+
53
74
  export interface EffectiveSubagentModel {
54
75
  model: string;
55
76
  efforts: string[];
@@ -86,7 +107,7 @@ export function effectiveSubagentRoster(
86
107
  .map((entry, index) => ({ entry, index }))
87
108
  .filter(({ entry }) => typeof entry.slug === "string")
88
109
  .filter(({ entry }) => entry.visibility === "list")
89
- .filter(({ entry }) => surface !== "v2" || entry.multi_agent_version === "v2")
110
+ .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry))
90
111
  .sort((left, right) => {
91
112
  const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
92
113
  ? left.entry.priority : Number.MAX_SAFE_INTEGER;
@@ -110,7 +131,7 @@ export function effectiveSubagentRoster(
110
131
  if (entry.visibility !== "list") {
111
132
  return [{ configured: model, catalogModel, reason: "picker_hidden" }];
112
133
  }
113
- if (surface === "v2" && entry.multi_agent_version !== "v2") {
134
+ if (surface === "v2" && !isEligibleV2SubagentEntry(entry)) {
114
135
  return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
115
136
  }
116
137
  if (!candidates.some(candidate => candidate.model === catalogModel)) {
@@ -284,7 +305,7 @@ export function buildCatalogEntries(
284
305
  delete entry.prefer_websockets;
285
306
  }
286
307
  }
287
- return applyMultiAgentMode(out, multiAgentMode);
308
+ return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled());
288
309
  }
289
310
 
290
311
  export function resetCatalogRuntimeStateForTests(): void {
@@ -296,6 +317,7 @@ export function resetCatalogRuntimeStateForTests(): void {
296
317
  comboMasqueradeCollisionWarnings.clear();
297
318
  clearLastComboCatalogOmissions();
298
319
  clearModelCache();
320
+ clearGatherRoutedModelsInflight();
299
321
  }
300
322
 
301
323
  export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {
@@ -329,9 +351,11 @@ export function mergeCatalogEntriesForSync(
329
351
  multiAgentMode: MultiAgentMode = "default",
330
352
  exactComboSlugs: ReadonlySet<string> = new Set(),
331
353
  hasPhysicalComboProvider = false,
354
+ includeNativeOpenAi = true,
332
355
  ): RawEntry[] {
333
356
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
334
- const native = catalogModels
357
+ const native = includeNativeOpenAi
358
+ ? catalogModels
335
359
  .filter(m => typeof m.slug === "string"
336
360
  && !(m.slug as string).includes("/")
337
361
  && m.owned_by !== COMBO_NAMESPACE
@@ -367,11 +391,14 @@ export function mergeCatalogEntriesForSync(
367
391
  // for subagent max spawns; wire-clamped to the model's real top rung).
368
392
  if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
369
393
  return preserved;
370
- });
394
+ })
395
+ : [];
371
396
 
372
397
  // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
373
398
  // routed provider exposing the same id can never delete the native OpenAI/Codex base row.
399
+ // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404.
374
400
  const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
401
+ if (includeNativeOpenAi) {
375
402
  for (const slug of nativeOpenAiSlugs()) {
376
403
  if (nativeSlugs.has(slug)) continue;
377
404
  nativeSlugs.add(slug);
@@ -382,6 +409,7 @@ export function mergeCatalogEntriesForSync(
382
409
  : 9;
383
410
  native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
384
411
  }
412
+ }
385
413
 
386
414
  const freshSlugs = new Set(
387
415
  routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
@@ -451,7 +479,7 @@ export function mergeCatalogEntriesForSync(
451
479
  });
452
480
  // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
453
481
  // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
454
- return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
482
+ return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode, isMultiAgentV2Enabled());
455
483
  }
456
484
 
457
485
  export async function syncCatalogModels(config: OcxConfig): Promise<{
@@ -497,7 +525,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
497
525
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
498
526
  // native template can never leak supports_websockets while the flag is off.
499
527
  const wsEnabled = websocketsEnabled(config);
500
- catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
528
+ const enabledProviders = Object.entries(config.providers ?? {})
529
+ .filter(([, prov]) => prov.disabled !== true);
530
+ const hasCanonicalOpenai = enabledProviders.some(([name, prov]) =>
531
+ name === "openai" && isCanonicalOpenAiForwardProvider(prov),
532
+ );
533
+ // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
534
+ // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
535
+ // providers are configured yet (fresh install / catalog bootstrap tests).
536
+ const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
537
+ catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
501
538
  clampCatalogModelsToCodexSupport(catalog.models);
502
539
 
503
540
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
@@ -1,11 +1,11 @@
1
1
  // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
- export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
3
+ export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
5
  export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
- export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
8
+ export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
9
9
  export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
10
10
  export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
11
11
  export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";