@bitkyc08/opencodex 2.7.43 → 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 (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -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";
@@ -45,6 +45,13 @@ import {
45
45
  providerRedirectError,
46
46
  } from "../../lib/provider-outbound";
47
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";
48
55
  import upstreamModelsSnapshot from "../data/upstream-models.json";
49
56
 
50
57
 
@@ -54,51 +61,72 @@ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpen
54
61
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
55
62
  import type { ComboCatalogOmission } from "./aggregation";
56
63
 
57
- export type ProviderModelsApiItem = {
58
- id: string;
59
- owned_by?: string;
60
- context_length?: number;
61
- max_model_len?: number;
62
- metadata?: {
63
- capabilities?: Record<string, unknown>;
64
- limits?: Record<string, unknown>;
65
- };
66
- };
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
+ }
67
70
 
68
- export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
69
- return Array.isArray(value) && value.every(item =>
70
- item !== null
71
- && typeof item === "object"
72
- && !Array.isArray(item)
73
- && typeof (item as { id?: unknown }).id === "string"
74
- && (item as { id: string }).id.trim().length > 0
75
- );
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
+ });
76
80
  }
77
81
 
78
- /**
79
- * Normalize OpenAI-compatible /models payloads for catalog discovery.
80
- * Supports `{ data: [...] }` and top-level arrays (Together AI `#617`).
81
- * Google's `{ models: [...] }` is handled by the connectivity probe only — catalog
82
- * discovery must not treat a stray `models` key on openai-chat responses as valid.
83
- */
84
- export function providerModelsListFromResponse(json: unknown): unknown {
85
- if (Array.isArray(json)) return json;
86
- if (json !== null && typeof json === "object" && !Array.isArray(json)) {
87
- const data = (json as { data?: unknown }).data;
88
- if (Array.isArray(data)) return data;
89
- }
90
- return undefined;
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,
102
+ };
91
103
  }
92
104
 
93
- /** Connectivity-probe shape: also accepts Google `{ models: [...] }`. */
94
- export function providerModelsListFromProbeResponse(json: unknown): unknown {
95
- if (Array.isArray(json)) return json;
96
- if (json !== null && typeof json === "object" && !Array.isArray(json)) {
97
- const obj = json as { data?: unknown; models?: unknown };
98
- if (Array.isArray(obj.data)) return obj.data;
99
- if (Array.isArray(obj.models)) return obj.models;
100
- }
101
- return undefined;
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();
102
130
  }
103
131
 
104
132
  export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
@@ -223,31 +251,134 @@ export function isGlm52ModelId(id: string): boolean {
223
251
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
224
252
  }
225
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
+
226
341
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
227
- const capabilities = item.metadata?.capabilities;
228
- 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);
229
345
  const contextWindow =
230
- typeof limits?.max_context_length === "number" ? limits.max_context_length
231
- : typeof item.context_length === "number" ? item.context_length
232
- : typeof item.max_model_len === "number" ? item.max_model_len
233
- : undefined;
234
- const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
235
- ? (capabilities.reasoning_effort
236
- ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
237
- ? ["low", "medium", "high", "xhigh", "max"]
238
- : ["low", "medium", "high", "xhigh"])
239
- : [])
240
- : undefined;
241
- const inputModalities = capabilities && typeof capabilities.vision === "boolean"
242
- ? (capabilities.vision ? ["text", "image"] : ["text"])
243
- : 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);
244
367
  return {
245
368
  ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
369
+ ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
246
370
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
247
371
  ...(inputModalities ? { inputModalities } : {}),
372
+ ...(capabilities ? { capabilities } : {}),
248
373
  };
249
374
  }
250
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
+
251
382
  export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
252
383
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
253
384
  const apiKey = await resolveModelsAuthToken(name, prov);
@@ -329,6 +460,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
329
460
  const stale = getStaleCached(name);
330
461
  return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
331
462
  }
463
+ const discovery = resolveProviderModelDiscovery(name, prov);
332
464
  const { url, headers } = buildModelsRequest(prov, apiKey, name);
333
465
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
334
466
  ? "vertex-aiplatform"
@@ -379,15 +511,14 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
379
511
  const contentType = (
380
512
  res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
381
513
  ).slice(0, 80);
382
- const body = await res.text();
383
- let json: unknown;
384
- try {
385
- json = JSON.parse(body) as unknown;
386
- } catch {
514
+ const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
515
+ if (!bounded.ok) {
387
516
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
388
- const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
389
- ? "returned invalid JSON in a 2xx response"
390
- : "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";
391
522
  if (shouldLog) {
392
523
  console.warn(
393
524
  `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
@@ -395,23 +526,32 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
395
526
  }
396
527
  return models;
397
528
  }
398
- const data = providerModelsListFromResponse(json);
399
- if (!isProviderModelsApiItems(data)) {
529
+ const extracted = extractProviderModelItems(bounded.value, discovery);
530
+ if (!extracted.ok) {
400
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
+ };
401
538
  if (shouldLog) {
402
539
  console.warn(
403
- `[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}].`,
404
541
  );
405
542
  }
406
543
  return models;
407
544
  }
408
- const items = data;
409
- const live = items.map(m => applyProviderConfigHints(name, prov, {
410
- id: m.id,
411
- provider: name,
412
- owned_by: m.owned_by,
413
- ...catalogHintsFromModelsApiItem(name, m),
414
- }, 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
+ })
415
555
  .filter(m => shouldExposeProviderModel(name, m.id));
416
556
  // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into
417
557
  // `live`; otherwise configured entries would be reported as discovered ones.
@@ -505,7 +645,29 @@ export async function gatherRoutedModels(
505
645
  config: OcxConfig,
506
646
  options?: { comboOmissions?: ComboCatalogOmission[] },
507
647
  ): Promise<CatalogModel[]> {
508
- // 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.
509
671
  const localOmissions: ComboCatalogOmission[] = [];
510
672
  const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
511
673
  // Persisted provider entries can predate newer registry fields (noVisionModels,
@@ -587,10 +749,6 @@ export async function gatherRoutedModels(
587
749
  else warnUncataloguedComboOnce(id, combo, members, localOmissions);
588
750
  }
589
751
  replaceLastComboCatalogOmissions(localOmissions);
590
- if (options?.comboOmissions) {
591
- options.comboOmissions.length = 0;
592
- options.comboOmissions.push(...localOmissions);
593
- }
594
752
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
595
753
  // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
596
754
  // custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
@@ -624,7 +782,7 @@ export async function gatherRoutedModels(
624
782
  // Custom rows override discovered rows that encode to the same Codex-facing slug.
625
783
  const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
626
784
  const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
627
- return [...deduped, ...customModels];
785
+ return { models: [...deduped, ...customModels], comboOmissions: localOmissions };
628
786
  }
629
787
 
630
788
  export function augmentRoutedModelsWithRegistryOpenAiApiRows(
@@ -632,7 +790,7 @@ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
632
790
  config: OcxConfig,
633
791
  ): CatalogModel[] {
634
792
  const configured = config.providers[OPENAI_API_PROVIDER_ID];
635
- if (!configured || configured.disabled === true) return models;
793
+ if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models;
636
794
  const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
637
795
  if (!entry?.models) return models;
638
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[] {
@@ -457,7 +479,7 @@ export function mergeCatalogEntriesForSync(
457
479
  });
458
480
  // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
459
481
  // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
460
- return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
482
+ return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode, isMultiAgentV2Enabled());
461
483
  }
462
484
 
463
485
  export async function syncCatalogModels(config: OcxConfig): Promise<{
@@ -5,7 +5,7 @@ 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";