@yansigit/opencodex 2.31.3 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code.ts +2 -3
  9. package/src/adapters/cursor/live-models.ts +8 -0
  10. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  11. package/src/adapters/cursor/tool-definitions.ts +1 -1
  12. package/src/adapters/google.ts +6 -7
  13. package/src/adapters/kiro.ts +0 -3
  14. package/src/adapters/openai-responses.ts +3 -0
  15. package/src/adapters/tool-catalog-nudge.ts +1 -1
  16. package/src/adapters/xai-web-search.ts +7 -2
  17. package/src/bridge.ts +21 -15
  18. package/src/cli/dispatch.ts +50 -2
  19. package/src/cli/doctor.ts +24 -11
  20. package/src/cli/help.ts +4 -3
  21. package/src/cli/index.ts +11 -4
  22. package/src/cli/models.ts +13 -3
  23. package/src/cli/observe.ts +20 -5
  24. package/src/cli/provider.ts +2 -1
  25. package/src/cli/registry.ts +7 -5
  26. package/src/cli/status.ts +2 -1
  27. package/src/cli/system-restart-client.ts +1 -1
  28. package/src/cli/usage-report.ts +134 -0
  29. package/src/codex/app-server-processes.ts +3 -1
  30. package/src/codex/catalog/aggregation.ts +13 -1
  31. package/src/codex/catalog/effort.ts +24 -9
  32. package/src/codex/catalog/model-metadata.ts +566 -0
  33. package/src/codex/catalog/parsing.ts +33 -0
  34. package/src/codex/catalog/provider-fetch.ts +123 -32
  35. package/src/codex/catalog/sync.ts +5 -4
  36. package/src/codex/desktop-app-restart.ts +342 -0
  37. package/src/codex/history-job.ts +32 -3
  38. package/src/codex/history-manifest.ts +112 -0
  39. package/src/codex/history-migration-guardian.ts +5 -5
  40. package/src/codex/history-provider.ts +825 -247
  41. package/src/codex/history-worker.ts +8 -5
  42. package/src/codex/inject.ts +49 -21
  43. package/src/codex/injected-marker.ts +1 -1
  44. package/src/codex/internal/history-writer.ts +4 -3
  45. package/src/codex/native-profile-startup.ts +157 -27
  46. package/src/codex/native-residue.ts +26 -33
  47. package/src/combos/failover.ts +27 -0
  48. package/src/compatibility/index.ts +26 -0
  49. package/src/compatibility/manifest.ts +253 -0
  50. package/src/compatibility/openai-responses.ts +81 -0
  51. package/src/config/atomic-write.ts +219 -0
  52. package/src/config/paths.ts +40 -0
  53. package/src/config/process-state.ts +308 -0
  54. package/src/config/provider-validation.ts +177 -0
  55. package/src/config.ts +75 -812
  56. package/src/generated/compatibility-version.json +145 -85
  57. package/src/images/plan.ts +5 -4
  58. package/src/integrations/ownership-policy.ts +141 -0
  59. package/src/integrations/ownership.ts +10 -0
  60. package/src/integrations/state.ts +44 -5
  61. package/src/integrations/writer.ts +6 -0
  62. package/src/lib/bounded-body.ts +14 -2
  63. package/src/lib/process-control.ts +2 -1
  64. package/src/lib/state-store-registrations.ts +2 -0
  65. package/src/lib/tool-argument-integers.ts +56 -5
  66. package/src/oauth/health.ts +1 -1
  67. package/src/providers/registry.ts +1 -1
  68. package/src/reasoning-effort.ts +19 -2
  69. package/src/responses/apply-patch-envelope.ts +63 -0
  70. package/src/responses/custom-tool-compat.ts +132 -38
  71. package/src/responses/parser.ts +3 -2
  72. package/src/responses/reasoning-replay-cache.ts +81 -3
  73. package/src/server/auth-cors.ts +9 -7
  74. package/src/server/index.ts +102 -21
  75. package/src/server/local-management-read-client.ts +1 -1
  76. package/src/server/local-provider-reload-client.ts +1 -1
  77. package/src/server/management/agent-settings-routes.ts +1 -1
  78. package/src/server/management/config-routes.ts +4 -1
  79. package/src/server/management/context.ts +1 -1
  80. package/src/server/management/logs-usage-routes.ts +27 -6
  81. package/src/server/management/model-routes.ts +8 -4
  82. package/src/server/management/native-integration-routes.ts +2 -1
  83. package/src/server/management/provider-capability-config.ts +1 -1
  84. package/src/server/management/system-restart.ts +1 -1
  85. package/src/server/port-reclaim.ts +1 -1
  86. package/src/server/proxy-liveness.ts +2 -1
  87. package/src/server/request-log-conversation.ts +30 -0
  88. package/src/server/responses/codex-auth-error.ts +55 -0
  89. package/src/server/responses/combo-stream-preflight.ts +171 -0
  90. package/src/server/responses/compact.ts +6 -21
  91. package/src/server/responses/core.ts +225 -94
  92. package/src/server/responses/fetch-helpers.ts +2 -97
  93. package/src/server/responses-custom-tool-repair.ts +41 -5
  94. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  95. package/src/service.ts +8 -4
  96. package/src/types/request.ts +6 -1
  97. package/src/types/tools.ts +87 -11
  98. package/src/types.ts +1 -1
  99. package/src/update/index.ts +5 -4
  100. package/src/update/job.ts +3 -1
  101. package/src/update/transactional-install.mjs +8 -1
  102. package/src/usage/log.ts +16 -8
  103. package/src/usage/summary.ts +201 -8
  104. package/src/vision/describe.ts +18 -13
  105. package/src/web-search/executor.ts +10 -3
  106. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  107. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -0,0 +1,566 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile, getConfigDir } from "../../config";
