@bitkyc08/opencodex 2.7.23 → 2.7.24

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 (55) hide show
  1. package/README.ko.md +37 -7
  2. package/README.md +52 -11
  3. package/README.zh-CN.md +36 -7
  4. package/bin/ocx.mjs +5 -3
  5. package/gui/dist/assets/index-BzhyTAco.js +40 -0
  6. package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/opencode.svg +1 -1
  9. package/package.json +5 -2
  10. package/src/adapters/anthropic-image-normalize.ts +70 -29
  11. package/src/adapters/cursor/transport-retry.ts +20 -1
  12. package/src/adapters/run-turn-queue.ts +40 -0
  13. package/src/codex/auth-api.ts +10 -1
  14. package/src/codex/auth-context.ts +33 -7
  15. package/src/codex/catalog.ts +357 -23
  16. package/src/codex/routing.ts +10 -4
  17. package/src/combos/failover.ts +102 -0
  18. package/src/combos/index.ts +37 -0
  19. package/src/combos/request.ts +31 -0
  20. package/src/combos/resolve.ts +171 -0
  21. package/src/combos/types.ts +203 -0
  22. package/src/config.ts +280 -11
  23. package/src/lib/errors.ts +86 -24
  24. package/src/lib/upstream-retry.ts +8 -4
  25. package/src/oauth/index.ts +7 -1
  26. package/src/oauth/key-providers.ts +2 -32
  27. package/src/oauth/login-cli.ts +4 -3
  28. package/src/oauth/token-guardian.ts +38 -3
  29. package/src/providers/derive.ts +27 -2
  30. package/src/providers/kiro-models.ts +8 -3
  31. package/src/providers/label.ts +3 -1
  32. package/src/providers/openai-sidecar.ts +94 -0
  33. package/src/providers/openai-tier-startup.ts +27 -0
  34. package/src/providers/openai-tiers.ts +283 -0
  35. package/src/providers/openai-virtual-models.ts +82 -0
  36. package/src/providers/quota.ts +344 -24
  37. package/src/providers/registry.ts +112 -20
  38. package/src/reasoning-effort.ts +12 -11
  39. package/src/router.ts +80 -36
  40. package/src/server/auth-cors.ts +85 -9
  41. package/src/server/images.ts +31 -75
  42. package/src/server/index.ts +45 -86
  43. package/src/server/management-api.ts +273 -21
  44. package/src/server/request-log.ts +221 -20
  45. package/src/server/responses.ts +594 -75
  46. package/src/server/search.ts +22 -37
  47. package/src/types.ts +49 -1
  48. package/src/update/index.ts +50 -6
  49. package/src/update/job.ts +21 -4
  50. package/src/usage/log.ts +124 -1
  51. package/src/usage/summary.ts +147 -56
  52. package/src/vision/index.ts +20 -19
  53. package/src/web-search/index.ts +15 -17
  54. package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
  55. package/gui/dist/assets/index-DQjt6Hly.js +0 -40
@@ -4,17 +4,28 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
5
  import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../config";
6
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./paths";
7
- import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
7
+ import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
8
8
  import { buildModelsRequest, resolveModelsAuthToken } from "../oauth";
9
9
  import type { OcxConfig, OcxProviderConfig } from "../types";
10
10
  import { modelInList } from "../types";
11
- import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
11
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../reasoning-effort";
12
12
  import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../generated/jawcode-model-metadata";
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
14
+ import { getProviderRegistryEntry } from "../providers/registry";
14
15
  import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
15
16
  import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
16
17
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../adapters/cursor/discovery";
17
18
  import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
19
+ import { OPENAI_API_PROVIDER_ID } from "../providers/openai-tiers";
20
+ import {
21
+ COMBO_NAMESPACE,
22
+ comboModelId,
23
+ getCombo,
24
+ listComboIds,
25
+ targetKey,
26
+ } from "../combos";
27
+ import type { NormalizedComboConfig } from "../combos/types";
28
+ import { redactSecretString } from "../lib/redact";
18
29
  import upstreamModelsSnapshot from "./data/upstream-models.json";
19
30
 
20
31
  const BUNDLED_CATALOG_CACHE_MS = 60_000;
