@bitkyc08/opencodex 2.7.33 → 2.7.34

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 (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
@@ -13,11 +13,11 @@ import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJa
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../providers/registry";
15
15
  import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
16
- import { encodeRoutedModelId, routedSlug, slugEquals, slugsEquivalent } from "../providers/slug-codec";
16
+ import { routedSlug, slugEquals, slugsEquivalent } from "../providers/slug-codec";
17
17
  import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
18
18
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../adapters/cursor/discovery";
19
19
  import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
20
- import { OPENAI_API_PROVIDER_ID } from "../providers/openai-tiers";
20
+ import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
21
21
  import {
22
22
  COMBO_NAMESPACE,
23
23
  comboModelId,
@@ -26,6 +26,7 @@ import {
26
26
  targetKey,
27
27
  } from "../combos";
28
28
  import type { NormalizedComboConfig } from "../combos/types";
29
+ import { providerDestinationResolvedError } from "../lib/destination-policy";
29
30
  import { redactSecretString } from "../lib/redact";
30
31
  import upstreamModelsSnapshot from "./data/upstream-models.json";
31
32
 
@@ -138,6 +139,54 @@ export function nativeOpenAiContextWindow(slug: string): number | undefined {
138
139
  : undefined);
139
140
  }
140
141
 
142
+ /* ── Native-slug capability helpers for combo member resolution (issue #268) ── */
143
+
144
+ /** Input modalities for a native OpenAI slug, defaulting to the GPT family baseline. */
145
+ function nativeInputModalities(slug: string): string[] {
146
+ const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
147
+ if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
148
+ return [...upstream!.input_modalities as string[]];
149
+ }
150
+ // gpt-5.3-codex-spark is not in the upstream snapshot; all supported natives are
151
+ // text+image capable, so default to the family baseline rather than text-only.
152
+ return ["text", "image"];
153
+ }
154
+
155
+ /** Reasoning effort ladder for a native OpenAI slug, mirroring the catalog emission path. */
156
+ function nativeReasoningEfforts(slug: string): string[] {
157
+ const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
158
+ const levels = Array.isArray(upstream?.supported_reasoning_levels)
159
+ ? upstream!.supported_reasoning_levels as Array<{ effort?: string }>
160
+ : [];
161
+ if (levels.length > 0) {
162
+ const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
163
+ // gpt-5.6 natives get max+ultra restored (ensureGpt56ReasoningLevels catalog path does
164
+ // the same); older natives (gpt-5.5/5.4/5.4-mini/5.3-codex-spark) stop at xhigh per
165
+ // upstream snapshot.
166
+ if (isGpt56NativeSlug(slug)) {
167
+ const set = new Set(efforts);
168
+ for (const e of ["max", "ultra"]) set.add(e);
169
+ return [...set];
170
+ }
171
+ return efforts;
172
+ }
173
+ // gpt-5.3-codex-spark is not in upstream snapshot — use the standard old-ladder default.
174
+ return ["low", "medium", "high", "xhigh"];
175
+ }
176
+
177
+ /** Whether a native OpenAI slug supports parallel tool calls (per upstream snapshot). */
178
+ function nativeParallelToolCalls(slug: string): boolean {
179
+ return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true
180
+ || false;
181
+ }
182
+
183
+ /** Quick check whether the config has any combo targets at all. */
184
+ function hasComboTargets(config: { combos?: Record<string, { targets?: unknown[] }> }): boolean {
185
+ const combos = config.combos;
186
+ if (!combos) return false;
187
+ return Object.values(combos).some(c => Array.isArray(c?.targets) && c!.targets!.length > 0);
188
+ }
189
+
141
190
  /**
142
191
  * Bare (slash-free) entries of `disabledModels` — the native GPT half of the single
143
192
  * enable/disable choke point. Routed ids are always namespaced `provider/id`, so bare
@@ -252,6 +301,22 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str
252
301
  return highest ?? null;
253
302
  }
254
303
 
304
+ /**
305
+ * Request-time guard for `nativeEffortClamp`: only the canonical built-in OpenAI/Codex
306
+ * forward provider should ever use the native mock-max repair. Bare third-party
307
+ * `defaultModel` selectors are still routed models whose adapters own effort mapping,
308
+ * and explicit `provider/model` requests are routed by construction.
309
+ */
310
+ export function shouldApplyNativeEffortClamp(
311
+ providerName: string,
312
+ provider: OcxProviderConfig,
313
+ requestedModelId: string,
314
+ ): boolean {
315
+ return !requestedModelId.includes("/")
316
+ && providerName === OPENAI_CODEX_PROVIDER_ID
317
+ && isCanonicalOpenAiForwardProvider(provider);
318
+ }
319
+
255
320
  /**
256
321
  * True when a preserved catalog entry for a snapshot-covered slug should be UPGRADED to the
257
322
  * pinned upstream entry. Discriminator: `display_name === slug` — both ocx synthesis and the
@@ -292,6 +357,100 @@ export function catalogModelEfforts(slugs: readonly string[]): Map<string, strin
292
357
  return out;
293
358
  }
294
359
 
360
+ export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
361
+
362
+ export type SpawnAgentSurface = "v1" | "v2";
363
+ export type SubagentRosterExclusionReason =
364
+ | "missing_catalog_entry"
365
+ | "picker_hidden"
366
+ | "surface_incompatible"
367
+ | "outside_display_limit";
368
+
369
+ export interface EffectiveSubagentModel {
370
+ model: string;
371
+ efforts: string[];
372
+ }
373
+
374
+ export interface SubagentRosterExclusion {
375
+ configured: string;
376
+ reason: SubagentRosterExclusionReason;
377
+ catalogModel?: string;
378
+ }
379
+
380
+ export interface EffectiveSubagentRoster {
381
+ candidates: EffectiveSubagentModel[];
382
+ advertised: EffectiveSubagentModel[];
383
+ excluded: SubagentRosterExclusion[];
384
+ }
385
+
386
+ function catalogEntryEfforts(entry: RawEntry): string[] {
387
+ const levels = Array.isArray(entry.supported_reasoning_levels)
388
+ ? entry.supported_reasoning_levels as Array<{ effort?: string }>
389
+ : [];
390
+ return levels.flatMap(level => typeof level.effort === "string" ? [level.effort] : []);
391
+ }
392
+
393
+ function configuredCatalogEntry(entries: RawEntry[], configured: string): RawEntry | undefined {
394
+ return entries.find(entry => entry.slug === configured)
395
+ ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug));
396
+ }
397
+
398
+ /**
399
+ * The effective sub-agent roster for a collaboration surface: Codex's picker-visible
400
+ * (`visibility === "list"`), surface-compatible, priority-sorted first five catalog
401
+ * entries (candidates), intersected with the configured subagentModels (advertised),
402
+ * plus a reason for every configured entry that did not make the set (excluded).
403
+ * Canonical `entry.slug` is returned; legacy raw aliases are matching inputs only.
404
+ */
405
+ export function effectiveSubagentRoster(
406
+ configuredModels: readonly string[],
407
+ surface: SpawnAgentSurface,
408
+ ): EffectiveSubagentRoster {
409
+ const configured = configuredModels
410
+ .filter(model => model.trim().length > 0)
411
+ .filter((model, index, all) =>
412
+ !all.slice(0, index).some(previous => slugsEquivalent(previous, model))
413
+ );
414
+ const entries = readCatalog(readCodexCatalogPath())?.models ?? [];
415
+ const ordered = entries
416
+ .map((entry, index) => ({ entry, index }))
417
+ .filter(({ entry }) => typeof entry.slug === "string")
418
+ .filter(({ entry }) => entry.visibility === "list")
419
+ .filter(({ entry }) => surface !== "v2" || entry.multi_agent_version === "v2")
420
+ .sort((left, right) => {
421
+ const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
422
+ ? left.entry.priority : Number.MAX_SAFE_INTEGER;
423
+ const rightPriority = typeof right.entry.priority === "number" && Number.isFinite(right.entry.priority)
424
+ ? right.entry.priority : Number.MAX_SAFE_INTEGER;
425
+ return leftPriority - rightPriority || left.index - right.index;
426
+ })
427
+ .slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
428
+
429
+ const candidates = ordered.map(({ entry }) => ({
430
+ model: entry.slug as string,
431
+ efforts: catalogEntryEfforts(entry),
432
+ }));
433
+ const advertised = candidates.filter(candidate =>
434
+ configured.some(model => slugsEquivalent(model, candidate.model))
435
+ );
436
+ const excluded = configured.flatMap((model): SubagentRosterExclusion[] => {
437
+ const entry = configuredCatalogEntry(entries, model);
438
+ if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }];
439
+ const catalogModel = entry.slug as string;
440
+ if (entry.visibility !== "list") {
441
+ return [{ configured: model, catalogModel, reason: "picker_hidden" }];
442
+ }
443
+ if (surface === "v2" && entry.multi_agent_version !== "v2") {
444
+ return [{ configured: model, catalogModel, reason: "surface_incompatible" }];
445
+ }
446
+ if (!candidates.some(candidate => candidate.model === catalogModel)) {
447
+ return [{ configured: model, catalogModel, reason: "outside_display_limit" }];
448
+ }
449
+ return [];
450
+ });
451
+ return { candidates, advertised, excluded };
452
+ }
453
+
295
454
  /**
296
455
  * The native (passthrough) OpenAI slugs to advertise — the LIVE Codex catalog's own bare slugs when
297
456
  * available, with documented Codex-native additions layered in, else the static fallback above.
@@ -305,6 +464,8 @@ export function nativeOpenAiSlugs(): string[] {
305
464
  export interface CatalogModel {
306
465
  id: string;
307
466
  provider: string;
467
+ /** Public Codex-facing slug override (used by combo aliases). */
468
+ alias?: string;
308
469
  owned_by?: string;
