@bitkyc08/opencodex 2.7.35 → 2.7.36
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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +4 -2
- package/README.ru.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/ocx.mjs +52 -0
- package/gui/dist/assets/index-BpX-hoSd.css +1 -0
- package/gui/dist/assets/index-ZmFopEYw.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +38 -1
- package/src/adapters/cursor/discovery.ts +1 -0
- package/src/adapters/cursor/effort-map.ts +1 -0
- package/src/adapters/cursor/live-models.ts +22 -5
- package/src/adapters/cursor/live-transport.ts +82 -7
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +5 -2
- package/src/adapters/openai-responses.ts +64 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +10 -0
- package/src/cli/index.ts +88 -9
- package/src/cli/internal-dispatch.ts +20 -0
- package/src/cli/status.ts +15 -4
- package/src/cli/tray-proxy.ts +52 -0
- package/src/codex/auth-api.ts +46 -5
- package/src/codex/autostart-health.ts +149 -0
- package/src/codex/catalog/aggregation.ts +268 -0
- package/src/codex/catalog/bundled.ts +188 -0
- package/src/codex/catalog/effort.ts +263 -0
- package/src/codex/catalog/metadata.ts +176 -0
- package/src/codex/catalog/parsing.ts +399 -0
- package/src/codex/catalog/provider-fetch.ts +609 -0
- package/src/codex/catalog/sync.ts +540 -0
- package/src/codex/catalog.ts +11 -2426
- package/src/codex/inject.ts +165 -3
- package/src/codex/shim.ts +141 -8
- package/src/codex/sync.ts +17 -2
- package/src/config.ts +23 -0
- package/src/lib/errors.ts +11 -0
- package/src/providers/antigravity-models.ts +33 -0
- package/src/providers/kiro-models.ts +2 -0
- package/src/providers/registry.ts +2 -2
- package/src/responses/state.ts +69 -6
- package/src/server/auth-cors.ts +3 -0
- package/src/server/management/agent-settings-routes.ts +536 -0
- package/src/server/management/combo-routes.ts +210 -0
- package/src/server/management/config-routes.ts +302 -0
- package/src/server/management/context.ts +21 -0
- package/src/server/management/logs-usage-routes.ts +176 -0
- package/src/server/management/model-routes.ts +253 -0
- package/src/server/management/oauth-account-routes.ts +301 -0
- package/src/server/management/provider-routes.ts +408 -0
- package/src/server/management/shared.ts +186 -0
- package/src/server/management-api.ts +23 -1806
- package/src/server/responses/collaboration.ts +300 -0
- package/src/server/responses/compact.ts +342 -0
- package/src/server/responses/core.ts +1498 -0
- package/src/server/responses/encrypted-payload.ts +231 -0
- package/src/server/responses/fetch-helpers.ts +157 -0
- package/src/server/responses.ts +9 -2172
- package/src/server/startup-action-control.ts +41 -0
- package/src/server/startup-health-cache.ts +100 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/service.ts +171 -19
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +290 -0
- package/src/tray/windows.ts +628 -0
- package/src/types.ts +5 -0
- package/src/update/index.ts +43 -0
- package/src/update/job.ts +46 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -2
- package/src/usage/summary.ts +42 -7
- package/gui/dist/assets/index-BunUANVE.js +0 -52
- package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
package/src/codex/catalog.ts
CHANGED
|
@@ -1,2426 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../generated/jawcode-model-metadata";
|
|
13
|
-
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../providers/derive";
|
|
14
|
-
import { getProviderRegistryEntry } from "../providers/registry";
|
|
15
|
-
import { applyProviderContextCap, providerContextCap } from "../providers/context-cap";
|
|
16
|
-
import { routedSlug, slugEquals, slugsEquivalent } from "../providers/slug-codec";
|
|
17
|
-
import { CODEX_GPT5_IDENTITY_LINE } from "../adapters/identity";
|
|
18
|
-
import { filterCursorConfiguredModelsByLiveDiscovery } from "../adapters/cursor/discovery";
|
|
19
|
-
import { fetchCursorUsableModels } from "../adapters/cursor/live-models";
|
|
20
|
-
import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
21
|
-
import {
|
|
22
|
-
COMBO_NAMESPACE,
|
|
23
|
-
comboModelId,
|
|
24
|
-
getCombo,
|
|
25
|
-
listComboIds,
|
|
26
|
-
targetKey,
|
|
27
|
-
} from "../combos";
|
|
28
|
-
import type { NormalizedComboConfig } from "../combos/types";
|
|
29
|
-
import { providerDestinationResolvedError } from "../lib/destination-policy";
|
|
30
|
-
import { redactSecretString } from "../lib/redact";
|
|
31
|
-
import upstreamModelsSnapshot from "./data/upstream-models.json";
|
|
32
|
-
|
|
33
|
-
const BUNDLED_CATALOG_CACHE_MS = 60_000;
|
|
34
|
-
let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
|
|
35
|
-
|
|
36
|
-
function legacyCatalogBackupPath(): string {
|
|
37
|
-
return join(getConfigDir(), "catalog-backup.json");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function catalogBackupPathFor(catalogPath: string): string {
|
|
41
|
-
const normalized = process.platform === "win32" ? resolve(catalogPath).toLowerCase() : resolve(catalogPath);
|
|
42
|
-
const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
43
|
-
return join(getConfigDir(), `catalog-backup-${id}.json`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function samePath(a: string, b: string): boolean {
|
|
47
|
-
const left = resolve(a);
|
|
48
|
-
const right = resolve(b);
|
|
49
|
-
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function activeCodexHome(): string | null {
|
|
53
|
-
const raw = process.env.CODEX_HOME?.trim();
|
|
54
|
-
if (!raw) return null;
|
|
55
|
-
const path = resolve(expandUserPath(raw));
|
|
56
|
-
try {
|
|
57
|
-
return realpathSync.native(path);
|
|
58
|
-
} catch {
|
|
59
|
-
return path;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function activeCodexConfigPath(): string {
|
|
64
|
-
const home = activeCodexHome();
|
|
65
|
-
return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function activeDefaultCatalogPath(): string {
|
|
69
|
-
const home = activeCodexHome();
|
|
70
|
-
return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function activeCodexModelsCachePath(): string {
|
|
74
|
-
const home = activeCodexHome();
|
|
75
|
-
return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function resolveActiveCodexConfigPath(path: string): string {
|
|
79
|
-
const home = activeCodexHome();
|
|
80
|
-
return home ? resolve(home, path) : resolveCodexConfigPath(path);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isDefaultCatalogPath(path: string): boolean {
|
|
84
|
-
return samePath(path, activeDefaultCatalogPath());
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Native OpenAI / Codex models served via ChatGPT OAuth passthrough — FALLBACK only. The ChatGPT
|
|
89
|
-
* backend has no `GET /models`, so the real set is read from the live Codex catalog via
|
|
90
|
-
* nativeOpenAiSlugs(); this static list is used when no catalog is present, plus selected documented
|
|
91
|
-
* Codex-native additions that may lag in a user's installed Codex catalog.
|
|
92
|
-
*/
|
|
93
|
-
export const NATIVE_OPENAI_MODELS = [
|
|
94
|
-
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
|
|
95
|
-
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
|
|
99
|
-
"gpt-5.3-codex-spark",
|
|
100
|
-
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The ONLY native OpenAI/Codex slugs opencodex advertises. A user's installed Codex ships extra
|
|
105
|
-
* native models in its live catalog (e.g. `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`); those
|
|
106
|
-
* are legacy/internal and must never surface in `/v1/models` or the subagent picker. Live-catalog
|
|
107
|
-
* native slugs are filtered against this allowlist so only the supported set is exposed.
|
|
108
|
-
*/
|
|
109
|
-
const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* True when a bare slug is an OpenAI/Codex-family native that opencodex does NOT support
|
|
113
|
-
* (legacy/internal like `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`). Used to drop these
|
|
114
|
-
* from the ON-DISK catalog so the Codex file picker matches the live `/v1/models` filter,
|
|
115
|
-
* WITHOUT removing genuine user-added natives (non gpt-/codex- slugs are preserved).
|
|
116
|
-
*/
|
|
117
|
-
function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
|
|
118
|
-
if (slug.includes("/")) return false;
|
|
119
|
-
if (SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) return false;
|
|
120
|
-
return /^(?:gpt|codex)-/.test(slug);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const NATIVE_GPT56_CONTEXT_WINDOW = 372_000;
|
|
124
|
-
|
|
125
|
-
const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
|
|
126
|
-
"gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
|
|
127
|
-
"gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
|
|
128
|
-
"gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
|
|
129
|
-
"gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
|
|
130
|
-
"gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
|
|
131
|
-
"gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_CONTEXT_WINDOW },
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/** Known context window for a supported native OpenAI slug (management API display). */
|
|
135
|
-
export function nativeOpenAiContextWindow(slug: string): number | undefined {
|
|
136
|
-
return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow
|
|
137
|
-
?? (typeof UPSTREAM_NATIVE_ENTRIES.get(slug)?.context_window === "number"
|
|
138
|
-
? UPSTREAM_NATIVE_ENTRIES.get(slug)!.context_window as number
|
|
139
|
-
: undefined);
|
|
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
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Bare (slash-free) entries of `disabledModels` — the native GPT half of the single
|
|
192
|
-
* enable/disable choke point. Routed ids are always namespaced `provider/id`, so bare
|
|
193
|
-
* slugs can never collide with them.
|
|
194
|
-
*/
|
|
195
|
-
export function disabledNativeSlugs(config: Pick<OcxConfig, "disabledModels">): Set<string> {
|
|
196
|
-
return new Set((config.disabledModels ?? []).filter(id => !id.includes("/")));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Native slugs to expose on bare availability surfaces (the OpenAI list shape of
|
|
201
|
-
* /v1/models): the advertised set minus config-disabled natives. Catalog-shaped
|
|
202
|
-
* emissions keep disabled entries with `visibility: "hide"` instead (codex-rs hides
|
|
203
|
-
* them from the picker itself), so sync/restore stays symmetric.
|
|
204
|
-
*/
|
|
205
|
-
export function visibleNativeSlugs(config: Pick<OcxConfig, "disabledModels">): string[] {
|
|
206
|
-
const disabled = disabledNativeSlugs(config);
|
|
207
|
-
return nativeOpenAiSlugs().filter(slug => !disabled.has(slug));
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Native GPT rows for the management dashboard. Sourced from the STATIC supported set —
|
|
212
|
-
* independent of catalog visibility flips, so a disabled model stays listed and can be
|
|
213
|
-
* re-enabled from the GUI.
|
|
214
|
-
*/
|
|
215
|
-
export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
|
|
216
|
-
const disabled = disabledNativeSlugs(config);
|
|
217
|
-
return NATIVE_OPENAI_MODELS.map(slug => {
|
|
218
|
-
const contextWindow = nativeOpenAiContextWindow(slug);
|
|
219
|
-
return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Central visibility flip for supported native entries in catalog-shaped output:
|
|
225
|
-
* disabled -> "hide" (entry preserved for template/backup/restore), enabled -> "list".
|
|
226
|
-
* Unsupported natives and routed entries are untouched.
|
|
227
|
-
*/
|
|
228
|
-
export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set<string>): RawEntry[] {
|
|
229
|
-
for (const entry of entries) {
|
|
230
|
-
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
231
|
-
if (!slug || slug.includes("/") || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue;
|
|
232
|
-
entry.visibility = disabledNative.has(slug) ? "hide" : "list";
|
|
233
|
-
}
|
|
234
|
-
return entries;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Pinned upstream models.json snapshot (openai/codex PR #31684, codex-rs/models-manager/models.json)
|
|
239
|
-
* providing the REAL catalog entries for supported native slugs the installed Codex binary may
|
|
240
|
-
* predate (gpt-5.6-sol/terra/luna). Restricted to supported gpt-5.6 slugs ONLY: for
|
|
241
|
-
* gpt-5.5/5.4/5.4-mini the installed catalog's live entries are RICHER than this bundled
|
|
242
|
-
* fallback (the snapshot ships gpt-5.5 with tool_mode null / use_responses_lite false /
|
|
243
|
-
* comp_hash 2911), so substituting them would downgrade real entries. gpt-5.6 has no real
|
|
244
|
-
* installed entry to downgrade — the alternative is gpt-5.5-template synthesis, which this
|
|
245
|
-
* snapshot strictly improves on (exact ladders: luna has NO ultra; sol defaults to low).
|
|
246
|
-
*/
|
|
247
|
-
const UPSTREAM_NATIVE_ENTRIES: Map<string, RawEntry> = new Map(
|
|
248
|
-
((upstreamModelsSnapshot as unknown as { models?: RawEntry[] }).models ?? [])
|
|
249
|
-
.filter(m => typeof m.slug === "string"
|
|
250
|
-
&& SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string)
|
|
251
|
-
&& (m.slug as string).startsWith("gpt-5.6-"))
|
|
252
|
-
.map(m => [m.slug as string, m]),
|
|
253
|
-
);
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Deep clone of the pinned upstream entry for a native slug, adapted for ocx emission:
|
|
257
|
-
* `minimal_client_version` is stripped (a pinned client-version gate would hide the model from
|
|
258
|
-
* older installed clients; ocx targets whatever client is installed, matching the synthesis
|
|
259
|
-
* path which never emits the field). `prefer_websockets` is left in place — the central
|
|
260
|
-
* websocket overrides in buildCatalogEntries/mergeCatalogEntriesForSync gate it with
|
|
261
|
-
* supports_websockets.
|
|
262
|
-
*/
|
|
263
|
-
export function upstreamNativeEntry(slug: string): RawEntry | null {
|
|
264
|
-
const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
|
|
265
|
-
if (!entry) return null;
|
|
266
|
-
const clone = JSON.parse(JSON.stringify(entry)) as RawEntry;
|
|
267
|
-
delete clone.minimal_client_version;
|
|
268
|
-
return clone;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Mock-max wire clamp (devlog/260709_v2_gated_ultra): the catalog advertises `ultra`
|
|
273
|
-
* on natives whose REAL upstream ladder stops below max (gpt-5.5/5.4/…); codex-rs
|
|
274
|
-
* converts ultra -> max at its inference boundary, and the ChatGPT backend then
|
|
275
|
-
* rejects `max` for those models ("Invalid value: 'max'"). Returns the model's
|
|
276
|
-
* highest real effort when the requested top-tier effort (max/ultra) is not in the
|
|
277
|
-
* native ladder; null when no clamp is needed (routed slugs, real-max natives,
|
|
278
|
-
* ordinary efforts, unknown slugs).
|
|
279
|
-
*/
|
|
280
|
-
export function nativeEffortClamp(slug: string, effort: string | undefined): string | null {
|
|
281
|
-
if (!effort || (effort !== "max" && effort !== "ultra")) return null;
|
|
282
|
-
if (slug.includes("/")) return null; // routed models map efforts in their adapters
|
|
283
|
-
const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
|
|
284
|
-
const levels = Array.isArray(entry?.supported_reasoning_levels)
|
|
285
|
-
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
286
|
-
: [];
|
|
287
|
-
if (levels.length === 0) {
|
|
288
|
-
// Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels
|
|
289
|
-
// restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5/5.4/
|
|
290
|
-
// 5.4-mini/5.3-codex-spark and future old-ladder slugs) really stops at xhigh — the
|
|
291
|
-
// ChatGPT backend error names exactly none..xhigh — so clamp the synthetic top tier.
|
|
292
|
-
return isGpt56NativeSlug(slug) ? null : "xhigh";
|
|
293
|
-
}
|
|
294
|
-
const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
|
|
295
|
-
if (supported.includes(effort)) return null;
|
|
296
|
-
const rank = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
|
297
|
-
const highest = supported
|
|
298
|
-
.filter(e => rank.includes(e))
|
|
299
|
-
.sort((a, b) => rank.indexOf(a) - rank.indexOf(b))
|
|
300
|
-
.at(-1);
|
|
301
|
-
return highest ?? null;
|
|
302
|
-
}
|
|
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
|
-
|
|
320
|
-
/**
|
|
321
|
-
* True when a preserved catalog entry for a snapshot-covered slug should be UPGRADED to the
|
|
322
|
-
* pinned upstream entry. Discriminator: `display_name === slug` — both ocx synthesis and the
|
|
323
|
-
* codex-rs model_info fallback stamp the bare slug as display name, while genuine upstream
|
|
324
|
-
* entries always carry marketing names ("GPT-5.6-Sol"). Fallback-quality entries are
|
|
325
|
-
* intentionally overwritten; a real newer catalog entry is preserved untouched.
|
|
326
|
-
*/
|
|
327
|
-
function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean {
|
|
328
|
-
return typeof entry.slug === "string"
|
|
329
|
-
&& UPSTREAM_NATIVE_ENTRIES.has(entry.slug)
|
|
330
|
-
&& entry.display_name === entry.slug;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Reasoning efforts each requested slug advertises in the INJECTED on-disk catalog —
|
|
335
|
-
* the exact list codex-rs validates spawn_agent `reasoning_effort` arguments against
|
|
336
|
-
* (unsupported rungs are then clamped on the wire, nativeEffortClamp/adapters).
|
|
337
|
-
* Slugs missing from the catalog are omitted from the result. Used by the delegation
|
|
338
|
-
* prompt to advertise the featured sub-agent roster with honest effort ladders.
|
|
339
|
-
*/
|
|
340
|
-
export function catalogModelEfforts(slugs: readonly string[]): Map<string, string[]> {
|
|
341
|
-
const out = new Map<string, string[]>();
|
|
342
|
-
if (slugs.length === 0) return out;
|
|
343
|
-
const catalog = readCatalog(readCodexCatalogPath());
|
|
344
|
-
if (!catalog) return out;
|
|
345
|
-
for (const entry of catalog.models ?? []) {
|
|
346
|
-
if (typeof entry.slug !== "string") continue;
|
|
347
|
-
// Tolerate raw legacy config slugs (`provider/vendor/model`) against the
|
|
348
|
-
// Codex-facing encoded catalog slug (`provider/vendor-model`).
|
|
349
|
-
const callerSlug = slugs.find(s => slugsEquivalent(s, entry.slug as string));
|
|
350
|
-
if (callerSlug === undefined) continue;
|
|
351
|
-
const levels = Array.isArray(entry.supported_reasoning_levels)
|
|
352
|
-
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
353
|
-
: [];
|
|
354
|
-
const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
|
|
355
|
-
if (efforts.length > 0) out.set(callerSlug, efforts);
|
|
356
|
-
}
|
|
357
|
-
return out;
|
|
358
|
-
}
|
|
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
|
-
|
|
454
|
-
/**
|
|
455
|
-
* The native (passthrough) OpenAI slugs to advertise — the LIVE Codex catalog's own bare slugs when
|
|
456
|
-
* available, with documented Codex-native additions layered in, else the static fallback above.
|
|
457
|
-
* Single source for the /v1/models native list and the subagent-default seed.
|
|
458
|
-
*/
|
|
459
|
-
export function nativeOpenAiSlugs(): string[] {
|
|
460
|
-
const live = listCatalogNativeSlugs();
|
|
461
|
-
return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
export interface CatalogModel {
|
|
465
|
-
id: string;
|
|
466
|
-
provider: string;
|
|
467
|
-
/** Public Codex-facing slug override (used by combo aliases). */
|
|
468
|
-
alias?: string;
|
|
469
|
-
/**
|
|
470
|
-
* Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
|
|
471
|
-
* affects the routing slug, alias-collision order, native marketing-name precedence, or provider
|
|
472
|
-
* behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior).
|
|
473
|
-
* Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
|
|
474
|
-
* which carries no CatalogModel, so a configured displayName can never override a native name.
|
|
475
|
-
*/
|
|
476
|
-
displayName?: string;
|
|
477
|
-
owned_by?: string;
|
|
478
|
-
reasoningEfforts?: string[];
|
|
479
|
-
defaultReasoningEffort?: string;
|
|
480
|
-
contextWindow?: number;
|
|
481
|
-
maxInputTokens?: number;
|
|
482
|
-
contextCap?: number;
|
|
483
|
-
contextCapped?: boolean;
|
|
484
|
-
inputModalities?: string[];
|
|
485
|
-
/** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
|
|
486
|
-
parallelToolCalls?: boolean;
|
|
487
|
-
/** Whether Codex may send Responses text.verbosity for this routed model. */
|
|
488
|
-
supportsVerbosity?: boolean;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
type RawEntry = Record<string, unknown>;
|
|
492
|
-
type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
|
|
493
|
-
const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Exact provider/model pairs whose discovery endpoint advertises them but whose inference backend
|
|
497
|
-
* rejects them. Apply this after live/static/metadata sources converge so no source can resurrect
|
|
498
|
-
* an uncallable picker row. Remove an entry once authenticated inference proves it usable again.
|
|
499
|
-
*/
|
|
500
|
-
const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
|
|
501
|
-
// Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
|
|
502
|
-
"opencode-go/hy3-preview",
|
|
503
|
-
]);
|
|
504
|
-
|
|
505
|
-
function isRoutedModelCompatibilityExcluded(slug: string): boolean {
|
|
506
|
-
return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
|
|
511
|
-
* generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
|
|
512
|
-
* and must never surface in the dashboard, /v1/models, or the routed catalog. The metadata has no
|
|
513
|
-
* output-modality field, so we classify by id. Extend this list as providers add media models.
|
|
514
|
-
*/
|
|
515
|
-
const MEDIA_GEN_FAMILIES = [
|
|
516
|
-
"dall-e", "dalle", "imagen", "sora", "veo", "flux", "kling",
|
|
517
|
-
"seedance", "hailuo", "stable-diffusion", "sdxl", "midjourney",
|
|
518
|
-
];
|
|
519
|
-
const MEDIA_GEN_ID_RE = new RegExp(
|
|
520
|
-
`(?:^|[/_-])(?:image|video)(?:[/_-]|$)|(?:^|[/_-])(?:${MEDIA_GEN_FAMILIES.join("|")})(?:[/_-]|$|\\d)`,
|
|
521
|
-
"i",
|
|
522
|
-
);
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* True when a model id denotes image/video GENERATION (so it should be hidden everywhere). Vision
|
|
526
|
-
* *input* chat models — `grok-2-vision`, `qwen3-vl-*`, `gpt-4o`, `gemini-3-pro-preview` — are
|
|
527
|
-
* intentionally NOT matched: they carry no `image`/`video` id segment and no generation-family token.
|
|
528
|
-
*/
|
|
529
|
-
export function isMediaGenerationModelId(id: string): boolean {
|
|
530
|
-
return MEDIA_GEN_ID_RE.test(id);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function shouldExposeRoutedModel(model: CatalogModel): boolean {
|
|
534
|
-
if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
|
|
535
|
-
if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
|
|
536
|
-
return !isMediaGenerationModelId(model.id);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
|
|
540
|
-
export function readCodexCatalogPath(): string {
|
|
541
|
-
try {
|
|
542
|
-
const configPath = activeCodexConfigPath();
|
|
543
|
-
if (existsSync(configPath)) {
|
|
544
|
-
const toml = readFileSync(configPath, "utf-8");
|
|
545
|
-
const path = readRootTomlString(toml, "model_catalog_json");
|
|
546
|
-
if (path) return resolveActiveCodexConfigPath(path);
|
|
547
|
-
}
|
|
548
|
-
} catch { /* ignore */ }
|
|
549
|
-
return activeDefaultCatalogPath();
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function parseCatalogJson(raw: string): RawCatalog | null {
|
|
553
|
-
try {
|
|
554
|
-
const cat = JSON.parse(raw);
|
|
555
|
-
return (cat && Array.isArray(cat.models)) ? cat : null;
|
|
556
|
-
} catch { return null; }
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function readCatalog(path: string): RawCatalog | null {
|
|
560
|
-
try {
|
|
561
|
-
if (!existsSync(path)) return null;
|
|
562
|
-
return parseCatalogJson(readFileSync(path, "utf-8"));
|
|
563
|
-
} catch { return null; }
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null {
|
|
567
|
-
return catalog?.models?.find(
|
|
568
|
-
m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
|
|
569
|
-
) ?? null;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function normalizeServiceTiers(entry: RawEntry): RawEntry {
|
|
573
|
-
// Codex stores the user-facing config spelling as "fast", but the catalog/request
|
|
574
|
-
// service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
|
|
575
|
-
if (entry.service_tier === "fast") entry.service_tier = "priority";
|
|
576
|
-
if (Array.isArray(entry.service_tiers)) {
|
|
577
|
-
entry.service_tiers = entry.service_tiers.map(tier => {
|
|
578
|
-
if (tier && typeof tier === "object" && "id" in tier && tier.id === "fast") {
|
|
579
|
-
return { ...tier, id: "priority" };
|
|
580
|
-
}
|
|
581
|
-
return tier;
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
return entry;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
|
|
588
|
-
if (
|
|
589
|
-
typeof entry.context_window === "number"
|
|
590
|
-
&& entry.context_window > 0
|
|
591
|
-
&& typeof entry.auto_compact_token_limit !== "number"
|
|
592
|
-
) {
|
|
593
|
-
entry.auto_compact_token_limit = Math.floor(entry.context_window * 0.9);
|
|
594
|
-
}
|
|
595
|
-
return entry;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function isNativeOpenAiEntry(entry: RawEntry): boolean {
|
|
599
|
-
return typeof entry.slug === "string" && !entry.slug.includes("/");
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function applyNativeOpenAiContextOverride(entry: RawEntry): void {
|
|
603
|
-
if (!isNativeOpenAiEntry(entry)) return;
|
|
604
|
-
const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string];
|
|
605
|
-
if (!override) return;
|
|
606
|
-
if (typeof override.contextWindow === "number") {
|
|
607
|
-
entry.context_window = override.contextWindow;
|
|
608
|
-
entry.auto_compact_token_limit = Math.floor(override.contextWindow * 0.9);
|
|
609
|
-
}
|
|
610
|
-
if (typeof override.maxContextWindow === "number") {
|
|
611
|
-
entry.max_context_window = override.maxContextWindow;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function ensureStrictCatalogFields(
|
|
616
|
-
entry: RawEntry,
|
|
617
|
-
options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {},
|
|
618
|
-
): RawEntry {
|
|
619
|
-
if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
|
|
620
|
-
if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
|
|
621
|
-
if (typeof entry.support_verbosity !== "boolean") entry.support_verbosity = true;
|
|
622
|
-
if (typeof entry.default_verbosity !== "string") entry.default_verbosity = "low";
|
|
623
|
-
if (typeof entry.apply_patch_tool_type !== "string") entry.apply_patch_tool_type = "freeform";
|
|
624
|
-
if (!entry.truncation_policy || typeof entry.truncation_policy !== "object" || Array.isArray(entry.truncation_policy)) {
|
|
625
|
-
entry.truncation_policy = { mode: "tokens", limit: 10000 };
|
|
626
|
-
}
|
|
627
|
-
if (typeof entry.supports_parallel_tool_calls !== "boolean") entry.supports_parallel_tool_calls = true;
|
|
628
|
-
if (typeof entry.supports_image_detail_original !== "boolean") entry.supports_image_detail_original = false;
|
|
629
|
-
if (!Array.isArray(entry.experimental_supported_tools)) entry.experimental_supported_tools = [];
|
|
630
|
-
if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
|
|
631
|
-
entry.input_modalities = ["text"];
|
|
632
|
-
}
|
|
633
|
-
const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
|
|
634
|
-
entry.context_window = contextWindow;
|
|
635
|
-
if (
|
|
636
|
-
typeof entry.max_context_window !== "number"
|
|
637
|
-
|| entry.max_context_window <= 0
|
|
638
|
-
|| ((options.isRouted === true || !isNativeOpenAiEntry(entry)) && entry.max_context_window > contextWindow)
|
|
639
|
-
) {
|
|
640
|
-
entry.max_context_window = contextWindow;
|
|
641
|
-
}
|
|
642
|
-
if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
|
|
643
|
-
if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
|
|
644
|
-
return ensureAutoCompactTokenLimit(entry);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** Multi-agent surface mode — see OcxConfig.multiAgentMode. */
|
|
648
|
-
export type MultiAgentMode = "v1" | "default" | "v2";
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Apply the 3-state multi-agent surface override to catalog entries.
|
|
652
|
-
* - "v1": force multi_agent_version = "v1" on ALL entries (override upstream pins)
|
|
653
|
-
* - "default": RESTORE upstream pins — clear stale forced values so entries that were
|
|
654
|
-
* previously forced to v1/v2 revert to their natural state (upstream-pinned natives
|
|
655
|
-
* get their snapshot pin, others get null so the codex feature flag decides)
|
|
656
|
-
* - "v2": force multi_agent_version = "v2" on ALL entries (override upstream pins)
|
|
657
|
-
*/
|
|
658
|
-
function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
|
|
659
|
-
if (mode === "default") {
|
|
660
|
-
// Restore upstream defaults: clear any stale forced multi_agent_version and
|
|
661
|
-
// re-apply upstream pins from the snapshot for native entries that have one.
|
|
662
|
-
for (const entry of entries) {
|
|
663
|
-
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
664
|
-
const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug);
|
|
665
|
-
const upstreamPin = upstream?.multi_agent_version;
|
|
666
|
-
if (typeof upstreamPin === "string") {
|
|
667
|
-
entry.multi_agent_version = upstreamPin;
|
|
668
|
-
} else {
|
|
669
|
-
delete entry.multi_agent_version;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return entries;
|
|
673
|
-
}
|
|
674
|
-
for (const entry of entries) {
|
|
675
|
-
entry.multi_agent_version = mode;
|
|
676
|
-
}
|
|
677
|
-
return entries;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
|
|
681
|
-
delete entry.model_messages;
|
|
682
|
-
delete entry.tool_mode;
|
|
683
|
-
delete entry.multi_agent_version;
|
|
684
|
-
delete entry.use_responses_lite;
|
|
685
|
-
delete entry.supports_websockets;
|
|
686
|
-
delete entry.additional_speed_tiers;
|
|
687
|
-
delete entry.service_tier;
|
|
688
|
-
delete entry.service_tiers;
|
|
689
|
-
delete entry.default_service_tier;
|
|
690
|
-
const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
|
|
691
|
-
// Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
|
|
692
|
-
// runs through native gpt-5.4-mini, so image search is available and verbalized for text-only
|
|
693
|
-
// models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and
|
|
694
|
-
// rejects server search queries — advertising the tool would make models call into a void.
|
|
695
|
-
if (isCursorEntry) {
|
|
696
|
-
delete entry.web_search_tool_type;
|
|
697
|
-
entry.supports_search_tool = false;
|
|
698
|
-
} else {
|
|
699
|
-
entry.web_search_tool_type = "text_and_image";
|
|
700
|
-
entry.supports_search_tool = true;
|
|
701
|
-
}
|
|
702
|
-
// Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
|
|
703
|
-
// Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
|
|
704
|
-
// Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
|
|
705
|
-
// openai-chat adapter stops forcing parallel_tool_calls:false and the buffered stream parser
|
|
706
|
-
// assembles multi-call turns (devlog/_plan/260709_parallel_tool_calls).
|
|
707
|
-
entry.supports_parallel_tool_calls = isCursorEntry || parallelToolCalls === true;
|
|
708
|
-
return ensureStrictCatalogFields(entry, { isRouted: true });
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// provider + NATIVE model id are passed separately: the Codex-facing slug may carry an
|
|
712
|
-
// encoded alias (`provider/vendor-model`) that must never reach the metadata lookup,
|
|
713
|
-
// whose keys are native ids (openrouter `anthropic/...`, nvidia `moonshotai/...`).
|
|
714
|
-
function applyJawcodeCatalogMetadata(entry: RawEntry, provider: string, modelId: string, contextCap?: number): void {
|
|
715
|
-
const jawcodeProvider = resolveJawcodeProvider(provider);
|
|
716
|
-
if (!jawcodeProvider) return;
|
|
717
|
-
const meta = getJawcodeModelMetadata(jawcodeProvider, modelId)
|
|
718
|
-
?? (shouldCaseFoldMetadataModelId(provider) ? getJawcodeModelMetadataCaseInsensitive(jawcodeProvider, modelId) : undefined);
|
|
719
|
-
if (!meta) return;
|
|
720
|
-
if (typeof meta.contextWindow === "number" && meta.contextWindow > 0) {
|
|
721
|
-
const contextWindow = applyProviderContextCap(meta.contextWindow, contextCap) ?? meta.contextWindow;
|
|
722
|
-
entry.context_window = contextWindow;
|
|
723
|
-
entry.max_context_window = contextWindow;
|
|
724
|
-
entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9);
|
|
725
|
-
}
|
|
726
|
-
if (Array.isArray(meta.input) && meta.input.length > 0) {
|
|
727
|
-
entry.input_modalities = meta.input;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
type ExecFile = (
|
|
732
|
-
file: string,
|
|
733
|
-
args: string[],
|
|
734
|
-
options: {
|
|
735
|
-
encoding: "utf8";
|
|
736
|
-
stdio: ["ignore", "pipe", "ignore"];
|
|
737
|
-
timeout: number;
|
|
738
|
-
windowsHide: boolean;
|
|
739
|
-
shell?: boolean;
|
|
740
|
-
},
|
|
741
|
-
) => string;
|
|
742
|
-
|
|
743
|
-
interface BundledCatalogDeps {
|
|
744
|
-
commandCandidates?: () => string[];
|
|
745
|
-
execFileSync?: ExecFile;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function unique(values: string[]): string[] {
|
|
749
|
-
return [...new Set(values.filter(Boolean))];
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function codexCommandCandidates(): string[] {
|
|
753
|
-
const envPath = process.env.CODEX_CLI_PATH?.trim();
|
|
754
|
-
const candidates = envPath ? [envPath] : [];
|
|
755
|
-
candidates.push(...codexShimCommandCandidates());
|
|
756
|
-
if (process.platform === "win32") {
|
|
757
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
758
|
-
candidates.push(join(dir, "codex.exe"), join(dir, "codex.cmd"));
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
candidates.push("codex");
|
|
762
|
-
return unique(candidates);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Windows probe guard: only PE/batch launchers can be spawned as processes. Anything
|
|
767
|
-
* else pulled from the shim state (the extensionless Git-Bash sh backup
|
|
768
|
-
* `codex.opencodex-real`, `.ps1` scripts) risks falling through to the cmd/ShellExecute
|
|
769
|
-
* document-association path — Windows then OPENS the file in the user's editor
|
|
770
|
-
* (e.g. VS Code) on every `codex` launch instead of executing it.
|
|
771
|
-
*/
|
|
772
|
-
export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
773
|
-
if (platform !== "win32") return true;
|
|
774
|
-
return /\.(cmd|bat|exe|com)$/i.test(path);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function codexShimCommandCandidates(): string[] {
|
|
778
|
-
try {
|
|
779
|
-
const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
|
|
780
|
-
wrapperPath?: unknown;
|
|
781
|
-
originalPath?: unknown;
|
|
782
|
-
backupPath?: unknown;
|
|
783
|
-
wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
|
|
784
|
-
};
|
|
785
|
-
const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
|
|
786
|
-
const out: string[] = [];
|
|
787
|
-
for (const file of files) {
|
|
788
|
-
for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
|
|
789
|
-
if (typeof value !== "string" || value.length === 0) continue;
|
|
790
|
-
if (!isSpawnableCodexCandidate(value)) continue;
|
|
791
|
-
out.push(value);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
return out;
|
|
795
|
-
} catch {
|
|
796
|
-
return [];
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/**
|
|
801
|
-
* `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
|
|
802
|
-
* and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
|
|
803
|
-
* swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
|
|
804
|
-
* Route those through the shell (repo convention — see src/update/index.ts, bin/ocx.mjs) and
|
|
805
|
-
* pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
|
|
806
|
-
* spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
|
|
807
|
-
*/
|
|
808
|
-
export function codexExecInvocation(
|
|
809
|
-
command: string,
|
|
810
|
-
platform: NodeJS.Platform = process.platform,
|
|
811
|
-
): { file: string; shell: boolean } {
|
|
812
|
-
if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
|
|
813
|
-
return { file: `"${command.replace(/"/g, "")}"`, shell: true };
|
|
814
|
-
}
|
|
815
|
-
return { file: command, shell: false };
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function runCodexDebugModels(command: string, execFile: ExecFile): string {
|
|
819
|
-
const args = ["debug", "models", "--bundled"];
|
|
820
|
-
const invocation = codexExecInvocation(command);
|
|
821
|
-
return execFile(invocation.file, args, {
|
|
822
|
-
encoding: "utf8" as const,
|
|
823
|
-
stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
|
|
824
|
-
timeout: 10_000,
|
|
825
|
-
windowsHide: true,
|
|
826
|
-
shell: invocation.shell,
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
|
|
831
|
-
const useCache = !deps.commandCandidates && !deps.execFileSync;
|
|
832
|
-
if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
|
|
833
|
-
return bundledCatalogCache.value;
|
|
834
|
-
}
|
|
835
|
-
const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
|
|
836
|
-
const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
|
|
837
|
-
for (const command of candidates) {
|
|
838
|
-
try {
|
|
839
|
-
const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
|
|
840
|
-
if (catalog && findNativeTemplate(catalog)) {
|
|
841
|
-
if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: catalog };
|
|
842
|
-
return catalog;
|
|
843
|
-
}
|
|
844
|
-
} catch { /* try next candidate */ }
|
|
845
|
-
}
|
|
846
|
-
if (useCache) bundledCatalogCache = { expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS, value: null };
|
|
847
|
-
return null;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null {
|
|
851
|
-
const catalog = loadBundledCodexCatalog(deps);
|
|
852
|
-
if (!catalog) return null;
|
|
853
|
-
try {
|
|
854
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
855
|
-
atomicWriteFile(path, JSON.stringify(catalog, null, 2) + "\n");
|
|
856
|
-
} catch {
|
|
857
|
-
return null;
|
|
858
|
-
}
|
|
859
|
-
return catalog;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function loadCatalogForSync(path: string): RawCatalog | null {
|
|
863
|
-
const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
|
|
864
|
-
if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
|
|
865
|
-
const catalog = readCatalog(path);
|
|
866
|
-
if (catalog && findNativeTemplate(catalog)) return catalog;
|
|
867
|
-
return readCatalog(catalogBackupPathFor(path))
|
|
868
|
-
?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
|
|
869
|
-
?? readCatalog(activeCodexModelsCachePath())
|
|
870
|
-
?? materializeBundledCodexCatalog(path)
|
|
871
|
-
?? catalog;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
function readCurrentCatalogOrCache(): RawCatalog | null {
|
|
875
|
-
const path = readCodexCatalogPath();
|
|
876
|
-
return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
|
|
877
|
-
?? readCatalog(path)
|
|
878
|
-
?? readCatalog(activeCodexModelsCachePath());
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* A full native entry from the on-disk catalog, used as a clone template so injected
|
|
883
|
-
* entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
|
|
884
|
-
* Returns a deep copy, or null if no catalog/native entry exists.
|
|
885
|
-
*/
|
|
886
|
-
export function loadCatalogTemplate(): RawEntry | null {
|
|
887
|
-
const catalogPath = readCodexCatalogPath();
|
|
888
|
-
const native = findNativeTemplate(readCatalog(catalogPath))
|
|
889
|
-
?? findNativeTemplate(readCatalogBackup(catalogPath))
|
|
890
|
-
?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
|
|
891
|
-
?? findNativeTemplate(loadBundledCodexCatalog());
|
|
892
|
-
return native ? JSON.parse(JSON.stringify(native)) : null;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Codex accepts its native labels plus model-defined effort strings such as `max` in current builds.
|
|
897
|
-
* Provider-specific aliases still map at request time by src/reasoning-effort.ts.
|
|
898
|
-
*/
|
|
899
|
-
// Routed models default to the low..max ladder: upstream bundled catalogs advertise no "ultra"
|
|
900
|
-
// either — but opencodex exposes ultra universally so routed models can use the auto-delegation
|
|
901
|
-
// mode (codex-rs converts ultra → max on the wire before any provider request).
|
|
902
|
-
const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS];
|
|
903
|
-
|
|
904
|
-
function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
|
|
905
|
-
if (!model) return;
|
|
906
|
-
// This marker survives strict catalog normalization and lets sync distinguish a stale
|
|
907
|
-
// bare combo alias from a genuine native model row.
|
|
908
|
-
if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
|
|
909
|
-
// displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing
|
|
910
|
-
// slug, alias, or provider. deriveEntry already stamped the slug as display_name; a
|
|
911
|
-
// configured displayName overrides just the label. The `/` separator is rejected at every
|
|
912
|
-
// input boundary (CLI `ocx models add`, management API), so the catalog trusts its source.
|
|
913
|
-
// Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine
|
|
914
|
-
// upstream marketing names and combo alias labels are preserved untouched.
|
|
915
|
-
const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
|
|
916
|
-
if (displayName) entry.display_name = displayName;
|
|
917
|
-
if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
|
|
918
|
-
entry.context_window = model.contextWindow;
|
|
919
|
-
entry.max_context_window = model.contextWindow;
|
|
920
|
-
entry.auto_compact_token_limit = Math.min(
|
|
921
|
-
Math.floor(model.contextWindow * 0.9),
|
|
922
|
-
model.maxInputTokens ?? Number.POSITIVE_INFINITY,
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
if (Array.isArray(model.inputModalities) && model.inputModalities.length > 0) {
|
|
926
|
-
entry.input_modalities = model.inputModalities;
|
|
927
|
-
}
|
|
928
|
-
if (typeof model.supportsVerbosity === "boolean") {
|
|
929
|
-
entry.support_verbosity = model.supportsVerbosity;
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function applyReasoningLevels(
|
|
934
|
-
entry: RawEntry,
|
|
935
|
-
effortsOverride?: string[],
|
|
936
|
-
defaultOverride?: string,
|
|
937
|
-
preserveExact = false,
|
|
938
|
-
): void {
|
|
939
|
-
let efforts = sanitizeCodexReasoningEfforts(effortsOverride) ?? ROUTED_REASONING_LEVELS.map(l => l.effort);
|
|
940
|
-
// Mock top tiers (user decision 260709): every reasoning-capable model advertises `max`
|
|
941
|
-
// even when the provider ladder stops lower — subagent spawns pass `max` DIRECTLY
|
|
942
|
-
// (no ultra->max client conversion) and codex-rs validates it by catalog membership,
|
|
943
|
-
// so a missing max rung hard-fails spawn_agent effort overrides. The wire stays honest:
|
|
944
|
-
// routed adapters clamp via clampToSupportedCodexEffort and natives via
|
|
945
|
-
// nativeEffortClamp (max -> the model's real top rung).
|
|
946
|
-
if (!preserveExact && efforts.length > 0) {
|
|
947
|
-
const additions: string[] = [];
|
|
948
|
-
if (!efforts.includes("max")) additions.push("max");
|
|
949
|
-
if (!efforts.includes("ultra")) additions.push("ultra");
|
|
950
|
-
if (additions.length > 0) efforts = sanitizeCodexReasoningEfforts([...efforts, ...additions]) ?? efforts;
|
|
951
|
-
}
|
|
952
|
-
const byEffort = new Map(
|
|
953
|
-
(Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
|
|
954
|
-
.map((l: { effort?: string }) => [l.effort, l]),
|
|
955
|
-
);
|
|
956
|
-
entry.supported_reasoning_levels = efforts.map(effort => {
|
|
957
|
-
const native = byEffort.get(effort);
|
|
958
|
-
if (native) return native;
|
|
959
|
-
// Description lookup uses the FULL ladder so an opt-in effort outside the routed default
|
|
960
|
-
// (e.g. "ultra") still renders its canonical description.
|
|
961
|
-
return CODEX_REASONING_LEVELS.find(l => l.effort === effort) ?? { effort, description: `${effort} reasoning` };
|
|
962
|
-
});
|
|
963
|
-
if (efforts.length === 0) {
|
|
964
|
-
delete entry.default_reasoning_level;
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
entry.default_reasoning_level = defaultOverride && efforts.includes(defaultOverride)
|
|
968
|
-
? defaultOverride
|
|
969
|
-
: efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
function isGpt56NativeSlug(slug: string): boolean {
|
|
973
|
-
return !slug.includes("/") && slug.startsWith("gpt-5.6-");
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Fallback ladder fix for a gpt-5.6 native slug NOT covered by the upstream snapshot (a future
|
|
978
|
-
* variant the snapshot predates): entries cloned from an older template (gpt-5.5) stop at xhigh,
|
|
979
|
-
* so append max+ultra in upstream rank order when absent. Snapshot-covered slugs never reach
|
|
980
|
-
* this — deriveEntry returns their real entry first.
|
|
981
|
-
*/
|
|
982
|
-
function ensureGpt56ReasoningLevels(entry: RawEntry): void {
|
|
983
|
-
const levels = Array.isArray(entry.supported_reasoning_levels)
|
|
984
|
-
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
985
|
-
: [];
|
|
986
|
-
const out = [...levels];
|
|
987
|
-
// max is a real native rung on the 5.6 family — always restored; ultra always advertised.
|
|
988
|
-
for (const effort of ["max", "ultra"]) {
|
|
989
|
-
if (out.some(level => level.effort === effort)) continue;
|
|
990
|
-
out.push(CODEX_REASONING_LEVELS.find(level => level.effort === effort)
|
|
991
|
-
?? { effort, description: `${effort} reasoning` });
|
|
992
|
-
}
|
|
993
|
-
entry.supported_reasoning_levels = out;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Ensure the mock top tiers on a native model's advertised ladder: `max` and `ultra`
|
|
998
|
-
* are always advertised (subagent spawns pass max directly and codex-rs validates by
|
|
999
|
-
* catalog membership — the ocx wire clamp routes it to the model's real top rung).
|
|
1000
|
-
*/
|
|
1001
|
-
function ensureUltraReasoningLevel(entry: RawEntry): void {
|
|
1002
|
-
const levels = Array.isArray(entry.supported_reasoning_levels)
|
|
1003
|
-
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
1004
|
-
: [];
|
|
1005
|
-
if (levels.length === 0) return;
|
|
1006
|
-
const wanted = ["max", "ultra"];
|
|
1007
|
-
for (const effort of wanted) {
|
|
1008
|
-
if (levels.some(level => level.effort === effort)) continue;
|
|
1009
|
-
levels.push(
|
|
1010
|
-
CODEX_REASONING_LEVELS.find(level => level.effort === effort)
|
|
1011
|
-
?? { effort, description: `${effort} reasoning` },
|
|
1012
|
-
);
|
|
1013
|
-
}
|
|
1014
|
-
entry.supported_reasoning_levels = levels;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/** Reasoning-effort labels accepted by the installed Codex binary's bundled catalog. */
|
|
1018
|
-
export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set<string> | null {
|
|
1019
|
-
const bundled = loadBundledCodexCatalog(deps);
|
|
1020
|
-
if (!bundled) return null;
|
|
1021
|
-
const efforts = new Set<string>();
|
|
1022
|
-
for (const model of bundled.models ?? []) {
|
|
1023
|
-
if (typeof model.slug !== "string" || model.slug.includes("/")) continue;
|
|
1024
|
-
const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
|
|
1025
|
-
for (const level of levels) {
|
|
1026
|
-
const effort = (level as { effort?: unknown })?.effort;
|
|
1027
|
-
if (typeof effort === "string") efforts.add(effort);
|
|
1028
|
-
}
|
|
1029
|
-
if (typeof model.default_reasoning_level === "string") efforts.add(model.default_reasoning_level);
|
|
1030
|
-
}
|
|
1031
|
-
return efforts.size > 0 ? efforts : null;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/** Highest surviving rung at or below the original default, with a conservative empty fallback. */
|
|
1035
|
-
export function clampedDefaultEffort(original: string, surviving: readonly string[]): string {
|
|
1036
|
-
if (surviving.length === 0) return "medium";
|
|
1037
|
-
const ranked = [...surviving]
|
|
1038
|
-
.map(effort => ({ effort, rank: codexEffortRank(effort) }))
|
|
1039
|
-
.sort((a, b) => a.rank - b.rank);
|
|
1040
|
-
const originalRank = codexEffortRank(original);
|
|
1041
|
-
const atOrBelow = ranked.filter(item => item.rank >= 0 && item.rank <= originalRank);
|
|
1042
|
-
return (atOrBelow.at(-1) ?? ranked[0]!).effort;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** Remove reasoning efforts the installed Codex binary cannot deserialize from one entry. */
|
|
1046
|
-
export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set<string> | null): void {
|
|
1047
|
-
if (!supported) return;
|
|
1048
|
-
const levels = Array.isArray(entry.supported_reasoning_levels)
|
|
1049
|
-
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
1050
|
-
: null;
|
|
1051
|
-
if (levels && levels.length > 0) {
|
|
1052
|
-
const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
|
|
1053
|
-
entry.supported_reasoning_levels = kept.length > 0
|
|
1054
|
-
? kept
|
|
1055
|
-
: CODEX_REASONING_LEVELS
|
|
1056
|
-
.filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high")
|
|
1057
|
-
.map(level => ({ ...level }));
|
|
1058
|
-
}
|
|
1059
|
-
const currentDefault = entry.default_reasoning_level;
|
|
1060
|
-
if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
|
|
1061
|
-
const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
|
|
1062
|
-
.flatMap(level => typeof (level as { effort?: string })?.effort === "string"
|
|
1063
|
-
? [(level as { effort: string }).effort]
|
|
1064
|
-
: []);
|
|
1065
|
-
entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/** Clamp every catalog entry to the reasoning ladder accepted by the installed Codex binary. */
|
|
1070
|
-
export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
|
|
1071
|
-
const supported = codexSupportedReasoningEfforts(deps);
|
|
1072
|
-
if (!supported) return models;
|
|
1073
|
-
for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
|
|
1074
|
-
return models;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
/**
|
|
1078
|
-
* Native entry from the pinned upstream snapshot, finished for emission. Keeps the entry's
|
|
1079
|
-
* OWN identity (display_name, description, priority, availability_nux — it is the model's own
|
|
1080
|
-
* NUX, not another model's) instead of the caller's generic passthrough blurb. The caller's
|
|
1081
|
-
* `priority` wins only when it is a deliberate override (featured rank / push-down), i.e. not
|
|
1082
|
-
* the native default 9.
|
|
1083
|
-
*/
|
|
1084
|
-
function finishUpstreamNativeEntry(clone: RawEntry, priority: number): RawEntry {
|
|
1085
|
-
if (priority !== 9) clone.priority = priority;
|
|
1086
|
-
applyNativeOpenAiContextOverride(clone);
|
|
1087
|
-
// GPT-5.6 natives keep their exact upstream ladders (e.g. luna has max but no ultra).
|
|
1088
|
-
// Older natives (gpt-5.5 / 5.4 / 5.4-mini / 5.3-codex-spark) get mock max + ultra
|
|
1089
|
-
// (wire-clamped to xhigh). Ultra is always advertised regardless of v2 toggle.
|
|
1090
|
-
if (!isGpt56NativeSlug(String(clone.slug ?? ""))) ensureUltraReasoningLevel(clone);
|
|
1091
|
-
return ensureStrictCatalogFields(normalizeServiceTiers(clone));
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function isExactComboCatalogModel(
|
|
1095
|
-
model: CatalogModel | undefined,
|
|
1096
|
-
exactComboSlugs: ReadonlySet<string>,
|
|
1097
|
-
): boolean {
|
|
1098
|
-
return model !== undefined && exactComboSlugs.has(catalogModelSlug(model));
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
export function catalogModelSlug(model: CatalogModel): string {
|
|
1102
|
-
return model.alias ?? routedSlug(model.provider, model.id);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function deriveEntry(
|
|
1106
|
-
template: RawEntry | null,
|
|
1107
|
-
slug: string,
|
|
1108
|
-
desc: string,
|
|
1109
|
-
priority: number,
|
|
1110
|
-
model?: CatalogModel,
|
|
1111
|
-
exactComboSlugs: ReadonlySet<string> = new Set(),
|
|
1112
|
-
): RawEntry {
|
|
1113
|
-
const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
|
|
1114
|
-
const isRouted = model !== undefined;
|
|
1115
|
-
if (!isRouted && !slug.includes("/")) {
|
|
1116
|
-
// Supported native slug covered by the upstream snapshot: use the REAL entry (exact
|
|
1117
|
-
// reasoning ladder — e.g. luna has no ultra — default effort, identity, model_messages)
|
|
1118
|
-
// instead of cloning an older template.
|
|
1119
|
-
const upstream = upstreamNativeEntry(slug);
|
|
1120
|
-
if (upstream) return finishUpstreamNativeEntry(upstream, priority);
|
|
1121
|
-
}
|
|
1122
|
-
if (template) {
|
|
1123
|
-
const e = JSON.parse(JSON.stringify(template)) as RawEntry;
|
|
1124
|
-
e.slug = slug;
|
|
1125
|
-
e.display_name = slug;
|
|
1126
|
-
e.description = desc;
|
|
1127
|
-
e.priority = priority;
|
|
1128
|
-
e.visibility = "list";
|
|
1129
|
-
if ("upgrade" in e) e.upgrade = null;
|
|
1130
|
-
delete e.availability_nux; // don't replay another model's "now available" NUX
|
|
1131
|
-
// Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
|
|
1132
|
-
// and advertise the reasoning ladder Codex accepts.
|
|
1133
|
-
if (isRouted) {
|
|
1134
|
-
// Native id for identity text + metadata lookups — the slug may be an encoded
|
|
1135
|
-
// alias (`provider/vendor-model`); the model object carries the native id.
|
|
1136
|
-
const modelName = model?.id ?? slug.slice(slug.indexOf("/") + 1);
|
|
1137
|
-
if (typeof e.base_instructions === "string") {
|
|
1138
|
-
// Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy
|
|
1139
|
-
// (leaking that into base_instructions is a non-first-party signature → ToS risk).
|
|
1140
|
-
e.base_instructions = e.base_instructions.replace(
|
|
1141
|
-
CODEX_GPT5_IDENTITY_LINE,
|
|
1142
|
-
`You are a coding agent powered by the ${modelName} model. Do not claim to be GPT-5 or made by OpenAI.`,
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
|
|
1146
|
-
normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
|
|
1147
|
-
if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
|
|
1148
|
-
applyCatalogModelMetadata(e, model);
|
|
1149
|
-
} else {
|
|
1150
|
-
applyNativeOpenAiContextOverride(e);
|
|
1151
|
-
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
|
|
1152
|
-
else ensureUltraReasoningLevel(e);
|
|
1153
|
-
// Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
|
|
1154
|
-
// the template may carry the flag from a 5.6 entry — strip it so codex-rs does
|
|
1155
|
-
// not inject reasoning.context: "all_turns" for models that reject it.
|
|
1156
|
-
if (!isGpt56NativeSlug(slug)) {
|
|
1157
|
-
// Spark NEEDS use_responses_lite: true — it controls the tool delivery format
|
|
1158
|
-
// (AdditionalTools in input vs top-level tools). The reasoning params that
|
|
1159
|
-
// use_responses_lite triggers (context: "all_turns", summary) are stripped
|
|
1160
|
-
// separately in the passthrough adapter (stripUnsupportedReasoningParams).
|
|
1161
|
-
if (!slug.includes("codex-spark")) delete e.use_responses_lite;
|
|
1162
|
-
delete e.supports_websockets;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
return ensureStrictCatalogFields(normalizeServiceTiers(e), {
|
|
1166
|
-
preserveExactInputModalities: preserveExact,
|
|
1167
|
-
isRouted,
|
|
1168
|
-
});
|
|
1169
|
-
}
|
|
1170
|
-
// Fallback when no template is available (best-effort; strict parser may need more).
|
|
1171
|
-
const entry: RawEntry = {
|
|
1172
|
-
slug, display_name: slug, description: desc,
|
|
1173
|
-
shell_type: "shell_command", visibility: "list", supported_in_api: true,
|
|
1174
|
-
priority, base_instructions: "You are a helpful coding assistant.",
|
|
1175
|
-
...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
|
|
1176
|
-
};
|
|
1177
|
-
if (isRouted) {
|
|
1178
|
-
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
|
|
1179
|
-
}
|
|
1180
|
-
else {
|
|
1181
|
-
applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]);
|
|
1182
|
-
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
|
|
1183
|
-
}
|
|
1184
|
-
if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
|
|
1185
|
-
applyCatalogModelMetadata(entry, model);
|
|
1186
|
-
if (!isRouted) applyNativeOpenAiContextOverride(entry);
|
|
1187
|
-
return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
|
|
1188
|
-
preserveExactInputModalities: preserveExact,
|
|
1189
|
-
isRouted,
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
* Single source of truth for Codex-catalog-shaped entries, reused by both the on-disk
|
|
1195
|
-
* catalog sync and the proxy `/v1/models?client_version` branch.
|
|
1196
|
-
* Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
|
|
1197
|
-
*/
|
|
1198
|
-
export function buildCatalogEntries(
|
|
1199
|
-
template: RawEntry | null,
|
|
1200
|
-
gptSlugs: string[],
|
|
1201
|
-
goModels: CatalogModel[],
|
|
1202
|
-
featured?: string[],
|
|
1203
|
-
wsEnabled = false,
|
|
1204
|
-
multiAgentMode: MultiAgentMode = "default",
|
|
1205
|
-
exactComboSlugs: ReadonlySet<string> = new Set(),
|
|
1206
|
-
): RawEntry[] {
|
|
1207
|
-
// Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
|
|
1208
|
-
// models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
|
|
1209
|
-
// ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
|
|
1210
|
-
// it sorts to the front. This works for native gpt slugs AND routed slugs alike.
|
|
1211
|
-
const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
|
|
1212
|
-
const out: RawEntry[] = [];
|
|
1213
|
-
const collisionSkipped = resolveSlugAliasCollisions(goModels);
|
|
1214
|
-
const comboPublicSlugs = new Set(goModels
|
|
1215
|
-
.filter(model => model.provider === COMBO_NAMESPACE)
|
|
1216
|
-
.map(catalogModelSlug));
|
|
1217
|
-
for (const slug of gptSlugs) {
|
|
1218
|
-
const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
|
|
1219
|
-
if (rank.has(slug)) e.priority = rank.get(slug)!;
|
|
1220
|
-
out.push(e);
|
|
1221
|
-
}
|
|
1222
|
-
for (const m of goModels) {
|
|
1223
|
-
if (collisionSkipped.has(m)) continue;
|
|
1224
|
-
const slug = catalogModelSlug(m);
|
|
1225
|
-
if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
|
|
1226
|
-
warnComboMasqueradeCollisionOnce(slug);
|
|
1227
|
-
continue;
|
|
1228
|
-
}
|
|
1229
|
-
// Provider rows use the one-slash slug codec; combo aliases intentionally override that
|
|
1230
|
-
// public slug and may be bare.
|
|
1231
|
-
const e = deriveEntry(
|
|
1232
|
-
template,
|
|
1233
|
-
slug,
|
|
1234
|
-
`Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
|
|
1235
|
-
5,
|
|
1236
|
-
m,
|
|
1237
|
-
exactComboSlugs,
|
|
1238
|
-
);
|
|
1239
|
-
// Featured picks may be stored raw (legacy) or encoded — honor both.
|
|
1240
|
-
const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
|
|
1241
|
-
if (rankHit !== undefined) e.priority = rankHit;
|
|
1242
|
-
out.push(e);
|
|
1243
|
-
}
|
|
1244
|
-
// Central capability override (phase 120.4): the advertised flag must match the implemented WS
|
|
1245
|
-
// endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template
|
|
1246
|
-
// leak (deriveEntry clones the template as-is for native slugs).
|
|
1247
|
-
for (const entry of out) {
|
|
1248
|
-
if (wsEnabled) entry.supports_websockets = true;
|
|
1249
|
-
else {
|
|
1250
|
-
delete entry.supports_websockets;
|
|
1251
|
-
// Snapshot-backed native entries carry prefer_websockets: never advertise a preference
|
|
1252
|
-
// for an endpoint ocx has disabled.
|
|
1253
|
-
delete entry.prefer_websockets;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
return applyMultiAgentMode(out, multiAgentMode);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
|
|
1260
|
-
export function listCatalogNativeSlugs(): string[] {
|
|
1261
|
-
const cat = readCurrentCatalogOrCache();
|
|
1262
|
-
const live = filterSupportedNativeSlugs(cat?.models ?? []);
|
|
1263
|
-
// Ensure documented additions (e.g. gpt-5.3-codex-spark) appear even when the bundled catalog
|
|
1264
|
-
// predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models.
|
|
1265
|
-
return unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]);
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
/**
|
|
1269
|
-
* Keep only picker-visible, bare (non-routed) native slugs that opencodex actually supports.
|
|
1270
|
-
* A user's installed Codex may list legacy/internal natives (`gpt-5.2`, `gpt-5.3-codex`,
|
|
1271
|
-
* `codex-auto-review`, …); the allowlist drops them so `/v1/models` and the subagent picker
|
|
1272
|
-
* never advertise an unsupported native. Exported for regression coverage.
|
|
1273
|
-
*/
|
|
1274
|
-
export function filterSupportedNativeSlugs(models: RawEntry[]): string[] {
|
|
1275
|
-
return models
|
|
1276
|
-
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string))
|
|
1277
|
-
.map(m => m.slug as string);
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
/**
|
|
1281
|
-
* Native-model priority baseline read from the PRISTINE backup, so featuring stays reversible:
|
|
1282
|
-
* a featured native gets its low rank, and un-featuring restores its original catalog priority
|
|
1283
|
-
* (rather than the modified value left in the live catalog by a previous sync).
|
|
1284
|
-
*/
|
|
1285
|
-
function readCatalogBackup(catalogPath: string): RawCatalog | null {
|
|
1286
|
-
return readCatalog(catalogBackupPathFor(catalogPath))
|
|
1287
|
-
?? (isDefaultCatalogPath(catalogPath) ? readCatalog(legacyCatalogBackupPath()) : null);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
|
|
1291
|
-
return (catalog?.models ?? []).some(m => typeof m.slug === "string" && m.slug.includes("/"));
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
|
|
1295
|
-
if (existsSync(backupPath)) return;
|
|
1296
|
-
const onDisk = readCatalog(catalogPath);
|
|
1297
|
-
if (onDisk && !catalogHasRoutedEntries(onDisk)) {
|
|
1298
|
-
copyFileSync(catalogPath, backupPath);
|
|
1299
|
-
return;
|
|
1300
|
-
}
|
|
1301
|
-
if (!catalogHasRoutedEntries(catalog)) {
|
|
1302
|
-
atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
|
|
1307
|
-
const dir = getConfigDir();
|
|
1308
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1309
|
-
writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
|
|
1310
|
-
if (isDefaultCatalogPath(catalogPath)) writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
function readNativeBaseline(catalogPath: string): Map<string, number> {
|
|
1314
|
-
const backup = readCatalogBackup(catalogPath);
|
|
1315
|
-
const out = new Map<string, number>();
|
|
1316
|
-
for (const e of backup?.models ?? []) {
|
|
1317
|
-
if (typeof e.slug === "string" && !e.slug.includes("/") && typeof e.priority === "number") {
|
|
1318
|
-
out.set(e.slug, e.priority);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
return out;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
type ProviderModelsApiItem = {
|
|
1326
|
-
id: string;
|
|
1327
|
-
owned_by?: string;
|
|
1328
|
-
context_length?: number;
|
|
1329
|
-
max_model_len?: number;
|
|
1330
|
-
metadata?: {
|
|
1331
|
-
capabilities?: Record<string, unknown>;
|
|
1332
|
-
limits?: Record<string, unknown>;
|
|
1333
|
-
};
|
|
1334
|
-
};
|
|
1335
|
-
|
|
1336
|
-
function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
|
|
1337
|
-
return Array.isArray(value) && value.every(item =>
|
|
1338
|
-
item !== null
|
|
1339
|
-
&& typeof item === "object"
|
|
1340
|
-
&& !Array.isArray(item)
|
|
1341
|
-
&& typeof (item as { id?: unknown }).id === "string"
|
|
1342
|
-
&& (item as { id: string }).id.trim().length > 0
|
|
1343
|
-
);
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
|
|
1347
|
-
const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
|
|
1348
|
-
return typeof configured === "number" && configured > 0 ? configured : undefined;
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined {
|
|
1352
|
-
const modalities = modelRecordValue(prov.modelInputModalities, id);
|
|
1353
|
-
return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
|
|
1357
|
-
const configured = modelRecordValue(prov.modelMaxInputTokens, id);
|
|
1358
|
-
return typeof configured === "number" && configured > 0 ? configured : undefined;
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
|
|
1362
|
-
void name;
|
|
1363
|
-
const configuredCap = configuredContextWindow(prov, model.id);
|
|
1364
|
-
const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
|
|
1365
|
-
let inputModalities = configuredInputModalities(prov, model.id);
|
|
1366
|
-
// Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
|
|
1367
|
-
// (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
|
|
1368
|
-
// gates attachments client-side on input_modalities, and a text-only entry would block images
|
|
1369
|
-
// before the sidecar ever runs ("This model does not support image inputs").
|
|
1370
|
-
if (modelInList(prov.noVisionModels, model.id)) {
|
|
1371
|
-
const base = inputModalities ?? model.inputModalities ?? ["text"];
|
|
1372
|
-
inputModalities = base.includes("image") ? [...base] : [...base, "image"];
|
|
1373
|
-
}
|
|
1374
|
-
const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
|
|
1375
|
-
const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
|
|
1376
|
-
const hinted = {
|
|
1377
|
-
...model,
|
|
1378
|
-
...(configuredCap !== undefined
|
|
1379
|
-
? {
|
|
1380
|
-
contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0
|
|
1381
|
-
? Math.min(model.contextWindow, configuredCap)
|
|
1382
|
-
: configuredCap,
|
|
1383
|
-
}
|
|
1384
|
-
: {}),
|
|
1385
|
-
...(inputModalities ? { inputModalities } : {}),
|
|
1386
|
-
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
|
|
1387
|
-
...(configuredMaxInput !== undefined
|
|
1388
|
-
? {
|
|
1389
|
-
maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
|
|
1390
|
-
? Math.min(model.maxInputTokens, configuredMaxInput)
|
|
1391
|
-
: configuredMaxInput,
|
|
1392
|
-
}
|
|
1393
|
-
: {}),
|
|
1394
|
-
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
|
|
1395
|
-
...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
|
|
1396
|
-
// Default-on for openai-chat providers (explicit false opts out); other adapters
|
|
1397
|
-
// advertise only on explicit opt-in.
|
|
1398
|
-
...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
|
|
1399
|
-
? { parallelToolCalls: true }
|
|
1400
|
-
: {}),
|
|
1401
|
-
};
|
|
1402
|
-
const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
|
|
1403
|
-
if (providerCap !== undefined && capped !== hinted.contextWindow) {
|
|
1404
|
-
return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true };
|
|
1405
|
-
}
|
|
1406
|
-
return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial<CatalogModel> {
|
|
1410
|
-
const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap);
|
|
1411
|
-
const { provider: _provider, id: _id, ...hints } = hinted;
|
|
1412
|
-
return hints;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[], contextCap?: number): CatalogModel[] {
|
|
1416
|
-
return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
/**
|
|
1420
|
-
* TRUE when `liveId` is a dated release of the configured alias `configuredId`:
|
|
1421
|
-
* `<configuredId>-YYYYMMDD` (Anthropic's convention for superseded-but-callable models).
|
|
1422
|
-
*/
|
|
1423
|
-
export function isDatedVariantId(liveId: string, configuredId: string): boolean {
|
|
1424
|
-
if (!liveId.startsWith(`${configuredId}-`)) return false;
|
|
1425
|
-
return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// Same-signature dedupe: Codex polls /v1/models frequently, and an unchanged drop list
|
|
1429
|
-
// repeated on every poll is pure noise. Warn once per provider until the id set changes.
|
|
1430
|
-
const lastDropWarnSignature = new Map<string, string>();
|
|
1431
|
-
// These managed providers intentionally carry compatibility fallback ids while treating their
|
|
1432
|
-
// authenticated live catalogs as canonical. A non-empty live response already hides stale ids;
|
|
1433
|
-
// repeating that expected reconciliation on every startup only adds noise.
|
|
1434
|
-
const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
|
|
1435
|
-
// Direct OAuth chat-completions probes on 260718 confirmed these account-scoped ids still work
|
|
1436
|
-
// even though the providers omit them from `/models`. Preserve only the proven compatibility
|
|
1437
|
-
// ids; unknown configured ids and xAI's chat-incompatible multi-agent model remain hidden.
|
|
1438
|
-
const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
1439
|
-
kimi: new Set([
|
|
1440
|
-
"k3[1m]",
|
|
1441
|
-
"kimi-k2.7-code",
|
|
1442
|
-
"kimi-k2.7-code-highspeed",
|
|
1443
|
-
"kimi-k2.6",
|
|
1444
|
-
"kimi-k2.5",
|
|
1445
|
-
]),
|
|
1446
|
-
xai: new Set([
|
|
1447
|
-
"grok-4.3",
|
|
1448
|
-
"grok-4.20-0309-reasoning",
|
|
1449
|
-
"grok-4.20-0309-non-reasoning",
|
|
1450
|
-
"grok-build-0.1",
|
|
1451
|
-
"grok-composer-2.5-fast",
|
|
1452
|
-
]),
|
|
1453
|
-
};
|
|
1454
|
-
function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
|
|
1455
|
-
const signature = [...droppedConfiguredIds].sort().join(",");
|
|
1456
|
-
if (lastDropWarnSignature.get(name) === signature) return;
|
|
1457
|
-
lastDropWarnSignature.set(name, signature);
|
|
1458
|
-
console.warn(
|
|
1459
|
-
`[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
|
|
1460
|
-
);
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
function isGlm52ModelId(id: string): boolean {
|
|
1464
|
-
const normalized = id.toLowerCase();
|
|
1465
|
-
return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
|
|
1469
|
-
const capabilities = item.metadata?.capabilities;
|
|
1470
|
-
const limits = item.metadata?.limits;
|
|
1471
|
-
const contextWindow =
|
|
1472
|
-
typeof limits?.max_context_length === "number" ? limits.max_context_length
|
|
1473
|
-
: typeof item.context_length === "number" ? item.context_length
|
|
1474
|
-
: typeof item.max_model_len === "number" ? item.max_model_len
|
|
1475
|
-
: undefined;
|
|
1476
|
-
const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
|
|
1477
|
-
? (capabilities.reasoning_effort
|
|
1478
|
-
? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
|
|
1479
|
-
? ["low", "medium", "high", "xhigh", "max"]
|
|
1480
|
-
: ["low", "medium", "high", "xhigh"])
|
|
1481
|
-
: [])
|
|
1482
|
-
: undefined;
|
|
1483
|
-
const inputModalities = capabilities && typeof capabilities.vision === "boolean"
|
|
1484
|
-
? (capabilities.vision ? ["text", "image"] : ["text"])
|
|
1485
|
-
: undefined;
|
|
1486
|
-
return {
|
|
1487
|
-
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
|
1488
|
-
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
|
|
1489
|
-
...(inputModalities ? { inputModalities } : {}),
|
|
1490
|
-
};
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
/**
|
|
1494
|
-
* Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips
|
|
1495
|
-
* forward-auth providers. Fresh cache → no network; schema-valid live fetch → cache the
|
|
1496
|
-
* authoritative result; fetch failure or malformed data → last-known-good cache (so a provider
|
|
1497
|
-
* blip doesn't drop its models), else the static config list. This is the per-provider half of
|
|
1498
|
-
* jawcode's "always latest" resolver.
|
|
1499
|
-
*/
|
|
1500
|
-
async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
|
|
1501
|
-
if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
|
|
1502
|
-
const apiKey = await resolveModelsAuthToken(name, prov);
|
|
1503
|
-
const seedVertexDefault = prov.adapter === "google"
|
|
1504
|
-
&& prov.googleMode === "vertex"
|
|
1505
|
-
&& (prov.models?.length ?? 0) === 0
|
|
1506
|
-
&& Boolean(prov.defaultModel);
|
|
1507
|
-
const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
|
|
1508
|
-
const configured: CatalogModel[] = configuredIds.map(id => ({
|
|
1509
|
-
id,
|
|
1510
|
-
provider: name,
|
|
1511
|
-
...catalogHintsFromProviderConfig(name, prov, id, contextCap),
|
|
1512
|
-
}));
|
|
1513
|
-
// A configured default is a real callable selector and must remain discoverable when a
|
|
1514
|
-
// compatible provider's live /models request fails (issue #308). Keep this separate from the
|
|
1515
|
-
// explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
|
|
1516
|
-
// rows, while a failed live discovery may degrade to the default selector.
|
|
1517
|
-
const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
|
|
1518
|
-
? configured
|
|
1519
|
-
: [{
|
|
1520
|
-
id: prov.defaultModel,
|
|
1521
|
-
provider: name,
|
|
1522
|
-
...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap),
|
|
1523
|
-
}];
|
|
1524
|
-
const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
|
|
1525
|
-
const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
|
|
1526
|
-
vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
|
|
1527
|
-
? [...models, vertexDefaultSeed]
|
|
1528
|
-
: models
|
|
1529
|
-
);
|
|
1530
|
-
if (prov.adapter === "cursor") {
|
|
1531
|
-
if (prov.liveModels === false || !apiKey) return configured;
|
|
1532
|
-
// Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
|
|
1533
|
-
// variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
|
|
1534
|
-
// suffix) but filter the static seed to the bases the account actually has — so models not on the
|
|
1535
|
-
// plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
|
|
1536
|
-
const cachedCursor = getFreshCached(name, ttlMs);
|
|
1537
|
-
if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
|
|
1538
|
-
const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
|
|
1539
|
-
if (liveResult.ok) {
|
|
1540
|
-
const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
|
|
1541
|
-
const result = available.length > 0 ? available : configured;
|
|
1542
|
-
setCached(name, result);
|
|
1543
|
-
return result;
|
|
1544
|
-
}
|
|
1545
|
-
console.warn(
|
|
1546
|
-
`[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
|
|
1547
|
-
);
|
|
1548
|
-
const staleCursor = getStaleCached(name);
|
|
1549
|
-
return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
|
|
1550
|
-
}
|
|
1551
|
-
if (prov.authMode === "oauth" && !apiKey) {
|
|
1552
|
-
// No usable token (logged out, or account marked needsReauth). Still surface the
|
|
1553
|
-
// configured static catalog so the GUI Models tab / rail counts are not empty —
|
|
1554
|
-
// matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
|
|
1555
|
-
return configured;
|
|
1556
|
-
}
|
|
1557
|
-
if (prov.liveModels === false) {
|
|
1558
|
-
return configured;
|
|
1559
|
-
}
|
|
1560
|
-
const fresh = getFreshCached(name, ttlMs);
|
|
1561
|
-
if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
|
|
1562
|
-
if (isModelsFetchCoolingDown(name)) {
|
|
1563
|
-
// A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
|
|
1564
|
-
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
|
|
1565
|
-
const stale = getStaleCached(name);
|
|
1566
|
-
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
|
|
1567
|
-
}
|
|
1568
|
-
const { url, headers } = buildModelsRequest(prov, apiKey, name);
|
|
1569
|
-
const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
|
|
1570
|
-
? "vertex-aiplatform"
|
|
1571
|
-
: "provider-models";
|
|
1572
|
-
const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
|
|
1573
|
-
markModelsFetchFailure(name);
|
|
1574
|
-
const stale = getStaleCached(name);
|
|
1575
|
-
return {
|
|
1576
|
-
models: stale
|
|
1577
|
-
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
|
|
1578
|
-
: failedDiscoveryConfigured,
|
|
1579
|
-
fallback: stale ? "stale" : "configured",
|
|
1580
|
-
};
|
|
1581
|
-
};
|
|
1582
|
-
try {
|
|
1583
|
-
const destinationError = await providerDestinationResolvedError(name, {
|
|
1584
|
-
baseUrl: url,
|
|
1585
|
-
allowPrivateNetwork: prov.allowPrivateNetwork,
|
|
1586
|
-
});
|
|
1587
|
-
if (destinationError) {
|
|
1588
|
-
const { models, fallback } = failedDiscoveryFallback();
|
|
1589
|
-
console.warn(
|
|
1590
|
-
`[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
1591
|
-
);
|
|
1592
|
-
return models;
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
|
1596
|
-
if (!res.ok) {
|
|
1597
|
-
const { models, fallback } = failedDiscoveryFallback();
|
|
1598
|
-
console.warn(
|
|
1599
|
-
`[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
1600
|
-
);
|
|
1601
|
-
return models;
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
const contentType = (
|
|
1605
|
-
res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
|
|
1606
|
-
).slice(0, 80);
|
|
1607
|
-
const body = await res.text();
|
|
1608
|
-
let json: unknown;
|
|
1609
|
-
try {
|
|
1610
|
-
json = JSON.parse(body) as unknown;
|
|
1611
|
-
} catch {
|
|
1612
|
-
const { models, fallback } = failedDiscoveryFallback();
|
|
1613
|
-
const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
|
|
1614
|
-
? "returned invalid JSON in a 2xx response"
|
|
1615
|
-
: "returned a non-JSON 2xx response";
|
|
1616
|
-
console.warn(
|
|
1617
|
-
`[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
|
|
1618
|
-
);
|
|
1619
|
-
return models;
|
|
1620
|
-
}
|
|
1621
|
-
const data = json !== null && typeof json === "object" && !Array.isArray(json)
|
|
1622
|
-
? (json as { data?: unknown }).data
|
|
1623
|
-
: undefined;
|
|
1624
|
-
if (!isProviderModelsApiItems(data)) {
|
|
1625
|
-
const { models, fallback } = failedDiscoveryFallback();
|
|
1626
|
-
console.warn(
|
|
1627
|
-
`[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
|
|
1628
|
-
);
|
|
1629
|
-
return models;
|
|
1630
|
-
}
|
|
1631
|
-
const items = data;
|
|
1632
|
-
const live = items.map(m => applyProviderConfigHints(name, prov, {
|
|
1633
|
-
id: m.id,
|
|
1634
|
-
provider: name,
|
|
1635
|
-
owned_by: m.owned_by,
|
|
1636
|
-
...catalogHintsFromModelsApiItem(name, m),
|
|
1637
|
-
}, contextCap))
|
|
1638
|
-
.filter(m => shouldExposeProviderModel(name, m.id));
|
|
1639
|
-
const liveIds = new Set(live.map(m => m.id));
|
|
1640
|
-
// Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
|
|
1641
|
-
// ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
|
|
1642
|
-
// API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
|
|
1643
|
-
// in the authoritative catalog (alias id, hints from the dated live entry) instead of
|
|
1644
|
-
// dropping them and warning on every poll.
|
|
1645
|
-
const droppedConfiguredIds: string[] = [];
|
|
1646
|
-
for (const m of configured) {
|
|
1647
|
-
if (liveIds.has(m.id)) continue;
|
|
1648
|
-
const dated = live.find(l => isDatedVariantId(l.id, m.id));
|
|
1649
|
-
if (dated) {
|
|
1650
|
-
// Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
|
|
1651
|
-
live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
|
|
1652
|
-
} else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
|
|
1653
|
-
live.push(m);
|
|
1654
|
-
} else {
|
|
1655
|
-
droppedConfiguredIds.push(m.id);
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
|
|
1659
|
-
console.warn(
|
|
1660
|
-
`[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"}.`,
|
|
1661
|
-
);
|
|
1662
|
-
} else if (droppedConfiguredIds.length > 0
|
|
1663
|
-
&& name !== OPENAI_API_PROVIDER_ID
|
|
1664
|
-
&& !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
|
|
1665
|
-
warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
|
|
1666
|
-
}
|
|
1667
|
-
setCached(name, live);
|
|
1668
|
-
return live;
|
|
1669
|
-
} catch (error) {
|
|
1670
|
-
const { models, fallback } = failedDiscoveryFallback();
|
|
1671
|
-
console.warn(
|
|
1672
|
-
`[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
1673
|
-
);
|
|
1674
|
-
return models;
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
|
|
1679
|
-
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1680
|
-
return true;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
|
|
1684
|
-
if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
|
|
1685
|
-
if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
|
|
1686
|
-
return false;
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
/**
|
|
1690
|
-
* Narrow a raw routed-model list to what Codex's catalog / clients should see: drop the
|
|
1691
|
-
* `disabledModels` blocklist AND, for any provider with a non-empty `selectedModels` allowlist, keep
|
|
1692
|
-
* only those ids. This is the single choke point applied at every CATALOG emission point (on-disk
|
|
1693
|
-
* sync + /v1/models); the admin `/api/models` list stays unfiltered so the picker can show the full
|
|
1694
|
-
* set. Live discovery is unaffected — this only decides what ships. See issue_052.
|
|
1695
|
-
*/
|
|
1696
|
-
export function filterCatalogVisibleModels(
|
|
1697
|
-
models: CatalogModel[],
|
|
1698
|
-
config: Pick<OcxConfig, "disabledModels" | "providers">,
|
|
1699
|
-
): CatalogModel[] {
|
|
1700
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1701
|
-
const allowByProvider = new Map<string, Set<string>>();
|
|
1702
|
-
for (const [name, prov] of Object.entries(config.providers)) {
|
|
1703
|
-
const sel = prov.selectedModels;
|
|
1704
|
-
if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
|
|
1705
|
-
}
|
|
1706
|
-
return models.filter(m => {
|
|
1707
|
-
// disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
|
|
1708
|
-
for (const stored of disabled) {
|
|
1709
|
-
// Combo management stores the public alias, while canonical `combo/<id>` references
|
|
1710
|
-
// remain valid for backward compatibility through slugEquals below.
|
|
1711
|
-
if (m.alias !== undefined && stored === catalogModelSlug(m)) return false;
|
|
1712
|
-
if (slugEquals(stored, m.provider, m.id)) return false;
|
|
1713
|
-
}
|
|
1714
|
-
const allow = allowByProvider.get(m.provider);
|
|
1715
|
-
return !allow || allow.has(m.id);
|
|
1716
|
-
});
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
/**
|
|
1720
|
-
* Gather routed (non-forward) provider models across the config — the single source of truth for
|
|
1721
|
-
* the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
|
|
1722
|
-
* endpoints. Providers are fetched in parallel; the result is sorted (provider, then id) for a
|
|
1723
|
-
* stable listing. TTL comes from `config.modelCacheTtlMs` (default 5 min).
|
|
1724
|
-
*/
|
|
1725
|
-
export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
1726
|
-
const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
|
|
1727
|
-
// Persisted provider entries can predate newer registry fields (noVisionModels,
|
|
1728
|
-
// modelInputModalities, ...). The ROUTER merges registry seeds at request time
|
|
1729
|
-
// (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
|
|
1730
|
-
// same merged view or its advertisements drift from actual proxy behavior (e.g. a
|
|
1731
|
-
// vision-sidecar model advertised text-only, blocking image attachments app-side).
|
|
1732
|
-
// Enrich a CLONE: hydrated defaults must never leak into the persisted config.
|
|
1733
|
-
const activeProviders = Object.entries(config.providers)
|
|
1734
|
-
.filter(([, prov]) => prov.disabled !== true)
|
|
1735
|
-
.map(([name, prov]): [string, OcxProviderConfig] => {
|
|
1736
|
-
const enriched = { ...prov };
|
|
1737
|
-
enrichProviderFromRegistry(name, enriched);
|
|
1738
|
-
return [name, enriched];
|
|
1739
|
-
});
|
|
1740
|
-
const lists = await Promise.all(
|
|
1741
|
-
activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
|
|
1742
|
-
);
|
|
1743
|
-
const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config);
|
|
1744
|
-
const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
|
|
1745
|
-
// Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
|
|
1746
|
-
// intentionally mirrors Cursor's public model table, including Gemini image preview, so the
|
|
1747
|
-
// exposure decision goes through shouldExposeRoutedModel (single choke point).
|
|
1748
|
-
.filter(shouldExposeRoutedModel);
|
|
1749
|
-
const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
|
|
1750
|
-
// [Decision Log]
|
|
1751
|
-
// - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
|
|
1752
|
-
// 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
|
|
1753
|
-
// passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
|
|
1754
|
-
// 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
|
|
1755
|
-
// openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
|
|
1756
|
-
// - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
|
|
1757
|
-
// - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
|
|
1758
|
-
// 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
|
|
1759
|
-
// CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
|
|
1760
|
-
// 노출 경로에 영향 없음.
|
|
1761
|
-
// - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
|
|
1762
|
-
// - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
|
|
1763
|
-
// 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
|
|
1764
|
-
// - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
|
|
1765
|
-
// capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
|
|
1766
|
-
// 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
|
|
1767
|
-
// 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
|
|
1768
|
-
// 우선시하므로 실제 충돌 가능성은 낮음.
|
|
1769
|
-
if (!hasComboTargets(config)) {
|
|
1770
|
-
// Skip the native slug injection entirely when no combos are configured — avoids
|
|
1771
|
-
// calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
|
|
1772
|
-
// configs that will never need it.
|
|
1773
|
-
} else {
|
|
1774
|
-
const disabled = disabledNativeSlugs(config);
|
|
1775
|
-
for (const slug of nativeOpenAiSlugs()) {
|
|
1776
|
-
if (disabled.has(slug)) continue;
|
|
1777
|
-
const contextWindow = nativeOpenAiContextWindow(slug);
|
|
1778
|
-
if (contextWindow === undefined) continue;
|
|
1779
|
-
const synthetic: CatalogModel = {
|
|
1780
|
-
provider: "openai",
|
|
1781
|
-
id: slug,
|
|
1782
|
-
owned_by: "openai",
|
|
1783
|
-
contextWindow,
|
|
1784
|
-
maxInputTokens: contextWindow,
|
|
1785
|
-
inputModalities: nativeInputModalities(slug),
|
|
1786
|
-
reasoningEfforts: nativeReasoningEfforts(slug),
|
|
1787
|
-
...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
|
|
1788
|
-
};
|
|
1789
|
-
const key = `openai/${slug}`;
|
|
1790
|
-
// Only inject when not already present from a routed provider (an API-key
|
|
1791
|
-
// "openai" provider could shadow the native one).
|
|
1792
|
-
if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
for (const id of listComboIds(config)) {
|
|
1796
|
-
const combo = getCombo(config, id);
|
|
1797
|
-
if (!combo) continue;
|
|
1798
|
-
const members = combo.targets
|
|
1799
|
-
.map(target => memberByKey.get(targetKey(target)))
|
|
1800
|
-
.filter((member): member is CatalogModel => member !== undefined);
|
|
1801
|
-
const derived = deriveComboCatalogModel(id, combo, members);
|
|
1802
|
-
if (derived) all.push(derived);
|
|
1803
|
-
else warnUncataloguedComboOnce(id, combo, members);
|
|
1804
|
-
}
|
|
1805
|
-
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
1806
|
-
const customModels = (config.customModels ?? []).map(cm => ({
|
|
1807
|
-
id: cm.modelId,
|
|
1808
|
-
provider: cm.provider,
|
|
1809
|
-
// Display-only label: never feeds routing (customModels are keyed by routedSlug below).
|
|
1810
|
-
...(cm.displayName ? { displayName: cm.displayName } : {}),
|
|
1811
|
-
...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
|
|
1812
|
-
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
1813
|
-
}));
|
|
1814
|
-
// Custom rows override discovered rows that encode to the same Codex-facing slug.
|
|
1815
|
-
const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
|
|
1816
|
-
const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
|
|
1817
|
-
return [...deduped, ...customModels];
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
const openAiApiCollisionWarnings = new Set<string>();
|
|
1821
|
-
const comboCatalogWarningSignatures = new Map<string, string>();
|
|
1822
|
-
|
|
1823
|
-
function intersectStrings(values: readonly string[][]): string[] {
|
|
1824
|
-
if (values.length === 0) return [];
|
|
1825
|
-
const rest = values.slice(1).map(value => new Set(value));
|
|
1826
|
-
return [...new Set(values[0])].filter(value => rest.every(set => set.has(value)));
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
function effectiveComboDefault(
|
|
1830
|
-
configured: string | null | undefined,
|
|
1831
|
-
common: readonly string[],
|
|
1832
|
-
): string | undefined {
|
|
1833
|
-
if (!configured) return undefined;
|
|
1834
|
-
if (configured && common.includes(configured)) return configured;
|
|
1835
|
-
const requestedRank = codexEffortRank(configured);
|
|
1836
|
-
const ranked = common
|
|
1837
|
-
.map(effort => ({ effort, rank: codexEffortRank(effort) }))
|
|
1838
|
-
.filter(item => item.rank >= 0)
|
|
1839
|
-
.sort((a, b) => a.rank - b.rank);
|
|
1840
|
-
if (ranked.length === 0) return undefined;
|
|
1841
|
-
const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
|
|
1842
|
-
return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
export function deriveComboCatalogModel(
|
|
1846
|
-
id: string,
|
|
1847
|
-
combo: NormalizedComboConfig,
|
|
1848
|
-
members: readonly CatalogModel[],
|
|
1849
|
-
): CatalogModel | null {
|
|
1850
|
-
if (combo.targets.length === 0) return null;
|
|
1851
|
-
if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) return null;
|
|
1852
|
-
if (members.length !== combo.targets.length) return null;
|
|
1853
|
-
if (!members.every((member, index) => (
|
|
1854
|
-
`${member.provider}/${member.id}` === targetKey(combo.targets[index]!)
|
|
1855
|
-
))) return null;
|
|
1856
|
-
const contexts = members.map(member => member.contextWindow);
|
|
1857
|
-
if (contexts.some(value => typeof value !== "number" || value <= 0)) return null;
|
|
1858
|
-
|
|
1859
|
-
const inputModalities = intersectStrings(
|
|
1860
|
-
members.map(member => member.inputModalities ?? ["text"]),
|
|
1861
|
-
);
|
|
1862
|
-
if (inputModalities.length === 0) return null;
|
|
1863
|
-
const reasoningEfforts = intersectStrings(
|
|
1864
|
-
members.map(member => member.reasoningEfforts ?? []),
|
|
1865
|
-
);
|
|
1866
|
-
const contextWindow = Math.min(...contexts as number[]);
|
|
1867
|
-
const maxInputTokens = Math.min(
|
|
1868
|
-
...members.map(member => member.maxInputTokens ?? member.contextWindow!),
|
|
1869
|
-
);
|
|
1870
|
-
const defaultReasoningEffort = effectiveComboDefault(
|
|
1871
|
-
combo.defaultEffort,
|
|
1872
|
-
reasoningEfforts,
|
|
1873
|
-
);
|
|
1874
|
-
|
|
1875
|
-
return {
|
|
1876
|
-
provider: COMBO_NAMESPACE,
|
|
1877
|
-
id,
|
|
1878
|
-
owned_by: COMBO_NAMESPACE,
|
|
1879
|
-
contextWindow,
|
|
1880
|
-
maxInputTokens,
|
|
1881
|
-
inputModalities,
|
|
1882
|
-
reasoningEfforts,
|
|
1883
|
-
...(combo.alias ? { alias: combo.alias } : {}),
|
|
1884
|
-
...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
|
|
1885
|
-
...(members.every(member => member.parallelToolCalls === true)
|
|
1886
|
-
? { parallelToolCalls: true }
|
|
1887
|
-
: {}),
|
|
1888
|
-
};
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
function safeCatalogWarningLabel(value: string): string {
|
|
1892
|
-
return redactSecretString(value)
|
|
1893
|
-
.replace(/[\u0000-\u001f\u007f]/g, "?")
|
|
1894
|
-
.slice(0, 200);
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
function comboCatalogWarningSignature(
|
|
1898
|
-
combo: NormalizedComboConfig,
|
|
1899
|
-
members: readonly CatalogModel[],
|
|
1900
|
-
): string {
|
|
1901
|
-
const discovered = new Map<string, CatalogModel>(members.map(member => [
|
|
1902
|
-
`${member.provider}/${member.id}`,
|
|
1903
|
-
member,
|
|
1904
|
-
] as const));
|
|
1905
|
-
return JSON.stringify(combo.targets.map(target => {
|
|
1906
|
-
const key = targetKey(target);
|
|
1907
|
-
const member = discovered.get(key);
|
|
1908
|
-
return {
|
|
1909
|
-
key,
|
|
1910
|
-
contextWindow: member?.contextWindow ?? null,
|
|
1911
|
-
maxInputTokens: member?.maxInputTokens ?? null,
|
|
1912
|
-
inputModalities: [...new Set(member?.inputModalities ?? [])].sort(),
|
|
1913
|
-
reasoningEfforts: [...new Set(member?.reasoningEfforts ?? [])].sort(),
|
|
1914
|
-
parallelToolCalls: member?.parallelToolCalls === true,
|
|
1915
|
-
};
|
|
1916
|
-
}).sort((a, b) => a.key.localeCompare(b.key)));
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
function warnUncataloguedComboOnce(
|
|
1920
|
-
id: string,
|
|
1921
|
-
combo: NormalizedComboConfig,
|
|
1922
|
-
members: readonly CatalogModel[],
|
|
1923
|
-
): void {
|
|
1924
|
-
const signature = comboCatalogWarningSignature(combo, members);
|
|
1925
|
-
if (comboCatalogWarningSignatures.get(id) === signature) return;
|
|
1926
|
-
comboCatalogWarningSignatures.set(id, signature);
|
|
1927
|
-
const targets = combo.targets
|
|
1928
|
-
.map(target => safeCatalogWarningLabel(targetKey(target)))
|
|
1929
|
-
.sort((a, b) => a.localeCompare(b));
|
|
1930
|
-
console.warn(
|
|
1931
|
-
`[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because member capabilities are incomplete: ${targets.join(", ")}.`,
|
|
1932
|
-
);
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
export function exactComboCatalogSlugs(
|
|
1936
|
-
config: Pick<OcxConfig, "combos" | "disabledModels">,
|
|
1937
|
-
): Set<string> {
|
|
1938
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1939
|
-
return new Set(listComboIds(config).flatMap(id => {
|
|
1940
|
-
const alias = typeof config.combos?.[id]?.alias === "string"
|
|
1941
|
-
? config.combos[id]!.alias!.trim()
|
|
1942
|
-
: "";
|
|
1943
|
-
const canonical = comboModelId(id);
|
|
1944
|
-
const publicSlug = alias || canonical;
|
|
1945
|
-
return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug];
|
|
1946
|
-
}));
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
function normalizedOpenAiApiSignature(model: CatalogModel): string {
|
|
1950
|
-
const normalized = {
|
|
1951
|
-
provider: model.provider,
|
|
1952
|
-
id: model.id,
|
|
1953
|
-
contextWindow: model.contextWindow ?? null,
|
|
1954
|
-
maxInputTokens: model.maxInputTokens ?? null,
|
|
1955
|
-
inputModalities: [...new Set(model.inputModalities ?? [])].sort(),
|
|
1956
|
-
reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(),
|
|
1957
|
-
ownedBy: model.owned_by ?? null,
|
|
1958
|
-
};
|
|
1959
|
-
return JSON.stringify(normalized);
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
export function resetOpenAiApiCatalogWarningStateForTests(): void {
|
|
1963
|
-
openAiApiCollisionWarnings.clear();
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
/**
|
|
1967
|
-
* Encode-collision guard (slug-codec): two DISTINCT native ids of one provider mapping
|
|
1968
|
-
* to the same Codex-facing alias (`a/b` vs `a-b`) cannot be decoded bijectively and must
|
|
1969
|
-
* not emit duplicate catalog slugs. The plain-hyphen native id wins (matching decode
|
|
1970
|
-
* precedence: exact native match first); the loser is dropped from the catalog — it stays
|
|
1971
|
-
* callable via its raw full-slash selector — and we warn once per provider+alias.
|
|
1972
|
-
*/
|
|
1973
|
-
const slugAliasCollisionWarnings = new Set<string>();
|
|
1974
|
-
const comboMasqueradeCollisionWarnings = new Set<string>();
|
|
1975
|
-
|
|
1976
|
-
function warnComboMasqueradeCollisionOnce(slug: string): void {
|
|
1977
|
-
if (comboMasqueradeCollisionWarnings.has(slug)) return;
|
|
1978
|
-
comboMasqueradeCollisionWarnings.add(slug);
|
|
1979
|
-
console.warn(
|
|
1980
|
-
`[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the combo wins and the shadowed provider model is omitted from the catalog.`,
|
|
1981
|
-
);
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel> {
|
|
1985
|
-
const skipped = new Set<CatalogModel>();
|
|
1986
|
-
const winnerByAlias = new Map<string, CatalogModel>();
|
|
1987
|
-
for (const m of goModels) {
|
|
1988
|
-
// Combo aliases have their own collision policy below: they always shadow provider rows.
|
|
1989
|
-
if (m.provider === COMBO_NAMESPACE) continue;
|
|
1990
|
-
const key = catalogModelSlug(m);
|
|
1991
|
-
const winner = winnerByAlias.get(key);
|
|
1992
|
-
if (!winner) {
|
|
1993
|
-
winnerByAlias.set(key, m);
|
|
1994
|
-
continue;
|
|
1995
|
-
}
|
|
1996
|
-
const winnerIsPlainAlias = !winner.id.includes("/");
|
|
1997
|
-
const currentIsPlainAlias = !m.id.includes("/");
|
|
1998
|
-
if (currentIsPlainAlias && !winnerIsPlainAlias) {
|
|
1999
|
-
skipped.add(winner);
|
|
2000
|
-
winnerByAlias.set(key, m);
|
|
2001
|
-
} else {
|
|
2002
|
-
skipped.add(m);
|
|
2003
|
-
}
|
|
2004
|
-
if (!slugAliasCollisionWarnings.has(key)) {
|
|
2005
|
-
slugAliasCollisionWarnings.add(key);
|
|
2006
|
-
console.warn(
|
|
2007
|
-
`[opencodex] slug alias collision on "${key}": multiple native ids encode to the same Codex-facing slug; `
|
|
2008
|
-
+ "the plain-hyphen native id is cataloged, the slash id remains callable via its raw selector.",
|
|
2009
|
-
);
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
return skipped;
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
/** Apply Codex-facing encoded-slug collision policy to management and picker rows. */
|
|
2016
|
-
export function uniqueCatalogModelsForPublicList(goModels: CatalogModel[]): CatalogModel[] {
|
|
2017
|
-
const collisionSkipped = resolveSlugAliasCollisions(goModels);
|
|
2018
|
-
const comboPublicSlugs = new Set(goModels
|
|
2019
|
-
.filter(model => model.provider === COMBO_NAMESPACE)
|
|
2020
|
-
.map(catalogModelSlug));
|
|
2021
|
-
const seen = new Set<string>();
|
|
2022
|
-
const out: CatalogModel[] = [];
|
|
2023
|
-
for (const model of goModels) {
|
|
2024
|
-
if (collisionSkipped.has(model)) continue;
|
|
2025
|
-
const slug = catalogModelSlug(model);
|
|
2026
|
-
if (model.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
|
|
2027
|
-
warnComboMasqueradeCollisionOnce(slug);
|
|
2028
|
-
continue;
|
|
2029
|
-
}
|
|
2030
|
-
if (seen.has(slug)) continue;
|
|
2031
|
-
seen.add(slug);
|
|
2032
|
-
out.push(model);
|
|
2033
|
-
}
|
|
2034
|
-
return out;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
/**
|
|
2038
|
-
* Deduplicate the ordinary OpenAI `/v1/models` list by the exact id emitted by that
|
|
2039
|
-
* endpoint. Raw selectors that merely encode to the same Codex slug remain independently
|
|
2040
|
-
* callable and visible; an exact combo alias collision still resolves in favor of the combo.
|
|
2041
|
-
*/
|
|
2042
|
-
export function uniqueCatalogModelsForRawPublicList(goModels: CatalogModel[]): CatalogModel[] {
|
|
2043
|
-
const publicId = (model: CatalogModel): string => model.alias ?? `${model.provider}/${model.id}`;
|
|
2044
|
-
const comboPublicIds = new Set(goModels
|
|
2045
|
-
.filter(model => model.provider === COMBO_NAMESPACE)
|
|
2046
|
-
.map(publicId));
|
|
2047
|
-
const seen = new Set<string>();
|
|
2048
|
-
const out: CatalogModel[] = [];
|
|
2049
|
-
for (const model of goModels) {
|
|
2050
|
-
const id = publicId(model);
|
|
2051
|
-
if (model.provider !== COMBO_NAMESPACE && comboPublicIds.has(id)) {
|
|
2052
|
-
warnComboMasqueradeCollisionOnce(id);
|
|
2053
|
-
continue;
|
|
2054
|
-
}
|
|
2055
|
-
if (seen.has(id)) continue;
|
|
2056
|
-
seen.add(id);
|
|
2057
|
-
out.push(model);
|
|
2058
|
-
}
|
|
2059
|
-
return out;
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
/** Test-only reset for every process-global catalog cache/warning owner. */
|
|
2063
|
-
export function resetCatalogRuntimeStateForTests(): void {
|
|
2064
|
-
bundledCatalogCache = null;
|
|
2065
|
-
lastDropWarnSignature.clear();
|
|
2066
|
-
openAiApiCollisionWarnings.clear();
|
|
2067
|
-
comboCatalogWarningSignatures.clear();
|
|
2068
|
-
slugAliasCollisionWarnings.clear();
|
|
2069
|
-
comboMasqueradeCollisionWarnings.clear();
|
|
2070
|
-
clearModelCache();
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
export function augmentRoutedModelsWithRegistryOpenAiApiRows(
|
|
2074
|
-
models: CatalogModel[],
|
|
2075
|
-
config: OcxConfig,
|
|
2076
|
-
): CatalogModel[] {
|
|
2077
|
-
const configured = config.providers[OPENAI_API_PROVIDER_ID];
|
|
2078
|
-
if (!configured || configured.disabled === true) return models;
|
|
2079
|
-
const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
|
|
2080
|
-
if (!entry?.models) return models;
|
|
2081
|
-
|
|
2082
|
-
const existingById = new Map(
|
|
2083
|
-
models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
|
|
2084
|
-
);
|
|
2085
|
-
const trustedRows = entry.models.map((id): CatalogModel => {
|
|
2086
|
-
const officialContext = entry.modelContextWindows?.[id];
|
|
2087
|
-
const officialMaxInput = entry.modelMaxInputTokens?.[id];
|
|
2088
|
-
const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
|
|
2089
|
-
const userMaxInput = configured.modelMaxInputTokens?.[id];
|
|
2090
|
-
const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
|
|
2091
|
-
const contextWindow = typeof officialContext === "number"
|
|
2092
|
-
? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
|
|
2093
|
-
: undefined;
|
|
2094
|
-
const maxInputTokens = typeof officialMaxInput === "number"
|
|
2095
|
-
? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput)
|
|
2096
|
-
: undefined;
|
|
2097
|
-
return {
|
|
2098
|
-
provider: OPENAI_API_PROVIDER_ID,
|
|
2099
|
-
id,
|
|
2100
|
-
owned_by: OPENAI_API_PROVIDER_ID,
|
|
2101
|
-
...(contextWindow ? { contextWindow } : {}),
|
|
2102
|
-
...(maxInputTokens ? { maxInputTokens } : {}),
|
|
2103
|
-
...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
|
|
2104
|
-
...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
|
|
2105
|
-
};
|
|
2106
|
-
});
|
|
2107
|
-
|
|
2108
|
-
for (const trusted of trustedRows) {
|
|
2109
|
-
const live = existingById.get(trusted.id);
|
|
2110
|
-
if (!live) continue;
|
|
2111
|
-
const liveSignature = normalizedOpenAiApiSignature(live);
|
|
2112
|
-
const trustedSignature = normalizedOpenAiApiSignature(trusted);
|
|
2113
|
-
if (liveSignature === trustedSignature) continue;
|
|
2114
|
-
const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
|
|
2115
|
-
if (openAiApiCollisionWarnings.has(warningKey)) continue;
|
|
2116
|
-
openAiApiCollisionWarnings.add(warningKey);
|
|
2117
|
-
console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
return [
|
|
2121
|
-
...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
|
|
2122
|
-
...trustedRows,
|
|
2123
|
-
];
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
export function augmentRoutedModelsWithJawcodeMetadata(
|
|
2127
|
-
models: CatalogModel[],
|
|
2128
|
-
providerNames: string[],
|
|
2129
|
-
providers?: Record<string, OcxProviderConfig>,
|
|
2130
|
-
caps?: Pick<OcxConfig, "providerContextCaps">,
|
|
2131
|
-
): CatalogModel[] {
|
|
2132
|
-
const out = [...models];
|
|
2133
|
-
const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
|
|
2134
|
-
for (const provider of providerNames) {
|
|
2135
|
-
if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
|
|
2136
|
-
if (providers?.[provider]?.liveModels === false) continue;
|
|
2137
|
-
const jawcodeProvider = resolveJawcodeProvider(provider);
|
|
2138
|
-
if (!jawcodeProvider) continue;
|
|
2139
|
-
for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
|
|
2140
|
-
const key = `${provider}/${meta.id}`;
|
|
2141
|
-
if (seen.has(key)) continue;
|
|
2142
|
-
seen.add(key);
|
|
2143
|
-
const contextCap = caps ? providerContextCap(caps, provider) : undefined;
|
|
2144
|
-
const model: CatalogModel = {
|
|
2145
|
-
provider,
|
|
2146
|
-
id: meta.id,
|
|
2147
|
-
owned_by: provider,
|
|
2148
|
-
...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
|
|
2149
|
-
...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
|
|
2150
|
-
};
|
|
2151
|
-
out.push({
|
|
2152
|
-
...model,
|
|
2153
|
-
...(providers?.[provider] ? applyProviderConfigHints(provider, providers[provider], model, contextCap) : {}),
|
|
2154
|
-
});
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
return out;
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
/**
|
|
2161
|
-
* Reorder routed models so the configured subagent picks come FIRST (in the chosen order).
|
|
2162
|
-
* Codex's spawn_agent advertises only the first 5 routed catalog entries, so putting the chosen
|
|
2163
|
-
* ones first makes exactly them appear as overrides. Non-featured keep their relative order (stable
|
|
2164
|
-
* sort) and stay visibility:"list" — so they remain in the main /model picker and callable by name.
|
|
2165
|
-
*/
|
|
2166
|
-
export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {
|
|
2167
|
-
if (!featured || featured.length === 0) return goModels;
|
|
2168
|
-
const rank = new Map(featured.map((id, i) => [id, i]));
|
|
2169
|
-
// Featured picks may be stored raw (legacy) or encoded — match both forms.
|
|
2170
|
-
const rankOf = (m: CatalogModel) =>
|
|
2171
|
-
(m.alias ? rank.get(m.alias) : undefined)
|
|
2172
|
-
?? rank.get(`${m.provider}/${m.id}`)
|
|
2173
|
-
?? rank.get(routedSlug(m.provider, m.id))
|
|
2174
|
-
?? Number.MAX_SAFE_INTEGER;
|
|
2175
|
-
return [...goModels].sort((a, b) => {
|
|
2176
|
-
return rankOf(a) - rankOf(b);
|
|
2177
|
-
});
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
export function mergeCatalogEntriesForSync(
|
|
2181
|
-
catalogModels: RawEntry[],
|
|
2182
|
-
routedEntries: RawEntry[],
|
|
2183
|
-
baseline: Map<string, number>,
|
|
2184
|
-
featured: string[],
|
|
2185
|
-
wsEnabled: boolean,
|
|
2186
|
-
goIds: Set<string> = new Set(),
|
|
2187
|
-
template: RawEntry | null = null,
|
|
2188
|
-
disabledNative: Set<string> = new Set(),
|
|
2189
|
-
gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
|
|
2190
|
-
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
2191
|
-
const slash = slug.indexOf("/");
|
|
2192
|
-
return slash > 0 ? [slug.slice(0, slash)] : [];
|
|
2193
|
-
})),
|
|
2194
|
-
multiAgentMode: MultiAgentMode = "default",
|
|
2195
|
-
exactComboSlugs: ReadonlySet<string> = new Set(),
|
|
2196
|
-
hasPhysicalComboProvider = false,
|
|
2197
|
-
): RawEntry[] {
|
|
2198
|
-
const rank = new Map(featured.map((slug, i) => [slug, i] as const));
|
|
2199
|
-
const native = catalogModels
|
|
2200
|
-
.filter(m => typeof m.slug === "string"
|
|
2201
|
-
&& !(m.slug as string).includes("/")
|
|
2202
|
-
&& m.owned_by !== COMBO_NAMESPACE
|
|
2203
|
-
&& !goIds.has(m.slug as string)
|
|
2204
|
-
&& !isUnsupportedOpenAiNativeSlug(m.slug as string))
|
|
2205
|
-
.map(m => {
|
|
2206
|
-
const slug = m.slug as string;
|
|
2207
|
-
// Featured models rank first (rank order); non-featured natives are pushed below the featured
|
|
2208
|
-
// block when any model is featured, else keep their pristine baseline priority.
|
|
2209
|
-
const baselinePriority = baseline.get(slug) ?? (m.priority as number);
|
|
2210
|
-
const priority = rank.has(slug)
|
|
2211
|
-
? rank.get(slug)!
|
|
2212
|
-
: featured.length > 0
|
|
2213
|
-
? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
|
|
2214
|
-
: baselinePriority;
|
|
2215
|
-
// Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name
|
|
2216
|
-
// stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a
|
|
2217
|
-
// previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A
|
|
2218
|
-
// genuine catalog entry (real display name) is preserved untouched.
|
|
2219
|
-
if (shouldUpgradeToUpstreamEntry(m)) {
|
|
2220
|
-
const upstream = upstreamNativeEntry(slug)!;
|
|
2221
|
-
const upgradePriority = rank.has(slug)
|
|
2222
|
-
? rank.get(slug)!
|
|
2223
|
-
: featured.length > 0
|
|
2224
|
-
? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100)
|
|
2225
|
-
: typeof upstream.priority === "number" ? upstream.priority : priority;
|
|
2226
|
-
const finished = finishUpstreamNativeEntry(upstream, 9);
|
|
2227
|
-
finished.priority = upgradePriority;
|
|
2228
|
-
return finished;
|
|
2229
|
-
}
|
|
2230
|
-
const preserved = normalizeServiceTiers({ ...m, priority });
|
|
2231
|
-
// Older natives kept from disk still need the mock top tiers (max + ultra always
|
|
2232
|
-
// for subagent max spawns; wire-clamped to the model's real top rung).
|
|
2233
|
-
if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved);
|
|
2234
|
-
return preserved;
|
|
2235
|
-
});
|
|
2236
|
-
|
|
2237
|
-
// Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
|
|
2238
|
-
// routed provider exposing the same id can never delete the native OpenAI/Codex base row.
|
|
2239
|
-
const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
2240
|
-
for (const slug of nativeOpenAiSlugs()) {
|
|
2241
|
-
if (nativeSlugs.has(slug)) continue;
|
|
2242
|
-
nativeSlugs.add(slug);
|
|
2243
|
-
const priority = rank.has(slug)
|
|
2244
|
-
? rank.get(slug)!
|
|
2245
|
-
: featured.length > 0
|
|
2246
|
-
? featured.length + 100
|
|
2247
|
-
: 9;
|
|
2248
|
-
native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
const freshSlugs = new Set(
|
|
2252
|
-
routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []),
|
|
2253
|
-
);
|
|
2254
|
-
let finalRoutedEntries = routedEntries;
|
|
2255
|
-
const preservingExistingRouted = routedEntries.length === 0
|
|
2256
|
-
&& catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
|
|
2257
|
-
if (preservingExistingRouted) {
|
|
2258
|
-
finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
|
|
2259
|
-
} else {
|
|
2260
|
-
const preservedForeignRouted = catalogModels.filter(m => {
|
|
2261
|
-
if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
|
|
2262
|
-
const provider = m.slug.slice(0, m.slug.indexOf("/"));
|
|
2263
|
-
return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
|
|
2264
|
-
});
|
|
2265
|
-
finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
|
|
2266
|
-
}
|
|
2267
|
-
if (!hasPhysicalComboProvider) {
|
|
2268
|
-
finalRoutedEntries = finalRoutedEntries.filter(entry => {
|
|
2269
|
-
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
2270
|
-
const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE;
|
|
2271
|
-
return !comboOwned || freshSlugs.has(slug);
|
|
2272
|
-
});
|
|
2273
|
-
}
|
|
2274
|
-
finalRoutedEntries = finalRoutedEntries.filter(entry => {
|
|
2275
|
-
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
2276
|
-
return !exactComboSlugs.has(slug)
|
|
2277
|
-
|| (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0);
|
|
2278
|
-
});
|
|
2279
|
-
// Reapply final catalog policy to rows preserved from disk. Those rows bypass
|
|
2280
|
-
// gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
|
|
2281
|
-
finalRoutedEntries = finalRoutedEntries.filter(entry =>
|
|
2282
|
-
typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
|
|
2283
|
-
);
|
|
2284
|
-
if (preservingExistingRouted) {
|
|
2285
|
-
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
|
|
2289
|
-
const normalized = normalizeServiceTiers(m);
|
|
2290
|
-
applyNativeOpenAiContextOverride(normalized);
|
|
2291
|
-
const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug);
|
|
2292
|
-
const e = ensureStrictCatalogFields(normalized, {
|
|
2293
|
-
preserveExactInputModalities: exactCombo,
|
|
2294
|
-
isRouted: finalRoutedEntries.includes(m),
|
|
2295
|
-
});
|
|
2296
|
-
// Mock-max universality (260709): preserved routed entries from disk may predate
|
|
2297
|
-
// the max rung — ensure it here so subagent max spawns validate on every
|
|
2298
|
-
// reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact.
|
|
2299
|
-
if (!exactCombo) {
|
|
2300
|
-
const levels = Array.isArray(e.supported_reasoning_levels)
|
|
2301
|
-
? e.supported_reasoning_levels as Array<{ effort?: string }>
|
|
2302
|
-
: [];
|
|
2303
|
-
if (levels.length > 0 && !levels.some(level => level.effort === "max")) {
|
|
2304
|
-
levels.push(CODEX_REASONING_LEVELS.find(level => level.effort === "max")
|
|
2305
|
-
?? { effort: "max", description: "Maximum reasoning depth for the hardest problems" });
|
|
2306
|
-
e.supported_reasoning_levels = levels;
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
if (wsEnabled) e.supports_websockets = true;
|
|
2310
|
-
else {
|
|
2311
|
-
delete e.supports_websockets;
|
|
2312
|
-
// Match buildCatalogEntries: never advertise a websocket preference while WS is off.
|
|
2313
|
-
delete e.prefer_websockets;
|
|
2314
|
-
}
|
|
2315
|
-
return e;
|
|
2316
|
-
});
|
|
2317
|
-
// Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the
|
|
2318
|
-
// LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list.
|
|
2319
|
-
return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
/**
|
|
2323
|
-
* Merge namespaced routed-model entries into the on-disk Codex catalog.
|
|
2324
|
-
* Idempotent + non-destructive:
|
|
2325
|
-
* - native entries (slug without "/") are preserved untouched,
|
|
2326
|
-
* - previously injected entries (slug containing "/") are dropped and re-added,
|
|
2327
|
-
* - each injected entry is CLONED from a native template so it has all required fields,
|
|
2328
|
-
* - the catalog is backed up to ~/.opencodex/catalog-backup.json before writing.
|
|
2329
|
-
* No-op if the catalog file does not exist.
|
|
2330
|
-
*/
|
|
2331
|
-
export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
|
|
2332
|
-
const catalogPath = readCodexCatalogPath();
|
|
2333
|
-
const catalog = loadCatalogForSync(catalogPath);
|
|
2334
|
-
if (!catalog) return { added: 0, path: catalogPath };
|
|
2335
|
-
|
|
2336
|
-
const template = findNativeTemplate(catalog);
|
|
2337
|
-
|
|
2338
|
-
const goModels = await gatherRoutedModels(config);
|
|
2339
|
-
try {
|
|
2340
|
-
// Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
|
|
2341
|
-
// (later syncs would otherwise overwrite it with featured-modified priorities).
|
|
2342
|
-
ensureCatalogBackup(catalogPath, catalog);
|
|
2343
|
-
} catch { /* backup best-effort */ }
|
|
2344
|
-
|
|
2345
|
-
// Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
|
|
2346
|
-
// by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
|
|
2347
|
-
const enabledGo = filterCatalogVisibleModels(goModels, config);
|
|
2348
|
-
const featured = config.subagentModels ?? [];
|
|
2349
|
-
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
2350
|
-
const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
|
|
2351
|
-
const exactComboSlugs = exactComboCatalogSlugs(config);
|
|
2352
|
-
const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
|
|
2353
|
-
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs);
|
|
2354
|
-
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
|
|
2355
|
-
// routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
|
|
2356
|
-
// like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
|
|
2357
|
-
const baseline = readNativeBaseline(catalogPath);
|
|
2358
|
-
const goIds = new Set(enabledGo.map(m => m.id));
|
|
2359
|
-
const gatheredProviderNames = new Set(
|
|
2360
|
-
Object.entries(config.providers ?? {})
|
|
2361
|
-
.filter(([, prov]) => prov.disabled !== true)
|
|
2362
|
-
.map(([name]) => name),
|
|
2363
|
-
);
|
|
2364
|
-
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
2365
|
-
// native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
|
|
2366
|
-
// native template can never leak supports_websockets while the flag is off.
|
|
2367
|
-
const wsEnabled = websocketsEnabled(config);
|
|
2368
|
-
catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
|
|
2369
|
-
clampCatalogModelsToCodexSupport(catalog.models);
|
|
2370
|
-
|
|
2371
|
-
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
2372
|
-
return { added: goEntries.length, path: catalogPath };
|
|
2373
|
-
}
|
|
2374
|
-
|
|
2375
|
-
/**
|
|
2376
|
-
* Restore the Codex catalog to native-only by dropping every opencodex-injected
|
|
2377
|
-
* "<provider>/<model>" entry (those route through the proxy). Native gpt/codex slugs (no "/")
|
|
2378
|
-
* are kept, so plain `codex` works when the proxy is stopped. Idempotent; no-op if nothing injected.
|
|
2379
|
-
*/
|
|
2380
|
-
export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
|
|
2381
|
-
const catalogPath = readCodexCatalogPath();
|
|
2382
|
-
const catalog = readCatalog(catalogPath);
|
|
2383
|
-
if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
|
|
2384
|
-
const backup = readCatalogBackup(catalogPath);
|
|
2385
|
-
if (backup && Array.isArray(backup.models)) {
|
|
2386
|
-
const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
|
|
2387
|
-
const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
2388
|
-
const userNativeAdditions = (catalog.models ?? []).filter(m =>
|
|
2389
|
-
typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
|
|
2390
|
-
);
|
|
2391
|
-
const restored = {
|
|
2392
|
-
...backup,
|
|
2393
|
-
models: [...backup.models, ...userNativeAdditions],
|
|
2394
|
-
};
|
|
2395
|
-
atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
|
|
2396
|
-
return { removed, kept: restored.models.length, path: catalogPath };
|
|
2397
|
-
}
|
|
2398
|
-
const before = catalog.models.length;
|
|
2399
|
-
const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
|
|
2400
|
-
const removed = before - native.length;
|
|
2401
|
-
if (removed > 0) {
|
|
2402
|
-
catalog.models = native;
|
|
2403
|
-
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
2404
|
-
}
|
|
2405
|
-
return { removed, kept: native.length, path: catalogPath };
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
/**
|
|
2409
|
-
* Refresh Codex's models cache ($CODEX_HOME/models_cache.json) from the active catalog.
|
|
2410
|
-
* Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); copying the injected catalog
|
|
2411
|
-
* makes catalog edits (enable/disable, subagent reorder) apply on the next turn instead of waiting.
|
|
2412
|
-
*/
|
|
2413
|
-
export function invalidateCodexModelsCache(): void {
|
|
2414
|
-
try {
|
|
2415
|
-
const catalogPath = readCodexCatalogPath();
|
|
2416
|
-
if (!existsSync(catalogPath)) return;
|
|
2417
|
-
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
2418
|
-
const models = catalog.models ?? catalog;
|
|
2419
|
-
const wrapper = {
|
|
2420
|
-
fetched_at: "2000-01-01T00:00:00Z",
|
|
2421
|
-
client_version: "0.0.0",
|
|
2422
|
-
models,
|
|
2423
|
-
};
|
|
2424
|
-
atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
|
|
2425
|
-
} catch { /* best-effort */ }
|
|
2426
|
-
}
|
|
1
|
+
// AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
|
|
2
|
+
// Public surface preserved exactly; importers keep using "src/codex/catalog".
|
|
3
|
+
export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
|
|
4
|
+
export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
|
|
5
|
+
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
|
|
6
|
+
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
|
|
7
|
+
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
|
|
8
|
+
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
|
|
9
|
+
export { deriveComboCatalogModel, exactComboCatalogSlugs, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList } from "./catalog/aggregation";
|
|
10
|
+
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
|
|
11
|
+
export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
|