4
+ import { isModelCacheGenerationCurrent } from "../model-cache";
5
+ import type { GenerationContext } from "../../lib/state-store-sweeper";
6
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
7
+ import { assertNotRealHomeUnderTest } from "../../lib/test-home-guard";
8
+ import { generatedModelMetadata, type CatalogModel } from "./parsing";
9
+
10
+ /** Codex strict-parser placeholder; must never be treated as discovered evidence. */
11
+ export const COMPATIBILITY_CONTEXT_WINDOW = 128_000;
12
+
13
+ export type ModelMetadataSource =
14
+ | "live"
15
+ | "registry"
16
+ | "snapshot"
17
+ | "config_fallback"
18
+ | "unknown"
19
+ | "derived";
20
+
21
+ export interface ModelMetadataFields {
22
+ contextWindow?: number;
23
+ maxInputTokens?: number;
24
+ maxOutputTokens?: number;
25
+ inputModalities?: string[];
26
+ reasoningEfforts?: string[];
27
+ capabilities?: string[];
28
+ }
29
+
30
+ export type ModelMetadataFieldSources = {
31
+ contextWindow?: ModelMetadataSource;
32
+ maxInputTokens?: ModelMetadataSource;
33
+ maxOutputTokens?: ModelMetadataSource;
34
+ inputModalities?: ModelMetadataSource;
35
+ reasoningEfforts?: ModelMetadataSource;
36
+ capabilities?: ModelMetadataSource;
37
+ };
38
+
39
+ export interface ModelMetadataLayer extends ModelMetadataFields {
40
+ observedAt?: string;
41
+ }
42
+
43
+ export interface UserContextCaps {
44
+ /** `providers.*.modelContextWindows[id]` */
45
+ modelWindow?: number;
46
+ /** `providers.*.contextWindow` */
47
+ providerWindow?: number;
48
+ /** `providerContextCaps[provider]` */
49
+ providerCap?: number;
50
+ /** `providers.*.modelMaxInputTokens[id]` */
51
+ maxInputCap?: number;
52
+ }
53
+
54
+ export interface ResolveModelMetadataInput {
55
+ live?: ModelMetadataLayer;
56
+ liveFresh?: boolean;
57
+ snapshot?: ModelMetadataLayer;
58
+ registry?: ModelMetadataLayer;
59
+ caps?: UserContextCaps;
60
+ }
61
+
62
+ export interface ResolvedModelMetadata extends ModelMetadataFields {
63
+ source: ModelMetadataSource;
64
+ observedAt?: string;
65
+ stale: boolean;
66
+ contextCapped?: boolean;
67
+ contextCap?: number;
68
+ /** Pre-cap discovered window, used by the Models UI when a cap lowered it. */
69
+ detectedContextWindow?: number;
70
+ fieldSources: ModelMetadataFieldSources;
71
+ }
72
+
73
+ const NUMERIC_FIELDS = ["contextWindow", "maxInputTokens", "maxOutputTokens"] as const;
74
+ const LIST_FIELDS = ["inputModalities", "reasoningEfforts", "capabilities"] as const;
75
+
76
+ function positiveInt(value: unknown): number | undefined {
77
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
78
+ }
79
+
80
+ function copyList(value: unknown): string[] | undefined {
81
+ if (!Array.isArray(value) || value.length === 0) return undefined;
82
+ const out = value.filter((item): item is string => typeof item === "string" && item.length > 0);
83
+ return out.length > 0 ? [...out] : undefined;
84
+ }
85
+
86
+ /** Lowest explicit cap among the three user levers; never used to raise a known window. */
87
+ export function effectiveUserContextCap(caps: UserContextCaps | undefined): number | undefined {
88
+ if (!caps) return undefined;
89
+ const model = positiveInt(caps.modelWindow);
90
+ const provider = positiveInt(caps.providerWindow);
91
+ const providerCap = positiveInt(caps.providerCap);
92
+ if (model !== undefined) return providerCap !== undefined ? Math.min(model, providerCap) : model;
93
+ if (provider !== undefined) return providerCap !== undefined ? Math.min(provider, providerCap) : provider;
94
+ return providerCap;
95
+ }
96
+
97
+ function layerHasFreshLive(input: ResolveModelMetadataInput): boolean {
98
+ return input.liveFresh === true && input.live !== undefined;
99
+ }
100
+
101
+ function pickNumeric(
102
+ input: ResolveModelMetadataInput,
103
+ field: (typeof NUMERIC_FIELDS)[number],
104
+ ): { value: number; source: ModelMetadataSource; observedAt?: string } | undefined {
105
+ const liveValue = positiveInt(input.live?.[field]);
106
+ if (layerHasFreshLive(input) && liveValue !== undefined) {
107
+ return { value: liveValue, source: "live", observedAt: input.live?.observedAt };
108
+ }
109
+ const snapshotValue = positiveInt(input.snapshot?.[field]);
110
+ const registryValue = positiveInt(input.registry?.[field]);
111
+ if (input.liveFresh === true) {
112
+ if (snapshotValue !== undefined) {
113
+ return { value: snapshotValue, source: "snapshot", observedAt: input.snapshot?.observedAt };
114
+ }
115
+ if (registryValue !== undefined) {
116
+ return { value: registryValue, source: "registry", observedAt: input.registry?.observedAt };
117
+ }
118
+ return undefined;
119
+ }
120
+ if (registryValue !== undefined) {
121
+ return { value: registryValue, source: "registry", observedAt: input.registry?.observedAt };
122
+ }
123
+ if (snapshotValue !== undefined) {
124
+ return { value: snapshotValue, source: "snapshot", observedAt: input.snapshot?.observedAt };
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ function pickList(
130
+ input: ResolveModelMetadataInput,
131
+ field: (typeof LIST_FIELDS)[number],
132
+ ): { value: string[]; source: ModelMetadataSource; observedAt?: string } | undefined {
133
+ const liveValue = copyList(input.live?.[field]);
134
+ if (layerHasFreshLive(input) && liveValue !== undefined) {
135
+ return { value: liveValue, source: "live", observedAt: input.live?.observedAt };
136
+ }
137
+ const snapshotValue = copyList(input.snapshot?.[field]);
138
+ const registryValue = copyList(input.registry?.[field]);
139
+ if (input.liveFresh === true) {
140
+ if (snapshotValue !== undefined) {
141
+ return { value: snapshotValue, source: "snapshot", observedAt: input.snapshot?.observedAt };
142
+ }
143
+ if (registryValue !== undefined) {
144
+ return { value: registryValue, source: "registry", observedAt: input.registry?.observedAt };
145
+ }
146
+ return undefined;
147
+ }
148
+ if (registryValue !== undefined) {
149
+ return { value: registryValue, source: "registry", observedAt: input.registry?.observedAt };
150
+ }
151
+ if (snapshotValue !== undefined) {
152
+ return { value: snapshotValue, source: "snapshot", observedAt: input.snapshot?.observedAt };
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function clampToCap(value: number, cap: number | undefined): number {
158
+ if (cap === undefined) return value;
159
+ return value > cap ? cap : value;
160
+ }
161
+
162
+ export function resolveModelMetadata(input: ResolveModelMetadataInput): ResolvedModelMetadata {
163
+ const cap = effectiveUserContextCap(input.caps);
164
+ const maxInputCap = positiveInt(input.caps?.maxInputCap);
165
+ const fieldSources: ModelMetadataFieldSources = {};
166
+ let observedAt: string | undefined;
167
+ let stale = false;
168
+
169
+ const takeObserved = (layerAt?: string, source?: ModelMetadataSource) => {
170
+ if (source === "snapshot") stale = true;
171
+ if (!observedAt && typeof layerAt === "string" && layerAt.length > 0) observedAt = layerAt;
172
+ };
173
+
174
+ const contextPick = pickNumeric(input, "contextWindow");
175
+ let contextWindow = contextPick?.value;
176
+ let contextSource = contextPick?.source;
177
+ takeObserved(contextPick?.observedAt, contextPick?.source);
178
+
179
+ let contextCapped = false;
180
+ if (contextWindow !== undefined) {
181
+ const lowered = clampToCap(contextWindow, cap);
182
+ contextCapped = cap !== undefined && lowered < contextPick!.value;
183
+ contextWindow = lowered;
184
+ fieldSources.contextWindow = contextSource;
185
+ } else if (cap !== undefined) {
186
+ contextWindow = cap;
187
+ contextSource = "config_fallback";
188
+ fieldSources.contextWindow = "config_fallback";
189
+ }
190
+
191
+ const maxInputPick = pickNumeric(input, "maxInputTokens");
192
+ let maxInputTokens = maxInputPick?.value;
193
+ takeObserved(maxInputPick?.observedAt, maxInputPick?.source);
194
+ if (maxInputTokens !== undefined) {
195
+ maxInputTokens = clampToCap(maxInputTokens, maxInputCap);
196
+ if (contextWindow !== undefined) maxInputTokens = Math.min(maxInputTokens, contextWindow);
197
+ fieldSources.maxInputTokens = maxInputPick!.source;
198
+ } else if (maxInputCap !== undefined) {
199
+ maxInputTokens = contextWindow !== undefined ? Math.min(maxInputCap, contextWindow) : maxInputCap;
200
+ fieldSources.maxInputTokens = "config_fallback";
201
+ } else if (contextWindow !== undefined && contextCapped) {
202
+ maxInputTokens = contextWindow;
203
+ fieldSources.maxInputTokens = fieldSources.contextWindow;
204
+ }
205
+
206
+ const maxOutputPick = pickNumeric(input, "maxOutputTokens");
207
+ if (maxOutputPick) {
208
+ fieldSources.maxOutputTokens = maxOutputPick.source;
209
+ takeObserved(maxOutputPick.observedAt, maxOutputPick.source);
210
+ }
211
+
212
+ const modalitiesPick = pickList(input, "inputModalities");
213
+ if (modalitiesPick) {
214
+ fieldSources.inputModalities = modalitiesPick.source;
215
+ takeObserved(modalitiesPick.observedAt, modalitiesPick.source);
216
+ }
217
+ const effortsPick = pickList(input, "reasoningEfforts");
218
+ if (effortsPick) {
219
+ fieldSources.reasoningEfforts = effortsPick.source;
220
+ takeObserved(effortsPick.observedAt, effortsPick.source);
221
+ }
222
+ const capabilitiesPick = pickList(input, "capabilities");
223
+ if (capabilitiesPick) {
224
+ fieldSources.capabilities = capabilitiesPick.source;
225
+ takeObserved(capabilitiesPick.observedAt, capabilitiesPick.source);
226
+ }
227
+
228
+ const source: ModelMetadataSource = contextSource
229
+ ?? modalitiesPick?.source
230
+ ?? maxOutputPick?.source
231
+ ?? "unknown";
232
+ const detectedContextWindow = contextPick?.value;
233
+
234
+ return {
235
+ source,
236
+ stale,
237
+ fieldSources,
238
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
239
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
240
+ ...(maxOutputPick ? { maxOutputTokens: maxOutputPick.value } : {}),
241
+ ...(modalitiesPick ? { inputModalities: modalitiesPick.value } : {}),
242
+ ...(effortsPick ? { reasoningEfforts: effortsPick.value } : {}),
243
+ ...(capabilitiesPick ? { capabilities: capabilitiesPick.value } : {}),
244
+ ...(observedAt ? { observedAt } : {}),
245
+ ...(cap !== undefined ? { contextCap: cap } : {}),
246
+ ...(contextWindow !== undefined ? { contextCapped } : {}),
247
+ ...(detectedContextWindow !== undefined ? { detectedContextWindow } : {}),
248
+ };
249
+ }
250
+
251
+ export function shouldStampContextProvenance(
252
+ model: Pick<CatalogModel, "contextWindow" | "metadataSource" | "metadataFieldSources">,
253
+ ): boolean {
254
+ if (typeof model.contextWindow !== "number" || model.contextWindow <= 0) return false;
255
+ if (model.metadataSource === "unknown" || model.metadataSource === "config_fallback") return false;
256
+ if (model.metadataFieldSources?.contextWindow === "config_fallback") return false;
257
+ if (model.metadataSource === undefined && model.contextWindow === COMPATIBILITY_CONTEXT_WINDOW) {
258
+ return false;
259
+ }
260
+ return model.metadataSource === "live"
261
+ || model.metadataSource === "registry"
262
+ || model.metadataSource === "snapshot"
263
+ || model.metadataSource === "derived"
264
+ || (model.metadataSource === undefined && model.contextWindow !== COMPATIBILITY_CONTEXT_WINDOW);
265
+ }
266
+
267
+ export function applyResolvedMetadataToCatalogModel(
268
+ model: CatalogModel,
269
+ resolved: ResolvedModelMetadata,
270
+ ): CatalogModel {
271
+ return {
272
+ ...model,
273
+ ...(resolved.contextWindow !== undefined ? { contextWindow: resolved.contextWindow } : {}),
274
+ ...(resolved.maxInputTokens !== undefined ? { maxInputTokens: resolved.maxInputTokens } : {}),
275
+ ...(resolved.maxOutputTokens !== undefined ? { maxOutputTokens: resolved.maxOutputTokens } : {}),
276
+ ...(resolved.inputModalities ? { inputModalities: resolved.inputModalities } : {}),
277
+ ...(resolved.reasoningEfforts ? { reasoningEfforts: resolved.reasoningEfforts } : {}),
278
+ ...(resolved.capabilities ? { capabilities: resolved.capabilities } : {}),
279
+ metadataSource: resolved.source,
280
+ metadataStale: resolved.stale,
281
+ ...(resolved.observedAt ? { metadataObservedAt: resolved.observedAt } : {}),
282
+ ...(resolved.fieldSources ? { metadataFieldSources: resolved.fieldSources } : {}),
283
+ ...(resolved.contextCap !== undefined ? { contextCap: resolved.contextCap } : {}),
284
+ ...(resolved.contextCapped !== undefined ? { contextCapped: resolved.contextCapped } : {}),
285
+ ...(resolved.detectedContextWindow !== undefined
286
+ ? { detectedContextWindow: resolved.detectedContextWindow }
287
+ : {}),
288
+ };
289
+ }
290
+
291
+ export const MODEL_METADATA_CACHE_FILENAME = "model-metadata-cache.json";
292
+ const SNAPSHOT_VERSION = 1;
293
+ const SNAPSHOT_MAX_ROWS = 8_192;
294
+ const SNAPSHOT_ID_MAX = 256;
295
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
296
+
297
+ export type LiveSnapshotRow = ModelMetadataLayer & { id: string };
298
+
299
+ type SnapshotFile = {
300
+ version: number;
301
+ models: Record<string, ModelMetadataLayer>;
302
+ };
303
+
304
+ let diskHydrated = false;
305
+ const snapshotRows = new Map<string, ModelMetadataLayer>();
306
+ let lastSnapshotReconciledGeneration = 0;
307
+
308
+ function trimSnapshotRowsToCap(): void {
309
+ if (snapshotRows.size <= SNAPSHOT_MAX_ROWS) return;
310
+ const ranked = [...snapshotRows.entries()].sort((a, b) => {
311
+ const aAt = Date.parse(a[1].observedAt ?? "") || 0;
312
+ const bAt = Date.parse(b[1].observedAt ?? "") || 0;
313
+ return aAt - bAt;
314
+ });
315
+ const drop = ranked.length - SNAPSHOT_MAX_ROWS;
316
+ for (let i = 0; i < drop; i += 1) snapshotRows.delete(ranked[i]![0]);
317
+ }
318
+
319
+ export function snapshotKey(provider: string, modelId: string): string {
320
+ return `${provider}/${modelId}`;
321
+ }
322
+
323
+ function sanitizeIdentity(value: unknown): string | undefined {
324
+ if (typeof value !== "string" || value.length === 0 || value.length > SNAPSHOT_ID_MAX) return undefined;
325
+ if (CONTROL_CHARS.test(value)) return undefined;
326
+ return value;
327
+ }
328
+
329
+ function sanitizeLayer(raw: unknown): ModelMetadataLayer | undefined {
330
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
331
+ const row = raw as Record<string, unknown>;
332
+ const contextWindow = positiveInt(row.contextWindow);
333
+ const maxInputTokens = positiveInt(row.maxInputTokens);
334
+ const maxOutputTokens = positiveInt(row.maxOutputTokens);
335
+ const inputModalities = copyList(row.inputModalities);
336
+ const reasoningEfforts = copyList(row.reasoningEfforts);
337
+ const capabilities = copyList(row.capabilities);
338
+ const observedAt = typeof row.observedAt === "string" && !CONTROL_CHARS.test(row.observedAt)
339
+ ? row.observedAt.slice(0, 40)
340
+ : undefined;
341
+ if (
342
+ contextWindow === undefined
343
+ && maxInputTokens === undefined
344
+ && maxOutputTokens === undefined
345
+ && !inputModalities
346
+ && !reasoningEfforts
347
+ && !capabilities
348
+ ) {
349
+ return undefined;
350
+ }
351
+ return {
352
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
353
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
354
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
355
+ ...(inputModalities ? { inputModalities } : {}),
356
+ ...(reasoningEfforts ? { reasoningEfforts } : {}),
357
+ ...(capabilities ? { capabilities } : {}),
358
+ ...(observedAt ? { observedAt } : {}),
359
+ };
360
+ }
361
+
362
+ function snapshotPath(): string {
363
+ return join(getConfigDir(), MODEL_METADATA_CACHE_FILENAME);
364
+ }
365
+
366
+ function hydrateSnapshotFromDisk(): void {
367
+ if (diskHydrated) return;
368
+ diskHydrated = true;
369
+ try {
370
+ const path = snapshotPath();
371
+ if (!existsSync(path)) return;
372
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as SnapshotFile;
373
+ if (!parsed || parsed.version !== SNAPSHOT_VERSION || !parsed.models || typeof parsed.models !== "object") {
374
+ return;
375
+ }
376
+ for (const [key, value] of Object.entries(parsed.models)) {
377
+ if (typeof key !== "string" || CONTROL_CHARS.test(key) || key.length > SNAPSHOT_ID_MAX * 2) continue;
378
+ const layer = sanitizeLayer(value);
379
+ if (layer) snapshotRows.set(key, layer);
380
+ }
381
+ trimSnapshotRowsToCap();
382
+ } catch {
383
+ // Corrupt cache must never block discovery or catalog sync.
384
+ }
385
+ }
386
+
387
+ function persistSnapshotToDisk(): void {
388
+ const dir = getConfigDir();
389
+ assertNotRealHomeUnderTest(dir);
390
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
391
+ trimSnapshotRowsToCap();
392
+ const models: Record<string, ModelMetadataLayer> = {};
393
+ for (const [key, row] of snapshotRows) models[key] = row;
394
+ const body: SnapshotFile = { version: SNAPSHOT_VERSION, models };
395
+ atomicWriteFile(snapshotPath(), `${JSON.stringify(body)}\n`);
396
+ }
397
+
398
+ export function persistLiveModelMetadata(
399
+ provider: string,
400
+ rows: LiveSnapshotRow[],
401
+ options?: { writerGeneration?: string | number; observedAt?: string },
402
+ ): void {
403
+ if (options?.writerGeneration !== undefined) {
404
+ if (typeof options.writerGeneration === "string") {
405
+ if (!isModelCacheGenerationCurrent(provider, options.writerGeneration)) return;
406
+ } else if (options.writerGeneration < captureConfigGeneration()) {
407
+ return;
408
+ }
409
+ }
410
+ const providerId = sanitizeIdentity(provider);
411
+ if (!providerId) return;
412
+ hydrateSnapshotFromDisk();
413
+ const observedAt = options?.observedAt ?? new Date().toISOString();
414
+ let changed = false;
415
+ for (const row of rows) {
416
+ const id = sanitizeIdentity(row.id);
417
+ if (!id) continue;
418
+ const layer = sanitizeLayer({ ...row, observedAt: row.observedAt ?? observedAt });
419
+ if (!layer) continue;
420
+ snapshotRows.set(snapshotKey(providerId, id), layer);
421
+ changed = true;
422
+ }
423
+ if (!changed) return;
424
+ try {
425
+ persistSnapshotToDisk();
426
+ } catch {
427
+ // Best-effort persistence only.
428
+ }
429
+ }
430
+
431
+ export function readSnapshotLayer(provider: string, modelId: string): ModelMetadataLayer | undefined {
432
+ hydrateSnapshotFromDisk();
433
+ return snapshotRows.get(snapshotKey(provider, modelId));
434
+ }
435
+
436
+ export function resetModelMetadataCacheForTests(options?: { hydrateFromDisk?: boolean }): void {
437
+ snapshotRows.clear();
438
+ diskHydrated = false;
439
+ lastSnapshotReconciledGeneration = 0;
440
+ if (options?.hydrateFromDisk) hydrateSnapshotFromDisk();
441
+ }
442
+
443
+ export function reconcileModelMetadataSnapshot(context: GenerationContext): number {
444
+ if (context.generation <= lastSnapshotReconciledGeneration) return 0;
445
+ hydrateSnapshotFromDisk();
446
+ let removed = 0;
447
+ for (const key of [...snapshotRows.keys()]) {
448
+ const slash = key.indexOf("/");
449
+ if (slash <= 0) {
450
+ snapshotRows.delete(key);
451
+ removed += 1;
452
+ continue;
453
+ }
454
+ const provider = key.slice(0, slash);
455
+ if (!context.providerNames.has(provider)) {
456
+ snapshotRows.delete(key);
457
+ removed += 1;
458
+ }
459
+ }
460
+ if (removed > 0) {
461
+ try {
462
+ persistSnapshotToDisk();
463
+ } catch {
464
+ // Best-effort persistence only.
465
+ }
466
+ }
467
+ lastSnapshotReconciledGeneration = context.generation;
468
+ return removed;
469
+ }
470
+
471
+ export function registryLayerForModel(provider: string, modelId: string): ModelMetadataLayer | undefined {
472
+ const meta = generatedModelMetadata(provider, modelId);
473
+ if (!meta) return undefined;
474
+ return {
475
+ ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0
476
+ ? { contextWindow: meta.contextWindow }
477
+ : {}),
478
+ ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0
479
+ ? { maxOutputTokens: meta.maxTokens }
480
+ : {}),
481
+ ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}),
482
+ };
483
+ }
484
+
485
+ function layerFromCatalogModel(model: CatalogModel): ModelMetadataLayer | undefined {
486
+ return sanitizeLayer({
487
+ contextWindow: model.contextWindow,
488
+ maxInputTokens: model.maxInputTokens,
489
+ maxOutputTokens: model.maxOutputTokens,
490
+ inputModalities: model.inputModalities,
491
+ reasoningEfforts: model.reasoningEfforts,
492
+ capabilities: model.capabilities,
493
+ observedAt: model.metadataObservedAt,
494
+ });
495
+ }
496
+
497
+ export function capsFromProvider(
498
+ provider: { contextWindow?: number; modelContextWindows?: Record<string, number>; modelMaxInputTokens?: Record<string, number> } | undefined,
499
+ modelId: string,
500
+ providerCap?: number,
501
+ ): UserContextCaps | undefined {
502
+ if (!provider && providerCap === undefined) return undefined;
503
+ const modelWindow = provider?.modelContextWindows?.[modelId];
504
+ const maxInputCap = provider?.modelMaxInputTokens?.[modelId];
505
+ return {
506
+ ...(typeof modelWindow === "number" ? { modelWindow } : {}),
507
+ ...(typeof provider?.contextWindow === "number" ? { providerWindow: provider.contextWindow } : {}),
508
+ ...(providerCap !== undefined ? { providerCap } : {}),
509
+ ...(typeof maxInputCap === "number" ? { maxInputCap } : {}),
510
+ };
511
+ }
512
+
513
+ function mergeLayers(
514
+ preferred?: ModelMetadataLayer,
515
+ fallback?: ModelMetadataLayer,
516
+ ): ModelMetadataLayer | undefined {
517
+ if (!preferred && !fallback) return undefined;
518
+ return {
519
+ ...fallback,
520
+ ...preferred,
521
+ observedAt: preferred?.observedAt ?? fallback?.observedAt,
522
+ };
523
+ }
524
+
525
+ export function enrichCatalogModelMetadata(
526
+ model: CatalogModel,
527
+ options?: {
528
+ caps?: UserContextCaps;
529
+ liveFresh?: boolean;
530
+ snapshot?: ModelMetadataLayer;
531
+ },
532
+ ): CatalogModel {
533
+ // Combo rows already min() member evidence; re-resolving would relabel them as snapshot.
534
+ if (model.metadataSource === "derived") return model;
535
+ const disk = options?.snapshot ?? readSnapshotLayer(model.provider, model.id);
536
+ const fromModel = layerFromCatalogModel(model);
537
+ const liveFresh = options?.liveFresh ?? (model.metadataSource === "live" && model.metadataStale !== true);
538
+ // A cached row may already contain the operator cap. Keep the raw last-known-good
539
+ // snapshot as the evidence layer when rebuilding it after a failed refresh; otherwise
540
+ // the capped value overwrites its own detected window on every convergence pass.
541
+ const staleModelPreferred = model.contextCapped !== true;
542
+ const snapshot = liveFresh
543
+ ? disk
544
+ : (staleModelPreferred ? mergeLayers(fromModel, disk) : mergeLayers(disk, fromModel));
545
+ const liveLayer = liveFresh && fromModel && model.contextCapped === true
546
+ && typeof model.detectedContextWindow === "number"
547
+ && model.detectedContextWindow > (model.contextWindow ?? 0)
548
+ ? { ...fromModel, contextWindow: model.detectedContextWindow }
549
+ : fromModel;
550
+ const resolved = resolveModelMetadata({
551
+ ...(liveFresh && liveLayer ? { live: liveLayer } : {}),
552
+ liveFresh,
553
+ ...(snapshot ? { snapshot } : {}),
554
+ ...(registryLayerForModel(model.provider, model.id)
555
+ ? { registry: registryLayerForModel(model.provider, model.id) }
556
+ : {}),
557
+ ...(options?.caps ? { caps: options.caps } : {}),
558
+ });
559
+ const enriched = applyResolvedMetadataToCatalogModel(model, resolved);
560
+ // A provider-cap marker can be lost when a later convergence pass resolves the
561
+ // already-capped value as its new baseline. Preserve the marker from the prior
562
+ // pass; explicit smaller model windows are never marked by applyProviderConfigHints.
563
+ return model.contextCapped === true && enriched.contextWindow === model.contextWindow
564
+ ? { ...enriched, contextCapped: true }
565
+ : enriched;
566
+ }
@@ -111,8 +111,23 @@ export interface CatalogModel {
111
111
  defaultReasoningEffort?: string;
112
112
  contextWindow?: number;
113
113
  maxInputTokens?: number;
114
+ /** Generated `maxTokens` and live output limits; never treated as context. */
115
+ maxOutputTokens?: number;
114
116
  contextCap?: number;
115
117
  contextCapped?: boolean;
118
+ /** Pre-cap discovered window for Models UI copy when a cap lowered it. */
119
+ detectedContextWindow?: number;
120
+ metadataSource?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
121
+ metadataObservedAt?: string;
122
+ metadataStale?: boolean;
123
+ metadataFieldSources?: {
124
+ contextWindow?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
125
+ maxInputTokens?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
126
+ maxOutputTokens?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
127
+ inputModalities?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
128
+ reasoningEfforts?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
129
+ capabilities?: "live" | "registry" | "snapshot" | "config_fallback" | "unknown" | "derived";
130
+ };
116
131
  inputModalities?: string[];
117
132
  /** Provider opted into parallel tool calls (OcxProviderConfig.parallelToolCalls). */
118
133
  parallelToolCalls?: boolean;
@@ -149,6 +164,9 @@ export const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go", "deepse
149
164
  export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
150
165
  // Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
151
166
  "opencode-go/hy3-preview",
167
+ // Issue #2330: OpenCode Go models absent from current documentation or returning terminal HTTP 400 errors.
168
+ "opencode-go/mimo-v2-omni",
169
+ "opencode-go/mimo-v2-pro",
152
170
  ]);