@@ -292,13 +303,16 @@ export interface CatalogModel {
292
303
  provider: string;
293
304
  owned_by?: string;
294
305
  reasoningEfforts?: string[];
306
+ defaultReasoningEffort?: string;
295
307
  contextWindow?: number;
308
+ maxInputTokens?: number;
296
309
  contextCap?: number;
297
310
  contextCapped?: boolean;
298
311
  inputModalities?: string[];
299
312
  /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
300
313
  parallelToolCalls?: boolean;
301
314
  }
315
+
302
316
  type RawEntry = Record<string, unknown>;
303
317
  type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
304
318
  const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
@@ -423,7 +437,10 @@ function applyNativeOpenAiContextOverride(entry: RawEntry): void {
423
437
  }
424
438
  }
425
439
 
426
- function ensureStrictCatalogFields(entry: RawEntry): RawEntry {
440
+ function ensureStrictCatalogFields(
441
+ entry: RawEntry,
442
+ options: { preserveExactInputModalities?: boolean } = {},
443
+ ): RawEntry {
427
444
  if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
428
445
  if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
429
446
  if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
@@ -435,7 +452,9 @@ function ensureStrictCatalogFields(entry: RawEntry): RawEntry {
435
452
  if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true;
436
453
  if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false;
437
454
  if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = [];
438
- if (!Array.isArray(entry.input_modalities)) entry.input_modalities = ["text"];
455
+ if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
456
+ entry.input_modalities = ["text"];
457
+ }
439
458
  const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
440
459
  entry.context_window = contextWindow;
441
460
  if (
@@ -713,14 +732,22 @@ function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void
713
732
  if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
714
733
  entry.context_window = model.contextWindow;
715
734
  entry.max_context_window = model.contextWindow;
716
- entry.auto_compact_token_limit = Math.floor(model.contextWindow * 0.9);
735
+ entry.auto_compact_token_limit = Math.min(
736
+ Math.floor(model.contextWindow * 0.9),
737
+ model.maxInputTokens ?? Number.POSITIVE_INFINITY,
738
+ );
717
739
  }
718
740
  if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
719
741
  entry.input_modalities = model.inputModalities;
720
742
  }
721
743
  }
722
744
 
