@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.
Files changed (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -0,0 +1,609 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
4
+ import { delimiter, dirname, join, resolve } from "node:path";
5
+ import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
+ import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
+ import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
8
+ import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
+ import type { OcxConfig, OcxProviderConfig } from "../../types";
10
+ import { modelInList } from "../../types";
11
+ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
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
+
34
+ import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
35
+ import type { CatalogModel } from "./parsing";
36
+ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
37
+ import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, warnUncataloguedComboOnce } from "./aggregation";
38
+
39
+ type OcxProviderConfigWithReasoningSummaries = OcxProviderConfig & {
40
+ modelSupportsReasoningSummaries?: Record<string, boolean>;
41
+ };
42
+
43
+ export type ProviderModelsApiItem = {
44
+ id: string;
45
+ owned_by?: string;
46
+ context_length?: number;
47
+ max_model_len?: number;
48
+ metadata?: {
49
+ capabilities?: Record<string, unknown>;
50
+ limits?: Record<string, unknown>;
51
+ };
52
+ };
53
+
54
+ export function isProviderModelsApiItems(value: unknown): value is ProviderModelsApiItem[] {
55
+ return Array.isArray(value) && value.every(item =>
56
+ item !== null
57
+ && typeof item === "object"
58
+ && !Array.isArray(item)
59
+ && typeof (item as { id?: unknown }).id === "string"
60
+ && (item as { id: string }).id.trim().length > 0
61
+ );
62
+ }
63
+
64
+ export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
65
+ const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
66
+ return typeof configured === "number" && configured > 0 ? configured : undefined;
67
+ }
68
+
69
+ export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined {
70
+ const modalities = modelRecordValue(prov.modelInputModalities, id);
71
+ return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
72
+ }
73
+
74
+ export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined {
75
+ const configured = modelRecordValue(prov.modelMaxInputTokens, id);
76
+ return typeof configured === "number" && configured > 0 ? configured : undefined;
77
+ }
78
+
79
+ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
80
+ void name;
81
+ const configuredCap = configuredContextWindow(prov, model.id);
82
+ const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
83
+ let inputModalities = configuredInputModalities(prov, model.id);
84
+ // Vision-sidecar coverage: `noVisionModels` marks models whose images the PROXY describes
85
+ // (src/vision/index.ts). The catalog must still advertise image input for them — the Codex app
86
+ // gates attachments client-side on input_modalities, and a text-only entry would block images
87
+ // before the sidecar ever runs ("This model does not support image inputs").
88
+ if (modelInList(prov.noVisionModels, model.id)) {
89
+ const base = inputModalities ?? model.inputModalities ?? ["text"];
90
+ inputModalities = base.includes("image") ? [...base] : [...base, "image"];
91
+ }
92
+ const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
93
+ const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
94
+ const supportsReasoningSummaries = modelRecordValue(
95
+ (prov as OcxProviderConfigWithReasoningSummaries).modelSupportsReasoningSummaries,
96
+ model.id,
97
+ );
98
+ const hinted = {
99
+ ...model,
100
+ ...(configuredCap !== undefined
101
+ ? {
102
+ contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0
103
+ ? Math.min(model.contextWindow, configuredCap)
104
+ : configuredCap,
105
+ }
106
+ : {}),
107
+ ...(inputModalities ? { inputModalities } : {}),
108
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
109
+ ...(configuredMaxInput !== undefined
110
+ ? {
111
+ maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0
112
+ ? Math.min(model.maxInputTokens, configuredMaxInput)
113
+ : configuredMaxInput,
114
+ }
115
+ : {}),
116
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
117
+ ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
118
+ ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}),
119
+ // Default-on for openai-chat providers (explicit false opts out); other adapters
120
+ // advertise only on explicit opt-in.
121
+ ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
122
+ ? { parallelToolCalls: true }
123
+ : {}),
124
+ };
125
+ const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
126
+ if (providerCap !== undefined && capped !== hinted.contextWindow) {
127
+ return { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true };
128
+ }
129
+ return providerCap !== undefined ? { ...hinted, contextCap: providerCap, contextCapped: false } : hinted;
130
+ }
131
+
132
+ export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial<CatalogModel> {
133
+ const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap);
134
+ const { provider: _provider, id: _id, ...hints } = hinted;
135
+ return hints;
136
+ }
137
+
138
+ export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[], contextCap?: number): CatalogModel[] {
139
+ return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
140
+ }
141
+
142
+ export function isDatedVariantId(liveId: string, configuredId: string): boolean {
143
+ if (!liveId.startsWith(`${configuredId}-`)) return false;
144
+ return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
145
+ }
146
+
147
+ export const lastDropWarnSignature = new Map<string, string>();
148
+
149
+ export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
150
+
151
+ export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly<Record<string, ReadonlySet<string>>> = {
152
+ kimi: new Set([
153
+ "k3[1m]",
154
+ "kimi-k2.7-code",
155
+ "kimi-k2.7-code-highspeed",
156
+ "kimi-k2.6",
157
+ "kimi-k2.5",
158
+ ]),
159
+ xai: new Set([
160
+ "grok-4.3",
161
+ "grok-4.20-0309-reasoning",
162
+ "grok-4.20-0309-non-reasoning",
163
+ "grok-build-0.1",
164
+ "grok-composer-2.5-fast",
165
+ ]),
166
+ };
167
+
168
+ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
169
+ const signature = [...droppedConfiguredIds].sort().join(",");
170
+ if (lastDropWarnSignature.get(name) === signature) return;
171
+ lastDropWarnSignature.set(name, signature);
172
+ console.warn(
173
+ `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
174
+ );
175
+ }
176
+
177
+ export function isGlm52ModelId(id: string): boolean {
178
+ const normalized = id.toLowerCase();
179
+ return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
180
+ }
181
+
182
+ export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
183
+ const capabilities = item.metadata?.capabilities;
184
+ const limits = item.metadata?.limits;
185
+ const contextWindow =
186
+ typeof limits?.max_context_length === "number" ? limits.max_context_length
187
+ : typeof item.context_length === "number" ? item.context_length
188
+ : typeof item.max_model_len === "number" ? item.max_model_len
189
+ : undefined;
190
+ const reasoningEfforts = capabilities && typeof capabilities.reasoning_effort === "boolean"
191
+ ? (capabilities.reasoning_effort
192
+ ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id)
193
+ ? ["low", "medium", "high", "xhigh", "max"]
194
+ : ["low", "medium", "high", "xhigh"])
195
+ : [])
196
+ : undefined;
197
+ const inputModalities = capabilities && typeof capabilities.vision === "boolean"
198
+ ? (capabilities.vision ? ["text", "image"] : ["text"])
199
+ : undefined;
200
+ return {
201
+ ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
202
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
203
+ ...(inputModalities ? { inputModalities } : {}),
204
+ };
205
+ }
206
+
207
+ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
208
+ if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
209
+ const apiKey = await resolveModelsAuthToken(name, prov);
210
+ const seedVertexDefault = prov.adapter === "google"
211
+ && prov.googleMode === "vertex"
212
+ && (prov.models?.length ?? 0) === 0
213
+ && Boolean(prov.defaultModel);
214
+ const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
215
+ const configured: CatalogModel[] = configuredIds.map(id => ({
216
+ id,
217
+ provider: name,
218
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
219
+ }));
220
+ // A configured default is a real callable selector and must remain discoverable when a
221
+ // compatible provider's live /models request fails (issue #308). Keep this separate from the
222
+ // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
223
+ // rows, while a failed live discovery may degrade to the default selector.
224
+ const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
225
+ ? configured
226
+ : [{
227
+ id: prov.defaultModel,
228
+ provider: name,
229
+ ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap),
230
+ }];
231
+ const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
232
+ const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
233
+ vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
234
+ ? [...models, vertexDefaultSeed]
235
+ : models
236
+ );
237
+ if (prov.adapter === "cursor") {
238
+ if (prov.liveModels === false || !apiKey) return configured;
239
+ // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
240
+ // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
241
+ // suffix) but filter the static seed to the bases the account actually has — so models not on the
242
+ // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
243
+ const cachedCursor = getFreshCached(name, ttlMs);
244
+ if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
245
+ if (isModelsFetchCoolingDown(name)) {
246
+ const cooling = getStaleCached(name);
247
+ return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured;
248
+ }
249
+ const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
250
+ if (liveResult.ok) {
251
+ const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
252
+ const result = available.length > 0 ? available : configured;
253
+ setCached(name, result);
254
+ return result;
255
+ }
256
+ markModelsFetchFailure(name);
257
+ console.warn(
258
+ `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
259
+ );
260
+ const staleCursor = getStaleCached(name);
261
+ return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
262
+ }
263
+ if (prov.authMode === "oauth" && !apiKey) {
264
+ // No usable token (logged out, or account marked needsReauth). Still surface the
265
+ // configured static catalog so the GUI Models tab / rail counts are not empty —
266
+ // matching Cursor's !apiKey → configured degradation and fetch-failure fallback.
267
+ return configured;
268
+ }
269
+ if (prov.liveModels === false) {
270
+ return configured;
271
+ }
272
+ const fresh = getFreshCached(name, ttlMs);
273
+ if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
274
+ if (isModelsFetchCoolingDown(name)) {
275
+ // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
276
+ // fetch timeout on every catalog poll — the dashboard polls this path per page load.
277
+ const stale = getStaleCached(name);
278
+ return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured;
279
+ }
280
+ const { url, headers } = buildModelsRequest(prov, apiKey, name);
281
+ const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
282
+ ? "vertex-aiplatform"
283
+ : "provider-models";
284
+ const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
285
+ markModelsFetchFailure(name);
286
+ const stale = getStaleCached(name);
287
+ return {
288
+ models: stale
289
+ ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
290
+ : failedDiscoveryConfigured,
291
+ fallback: stale ? "stale" : "configured",
292
+ };
293
+ };
294
+ try {
295
+ const destinationError = await providerDestinationResolvedError(name, {
296
+ baseUrl: url,
297
+ allowPrivateNetwork: prov.allowPrivateNetwork,
298
+ });
299
+ if (destinationError) {
300
+ const { models, fallback } = failedDiscoveryFallback();
301
+ console.warn(
302
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
303
+ );
304
+ return models;
305
+ }
306
+
307
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
308
+ if (!res.ok) {
309
+ const { models, fallback } = failedDiscoveryFallback();
310
+ console.warn(
311
+ `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
312
+ );
313
+ return models;
314
+ }
315
+
316
+ const contentType = (
317
+ res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing"
318
+ ).slice(0, 80);
319
+ const body = await res.text();
320
+ let json: unknown;
321
+ try {
322
+ json = JSON.parse(body) as unknown;
323
+ } catch {
324
+ const { models, fallback } = failedDiscoveryFallback();
325
+ const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
326
+ ? "returned invalid JSON in a 2xx response"
327
+ : "returned a non-JSON 2xx response";
328
+ console.warn(
329
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
330
+ );
331
+ return models;
332
+ }
333
+ const data = json !== null && typeof json === "object" && !Array.isArray(json)
334
+ ? (json as { data?: unknown }).data
335
+ : undefined;
336
+ if (!isProviderModelsApiItems(data)) {
337
+ const { models, fallback } = failedDiscoveryFallback();
338
+ console.warn(
339
+ `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
340
+ );
341
+ return models;
342
+ }
343
+ const items = data;
344
+ const live = items.map(m => applyProviderConfigHints(name, prov, {
345
+ id: m.id,
346
+ provider: name,
347
+ owned_by: m.owned_by,
348
+ ...catalogHintsFromModelsApiItem(name, m),
349
+ }, contextCap))
350
+ .filter(m => shouldExposeProviderModel(name, m.id));
351
+ const liveIds = new Set(live.map(m => m.id));
352
+ // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
353
+ // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
354
+ // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
355
+ // in the authoritative catalog (alias id, hints from the dated live entry) instead of
356
+ // dropping them and warning on every poll.
357
+ const droppedConfiguredIds: string[] = [];
358
+ for (const m of configured) {
359
+ if (liveIds.has(m.id)) continue;
360
+ const dated = live.find(l => isDatedVariantId(l.id, m.id));
361
+ if (dated) {
362
+ // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
363
+ live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
364
+ } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
365
+ live.push(m);
366
+ } else {
367
+ droppedConfiguredIds.push(m.id);
368
+ }
369
+ }
370
+ if (live.length === 0 && name !== OPENAI_API_PROVIDER_ID) {
371
+ console.warn(
372
+ `[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"}.`,
373
+ );
374
+ } else if (droppedConfiguredIds.length > 0
375
+ && name !== OPENAI_API_PROVIDER_ID
376
+ && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
377
+ warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
378
+ }
379
+ setCached(name, live);
380
+ return live;
381
+ } catch (error) {
382
+ const { models, fallback } = failedDiscoveryFallback();
383
+ console.warn(
384
+ `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
385
+ );
386
+ return models;
387
+ }
388
+ }
389
+
390
+ export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
391
+ if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
392
+ return true;
393
+ }
394
+
395
+ export function shouldRetainConfiguredProviderModel(providerName: string, modelId: string): boolean {
396
+ if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
397
+ if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
398
+ return false;
399
+ }
400
+
401
+ export function filterCatalogVisibleModels(
402
+ models: CatalogModel[],
403
+ config: Pick<OcxConfig, "disabledModels" | "providers">,
404
+ ): CatalogModel[] {
405
+ const disabled = new Set(config.disabledModels ?? []);
406
+ const allowByProvider = new Map<string, Set<string>>();
407
+ for (const [name, prov] of Object.entries(config.providers)) {
408
+ const sel = prov.selectedModels;
409
+ if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel));
410
+ }
411
+ return models.filter(m => {
412
+ // disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
413
+ for (const stored of disabled) {
414
+ // Combo management stores the public alias, while canonical `combo/<id>` references
415
+ // remain valid for backward compatibility through slugEquals below.
416
+ if (m.alias !== undefined && stored === catalogModelSlug(m)) return false;
417
+ if (slugEquals(stored, m.provider, m.id)) return false;
418
+ }
419
+ const allow = allowByProvider.get(m.provider);
420
+ return !allow || allow.has(m.id);
421
+ });
422
+ }
423
+
424
+ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
425
+ const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
426
+ // Persisted provider entries can predate newer registry fields (noVisionModels,
427
+ // modelInputModalities, ...). The ROUTER merges registry seeds at request time
428
+ // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the
429
+ // same merged view or its advertisements drift from actual proxy behavior (e.g. a
430
+ // vision-sidecar model advertised text-only, blocking image attachments app-side).
431
+ // Enrich a CLONE: hydrated defaults must never leak into the persisted config.
432
+ const activeProviders = Object.entries(config.providers)
433
+ .filter(([, prov]) => prov.disabled !== true)
434
+ .map(([name, prov]): [string, OcxProviderConfig] => {
435
+ const enriched = { ...prov };
436
+ enrichProviderFromRegistry(name, enriched);
437
+ return [name, enriched];
438
+ });
439
+ const lists = await Promise.all(
440
+ activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
441
+ );
442
+ const apiAugmented = augmentRoutedModelsWithRegistryOpenAiApiRows(lists.flat(), config);
443
+ const all = augmentRoutedModelsWithJawcodeMetadata(apiAugmented, activeProviders.map(([name]) => name), config.providers, config)
444
+ // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
445
+ // intentionally mirrors Cursor's public model table, including Gemini image preview, so the
446
+ // exposure decision goes through shouldExposeRoutedModel (single choke point).
447
+ .filter(shouldExposeRoutedModel);
448
+ const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model]));
449
+ // [Decision Log]
450
+ // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서
451
+ // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login
452
+ // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는
453
+ // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에
454
+ // openai/<slug> 키가 존재하지 않아 콤보가 조용히 drop됨.
455
+ // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성.
456
+ // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크
457
+ // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic
458
+ // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존
459
+ // 노출 경로에 영향 없음.
460
+ // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입.
461
+ // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크
462
+ // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문.
463
+ // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의
464
+ // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config
465
+ // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우
466
+ // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를
467
+ // 우선시하므로 실제 충돌 가능성은 낮음.
468
+ if (!hasComboTargets(config)) {
469
+ // Skip the native slug injection entirely when no combos are configured — avoids
470
+ // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for
471
+ // configs that will never need it.
472
+ } else {
473
+ const disabled = disabledNativeSlugs(config);
474
+ for (const slug of nativeOpenAiSlugs()) {
475
+ if (disabled.has(slug)) continue;
476
+ const contextWindow = nativeOpenAiContextWindow(slug);
477
+ if (contextWindow === undefined) continue;
478
+ const synthetic: CatalogModel = {
479
+ provider: "openai",
480
+ id: slug,
481
+ owned_by: "openai",
482
+ contextWindow,
483
+ maxInputTokens: contextWindow,
484
+ inputModalities: nativeInputModalities(slug),
485
+ reasoningEfforts: nativeReasoningEfforts(slug),
486
+ ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
487
+ };
488
+ const key = `openai/${slug}`;
489
+ // Only inject when not already present from a routed provider (an API-key
490
+ // "openai" provider could shadow the native one).
491
+ if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
492
+ }
493
+ }
494
+ for (const id of listComboIds(config)) {
495
+ const combo = getCombo(config, id);
496
+ if (!combo) continue;
497
+ const members = combo.targets
498
+ .map(target => memberByKey.get(targetKey(target)))
499
+ .filter((member): member is CatalogModel => member !== undefined);
500
+ const derived = deriveComboCatalogModel(id, combo, members);
501
+ if (derived) all.push(derived);
502
+ else warnUncataloguedComboOnce(id, combo, members);
503
+ }
504
+ all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
505
+ const customModels = (config.customModels ?? []).map(cm => {
506
+ const provider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
507
+ const supportsReasoningSummaries = modelRecordValue(provider?.modelSupportsReasoningSummaries, cm.modelId);
508
+ return {
509
+ id: cm.modelId,
510
+ provider: cm.provider,
511
+ // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
512
+ ...(cm.displayName ? { displayName: cm.displayName } : {}),
513
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
514
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
515
+ ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
516
+ };
517
+ });
518
+ // Custom rows override discovered rows that encode to the same Codex-facing slug.
519
+ const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
520
+ const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
521
+ return [...deduped, ...customModels];
522
+ }
523
+
524
+ export function augmentRoutedModelsWithRegistryOpenAiApiRows(
525
+ models: CatalogModel[],
526
+ config: OcxConfig,
527
+ ): CatalogModel[] {
528
+ const configured = config.providers[OPENAI_API_PROVIDER_ID];
529
+ if (!configured || configured.disabled === true) return models;
530
+ const entry = getProviderRegistryEntry(OPENAI_API_PROVIDER_ID);
531
+ if (!entry?.models) return models;
532
+
533
+ const existingById = new Map(
534
+ models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]),
535
+ );
536
+ const trustedRows = entry.models.map((id): CatalogModel => {
537
+ const officialContext = entry.modelContextWindows?.[id];
538
+ const officialMaxInput = entry.modelMaxInputTokens?.[id];
539
+ const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow;
540
+ const userMaxInput = configured.modelMaxInputTokens?.[id];
541
+ const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID);
542
+ const contextWindow = typeof officialContext === "number"
543
+ ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext)
544
+ : undefined;
545
+ const maxInputTokens = typeof officialMaxInput === "number"
546
+ ? Math.min(officialMaxInput, userMaxInput ?? officialMaxInput)
547
+ : undefined;
548
+ return {
549
+ provider: OPENAI_API_PROVIDER_ID,
550
+ id,
551
+ owned_by: OPENAI_API_PROVIDER_ID,
552
+ ...(contextWindow ? { contextWindow } : {}),
553
+ ...(maxInputTokens ? { maxInputTokens } : {}),
554
+ ...(entry.modelInputModalities?.[id] ? { inputModalities: [...entry.modelInputModalities[id]!] } : {}),
555
+ ...(entry.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...entry.modelReasoningEfforts[id]!] } : {}),
556
+ };
557
+ });
558
+
559
+ for (const trusted of trustedRows) {
560
+ const live = existingById.get(trusted.id);
561
+ if (!live) continue;
562
+ const liveSignature = normalizedOpenAiApiSignature(live);
563
+ const trustedSignature = normalizedOpenAiApiSignature(trusted);
564
+ if (liveSignature === trustedSignature) continue;
565
+ const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`;
566
+ if (openAiApiCollisionWarnings.has(warningKey)) continue;
567
+ openAiApiCollisionWarnings.add(warningKey);
568
+ console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`);
569
+ }
570
+
571
+ return [
572
+ ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID),
573
+ ...trustedRows,
574
+ ];
575
+ }
576
+
577
+ export function augmentRoutedModelsWithJawcodeMetadata(
578
+ models: CatalogModel[],
579
+ providerNames: string[],
580
+ providers?: Record<string, OcxProviderConfig>,
581
+ caps?: Pick<OcxConfig, "providerContextCaps">,
582
+ ): CatalogModel[] {
583
+ const out = [...models];
584
+ const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
585
+ for (const provider of providerNames) {
586
+ if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
587
+ if (providers?.[provider]?.liveModels === false) continue;
588
+ const jawcodeProvider = resolveJawcodeProvider(provider);
589
+ if (!jawcodeProvider) continue;
590
+ for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
591
+ const key = `${provider}/${meta.id}`;
592
+ if (seen.has(key)) continue;
593
+ seen.add(key);
594
+ const contextCap = caps ? providerContextCap(caps, provider) : undefined;
595
+ const model: CatalogModel = {
596
+ provider,
597
+ id: meta.id,
598
+ owned_by: provider,
599
+ ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}),
600
+ ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
601
+ };
602
+ out.push({
603
+ ...model,
604
+ ...(providers?.[provider] ? applyProviderConfigHints(provider, providers[provider], model, contextCap) : {}),
605
+ });
606
+ }
607
+ }
608
+ return out;
609
+ }