@bitkyc08/opencodex 2.6.17 → 2.6.18
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.md +9 -0
- package/bin/ocx.mjs +70 -5
- package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
- package/gui/dist/assets/index-DbTEyo46.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +9 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +88 -0
- package/src/adapters/cursor/cursor-errors.ts +85 -0
- package/src/adapters/cursor/discovery.ts +144 -0
- package/src/adapters/cursor/effort-map.ts +74 -0
- package/src/adapters/cursor/exec-policy.ts +44 -0
- package/src/adapters/cursor/framing.ts +136 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +25 -0
- package/src/adapters/cursor/live-models.ts +93 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +758 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +236 -0
- package/src/adapters/cursor/message-mapper.ts +46 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +177 -0
- package/src/adapters/cursor/native-exec-fs.ts +284 -0
- package/src/adapters/cursor/native-exec-mcp.ts +151 -0
- package/src/adapters/cursor/native-exec-network.ts +32 -0
- package/src/adapters/cursor/native-exec-shell.ts +191 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +177 -0
- package/src/adapters/cursor/protobuf-events.ts +309 -0
- package/src/adapters/cursor/protobuf-request.ts +347 -0
- package/src/adapters/cursor/request-builder.ts +98 -0
- package/src/adapters/cursor/tool-definitions.ts +301 -0
- package/src/adapters/cursor/transport-retry.ts +116 -0
- package/src/adapters/cursor/transport.ts +47 -0
- package/src/adapters/cursor/types.ts +36 -0
- package/src/adapters/cursor.ts +99 -0
- package/src/adapters/google.ts +7 -1
- package/src/adapters/kiro.ts +15 -0
- package/src/adapters/openai-chat.ts +7 -2
- package/src/adapters/run-turn-queue.ts +58 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/bridge.ts +7 -1
- package/src/cli-help.ts +9 -2
- package/src/cli-status.ts +7 -5
- package/src/cli.ts +122 -79
- package/src/codex-catalog.ts +213 -71
- package/src/codex-history-provider.ts +31 -14
- package/src/codex-inject.ts +17 -9
- package/src/codex-paths.ts +2 -1
- package/src/codex-shim.ts +30 -7
- package/src/codex-sync.ts +70 -0
- package/src/config.ts +58 -2
- package/src/doctor.ts +4 -2
- package/src/index.ts +1 -0
- package/src/model-cache.ts +22 -2
- package/src/oauth/callback-server.ts +44 -16
- package/src/oauth/cursor.ts +188 -0
- package/src/oauth/index.ts +29 -3
- package/src/oauth/key-providers.ts +20 -33
- package/src/oauth/login-cli.ts +7 -4
- package/src/open-url.ts +5 -1
- package/src/ports.ts +13 -0
- package/src/process-control.ts +76 -0
- package/src/provider-label.ts +10 -5
- package/src/providers/derive.ts +30 -3
- package/src/providers/registry.ts +39 -1
- package/src/proxy-liveness.ts +122 -0
- package/src/responses/parser.ts +1 -0
- package/src/responses/state.ts +83 -0
- package/src/router.ts +38 -23
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server.ts +130 -18
- package/src/service.ts +94 -32
- package/src/types.ts +24 -1
- package/src/update-job.ts +360 -0
- package/src/update.ts +73 -11
- package/src/usage-log.ts +3 -3
- package/src/usage-summary.ts +3 -2
- package/src/win-paths.ts +68 -0
- package/gui/dist/assets/index-DIBiVVC0.css +0 -1
- package/gui/dist/assets/index-DcnD944i.js +0 -9
package/src/codex-catalog.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
-
import { atomicWriteFile, getConfigDir, websocketsEnabled } from "./config";
|
|
5
|
+
import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "./config";
|
|
6
6
|
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
|
|
7
|
-
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
|
|
7
|
+
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "./model-cache";
|
|
8
8
|
import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
|
|
9
|
+
import { effectiveGoogleMode } from "./providers/registry";
|
|
9
10
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
10
11
|
import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "./reasoning-effort";
|
|
11
12
|
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
|
|
12
13
|
import { shouldCaseFoldMetadataModelId } from "./providers/derive";
|
|
13
14
|
import { applyProviderContextCap, providerContextCap } from "./provider-context-cap";
|
|
14
15
|
import { CODEX_GPT5_IDENTITY_LINE } from "./adapters/identity";
|
|
16
|
+
import { fetchCursorUsableModels } from "./adapters/cursor/live-models";
|
|
15
17
|
|
|
16
18
|
const BUNDLED_CATALOG_CACHE_MS = 60_000;
|
|
17
19
|
let bundledCatalogCache: { expiresAt: number; value: RawCatalog | null } | null = null;
|
|
@@ -32,8 +34,39 @@ function samePath(a: string, b: string): boolean {
|
|
|
32
34
|
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
function activeCodexHome(): string | null {
|
|
38
|
+
const raw = process.env.CODEX_HOME?.trim();
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
const path = resolve(expandUserPath(raw));
|
|
41
|
+
try {
|
|
42
|
+
return realpathSync.native(path);
|
|
43
|
+
} catch {
|
|
44
|
+
return path;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function activeCodexConfigPath(): string {
|
|
49
|
+
const home = activeCodexHome();
|
|
50
|
+
return home ? join(home, "config.toml") : CODEX_CONFIG_PATH;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function activeDefaultCatalogPath(): string {
|
|
54
|
+
const home = activeCodexHome();
|
|
55
|
+
return home ? join(home, "opencodex-catalog.json") : DEFAULT_CATALOG_PATH;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function activeCodexModelsCachePath(): string {
|
|
59
|
+
const home = activeCodexHome();
|
|
60
|
+
return home ? join(home, "models_cache.json") : CODEX_MODELS_CACHE_PATH;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveActiveCodexConfigPath(path: string): string {
|
|
64
|
+
const home = activeCodexHome();
|
|
65
|
+
return home ? resolve(home, path) : resolveCodexConfigPath(path);
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
function isDefaultCatalogPath(path: string): boolean {
|
|
36
|
-
return samePath(path,
|
|
69
|
+
return samePath(path, activeDefaultCatalogPath());
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
/**
|
|
@@ -71,6 +104,7 @@ function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
|
|
|
71
104
|
const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
|
|
72
105
|
"gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
|
|
73
106
|
"gpt-5.4": { maxContextWindow: 1_000_000 },
|
|
107
|
+
"gpt-5.3-codex-spark": { contextWindow: 128_000, maxContextWindow: 128_000 },
|
|
74
108
|
};
|
|
75
109
|
|
|
76
110
|
/**
|
|
@@ -121,16 +155,22 @@ export function isMediaGenerationModelId(id: string): boolean {
|
|
|
121
155
|
return MEDIA_GEN_ID_RE.test(id);
|
|
122
156
|
}
|
|
123
157
|
|
|
158
|
+
function shouldExposeRoutedModel(model: CatalogModel): boolean {
|
|
159
|
+
if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
|
|
160
|
+
return !isMediaGenerationModelId(model.id);
|
|
161
|
+
}
|
|
162
|
+
|
|
124
163
|
/** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
|
|
125
164
|
export function readCodexCatalogPath(): string {
|
|
126
165
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
166
|
+
const configPath = activeCodexConfigPath();
|
|
167
|
+
if (existsSync(configPath)) {
|
|
168
|
+
const toml = readFileSync(configPath, "utf-8");
|
|
129
169
|
const path = readRootTomlString(toml, "model_catalog_json");
|
|
130
|
-
if (path) return
|
|
170
|
+
if (path) return resolveActiveCodexConfigPath(path);
|
|
131
171
|
}
|
|
132
172
|
} catch { /* ignore */ }
|
|
133
|
-
return
|
|
173
|
+
return activeDefaultCatalogPath();
|
|
134
174
|
}
|
|
135
175
|
|
|
136
176
|
function parseCatalogJson(raw: string): RawCatalog | null {
|
|
@@ -237,7 +277,9 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
|
|
|
237
277
|
// runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
|
|
238
278
|
entry.web_search_tool_type = "text_and_image";
|
|
239
279
|
entry.supports_search_tool = true;
|
|
240
|
-
|
|
280
|
+
// Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
|
|
281
|
+
// Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
|
|
282
|
+
entry.supports_parallel_tool_calls = typeof entry.slug === "string" && entry.slug.startsWith("cursor/");
|
|
241
283
|
return ensureStrictCatalogFields(entry);
|
|
242
284
|
}
|
|
243
285
|
|
|
@@ -270,6 +312,7 @@ type ExecFile = (
|
|
|
270
312
|
stdio: ["ignore", "pipe", "ignore"];
|
|
271
313
|
timeout: number;
|
|
272
314
|
windowsHide: boolean;
|
|
315
|
+
shell?: boolean;
|
|
273
316
|
},
|
|
274
317
|
) => string;
|
|
275
318
|
|
|
@@ -318,13 +361,33 @@ function codexShimCommandCandidates(): string[] {
|
|
|
318
361
|
}
|
|
319
362
|
}
|
|
320
363
|
|
|
364
|
+
/**
|
|
365
|
+
* `.cmd`/`.bat` launchers (npm's `codex.cmd`) cannot be spawned shell-less — Node ≥18.20
|
|
366
|
+
* and Bun refuse with EINVAL (CVE-2024-27980 hardening), which the probe loop silently
|
|
367
|
+
* swallowed, so npm-only Codex installs never loaded the bundled catalog on Windows.
|
|
368
|
+
* Route those through the shell (repo convention — see src/update.ts, bin/ocx.mjs) and
|
|
369
|
+
* pre-quote the path: shell:true joins file+args verbatim, so an unquoted path with
|
|
370
|
+
* spaces (`C:\Users\John Doe\...`) would split. Windows paths cannot contain `"`.
|
|
371
|
+
*/
|
|
372
|
+
export function codexExecInvocation(
|
|
373
|
+
command: string,
|
|
374
|
+
platform: NodeJS.Platform = process.platform,
|
|
375
|
+
): { file: string; shell: boolean } {
|
|
376
|
+
if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
|
|
377
|
+
return { file: `"${command.replace(/"/g, "")}"`, shell: true };
|
|
378
|
+
}
|
|
379
|
+
return { file: command, shell: false };
|
|
380
|
+
}
|
|
381
|
+
|
|
321
382
|
function runCodexDebugModels(command: string, execFile: ExecFile): string {
|
|
322
383
|
const args = ["debug", "models", "--bundled"];
|
|
323
|
-
|
|
384
|
+
const invocation = codexExecInvocation(command);
|
|
385
|
+
return execFile(invocation.file, args, {
|
|
324
386
|
encoding: "utf8" as const,
|
|
325
387
|
stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
|
|
326
388
|
timeout: 10_000,
|
|
327
389
|
windowsHide: true,
|
|
390
|
+
shell: invocation.shell,
|
|
328
391
|
});
|
|
329
392
|
}
|
|
330
393
|
|
|
@@ -367,7 +430,7 @@ function loadCatalogForSync(path: string): RawCatalog | null {
|
|
|
367
430
|
if (catalog && findNativeTemplate(catalog)) return catalog;
|
|
368
431
|
return readCatalog(catalogBackupPathFor(path))
|
|
369
432
|
?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
|
|
370
|
-
?? readCatalog(
|
|
433
|
+
?? readCatalog(activeCodexModelsCachePath())
|
|
371
434
|
?? materializeBundledCodexCatalog(path)
|
|
372
435
|
?? catalog;
|
|
373
436
|
}
|
|
@@ -376,7 +439,7 @@ function readCurrentCatalogOrCache(): RawCatalog | null {
|
|
|
376
439
|
const path = readCodexCatalogPath();
|
|
377
440
|
return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
|
|
378
441
|
?? readCatalog(path)
|
|
379
|
-
?? readCatalog(
|
|
442
|
+
?? readCatalog(activeCodexModelsCachePath());
|
|
380
443
|
}
|
|
381
444
|
|
|
382
445
|
/**
|
|
@@ -388,7 +451,7 @@ export function loadCatalogTemplate(): RawEntry | null {
|
|
|
388
451
|
const catalogPath = readCodexCatalogPath();
|
|
389
452
|
const native = findNativeTemplate(readCatalog(catalogPath))
|
|
390
453
|
?? findNativeTemplate(readCatalogBackup(catalogPath))
|
|
391
|
-
?? findNativeTemplate(readCatalog(
|
|
454
|
+
?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
|
|
392
455
|
?? findNativeTemplate(loadBundledCodexCatalog());
|
|
393
456
|
return native ? JSON.parse(JSON.stringify(native)) : null;
|
|
394
457
|
}
|
|
@@ -585,6 +648,25 @@ type ProviderModelsApiItem = {
|
|
|
585
648
|
};
|
|
586
649
|
};
|
|
587
650
|
|
|
651
|
+
/** Generative Language API `models.list` item (`{ models: [...] }`, not OpenAI's `{ data }`). */
|
|
652
|
+
type GoogleModelsApiModel = {
|
|
653
|
+
name?: string;
|
|
654
|
+
inputTokenLimit?: number;
|
|
655
|
+
supportedGenerationMethods?: string[];
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
function googleModelsToApiItems(models: GoogleModelsApiModel[]): ProviderModelsApiItem[] {
|
|
659
|
+
return models
|
|
660
|
+
// Keep chat-capable models only; a model missing the field stays (defensive).
|
|
661
|
+
.filter(m => m.supportedGenerationMethods?.includes("generateContent") ?? true)
|
|
662
|
+
.map(m => ({
|
|
663
|
+
id: (m.name ?? "").replace(/^models\//, ""),
|
|
664
|
+
owned_by: "google",
|
|
665
|
+
...(typeof m.inputTokenLimit === "number" && m.inputTokenLimit > 0 ? { context_length: m.inputTokenLimit } : {}),
|
|
666
|
+
}))
|
|
667
|
+
.filter(m => m.id.length > 0);
|
|
668
|
+
}
|
|
669
|
+
|
|
588
670
|
function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
|
|
589
671
|
const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
|
|
590
672
|
return typeof configured === "number" && configured > 0 ? configured : undefined;
|
|
@@ -668,26 +750,54 @@ function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModel
|
|
|
668
750
|
async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
|
|
669
751
|
if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
|
|
670
752
|
const apiKey = await resolveModelsAuthToken(name, prov);
|
|
671
|
-
if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
|
|
672
753
|
const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
|
|
673
754
|
id,
|
|
674
755
|
provider: name,
|
|
675
756
|
...catalogHintsFromProviderConfig(name, prov, id, contextCap),
|
|
676
757
|
}));
|
|
758
|
+
if (prov.adapter === "cursor") {
|
|
759
|
+
if (prov.liveModels === false || !apiKey) return configured;
|
|
760
|
+
// Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
|
|
761
|
+
// variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
|
|
762
|
+
// suffix) but filter the static seed to the bases the account actually has — so models not on the
|
|
763
|
+
// plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed.
|
|
764
|
+
const cachedCursor = getFreshCached(name, ttlMs);
|
|
765
|
+
if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor);
|
|
766
|
+
const liveIds = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
|
|
767
|
+
if (liveIds) {
|
|
768
|
+
const available = configured.filter(m => liveIds.some(id => id === m.id || id.startsWith(`${m.id}-`)));
|
|
769
|
+
const result = available.length > 0 ? available : configured;
|
|
770
|
+
setCached(name, result);
|
|
771
|
+
return result;
|
|
772
|
+
}
|
|
773
|
+
const staleCursor = getStaleCached(name);
|
|
774
|
+
return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured;
|
|
775
|
+
}
|
|
776
|
+
if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
|
|
677
777
|
if (prov.liveModels === false) {
|
|
678
778
|
return configured;
|
|
679
779
|
}
|
|
680
780
|
const fresh = getFreshCached(name, ttlMs);
|
|
681
781
|
if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh, contextCap); // dedups Codex's frequent /v1/models polling within the TTL
|
|
682
|
-
|
|
782
|
+
if (isModelsFetchCoolingDown(name)) {
|
|
783
|
+
// A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
|
|
784
|
+
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
|
|
785
|
+
const stale = getStaleCached(name);
|
|
786
|
+
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
|
|
787
|
+
}
|
|
788
|
+
const { url, headers } = buildModelsRequest(prov, apiKey, name);
|
|
683
789
|
try {
|
|
684
790
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
|
685
791
|
if (!res.ok) {
|
|
792
|
+
markModelsFetchFailure(name);
|
|
686
793
|
const stale = getStaleCached(name);
|
|
687
794
|
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
|
|
688
795
|
}
|
|
689
|
-
const json = await res.json() as { data?: ProviderModelsApiItem[] };
|
|
690
|
-
const
|
|
796
|
+
const json = await res.json() as { data?: ProviderModelsApiItem[]; models?: GoogleModelsApiModel[] };
|
|
797
|
+
const items: ProviderModelsApiItem[] = effectiveGoogleMode(name, prov) === "ai-studio" && Array.isArray(json.models)
|
|
798
|
+
? googleModelsToApiItems(json.models)
|
|
799
|
+
: (json.data ?? []);
|
|
800
|
+
const live = items.map(m => applyProviderConfigHints(name, prov, {
|
|
691
801
|
id: m.id,
|
|
692
802
|
provider: name,
|
|
693
803
|
owned_by: m.owned_by,
|
|
@@ -699,6 +809,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
|
|
|
699
809
|
setCached(name, merged);
|
|
700
810
|
return merged;
|
|
701
811
|
} catch {
|
|
812
|
+
markModelsFetchFailure(name);
|
|
702
813
|
const stale = getStaleCached(name);
|
|
703
814
|
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
|
|
704
815
|
}
|
|
@@ -717,9 +828,10 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
|
|
|
717
828
|
activeProviders.map(([name, prov]) => fetchProviderModels(name, prov, ttlMs, providerContextCap(config, name))),
|
|
718
829
|
);
|
|
719
830
|
const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), activeProviders.map(([name]) => name), config.providers, config)
|
|
720
|
-
// Drop image/video generation models (e.g. Grok image/video)
|
|
721
|
-
//
|
|
722
|
-
|
|
831
|
+
// Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog
|
|
832
|
+
// intentionally mirrors Cursor's public model table, including Gemini image preview, so the
|
|
833
|
+
// exposure decision goes through shouldExposeRoutedModel (single choke point).
|
|
834
|
+
.filter(shouldExposeRoutedModel);
|
|
723
835
|
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
724
836
|
return all;
|
|
725
837
|
}
|
|
@@ -775,6 +887,77 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
|
|
|
775
887
|
});
|
|
776
888
|
}
|
|
777
889
|
|
|
890
|
+
export function mergeCatalogEntriesForSync(
|
|
891
|
+
catalogModels: RawEntry[],
|
|
892
|
+
routedEntries: RawEntry[],
|
|
893
|
+
baseline: Map<string, number>,
|
|
894
|
+
featured: string[],
|
|
895
|
+
wsEnabled: boolean,
|
|
896
|
+
goIds: Set<string> = new Set(),
|
|
897
|
+
template: RawEntry | null = null,
|
|
898
|
+
gatheredProviderNames: Set<string> = new Set(routedEntries.flatMap(entry => {
|
|
899
|
+
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
900
|
+
const slash = slug.indexOf("/");
|
|
901
|
+
return slash > 0 ? [slug.slice(0, slash)] : [];
|
|
902
|
+
})),
|
|
903
|
+
): RawEntry[] {
|
|
904
|
+
const rank = new Map(featured.map((slug, i) => [slug, i] as const));
|
|
905
|
+
const native = catalogModels
|
|
906
|
+
.filter(m => typeof m.slug === "string"
|
|
907
|
+
&& !(m.slug as string).includes("/")
|
|
908
|
+
&& !goIds.has(m.slug as string)
|
|
909
|
+
&& !isUnsupportedOpenAiNativeSlug(m.slug as string))
|
|
910
|
+
.map(m => {
|
|
911
|
+
const slug = m.slug as string;
|
|
912
|
+
// Featured models rank first (rank order); non-featured natives are pushed below the featured
|
|
913
|
+
// block when any model is featured, else keep their pristine baseline priority.
|
|
914
|
+
const baselinePriority = baseline.get(slug) ?? (m.priority as number);
|
|
915
|
+
const priority = rank.has(slug)
|
|
916
|
+
? rank.get(slug)!
|
|
917
|
+
: featured.length > 0
|
|
918
|
+
? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
|
|
919
|
+
: baselinePriority;
|
|
920
|
+
return normalizeServiceTiers({ ...m, priority });
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a
|
|
924
|
+
// routed provider exposing the same id can never delete the native OpenAI/Codex base row.
|
|
925
|
+
const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
926
|
+
for (const slug of nativeOpenAiSlugs()) {
|
|
927
|
+
if (nativeSlugs.has(slug)) continue;
|
|
928
|
+
nativeSlugs.add(slug);
|
|
929
|
+
const priority = rank.has(slug)
|
|
930
|
+
? rank.get(slug)!
|
|
931
|
+
: featured.length > 0
|
|
932
|
+
? featured.length + 100
|
|
933
|
+
: 9;
|
|
934
|
+
native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let finalRoutedEntries = routedEntries;
|
|
938
|
+
if (routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"))) {
|
|
939
|
+
finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
|
|
940
|
+
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
|
|
941
|
+
} else {
|
|
942
|
+
const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
|
|
943
|
+
const preservedForeignRouted = catalogModels.filter(m => {
|
|
944
|
+
if (typeof m.slug !== "string" || !m.slug.includes("/")) return false;
|
|
945
|
+
const provider = m.slug.slice(0, m.slug.indexOf("/"));
|
|
946
|
+
return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug);
|
|
947
|
+
});
|
|
948
|
+
finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return [...native, ...finalRoutedEntries].map(m => {
|
|
952
|
+
const normalized = normalizeServiceTiers(m);
|
|
953
|
+
applyNativeOpenAiContextOverride(normalized);
|
|
954
|
+
const e = ensureStrictCatalogFields(normalized);
|
|
955
|
+
if (wsEnabled) e.supports_websockets = true;
|
|
956
|
+
else delete e.supports_websockets;
|
|
957
|
+
return e;
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
778
961
|
/**
|
|
779
962
|
* Merge namespaced routed-model entries into the on-disk Codex catalog.
|
|
780
963
|
* Idempotent + non-destructive:
|
|
@@ -803,64 +986,23 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
803
986
|
const disabled = new Set(config.disabledModels ?? []);
|
|
804
987
|
const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
|
|
805
988
|
const featured = config.subagentModels ?? [];
|
|
806
|
-
const rank = new Map(featured.map((slug, i) => [slug, i] as const));
|
|
807
989
|
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
808
990
|
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
|
|
809
|
-
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields
|
|
810
|
-
//
|
|
811
|
-
//
|
|
991
|
+
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
|
|
992
|
+
// routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
|
|
993
|
+
// like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
|
|
812
994
|
const baseline = readNativeBaseline(catalogPath);
|
|
813
995
|
const goIds = new Set(enabledGo.map(m => m.id));
|
|
814
|
-
const
|
|
815
|
-
.
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
// codex-auto-review, …) from the on-disk catalog too, matching the live /v1/models
|
|
820
|
-
// allowlist. Genuine user-added natives (non gpt-/codex- slugs) are preserved.
|
|
821
|
-
&& !isUnsupportedOpenAiNativeSlug(m.slug as string))
|
|
822
|
-
.map(m => {
|
|
823
|
-
const slug = m.slug as string;
|
|
824
|
-
const baselinePriority = baseline.get(slug) ?? (m.priority as number);
|
|
825
|
-
const priority = rank.has(slug)
|
|
826
|
-
? rank.get(slug)!
|
|
827
|
-
: featured.length > 0
|
|
828
|
-
? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
|
|
829
|
-
: baselinePriority;
|
|
830
|
-
return normalizeServiceTiers({ ...m, priority });
|
|
831
|
-
});
|
|
832
|
-
const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
833
|
-
for (const slug of nativeOpenAiSlugs()) {
|
|
834
|
-
if (nativeSlugs.has(slug)) continue;
|
|
835
|
-
nativeSlugs.add(slug);
|
|
836
|
-
const priority = rank.has(slug)
|
|
837
|
-
? rank.get(slug)!
|
|
838
|
-
: featured.length > 0
|
|
839
|
-
? featured.length + 100
|
|
840
|
-
: 9;
|
|
841
|
-
native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
|
|
842
|
-
}
|
|
996
|
+
const gatheredProviderNames = new Set(
|
|
997
|
+
Object.entries(config.providers ?? {})
|
|
998
|
+
.filter(([, prov]) => prov.disabled !== true)
|
|
999
|
+
.map(([name]) => name),
|
|
1000
|
+
);
|
|
843
1001
|
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
844
1002
|
// native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
|
|
845
1003
|
// native template can never leak supports_websockets while the flag is off.
|
|
846
1004
|
const wsEnabled = websocketsEnabled(config);
|
|
847
|
-
|
|
848
|
-
// gatherRoutedModels returned nothing (provider down / flaky / cache miss) but the pre-sync
|
|
849
|
-
// catalog DID carry routed entries, preserve those prior routed entries instead of overwriting
|
|
850
|
-
// them with an empty set — otherwise the Codex picker silently loses kiro/opencode-go models.
|
|
851
|
-
let routedEntries = goEntries;
|
|
852
|
-
if (goEntries.length === 0 && catalogHasRoutedEntries(catalog)) {
|
|
853
|
-
routedEntries = (catalog.models ?? []).filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
|
|
854
|
-
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${routedEntries.length} existing routed entr${routedEntries.length === 1 ? "y" : "ies"} on disk.`);
|
|
855
|
-
}
|
|
856
|
-
catalog.models = [...native, ...routedEntries].map(m => {
|
|
857
|
-
const normalized = normalizeServiceTiers(m);
|
|
858
|
-
applyNativeOpenAiContextOverride(normalized);
|
|
859
|
-
const e = ensureStrictCatalogFields(normalized);
|
|
860
|
-
if (wsEnabled) e.supports_websockets = true;
|
|
861
|
-
else delete e.supports_websockets;
|
|
862
|
-
return e;
|
|
863
|
-
});
|
|
1005
|
+
catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, gatheredProviderNames);
|
|
864
1006
|
|
|
865
1007
|
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
866
1008
|
return { added: goEntries.length, path: catalogPath };
|
|
@@ -915,6 +1057,6 @@ export function invalidateCodexModelsCache(): void {
|
|
|
915
1057
|
client_version: "0.0.0",
|
|
916
1058
|
models,
|
|
917
1059
|
};
|
|
918
|
-
atomicWriteFile(
|
|
1060
|
+
atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
|
|
919
1061
|
} catch { /* best-effort */ }
|
|
920
1062
|
}
|
|
@@ -144,6 +144,8 @@ export interface CodexHistorySyncResult {
|
|
|
144
144
|
rows: number;
|
|
145
145
|
files: number;
|
|
146
146
|
ejectedRows?: number;
|
|
147
|
+
/** Set when a lock/busy error survived retries and the sync was SKIPPED, not empty. */
|
|
148
|
+
failed?: true;
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
interface ThreadRow {
|
|
@@ -351,7 +353,7 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
|
|
|
351
353
|
return { rows: rows.length, files };
|
|
352
354
|
}
|
|
353
355
|
|
|
354
|
-
function isRecoverableHistoryError(error: unknown): boolean {
|
|
356
|
+
export function isRecoverableHistoryError(error: unknown): boolean {
|
|
355
357
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
356
358
|
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
357
359
|
return code === "SQLITE_BUSY"
|
|
@@ -366,15 +368,33 @@ function isRecoverableHistoryError(error: unknown): boolean {
|
|
|
366
368
|
|| message.includes("permission denied");
|
|
367
369
|
}
|
|
368
370
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
const HISTORY_RETRY_DELAY_MS = 500;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Run a history mutation with one retry across recoverable lock/busy errors (the app's own
|
|
375
|
+
* connection holds `state_5.sqlite` in WAL with a 5 s busy_timeout; a transient writer
|
|
376
|
+
* usually clears within that window). Returns null when both attempts hit a recoverable
|
|
377
|
+
* error — callers surface that as `failed: true` instead of a silent no-op. Hard errors
|
|
378
|
+
* (corruption, programming bugs) still throw.
|
|
379
|
+
*/
|
|
380
|
+
export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) => void } = {}): T | null {
|
|
381
|
+
const sleepFn = io.sleepFn ?? Bun.sleepSync;
|
|
382
|
+
for (let attempt = 0; ; attempt++) {
|
|
383
|
+
try {
|
|
384
|
+
return fn();
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (!isRecoverableHistoryError(error)) throw error;
|
|
387
|
+
if (attempt >= 1) return null;
|
|
388
|
+
try { sleepFn(HISTORY_RETRY_DELAY_MS); } catch { /* sleep is best-effort */ }
|
|
389
|
+
}
|
|
375
390
|
}
|
|
376
391
|
}
|
|
377
392
|
|
|
393
|
+
export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
|
|
394
|
+
return withHistoryRetry(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath))
|
|
395
|
+
?? { rows: 0, files: 0, failed: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
378
398
|
function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
|
|
379
399
|
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
|
|
380
400
|
if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
|
|
@@ -495,17 +515,14 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
495
515
|
}
|
|
496
516
|
}
|
|
497
517
|
|
|
498
|
-
export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
|
|
499
|
-
|
|
500
|
-
|
|
518
|
+
export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number; failed?: true } {
|
|
519
|
+
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
|
|
520
|
+
return withHistoryRetry(() => {
|
|
501
521
|
const db = openStateDb(stateDbPath);
|
|
502
522
|
try {
|
|
503
523
|
return ejectRemainingOpencodexHistory(db);
|
|
504
524
|
} finally {
|
|
505
525
|
db.close();
|
|
506
526
|
}
|
|
507
|
-
}
|
|
508
|
-
if (isRecoverableHistoryError(error)) return { rows: 0, files: 0 };
|
|
509
|
-
throw error;
|
|
510
|
-
}
|
|
527
|
+
}) ?? { rows: 0, files: 0, failed: true };
|
|
511
528
|
}
|
package/src/codex-inject.ts
CHANGED
|
@@ -32,7 +32,11 @@ function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
|
32
32
|
|
|
33
33
|
function providerBaseHost(hostname: string | undefined): string {
|
|
34
34
|
const trimmed = (hostname ?? "127.0.0.1").trim();
|
|
35
|
-
|
|
35
|
+
const lower = trimmed.toLowerCase();
|
|
36
|
+
// Match what the server actually binds. Writing "localhost" while binding IPv4-only
|
|
37
|
+
// 127.0.0.1 breaks on Windows, where localhost commonly resolves to ::1 first.
|
|
38
|
+
if (lower === "::1" || lower === "[::1]") return "[::1]";
|
|
39
|
+
if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
|
|
36
40
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
|
37
41
|
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
|
38
42
|
}
|
|
@@ -283,9 +287,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
283
287
|
const catalogMessage = catalogPath
|
|
284
288
|
? ` Codex model catalog: ${catalogPath}\n`
|
|
285
289
|
: ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
|
|
286
|
-
const historyMessage = config?.syncResumeHistory
|
|
287
|
-
? ` Codex resume history:
|
|
288
|
-
:
|
|
290
|
+
const historyMessage = config?.syncResumeHistory === false
|
|
291
|
+
? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
|
|
292
|
+
: history.failed
|
|
293
|
+
? ` ⚠️ Codex resume history sync SKIPPED: the history DB is locked (Codex app/IDE open?). Close it and rerun 'ocx start'.\n`
|
|
294
|
+
: ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`;
|
|
289
295
|
return {
|
|
290
296
|
success: true,
|
|
291
297
|
message: `Injected opencodex as default provider into Codex config.\n` +
|
|
@@ -376,11 +382,13 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
|
|
|
376
382
|
const msg = cat.removed > 0
|
|
377
383
|
? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
|
|
378
384
|
: cfg.message;
|
|
379
|
-
const historyMsg = history.
|
|
380
|
-
? `
|
|
381
|
-
: history.
|
|
382
|
-
? ` ${history.
|
|
383
|
-
:
|
|
385
|
+
const historyMsg = history.failed
|
|
386
|
+
? ` ⚠️ Codex resume history could NOT be restored — the Codex app appears to be holding the history DB. Close the Codex app/IDE and run 'ocx stop' again; until then routed threads stay hidden in the native app.`
|
|
387
|
+
: history.rows > 0
|
|
388
|
+
? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
|
|
389
|
+
: history.ejectedRows
|
|
390
|
+
? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
|
|
391
|
+
: "";
|
|
384
392
|
return { success: cfg.success, message: `${msg}${historyMsg}` };
|
|
385
393
|
}
|
|
386
394
|
|
package/src/codex-paths.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { expandUserPath } from "./config";
|
|
4
5
|
|
|
5
6
|
function resolveCodexHome(): string {
|
|
6
7
|
const raw = process.env.CODEX_HOME?.trim();
|
|
7
8
|
if (raw) {
|
|
8
|
-
const path = resolve(raw);
|
|
9
|
+
const path = resolve(expandUserPath(raw));
|
|
9
10
|
let stat;
|
|
10
11
|
try {
|
|
11
12
|
stat = statSync(path);
|