723
- function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void {
745
+ function applyReasoningLevels(
746
+ entry: RawEntry,
747
+ effortsOverride?: string[],
748
+ defaultOverride?: string,
749
+ preserveExact = false,
750
+ ): void {
724
751
  let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
725
752
  // Mock top tiers (user decision 260709): every reasoning-capable model advertises `max`
726
753
  // even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY
@@ -728,7 +755,7 @@ function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void
728
755
  // so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
729
756
  // routed adapters clamp via clampToSupportedCodexEffort and natives via
730
757
  // nativeEffortClamp (max -> the model's real top rung).
731
- if (efforts.length > 0) {
758
+ if (!preserveExact && efforts.length > 0) {
732
759
  const additions: string[] = [];
733
760
  if (!efforts.includes("max")) additions.push("max");
734
761
  if (!efforts.includes("ultra")) additions.push("ultra");
@@ -749,7 +776,9 @@ function applyReasoningLevels(entry: RawEntry, effortsOverride?: string[]): void
749
776
  delete entry.default_reasoning_level;
750
777
  return;
751
778
  }
752
- entry.default_reasoning_level = efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
779
+ entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
780
+ ? defaultOverride
781
+ : efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
753
782
  }
754
783
 
755
784
  function isGpt56NativeSlug(slug: string): boolean {
@@ -814,7 +843,22 @@ function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry
814
843
  return ensureStrictCatalogFields(normalizeServiceTiers(clone));
815
844
  }
816
845
 
817
- function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number, model?: CatalogModel): RawEntry {
846
+ function isExactComboCatalogModel(
847
+ model: CatalogModel | undefined,
848
+ exactComboSlugs: ReadonlySet<string>,
849
+ ): boolean {
850
+ return model !== undefined && exactComboSlugs.has(`${model.provider}/${model.id}`);
851
+ }
852
+
853
+ function deriveEntry(
854
+ template: RawEntry | null,
855
+ slug: string,
856
+ desc: string,
857
+ priority: number,
858
+ model?: CatalogModel,
859
+ exactComboSlugs: ReadonlySet<string> = new Set(),
860
+ ): RawEntry {
861
+ const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
818
862
  if (!slug.includes("/")) {
819
863
  // Supported native slug covered by the upstream snapshot: use the REAL entry (exact
820
864
  // reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
@@ -843,7 +887,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
843
887
  `You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
844
888
  );
845
889
  }
846
- applyReasoningLevels(e, model?.reasoningEfforts);
890
+ applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
847
891
  normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
848
892
  applyJawcodeCatalogMetadata(e, slug, model?.contextCap);
849
893
  applyCatalogModelMetadata(e, model);
@@ -863,7 +907,9 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
863
907
  delete e.supports_websockets;
864
908
  }
865
909
  }
866
- return ensureStrictCatalogFields(normalizeServiceTiers(e));
910
+ return ensureStrictCatalogFields(normalizeServiceTiers(e), {
911
+ preserveExactInputModalities: preserveExact,
912
+ });
867
913
  }
868
914
  // Fallback when no template is available (best-effort; strict parser may need more).
869
915
  const entry: RawEntry = {
@@ -872,7 +918,9 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
872
918
  priority, base_instructions: "You are a helpful coding assistant.",
873
919
  ...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
874
920
  };
875
- if (slug.includes("/")) applyReasoningLevels(entry, model?.reasoningEfforts);
921
+ if (slug.includes("/")) {
922
+ applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
923
+ }
876
924
  else {
877
925
  applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
878
926
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
@@ -880,7 +928,9 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
880
928
  applyJawcodeCatalogMetadata(entry, slug, model?.contextCap);
881
929
  applyCatalogModelMetadata(entry, model);
882
930
  applyNativeOpenAiContextOverride(entry);
883
- return ensureStrictCatalogFields(normalizeServiceTiers(entry));
931
+ return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
932
+ preserveExactInputModalities: preserveExact,
933
+ });
884
934
  }
885
935
 
886
936
  /**
@@ -888,7 +938,15 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
888
938
  * catalog sync and the proxy `/v1/models?client_version` branch.
889
939
  * Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
890
940
  */
891
- export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[], wsEnabled = false, multiAgentMode: MultiAgentMode = "default"): RawEntry[] {
941
+ export function buildCatalogEntries(
942
+ template: RawEntry | null,
943
+ gptSlugs: string[],
944
+ goModels: CatalogModel[],
945
+ featured?: string[],
946
+ wsEnabled = false,
947
+ multiAgentMode: MultiAgentMode = "default",
948
+ exactComboSlugs: ReadonlySet<string> = new Set(),
949
+ ): RawEntry[] {
892
950
  // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
893
951
  // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
894
952
  // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
@@ -902,7 +960,14 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
902
960
  }
903
961
  for (const m of goModels) {
904
962
  const slug = `${m.provider}/${m.id}`;
905
- const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5, m);
963
+ const e = deriveEntry(
964
+ template,
965
+ slug,
966
+ `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
967
+ 5,
968
+ m,
969
+ exactComboSlugs,
970
+ );
906
971
  if (rank.has(slug)) e.priority = rank.get(slug)!;
907
972
  out.push(e);
908
973
  }
@@ -1018,9 +1083,15 @@ function configuredInputModalities(prov: OcxProviderConfig, id: string): string[
1018
1083
  return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
1019
1084
  }
1020
1085
 
1086
+ function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
1087
+ const configured = modelRecordValue(prov.modelMaxInputTokens, id);
1088
+ return typeof configured === "number" && configured > 0 ? configured : undefined;
1089
+ }
1090
+
1021
1091
  export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
1022
1092
  void name;
1023
1093
  const configuredCap = configuredContextWindow(prov, model.id);
1094
+ const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
1024
1095
  let inputModalities = configuredInputModalities(prov, model.id);
1025
1096
  // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
1026
1097
  // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
@@ -1031,6 +1102,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
1031
1102
  inputModalities = base.includes("image") ? [...base] : [...base, "image"];
1032
1103
  }