309
470
  reasoningEfforts?: string[];
310
471
  defaultReasoningEffort?: string;
@@ -315,6 +476,8 @@ export interface CatalogModel {
315
476
  inputModalities?: string[];
316
477
  /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
317
478
  parallelToolCalls?: boolean;
479
+ /** Whether Codex may send Responses text.verbosity for this routed model. */
480
+ supportsVerbosity?: boolean;
318
481
  }
319
482
 
320
483
  type RawEntry = Record<string, unknown>;
@@ -443,7 +606,7 @@ function applyNativeOpenAiContextOverride(entry: RawEntry): void {
443
606
 
444
607
  function ensureStrictCatalogFields(
445
608
  entry: RawEntry,
446
- options: { preserveExactInputModalities?: boolean } = {},
609
+ options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
447
610
  ): RawEntry {
448
611
  if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
449
612
  if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
@@ -464,7 +627,7 @@ function ensureStrictCatalogFields(
464
627
  if (
465
628
  typeof entry.max_context_window !== "number"
466
629
  || entry.max_context_window <= 0
467
- || (!isNativeOpenAiEntry(entry) && entry.max_context_window > contextWindow)
630
+ || ((options.isRouted === true || !isNativeOpenAiEntry(entry)) && entry.max_context_window > contextWindow)
468
631
  ) {
469
632
  entry.max_context_window = contextWindow;
470
633
  }
@@ -534,7 +697,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
534
697
  // openai-chat adapter stops forcing parallel_tool_calls:false and the buffered stream parser
535
698
  // assembles multi-call turns (devlog/_plan/260709_parallel_tool_calls).
536
699
  entry.supports_parallel_tool_calls = isCursorEntry || parallelToolCalls === true;
537
- return ensureStrictCatalogFields(entry);
700
+ return ensureStrictCatalogFields(entry, { isRouted: true });
538
701
  }
539
702
 
540
703
  // provider + NATIVE model id are passed separately: the Codex-facing slug may carry an
@@ -732,6 +895,9 @@ const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS];
732
895
 
733
896
  function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
734
897
  if (!model) return;
898
+ // This marker survives strict catalog normalization and lets sync distinguish a stale
899
+ // bare combo alias from a genuine native model row.
900
+ if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
735
901
  if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
736
902
  entry.context_window = model.contextWindow;
737
903
  entry.max_context_window = model.contextWindow;
@@ -743,6 +909,9 @@ function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void
743
909
  if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
744
910
  entry.input_modalities = model.inputModalities;
745
911
  }