153
171
 
154
172
  export function isRoutedModelCompatibilityExcluded(slug: string): boolean {
@@ -191,6 +209,8 @@ export function shouldExposeRoutedModel(model: CatalogModel): boolean {
191
209
  }
192
210
 
193
211
  export function readCodexCatalogPath(): string {
212
+ const home = activeCodexHome();
213
+ if (home) return readCodexCatalogPathForHome(home);
194
214
  try {
195
215
  const configPath = activeCodexConfigPath();
196
216
  if (existsSync(configPath)) {
@@ -202,6 +222,19 @@ export function readCodexCatalogPath(): string {
202
222
  return activeDefaultCatalogPath();
203
223
  }
204
224
 
225
+ /** Resolve the configured catalog without consulting ambient CODEX_HOME again. */
226
+ export function readCodexCatalogPathForHome(codexHome: string): string {
227
+ try {
228
+ const configPath = join(codexHome, "config.toml");
229
+ if (existsSync(configPath)) {
230
+ const toml = readFileSync(configPath, "utf-8");
231
+ const path = readRootTomlString(toml, "model_catalog_json");
232
+ if (path) return resolve(codexHome, path);
233
+ }
234
+ } catch { /* ignore */ }
235
+ return join(codexHome, "opencodex-catalog.json");
236
+ }
237
+
205
238
  export function parseCatalogJson(raw: string): RawCatalog | null {
206
239
  try {
207
240
  const cat = JSON.parse(raw);