1033
1104
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
1105
+ const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
1034
1106
  const hinted = {
1035
1107
  ...model,
1036
1108
  ...(configuredCap !== undefined
@@ -1042,6 +1114,14 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
1042
1114
  : {}),
1043
1115
  ...(inputModalities ? { inputModalities } : {}),
1044
1116
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1117
+ ...(configuredMaxInput !== undefined
1118
+ ? {
1119
+ maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
1120
+ ? Math.min(model.maxInputTokens, configuredMaxInput)
1121
+ : configuredMaxInput,
1122
+ }
1123
+ : {}),
1124
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1045
1125
  // Default-on for openai-chat providers (explicit false opts out); other adapters
1046
1126
  // advertise only on explicit opt-in.
1047
1127
  ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
@@ -1077,6 +1157,29 @@ export function isDatedVariantId(liveId: string, configuredId: string): boolean
1077
1157
  // Same-signature dedupe: Codex polls /v1/models frequently, and an unchanged drop list
1078
1158
  // repeated on every poll is pure noise. Warn once per provider until the id set changes.
1079
1159
  const lastDropWarnSignature = new Map<string, string>();
1160
+ // These managed providers intentionally carry compatibility fallback ids while treating their
1161
+ // authenticated live catalogs as canonical. A non-empty live response already hides stale ids;
1162
+ // repeating that expected reconciliation on every startup only adds noise.
1163
+ const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
1164
+ // Direct OAuth chat-completions probes on 260718 confirmed these account-scoped ids still work
1165
+ // even though the providers omit them from `/models`. Preserve only the proven compatibility
1166
+ // ids; unknown configured ids and xAI's chat-incompatible multi-agent model remain hidden.
1167
+ const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
1168
+ kimi: new Set([
1169
+ "k3[1m]",
1170
+ "kimi-k2.7-code",
1171
+ "kimi-k2.7-code-highspeed",
1172
+ "kimi-k2.6",
1173
+ "kimi-k2.5",
1174
+ ]),
1175
+ xai: new Set([
1176
+ "grok-4.3",
1177
+ "grok-4.20-0309-reasoning",
1178
+ "grok-4.20-0309-non-reasoning",
1179
+ "grok-build-0.1",
1180
+ "grok-composer-2.5-fast",
1181
+ ]),
1182
+ };
1080
1183
  function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
1081
1184
  const signature = [...droppedConfiguredIds].sort().join(",");
1082
1185
  if (lastDropWarnSignature.get(name) === signature) return;
@@ -1211,11 +1314,13 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1211
1314
  droppedConfiguredIds.push(m.id);
1212
1315
  }
1213
1316
  }