912
+ if (typeof model.supportsVerbosity === "boolean") {
913
+ entry.support_verbosity = model.supportsVerbosity;
914
+ }
746
915
  }
747
916
 
748
917
  function applyReasoningLevels(
@@ -910,7 +1079,11 @@ function isExactComboCatalogModel(
910
1079
  model: CatalogModel | undefined,
911
1080
  exactComboSlugs: ReadonlySet<string>,
912
1081
  ): boolean {
913
- return model !== undefined && exactComboSlugs.has(`${model.provider}/${model.id}`);
1082
+ return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
1083
+ }
1084
+
1085
+ export function catalogModelSlug(model: CatalogModel): string {
1086
+ return model.alias ?? routedSlug(model.provider, model.id);
914
1087
  }
915
1088
 
916
1089
  function deriveEntry(
@@ -922,7 +1095,8 @@ function deriveEntry(
922
1095
  exactComboSlugs: ReadonlySet<string> = new Set(),
923
1096
  ): RawEntry {
924
1097
  const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
925
- if (!slug.includes("/")) {
1098
+ const isRouted = model !== undefined;
1099
+ if (!isRouted && !slug.includes("/")) {
926
1100
  // Supported native slug covered by the upstream snapshot: use the REAL entry (exact
927
1101
  // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
928
1102
  // instead of cloning an older template.
@@ -940,7 +1114,7 @@ function deriveEntry(
940
1114
  delete e.availability_nux; // don't replay another model's "now available" NUX
941
1115
  // Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
942
1116
  // and advertise the reasoning ladder Codex accepts.
943
- if (slug.includes("/")) {
1117
+ if (isRouted) {
944
1118
  // Native id for identity text + metadata lookups — the slug may be an encoded
945
1119
  // alias (`provider/vendor-model`); the model object carries the native id.
946
1120
  const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
@@ -974,6 +1148,7 @@ function deriveEntry(
974
1148
  }
975
1149
  return ensureStrictCatalogFields(normalizeServiceTiers(e), {
976
1150
  preserveExactInputModalities: preserveExact,
1151
+ isRouted,
977
1152
  });
978
1153
  }
979
1154
  // Fallback when no template is available (best-effort; strict parser may need more).
@@ -981,20 +1156,21 @@ function deriveEntry(
981
1156
  slug, display_name: slug, description: desc,
982
1157
  shell_type: "shell_command", visibility: "list", supported_in_api: true,
983
1158
  priority, base_instructions: "You are a helpful coding assistant.",
984
- ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
1159
+ ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
985
1160
  };
986
- if (slug.includes("/")) {
1161
+ if (isRouted) {
987
1162
  applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
988
1163
  }
989
1164
  else {
990
1165
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
991
1166
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
992
1167
  }
993
- if (model && slug.includes("/")) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
1168
+ if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
994
1169
  applyCatalogModelMetadata(entry, model);
995
- applyNativeOpenAiContextOverride(entry);
1170
+ if (!isRouted) applyNativeOpenAiContextOverride(entry);
996
1171
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
997
1172
  preserveExactInputModalities: preserveExact,
1173
+ isRouted,
998
1174
  });
999
1175
  }
1000
1176
 
@@ -1019,6 +1195,9 @@ export function buildCatalogEntries(
1019
1195
  const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
1020
1196
  const out: RawEntry[] = [];
1021
1197
  const collisionSkipped = resolveSlugAliasCollisions(goModels);
1198
+ const comboPublicSlugs = new Set(goModels
1199
+ .filter(model => model.provider === COMBO_NAMESPACE)
1200
+ .map(catalogModelSlug));
1022
1201
  for (const slug of gptSlugs) {
1023
1202
  const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
1024
1203
  if (rank.has(slug)) e.priority = rank.get(slug)!;
@@ -1026,9 +1205,13 @@ export function buildCatalogEntries(
1026
1205
  }
1027
1206
  for (const m of goModels) {
1028
1207
  if (collisionSkipped.has(m)) continue;
1029
- // Codex-facing slug: exactly one "/" (slug-codec). Codex's models-manager metadata
1030
- // lookup rejects remainders containing "/", so native slash ids are aliased with "_".
1031
- const slug = routedSlug(m.provider, m.id);
1208
+ const slug = catalogModelSlug(m);
1209
+ if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
1210
+ warnComboMasqueradeCollisionOnce(slug);
1211
+ continue;
1212
+ }
1213
+ // Provider rows use the one-slash slug codec; combo aliases intentionally override that
1214
+ // public slug and may be bare.
1032
1215
  const e = deriveEntry(
1033
1216
  template,
1034
1217
  slug,
@@ -1193,6 +1376,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
1193
1376
  }
1194
1377
  : {}),
1195
1378
  ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1379
+ ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
1196
1380
  // Default-on for openai-chat providers (explicit false opts out); other adapters
1197
1381
  // advertise only on explicit opt-in.
1198
1382
  ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
@@ -1358,28 +1542,64 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1358
1542
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
1359
1543
  ? "vertex-aiplatform"
1360
1544
  : "provider-models";
1545
+ const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
1546
+ markModelsFetchFailure(name);
1547
+ const stale = getStaleCached(name);
1548
+ return {
1549
+ models: stale
1550
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
1551
+ : configured,
1552
+ fallback: stale ? "stale" : "configured",
1553
+ };
1554
+ };
1361
1555
  try {
1556
+ const destinationError = await providerDestinationResolvedError(name, {
1557
+ baseUrl: url,
1558
+ allowPrivateNetwork: prov.allowPrivateNetwork,
1559
+ });
1560
+ if (destinationError) {
1561
+ const { models, fallback } = failedDiscoveryFallback();
1562
+ console.warn(
1563
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
1564
+ );
1565
+ return models;
1566
+ }
1567
+
1362
1568
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
1363
1569
  if (!res.ok) {
1364
- markModelsFetchFailure(name);
1365
- const stale = getStaleCached(name);
1366
- const fallback = stale ? "stale" : "configured";
1570
+ const { models, fallback } = failedDiscoveryFallback();
1367
1571
  console.warn(
1368
1572
  `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
1369
1573
  );
1370
- return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1574
+ return models;
1575
+ }
1576
+
1577
+ const contentType = (
1578
+ res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
1579
+ ).slice(0, 80);
1580
+ const body = await res.text();
1581
+ let json: unknown;
1582
+ try {
1583
+ json = JSON.parse(body) as unknown;
1584
+ } catch {
1585
+ const { models, fallback } = failedDiscoveryFallback();
1586
+ const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
1587
+ ? "returned invalid JSON in a 2xx response"
1588
+ : "returned a non-JSON 2xx response";
1589
+ console.warn(
1590
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1591
+ );
1592
+ return models;
1371
1593
  }
1372
- const json = await res.json() as unknown;
1373
1594
  const data = json !== null && typeof json === "object" && !Array.isArray(json)
1374
1595
  ? (json as { data?: unknown }).data
1375
1596
  : undefined;
1376
1597
  if (!isProviderModelsApiItems(data)) {
1377
- markModelsFetchFailure(name);
1598
+ const { models, fallback } = failedDiscoveryFallback();
1378
1599
  console.warn(
1379
- `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data; using stale/static catalog degradation.`,
1600
+ `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
1380
1601
  );
1381
- const stale = getStaleCached(name);
1382
- return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1602
+ return models;
1383
1603
  }
1384
1604
  const items = data;
1385
1605
  const live = items.map(m => applyProviderConfigHints(name, prov, {
@@ -1420,13 +1640,11 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1420
1640
  setCached(name, live);
1421
1641
  return live;
1422
1642
  } catch (error) {
1423
- markModelsFetchFailure(name);
1424
- const stale = getStaleCached(name);
1425
- const fallback = stale ? "stale" : "configured";
1643
+ const { models, fallback } = failedDiscoveryFallback();
1426
1644
  console.warn(
1427
1645
  `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
1428
1646
  );
1429
- return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1647
+ return models;
1430
1648
  }
1431
1649
  }
1432
1650
 
@@ -1461,6 +1679,9 @@ export function filterCatalogVisibleModels(
1461
1679
  return models.filter(m => {
1462
1680
  // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
1463
1681
  for (const stored of disabled) {
1682
+ // Combo management stores the public alias, while canonical `combo/<id>` references
1683
+ // remain valid for backward compatibility through slugEquals below.
1684
+ if (m.alias !== undefined && stored === catalogModelSlug(m)) return false;
1464
1685
  if (slugEquals(stored, m.provider, m.id)) return false;
1465
1686
  }
1466
1687
  const allow = allowByProvider.get(m.provider);
@@ -1499,6 +1720,51 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
1499
1720
  // exposure decision goes through shouldExposeRoutedModel (single choke point).
1500
1721
  .filter(shouldExposeRoutedModel);
1501
1722
  const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
1723
+ // [Decision Log]
1724
+ // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
1725
+ // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
1726
+ // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
1727
+ // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
1728
+ // openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
1729
+ // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
1730
+ // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
1731
+ // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
1732
+ // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
1733
+ // 노출 경로에 영향 없음.
1734
+ // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
1735
+ // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
1736
+ // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
1737
+ // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
1738
+ // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
1739
+ // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
1740
+ // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
1741
+ // 우선시하므로 실제 충돌 가능성은 낮음.
1742
+ if (!hasComboTargets(config)) {
1743
+ // Skip the native slug injection entirely when no combos are configured — avoids
1744
+ // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
1745
+ // configs that will never need it.
1746
+ } else {
1747
+ const disabled = disabledNativeSlugs(config);
1748
+ for (const slug of nativeOpenAiSlugs()) {
1749
+ if (disabled.has(slug)) continue;
1750
+ const contextWindow = nativeOpenAiContextWindow(slug);
1751
+ if (contextWindow === undefined) continue;
1752
+ const synthetic: CatalogModel = {
1753
+ provider: "openai",
1754
+ id: slug,
1755
+ owned_by: "openai",
1756
+ contextWindow,
1757
+ maxInputTokens: contextWindow,
1758
+ inputModalities: nativeInputModalities(slug),
1759
+ reasoningEfforts: nativeReasoningEfforts(slug),
1760
+ ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
1761
+ };
1762
+ const key = `openai/${slug}`;
1763
+ // Only inject when not already present from a routed provider (an API-key
1764
+ // "openai" provider could shadow the native one).
1765
+ if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
1766
+ }
1767
+ }
1502
1768
  for (const id of listComboIds(config)) {
1503
1769
  const combo = getCombo(config, id);
1504
1770
  if (!combo) continue;
@@ -1585,6 +1851,7 @@ export function deriveComboCatalogModel(
1585
1851
  maxInputTokens,
1586
1852
  inputModalities,
1587
1853
  reasoningEfforts,
1854
+ ...(combo.alias ? { alias: combo.alias } : {}),
1588
1855
  ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1589
1856
  ...(members.every(member => member.parallelToolCalls === true)
1590
1857
  ? { parallelToolCalls: true }
@@ -1636,8 +1903,18 @@ function warnUncataloguedComboOnce(
1636
1903
  );
1637
1904
  }
1638
1905
 
1639
- export function exactComboCatalogSlugs(config: Pick<OcxConfig, "combos">): Set<string> {
1640
- return new Set(listComboIds(config).map(comboModelId));
1906
+ export function exactComboCatalogSlugs(
1907
+ config: Pick<OcxConfig, "combos" | "disabledModels">,
1908
+ ): Set<string> {
1909
+ const disabled = new Set(config.disabledModels ?? []);
1910
+ return new Set(listComboIds(config).flatMap(id => {
1911
+ const alias = typeof config.combos?.[id]?.alias === "string"
1912
+ ? config.combos[id]!.alias!.trim()
1913
+ : "";
1914
+ const canonical = comboModelId(id);
1915
+ const publicSlug = alias || canonical;
1916
+ return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug];
1917
+ }));
1641
1918
  }
1642
1919
 
1643
1920
  function normalizedOpenAiApiSignature(model: CatalogModel): string {
@@ -1665,12 +1942,23 @@ export function resetOpenAiApiCatalogWarningStateForTests(): void {
1665
1942
  * callable via its raw full-slash selector — and we warn once per provider+alias.
1666
1943
  */
1667
1944
  const slugAliasCollisionWarnings = new Set<string>();
1945
+ const comboMasqueradeCollisionWarnings = new Set<string>();
1946
+
1947
+ function warnComboMasqueradeCollisionOnce(slug: string): void {
1948
+ if (comboMasqueradeCollisionWarnings.has(slug)) return;
1949
+ comboMasqueradeCollisionWarnings.add(slug);
1950
+ console.warn(
1951
+ `[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the combo wins and the shadowed provider model is omitted from the catalog.`,
1952
+ );
1953
+ }
1668
1954
 
1669
1955
  function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel> {
1670
1956
  const skipped = new Set<CatalogModel>();
1671
1957
  const winnerByAlias = new Map<string, CatalogModel>();
1672
1958
  for (const m of goModels) {
1673
- const key = `${m.provider}/${encodeRoutedModelId(m.id)}`;
1959
+ // Combo aliases have their own collision policy below: they always shadow provider rows.
1960
+ if (m.provider === COMBO_NAMESPACE) continue;
1961
+ const key = catalogModelSlug(m);
1674
1962
  const winner = winnerByAlias.get(key);
1675
1963
  if (!winner) {
1676
1964
  winnerByAlias.set(key, m);
@@ -1695,6 +1983,53 @@ function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel>
1695
1983
  return skipped;
1696
1984
  }
1697
1985
 
1986
+ /** Apply Codex-facing encoded-slug collision policy to management and picker rows. */
1987
+ export function uniqueCatalogModelsForPublicList(goModels: CatalogModel[]): CatalogModel[] {
1988
+ const collisionSkipped = resolveSlugAliasCollisions(goModels);
1989
+ const comboPublicSlugs = new Set(goModels
1990
+ .filter(model => model.provider === COMBO_NAMESPACE)
1991
+ .map(catalogModelSlug));
1992
+ const seen = new Set<string>();
1993
+ const out: CatalogModel[] = [];
1994
+ for (const model of goModels) {
1995
+ if (collisionSkipped.has(model)) continue;
1996
+ const slug = catalogModelSlug(model);
1997
+ if (model.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
1998
+ warnComboMasqueradeCollisionOnce(slug);
1999
+ continue;
2000
+ }
2001
+ if (seen.has(slug)) continue;
2002
+ seen.add(slug);
2003
+ out.push(model);
2004
+ }
2005
+ return out;
2006
+ }
2007
+
2008
+ /**
2009
+ * Deduplicate the ordinary OpenAI `/v1/models` list by the exact id emitted by that
2010
+ * endpoint. Raw selectors that merely encode to the same Codex slug remain independently
2011
+ * callable and visible; an exact combo alias collision still resolves in favor of the combo.
2012
+ */
2013
+ export function uniqueCatalogModelsForRawPublicList(goModels: CatalogModel[]): CatalogModel[] {
2014
+ const publicId = (model: CatalogModel): string => model.alias ?? `${model.provider}/${model.id}`;
2015
+ const comboPublicIds = new Set(goModels
2016
+ .filter(model => model.provider === COMBO_NAMESPACE)
2017
+ .map(publicId));
2018
+ const seen = new Set<string>();
2019
+ const out: CatalogModel[] = [];
2020
+ for (const model of goModels) {
2021
+ const id = publicId(model);
2022
+ if (model.provider !== COMBO_NAMESPACE && comboPublicIds.has(id)) {
2023
+ warnComboMasqueradeCollisionOnce(id);
2024
+ continue;
2025
+ }
2026
+ if (seen.has(id)) continue;
2027
+ seen.add(id);
2028
+ out.push(model);
2029
+ }
2030
+ return out;
2031
+ }
2032
+
1698
2033
  /** Test-only reset for every process-global catalog cache/warning owner. */
1699
2034
  export function resetCatalogRuntimeStateForTests(): void {
1700
2035
  bundledCatalogCache = null;
@@ -1702,6 +2037,7 @@ export function resetCatalogRuntimeStateForTests(): void {
1702
2037
  openAiApiCollisionWarnings.clear();
1703
2038
  comboCatalogWarningSignatures.clear();
1704
2039
  slugAliasCollisionWarnings.clear();
2040
+ comboMasqueradeCollisionWarnings.clear();
1705
2041
  clearModelCache();
1706
2042
  }
1707
2043
 
@@ -1803,7 +2139,10 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
1803
2139
  const rank = new Map(featured.map((id, i) => [id, i]));
1804
2140
  // Featured picks may be stored raw (legacy) or encoded — match both forms.
1805
2141
  const rankOf = (m: CatalogModel) =>
1806
- rank.get(`${m.provider}/${m.id}`) ?? rank.get(routedSlug(m.provider, m.id)) ?? Number.MAX_SAFE_INTEGER;
2142
+ (m.alias ? rank.get(m.alias) : undefined)
2143
+ ?? rank.get(`${m.provider}/${m.id}`)
2144
+ ?? rank.get(routedSlug(m.provider, m.id))
2145
+ ?? Number.MAX_SAFE_INTEGER;
1807
2146
  return [...goModels].sort((a, b) => {
1808
2147
  return rankOf(a) - rankOf(b);
1809
2148
  });
@@ -1831,6 +2170,7 @@ export function mergeCatalogEntriesForSync(
1831
2170
  const native = catalogModels
1832
2171
  .filter(m => typeof m.slug === "string"
1833
2172
  && !(m.slug as string).includes("/")
2173
+ && m.owned_by !== COMBO_NAMESPACE
1834
2174
  && !goIds.has(m.slug as string)
1835
2175
  && !isUnsupportedOpenAiNativeSlug(m.slug as string))
1836
2176
  .map(m => {
@@ -1898,7 +2238,8 @@ export function mergeCatalogEntriesForSync(
1898
2238
  if (!hasPhysicalComboProvider) {
1899
2239
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
1900
2240
  const slug = typeof entry.slug === "string" ? entry.slug : "";
1901
- return !slug.startsWith(`${COMBO_NAMESPACE}/`) || freshSlugs.has(slug);
2241
+ const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
2242
+ return !comboOwned || freshSlugs.has(slug);
1902
2243
  });
1903
2244
  }
1904
2245
  finalRoutedEntries = finalRoutedEntries.filter(entry => {
@@ -1921,6 +2262,7 @@ export function mergeCatalogEntriesForSync(
1921
2262
  const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
1922
2263
  const e = ensureStrictCatalogFields(normalized, {
1923
2264
  preserveExactInputModalities: exactCombo,
2265
+ isRouted: finalRoutedEntries.includes(m),
1924
2266
  });
1925
2267
  // Mock-max universality (260709): preserved routed entries from disk may predate
1926
2268
  // the max rung — ensure it here so subagent max spawns validate on every
@@ -1,14 +1,17 @@
1
1
  export {
2
2
  COMBO_NAMESPACE,
3
+ comboAliasIssues,
3
4
  comboConfigError,
4
5
  comboConfigIssues,
5
6
  comboDefaultEffort,
6
7
  comboModelId,
8
+ comboPublicModelId,
7
9
  getCombo,
8
10
  isValidComboId,
9
11
  listComboIds,
10
12
  normalizeComboConfig,
11
13
  parseComboModelId,
14
+ resolveComboId,
12
15
  targetKey,
13
16
  } from "./types";
14
17
  export {