1214
- if (live.length === 0) {
1317
+ if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
1215
1318
  console.warn(
1216
1319
  `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
1217
1320
  );
1218
- } else if (droppedConfiguredIds.length > 0) {
1321
+ } else if (droppedConfiguredIds.length > 0
1322
+ && name !== OPENAI_API_PROVIDER_ID
1323
+ && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
1219
1324
  warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1220
1325
  }
1221
1326
  setCached(name, live);
@@ -1233,6 +1338,7 @@ function shouldExposeProviderModel(providerName: string, modelId: string): boole
1233
1338
  }
1234
1339
 
1235
1340
  function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
1341
+ if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
1236
1342
  if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
1237
1343
  return false;
1238
1344
  }
@@ -1285,15 +1391,223 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
1285
1391
  const lists = await Promise.all(
1286
1392
  activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
1287
1393
  );
1288
- const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), activeProviders.map(([name]) => name), config.providers, config)
1394
+ const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config);
1395
+ const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
1289
1396
  // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
1290
1397
  // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
1291
1398
  // exposure decision goes through shouldExposeRoutedModel (single choke point).
1292
1399
  .filter(shouldExposeRoutedModel);
1400
+ const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
1401
+ for (const id of listComboIds(config)) {
1402
+ const combo = getCombo(config, id);
1403
+ if (!combo) continue;
1404
+ const members = combo.targets
1405
+ .map(target => memberByKey.get(targetKey(target)))
1406
+ .filter((member): member is CatalogModel => member !== undefined);
1407
+ const derived = deriveComboCatalogModel(id, combo, members);
1408
+ if (derived) all.push(derived);
1409
+ else warnUncataloguedComboOnce(id, combo, members);
1410
+ }
1293
1411
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
1294
1412
  return all;
1295
1413
  }
1296
1414
 
1415
+ const openAiApiCollisionWarnings = new Set<string>();
1416
+ const comboCatalogWarningSignatures = new Map<string, string>();
1417
+
1418
+ function intersectStrings(values: readonly string[][]): string[] {
1419
+ if (values.length === 0) return [];
1420
+ const rest = values.slice(1).map(value => new Set(value));
1421
+ return [...new Set(values[0])].filter(value => rest.every(set => set.has(value)));
1422
+ }
1423
+
1424
+ function effectiveComboDefault(
1425
+ configured: string | undefined,
1426
+ common: readonly string[],
1427
+ ): string | undefined {
1428
+ if (configured && common.includes(configured)) return configured;
1429
+ const requestedRank = codexEffortRank(configured ?? "medium");
1430
+ const ranked = common
1431
+ .map(effort => ({ effort, rank: codexEffortRank(effort) }))
1432
+ .filter(item => item.rank >= 0)
1433
+ .sort((a, b) => a.rank - b.rank);
1434
+ if (ranked.length === 0) return undefined;
1435
+ const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
1436
+ return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
1437
+ }
1438
+
1439
+ export function deriveComboCatalogModel(
1440
+ id: string,
1441
+ combo: NormalizedComboConfig,
1442
+ members: readonly CatalogModel[],
1443
+ ): CatalogModel | null {
1444
+ if (combo.targets.length === 0) return null;
1445
+ if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) return null;
1446
+ if (members.length !== combo.targets.length) return null;
1447
+ if (!members.every((member, index) => (
1448
+ `${member.provider}/${member.id}` === targetKey(combo.targets[index]!)
1449
+ ))) return null;
1450
+ const contexts = members.map(member => member.contextWindow);
1451
+ if (contexts.some(value => typeof value !== "number" || value <= 0)) return null;
1452
+
1453
+ const inputModalities = intersectStrings(
1454
+ members.map(member => member.inputModalities ?? ["text"]),
1455
+ );
1456
+ if (inputModalities.length === 0) return null;
1457
+ const reasoningEfforts = intersectStrings(
1458
+ members.map(member => member.reasoningEfforts ?? []),
1459
+ );
1460
+ const contextWindow = Math.min(...contexts as number[]);
1461
+ const maxInputTokens = Math.min(
1462
+ ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
1463
+ );
1464
+ const defaultReasoningEffort = effectiveComboDefault(
1465
+ combo.defaultEffort,
1466
+ reasoningEfforts,
1467
+ );
1468
+
1469
+ return {
1470
+ provider: COMBO_NAMESPACE,
1471
+ id,
1472
+ owned_by: COMBO_NAMESPACE,
1473
+ contextWindow,
1474
+ maxInputTokens,
1475
+ inputModalities,
1476
+ reasoningEfforts,
1477
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
1478
+ ...(members.every(member => member.parallelToolCalls === true)
1479
+ ? { parallelToolCalls: true }
1480
+ : {}),
1481
+ };
1482
+ }
1483
+
1484
+ function safeCatalogWarningLabel(value: string): string {
1485
+ return redactSecretString(value)
1486
+ .replace(/[\u0000-\u001f\u007f]/g, "?")
1487
+ .slice(0, 200);
1488
+ }
1489
+
1490
+ function comboCatalogWarningSignature(
1491
+ combo: NormalizedComboConfig,
1492
+ members: readonly CatalogModel[],
1493
+ ): string {
1494
+ const discovered = new Map<string, CatalogModel>(members.map(member => [
1495
+ `${member.provider}/${member.id}`,
1496
+ member,
1497
+ ] as const));
1498
+ return JSON.stringify(combo.targets.map(target => {
1499
+ const key = targetKey(target);
1500
+ const member = discovered.get(key);
1501
+ return {
1502
+ key,
1503
+ contextWindow: member?.contextWindow ?? null,
1504
+ maxInputTokens: member?.maxInputTokens ?? null,
1505
+ inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
1506
+ reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
1507
+ parallelToolCalls: member?.parallelToolCalls === true,
1508
+ };
1509
+ }).sort((a, b) => a.key.localeCompare(b.key)));
1510
+ }
1511
+
1512
+ function warnUncataloguedComboOnce(
1513
+ id: string,
1514
+ combo: NormalizedComboConfig,
1515
+ members: readonly CatalogModel[],
1516
+ ): void {
1517
+ const signature = comboCatalogWarningSignature(combo, members);
1518
+ if (comboCatalogWarningSignatures.get(id) === signature) return;
1519
+ comboCatalogWarningSignatures.set(id, signature);
1520
+ const targets = combo.targets
1521
+ .map(target => safeCatalogWarningLabel(targetKey(target)))
1522
+ .sort((a, b) => a.localeCompare(b));
1523
+ console.warn(
1524
+ `[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because member capabilities are incomplete: ${targets.join(", ")}.`,
1525
+ );
1526
+ }
1527
+
1528
+ export function exactComboCatalogSlugs(config: Pick<OcxConfig, "combos">): Set<string> {
1529
+ return new Set(listComboIds(config).map(comboModelId));
1530
+ }
1531
+
1532
+ function normalizedOpenAiApiSignature(model: CatalogModel): string {
1533
+ const normalized = {
1534
+ provider: model.provider,
1535
+ id: model.id,
1536
+ contextWindow: model.contextWindow ?? null,
1537
+ maxInputTokens: model.maxInputTokens ?? null,
1538
+ inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
1539
+ reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
1540
+ ownedBy: model.owned_by ?? null,
1541
+ };
1542
+ return JSON.stringify(normalized);
1543
+ }
1544
+
1545
+ export function resetOpenAiApiCatalogWarningStateForTests(): void {
1546
+ openAiApiCollisionWarnings.clear();
1547
+ }
1548
+
1549
+ /** Test-only reset for every process-global catalog cache/warning owner. */
1550
+ export function resetCatalogRuntimeStateForTests(): void {
1551
+ bundledCatalogCache = null;
1552
+ lastDropWarnSignature.clear();
1553
+ openAiApiCollisionWarnings.clear();
1554
+ comboCatalogWarningSignatures.clear();
1555
+ clearModelCache();
1556
+ }
1557
+
1558
+ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
1559
+ models: CatalogModel[],
1560
+ config: OcxConfig,
1561
+ ): CatalogModel[] {
1562
+ const configured = config.providers[OPENAI_API_PROVIDER_ID];
1563
+ if (!configured || configured.disabled === true) return models;
1564
+ const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
1565
+ if (!entry?.models) return models;
1566
+
1567
+ const existingById = new Map(
1568
+ models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
1569
+ );
1570
+ const trustedRows = entry.models.map((id): CatalogModel => {
1571
+ const officialContext = entry.modelContextWindows?.[id];
1572
+ const officialMaxInput = entry.modelMaxInputTokens?.[id];
1573
+ const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
1574
+ const userMaxInput = configured.modelMaxInputTokens?.[id];
1575
+ const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
1576
+ const contextWindow = typeof officialContext === "number"
1577
+ ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
1578
+ : undefined;
1579
+ const maxInputTokens = typeof officialMaxInput === "number"
1580
+ ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput)
1581
+ : undefined;
1582
+ return {
1583
+ provider: OPENAI_API_PROVIDER_ID,
1584
+ id,
1585
+ owned_by: OPENAI_API_PROVIDER_ID,
1586
+ ...(contextWindow ? { contextWindow } : {}),
1587
+ ...(maxInputTokens ? { maxInputTokens } : {}),
1588
+ ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
1589
+ ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
1590
+ };
1591
+ });
1592
+
1593
+ for (const trusted of trustedRows) {
1594
+ const live = existingById.get(trusted.id);
1595
+ if (!live) continue;
1596
+ const liveSignature = normalizedOpenAiApiSignature(live);
1597
+ const trustedSignature = normalizedOpenAiApiSignature(trusted);
1598
+ if (liveSignature === trustedSignature) continue;
1599
+ const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
1600
+ if (openAiApiCollisionWarnings.has(warningKey)) continue;
1601
+ openAiApiCollisionWarnings.add(warningKey);
1602
+ console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
1603
+ }
1604
+
1605
+ return [
1606
+ ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
1607
+ ...trustedRows,
1608
+ ];
1609
+ }
1610
+
1297
1611
  export function augmentRoutedModelsWithJawcodeMetadata(
1298
1612
  models: CatalogModel[],
1299
1613
  providerNames: string[],
@@ -1360,6 +1674,8 @@ export function mergeCatalogEntriesForSync(
1360
1674
  return slash > 0 ? [slug.slice(0, slash)] : [];
1361
1675
  })),
1362
1676
  multiAgentMode: MultiAgentMode = "default",
1677
+ exactComboSlugs: ReadonlySet<string> = new Set(),
1678
+ hasPhysicalComboProvider = false,
1363
1679
  ): RawEntry[] {
1364
1680
  const rank = new Map(featured.map((slug, i) => [slug, i] as const));
1365
1681
  const native = catalogModels
@@ -1413,13 +1729,15 @@ export function mergeCatalogEntriesForSync(
1413
1729
  native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
1414
1730
  }
1415
1731
 
1732
+ const freshSlugs = new Set(
1733
+ routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
1734
+ );
1416
1735
  let finalRoutedEntries = routedEntries;
1417
1736
  const preservingExistingRouted = routedEntries.length === 0
1418
1737
  && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1419
1738
  if (preservingExistingRouted) {
1420
1739
  finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1421
1740
  } else {
1422
- const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
1423
1741
  const preservedForeignRouted = catalogModels.filter(m => {
1424
1742
  if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
1425
1743
  const provider = m.slug.slice(0, m.slug.indexOf("/"));
@@ -1427,6 +1745,17 @@ export function mergeCatalogEntriesForSync(
1427
1745
  });
1428
1746
  finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
1429
1747
  }
1748
+ if (!hasPhysicalComboProvider) {
1749
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
1750
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1751
+ return !slug.startsWith(`${COMBO_NAMESPACE}/`) || freshSlugs.has(slug);
1752
+ });
1753
+ }
1754
+ finalRoutedEntries = finalRoutedEntries.filter(entry => {
1755
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
1756
+ return !exactComboSlugs.has(slug)
1757
+ || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
1758
+ });
1430
1759
  // Reapply final catalog policy to rows preserved from disk. Those rows bypass
1431
1760
  // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
1432
1761
  finalRoutedEntries = finalRoutedEntries.filter(entry =>
@@ -1439,11 +1768,14 @@ export function mergeCatalogEntriesForSync(
1439
1768
  const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
1440
1769
  const normalized = normalizeServiceTiers(m);
1441
1770
  applyNativeOpenAiContextOverride(normalized);
1442
- const e = ensureStrictCatalogFields(normalized);
1771
+ const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
1772
+ const e = ensureStrictCatalogFields(normalized, {
1773
+ preserveExactInputModalities: exactCombo,
1774
+ });
1443
1775
  // Mock-max universality (260709): preserved routed entries from disk may predate
1444
1776
  // the max rung — ensure it here so subagent max spawns validate on every
1445
1777
  // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
1446
- {
1778
+ if (!exactCombo) {
1447
1779
  const levels = Array.isArray(e.supported_reasoning_levels)
1448
1780
  ? e.supported_reasoning_levels as Array<{ effort?: string }>
1449
1781
  : [];
@@ -1495,7 +1827,9 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
1495
1827
  const featured = config.subagentModels ?? [];
1496
1828
  const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
1497
1829
  const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
1498
- const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode);
1830
+ const exactComboSlugs = exactComboCatalogSlugs(config);
1831
+ const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
1832
+ const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
1499
1833
  // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
1500
1834
  // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
1501
1835
  // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
@@ -1510,7 +1844,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
1510
1844
  // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
1511
1845
  // native template can never leak supports_websockets while the flag is off.
1512
1846
  const wsEnabled = websocketsEnabled(config);
1513
- catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode);
1847
+ catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
1514
1848
 
1515
1849
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
1516
1850
  return { added: goEntries.length, path: catalogPath };
@@ -171,6 +171,7 @@ function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boole
171
171
  }
172
172
 
173
173
  function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
174
+ if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0;
174
175
  return isCodexAccountGenerationLive(entry.accountId, entry.generation);
175
176
  }
176
177
 
@@ -196,13 +197,13 @@ function pruneLruThreadAffinities(): void {
196
197
  }
197
198
 
198
199
  function bindThreadAffinity(threadId: string, accountId: string, now: number): void {
199
- const record = readCodexAccountRecord(accountId);
200
- if (!record?.credential || record.deletedAt != null) return;
200
+ const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
201
+ if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
201
202
  pruneExpiredThreadAffinities(now);
202
203
  const previous = threadAccountMap.get(threadId);
203
204
  threadAccountMap.set(threadId, {
204
205
  accountId,
205
- generation: record.generation,
206
+ generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
206
207
  createdAt: previous?.createdAt ?? now,
207
208
  lastUsedAt: now,
208
209
  lastReevalAt: now,
@@ -380,7 +381,12 @@ export function resolveCodexAccountForThreadDetailed(
380
381
  threadAccountMap.delete(threadId);
381
382
  }
382
383
  let active = config.activeCodexAccountId;
383
- if (!active) return { status: "none" };
384
+ if (!active) {
385
+ const selected = pickLowestUsageCodexAccount(config, undefined, now);
386
+ if (!selected) return { status: "none" };
387
+ setActiveCodexAccount(config, selected);
388
+ active = selected;
389
+ }
384
390
  if (!isCodexAccountSelectable(config, active, now)) {
385
391
  const fallback = pickLowestUsageCodexAccount(config, active, now);
386
392
  if (fallback) {