@bitkyc08/opencodex 0.2.1 → 1.9.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.
- package/README.ko.md +1 -1
- package/README.md +6 -3
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/{index-C9y3iMF1.js → index-CDhJ0DI7.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +3 -1
- package/src/abort.ts +29 -0
- package/src/adapters/anthropic.ts +15 -5
- package/src/adapters/google.ts +27 -11
- package/src/adapters/openai-chat.ts +38 -12
- package/src/adapters/openai-responses.ts +18 -1
- package/src/bridge.ts +155 -17
- package/src/cli.ts +38 -7
- package/src/codex-catalog.ts +130 -18
- package/src/codex-inject.ts +111 -12
- package/src/codex-paths.ts +59 -0
- package/src/config.ts +5 -0
- package/src/debug.ts +10 -0
- package/src/errors.ts +47 -0
- package/src/generated/jawcode-model-metadata.ts +69 -0
- package/src/init.ts +5 -32
- package/src/oauth/index.ts +19 -33
- package/src/oauth/key-providers.ts +2 -63
- package/src/providers/derive.ts +163 -0
- package/src/providers/registry.ts +140 -0
- package/src/responses/parser.ts +6 -1
- package/src/server.ts +182 -9
- package/src/service.ts +77 -14
- package/src/types.ts +6 -0
- package/src/vision/describe.ts +6 -1
- package/src/vision/index.ts +2 -1
- package/src/web-search/executor.ts +6 -1
- package/src/web-search/loop.ts +9 -3
- package/src/ws-bridge.ts +359 -0
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { restoreNativeCodex } from "./codex-inject";
|
|
3
4
|
import { loadConfig, readPid, removePid, writePid } from "./config";
|
|
4
5
|
import { serviceCommand } from "./service";
|
|
@@ -37,9 +38,12 @@ async function syncModelsToCodex(port?: number) {
|
|
|
37
38
|
const { injectCodexConfig } = await import("./codex-inject");
|
|
38
39
|
const result = await injectCodexConfig(p, config);
|
|
39
40
|
try {
|
|
40
|
-
const { syncCatalogModels } = await import("./codex-catalog");
|
|
41
|
+
const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
|
|
41
42
|
const cat = await syncCatalogModels(config);
|
|
42
|
-
if (cat.added > 0)
|
|
43
|
+
if (cat.added > 0) {
|
|
44
|
+
invalidateCodexModelsCache();
|
|
45
|
+
console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
|
|
46
|
+
}
|
|
43
47
|
} catch (e) {
|
|
44
48
|
console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
|
|
45
49
|
}
|
|
@@ -85,32 +89,59 @@ function handleStart() {
|
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
function killProxy(pid: number): void {
|
|
92
|
+
if (!isProcessAlive(pid)) return;
|
|
88
93
|
if (process.platform === "win32") {
|
|
94
|
+
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
89
95
|
try {
|
|
90
|
-
(
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (isProcessAlive(pid)) throw err;
|
|
99
|
+
}
|
|
93
100
|
} else {
|
|
94
101
|
process.kill(pid, "SIGTERM");
|
|
102
|
+
if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isProcessAlive(pid: number): boolean {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
function waitForExit(pid: number, timeoutMs: number): boolean {
|
|
117
|
+
const deadline = Date.now() + timeoutMs;
|
|
118
|
+
const marker = new Int32Array(new SharedArrayBuffer(4));
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (!isProcessAlive(pid)) return true;
|
|
121
|
+
Atomics.wait(marker, 0, 0, 50);
|
|
122
|
+
}
|
|
123
|
+
return !isProcessAlive(pid);
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
function handleStop() {
|
|
99
127
|
const pid = readPid();
|
|
128
|
+
let stopFailed = false;
|
|
100
129
|
if (pid) {
|
|
101
130
|
try {
|
|
102
131
|
killProxy(pid);
|
|
103
132
|
console.log(`✅ Proxy (PID ${pid}) stopped.`);
|
|
133
|
+
removePid();
|
|
104
134
|
} catch {
|
|
105
|
-
|
|
135
|
+
stopFailed = true;
|
|
136
|
+
console.error(`❌ Failed to stop proxy (PID ${pid}).`);
|
|
106
137
|
}
|
|
107
|
-
removePid();
|
|
108
138
|
} else {
|
|
109
139
|
console.log("No running proxy found.");
|
|
110
140
|
}
|
|
111
141
|
// Recover native Codex so plain `codex` keeps working while the proxy is down.
|
|
112
142
|
const r = restoreNativeCodex();
|
|
113
143
|
console.log(`↩️ ${r.message}`);
|
|
144
|
+
if (stopFailed) process.exit(1);
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
function handleStatus() {
|
package/src/codex-catalog.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { atomicWriteFile } from "./config";
|
|
4
|
+
import { atomicWriteFile, websocketsEnabled } from "./config";
|
|
5
|
+
import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "./codex-paths";
|
|
5
6
|
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
|
|
6
7
|
import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
|
|
7
8
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
9
|
+
import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "./generated/jawcode-model-metadata";
|
|
10
|
+
import { shouldCaseFoldMetadataModelId } from "./providers/derive";
|
|
8
11
|
|
|
9
|
-
const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
10
|
-
const DEFAULT_CATALOG_PATH = join(homedir(), ".codex", "opencodex-catalog.json");
|
|
11
12
|
const OCX_DIR = join(homedir(), ".opencodex");
|
|
12
13
|
const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
|
|
13
14
|
|
|
@@ -34,14 +35,15 @@ export function nativeOpenAiSlugs(): string[] {
|
|
|
34
35
|
|
|
35
36
|
export interface CatalogModel { id: string; provider: string; owned_by?: string; }
|
|
36
37
|
type RawEntry = Record<string, unknown>;
|
|
38
|
+
const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
|
|
37
39
|
|
|
38
40
|
/** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
|
|
39
41
|
export function readCodexCatalogPath(): string {
|
|
40
42
|
try {
|
|
41
43
|
if (existsSync(CODEX_CONFIG_PATH)) {
|
|
42
44
|
const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
+
const path = readRootTomlString(toml, "model_catalog_json");
|
|
46
|
+
if (path) return resolveCodexConfigPath(path);
|
|
45
47
|
}
|
|
46
48
|
} catch { /* ignore */ }
|
|
47
49
|
return DEFAULT_CATALOG_PATH;
|
|
@@ -55,13 +57,86 @@ function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown
|
|
|
55
57
|
} catch { return null; }
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
function normalizeServiceTiers(entry: RawEntry): RawEntry {
|
|
61
|
+
// Codex stores the user-facing config spelling as "fast", but the catalog/request
|
|
62
|
+
// service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
|
|
63
|
+
if (entry.service_tier === "fast") entry.service_tier = "priority";
|
|
64
|
+
if (Array.isArray(entry.service_tiers)) {
|
|
65
|
+
entry.service_tiers = entry.service_tiers.map(tier => {
|
|
66
|
+
if (tier && typeof tier === "object" && "id" in tier && tier.id === "fast") {
|
|
67
|
+
return { ...tier, id: "priority" };
|
|
68
|
+
}
|
|
69
|
+
return tier;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return entry;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
|
|
76
|
+
if (
|
|
77
|
+
typeof entry.context_window === "number"
|
|
78
|
+
&& entry.context_window > 0
|
|
79
|
+
&& typeof entry.auto_compact_token_limit !== "number"
|
|
80
|
+
) {
|
|
81
|
+
entry.auto_compact_token_limit = Math.floor(entry.context_window * 0.9);
|
|
82
|
+
}
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeRoutedCatalogEntry(entry: RawEntry): RawEntry {
|
|
87
|
+
delete entry.model_messages;
|
|
88
|
+
delete entry.tool_mode;
|
|
89
|
+
delete entry.multi_agent_version;
|
|
90
|
+
delete entry.use_responses_lite;
|
|
91
|
+
delete entry.supports_websockets;
|
|
92
|
+
delete entry.additional_speed_tiers;
|
|
93
|
+
delete entry.service_tier;
|
|
94
|
+
delete entry.service_tiers;
|
|
95
|
+
delete entry.default_service_tier;
|
|
96
|
+
// Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar
|
|
97
|
+
// runs through native gpt-5.4-mini, so image search is available and verbalized for text-only models.
|
|
98
|
+
entry.web_search_tool_type = "text_and_image";
|
|
99
|
+
entry.supports_search_tool = true;
|
|
100
|
+
return ensureAutoCompactTokenLimit(entry);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyJawcodeCatalogMetadata(entry: RawEntry, slug: string): void {
|
|
104
|
+
const slash = slug.indexOf("/");
|
|
105
|
+
if (slash < 0) return;
|
|
106
|
+
const provider = slug.slice(0, slash);
|
|
107
|
+
const modelId = slug.slice(slash + 1);
|
|
108
|
+
const jawcodeProvider = resolveJawcodeProvider(provider);
|
|
109
|
+
if (!jawcodeProvider) return;
|
|
110
|
+
const meta = getJawcodeModelMetadata(jawcodeProvider, modelId)
|
|
111
|
+
?? (shouldCaseFoldMetadataModelId(provider) ? getJawcodeModelMetadataCaseInsensitive(jawcodeProvider, modelId) : undefined);
|
|
112
|
+
if (!meta) return;
|
|
113
|
+
if (typeof meta.contextWindow === "number" && meta.contextWindow > 0) {
|
|
114
|
+
entry.context_window = meta.contextWindow;
|
|
115
|
+
entry.max_context_window = meta.contextWindow;
|
|
116
|
+
entry.auto_compact_token_limit = Math.floor(meta.contextWindow * 0.9);
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(meta.input) && meta.input.length > 0) {
|
|
119
|
+
entry.input_modalities = meta.input;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadCatalogForSync(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
|
|
124
|
+
const catalog = readCatalog(path);
|
|
125
|
+
if (catalog) return catalog;
|
|
126
|
+
return readCatalog(CODEX_MODELS_CACHE_PATH);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readCurrentCatalogOrCache(): { models?: RawEntry[]; [k: string]: unknown } | null {
|
|
130
|
+
return readCatalog(readCodexCatalogPath()) ?? readCatalog(CODEX_MODELS_CACHE_PATH);
|
|
131
|
+
}
|
|
132
|
+
|
|
58
133
|
/**
|
|
59
134
|
* A full native entry from the on-disk catalog, used as a clone template so injected
|
|
60
135
|
* entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
|
|
61
136
|
* Returns a deep copy, or null if no catalog/native entry exists.
|
|
62
137
|
*/
|
|
63
138
|
export function loadCatalogTemplate(): RawEntry | null {
|
|
64
|
-
const cat =
|
|
139
|
+
const cat = readCurrentCatalogOrCache();
|
|
65
140
|
const native = cat?.models?.find(
|
|
66
141
|
m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
|
|
67
142
|
);
|
|
@@ -108,17 +183,22 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
108
183
|
);
|
|
109
184
|
e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
|
|
110
185
|
e.default_reasoning_level = "medium";
|
|
186
|
+
normalizeRoutedCatalogEntry(e);
|
|
187
|
+
applyJawcodeCatalogMetadata(e, slug);
|
|
111
188
|
}
|
|
112
|
-
return e;
|
|
189
|
+
return ensureAutoCompactTokenLimit(normalizeServiceTiers(e));
|
|
113
190
|
}
|
|
114
191
|
// Fallback when no template is available (best-effort; strict parser may need more).
|
|
115
|
-
|
|
192
|
+
const entry: RawEntry = {
|
|
116
193
|
slug, display_name: slug, description: desc,
|
|
117
194
|
default_reasoning_level: "medium",
|
|
118
195
|
supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
|
|
119
196
|
shell_type: "shell_command", visibility: "list", supported_in_api: true,
|
|
120
197
|
priority, base_instructions: "You are a helpful coding assistant.",
|
|
198
|
+
...(slug.includes("/") ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}),
|
|
121
199
|
};
|
|
200
|
+
applyJawcodeCatalogMetadata(entry, slug);
|
|
201
|
+
return ensureAutoCompactTokenLimit(normalizeServiceTiers(entry));
|
|
122
202
|
}
|
|
123
203
|
|
|
124
204
|
/**
|
|
@@ -126,7 +206,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
126
206
|
* catalog sync and the proxy `/v1/models?client_version` branch.
|
|
127
207
|
* Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
|
|
128
208
|
*/
|
|
129
|
-
export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[]): RawEntry[] {
|
|
209
|
+
export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[], wsEnabled = false): RawEntry[] {
|
|
130
210
|
// Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
|
|
131
211
|
// models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
|
|
132
212
|
// ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
|
|
@@ -144,12 +224,19 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
|
|
|
144
224
|
if (rank.has(slug)) e.priority = rank.get(slug)!;
|
|
145
225
|
out.push(e);
|
|
146
226
|
}
|
|
227
|
+
// Central capability override (phase 120.4): the advertised flag must match the implemented WS
|
|
228
|
+
// endpoint. Overrides both the routed strip (normalizeRoutedCatalogEntry) and any native template
|
|
229
|
+
// leak (deriveEntry clones the template as-is for native slugs).
|
|
230
|
+
for (const entry of out) {
|
|
231
|
+
if (wsEnabled) entry.supports_websockets = true;
|
|
232
|
+
else delete entry.supports_websockets;
|
|
233
|
+
}
|
|
147
234
|
return out;
|
|
148
235
|
}
|
|
149
236
|
|
|
150
237
|
/** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
|
|
151
238
|
export function listCatalogNativeSlugs(): string[] {
|
|
152
|
-
const cat =
|
|
239
|
+
const cat = readCurrentCatalogOrCache();
|
|
153
240
|
return (cat?.models ?? [])
|
|
154
241
|
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
|
|
155
242
|
.map(m => m.slug as string);
|
|
@@ -211,11 +298,28 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
|
|
|
211
298
|
const lists = await Promise.all(
|
|
212
299
|
Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)),
|
|
213
300
|
);
|
|
214
|
-
const all = lists.flat();
|
|
301
|
+
const all = augmentRoutedModelsWithJawcodeMetadata(lists.flat(), Object.keys(config.providers));
|
|
215
302
|
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
216
303
|
return all;
|
|
217
304
|
}
|
|
218
305
|
|
|
306
|
+
export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], providerNames: string[]): CatalogModel[] {
|
|
307
|
+
const out = [...models];
|
|
308
|
+
const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
|
|
309
|
+
for (const provider of providerNames) {
|
|
310
|
+
if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
|
|
311
|
+
const jawcodeProvider = resolveJawcodeProvider(provider);
|
|
312
|
+
if (!jawcodeProvider) continue;
|
|
313
|
+
for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
|
|
314
|
+
const key = `${provider}/${meta.id}`;
|
|
315
|
+
if (seen.has(key)) continue;
|
|
316
|
+
seen.add(key);
|
|
317
|
+
out.push({ provider, id: meta.id, owned_by: provider });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
219
323
|
/**
|
|
220
324
|
* Reorder routed models so the configured subagent picks come FIRST (in the chosen order).
|
|
221
325
|
* Codex's spawn_agent advertises only the first 5 routed catalog entries, so putting the chosen
|
|
@@ -244,7 +348,7 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[])
|
|
|
244
348
|
*/
|
|
245
349
|
export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
|
|
246
350
|
const catalogPath = readCodexCatalogPath();
|
|
247
|
-
const catalog =
|
|
351
|
+
const catalog = loadCatalogForSync(catalogPath);
|
|
248
352
|
if (!catalog) return { added: 0, path: catalogPath };
|
|
249
353
|
|
|
250
354
|
const template = (catalog.models ?? []).find(
|
|
@@ -261,7 +365,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
261
365
|
const featured = config.subagentModels ?? [];
|
|
262
366
|
const rank = new Map(featured.map((slug, i) => [slug, i] as const));
|
|
263
367
|
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
264
|
-
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured);
|
|
368
|
+
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config));
|
|
265
369
|
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields, but drop bare
|
|
266
370
|
// duplicates of routed models (replaced by namespaced entries) + any prior "/" entries. Re-derive
|
|
267
371
|
// each native's priority from the pristine baseline so featuring a native is reversible.
|
|
@@ -272,9 +376,18 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
272
376
|
.map(m => {
|
|
273
377
|
const slug = m.slug as string;
|
|
274
378
|
const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
|
|
275
|
-
return { ...m, priority };
|
|
379
|
+
return normalizeServiceTiers({ ...m, priority });
|
|
276
380
|
});
|
|
277
|
-
catalog.
|
|
381
|
+
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
382
|
+
// native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
|
|
383
|
+
// native template can never leak supports_websockets while the flag is off.
|
|
384
|
+
const wsEnabled = websocketsEnabled(config);
|
|
385
|
+
catalog.models = [...native, ...goEntries].map(m => {
|
|
386
|
+
const e = ensureAutoCompactTokenLimit(normalizeServiceTiers(m));
|
|
387
|
+
if (wsEnabled) e.supports_websockets = true;
|
|
388
|
+
else delete e.supports_websockets;
|
|
389
|
+
return e;
|
|
390
|
+
});
|
|
278
391
|
|
|
279
392
|
try {
|
|
280
393
|
if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
|
|
@@ -306,13 +419,12 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
|
|
|
306
419
|
}
|
|
307
420
|
|
|
308
421
|
/**
|
|
309
|
-
* Delete Codex's models cache (
|
|
422
|
+
* Delete Codex's models cache ($CODEX_HOME/models_cache.json) so the next turn re-fetches /v1/models.
|
|
310
423
|
* Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); invalidating makes catalog edits
|
|
311
424
|
* (enable/disable, subagent reorder) apply on the next turn instead of waiting for the TTL.
|
|
312
425
|
*/
|
|
313
426
|
export function invalidateCodexModelsCache(): void {
|
|
314
427
|
try {
|
|
315
|
-
|
|
316
|
-
if (existsSync(p)) unlinkSync(p);
|
|
428
|
+
if (existsSync(CODEX_MODELS_CACHE_PATH)) unlinkSync(CODEX_MODELS_CACHE_PATH);
|
|
317
429
|
} catch { /* best-effort */ }
|
|
318
430
|
}
|
package/src/codex-inject.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { atomicWriteFile } from "./config";
|
|
2
|
+
import { atomicWriteFile, websocketsEnabled } from "./config";
|
|
5
3
|
import { restoreCodexCatalog } from "./codex-catalog";
|
|
4
|
+
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, tomlString } from "./codex-paths";
|
|
6
5
|
import type { OcxConfig } from "./types";
|
|
7
6
|
|
|
8
|
-
const CODEX_HOME = join(homedir(), ".codex");
|
|
9
|
-
const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
10
|
-
const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
|
|
11
|
-
|
|
12
7
|
const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
|
|
13
8
|
|
|
14
9
|
/**
|
|
@@ -19,7 +14,7 @@ const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
|
|
|
19
14
|
* whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
|
|
20
15
|
* never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
|
|
21
16
|
*/
|
|
22
|
-
function buildProviderTableBlock(port: number): string {
|
|
17
|
+
export function buildProviderTableBlock(port: number, supportsWebsockets = false): string {
|
|
23
18
|
const lines = [
|
|
24
19
|
"",
|
|
25
20
|
OCX_SECTION_MARKER,
|
|
@@ -27,7 +22,9 @@ function buildProviderTableBlock(port: number): string {
|
|
|
27
22
|
'name = "OpenCodex Proxy"',
|
|
28
23
|
`base_url = "http://localhost:${port}/v1"`,
|
|
29
24
|
'wire_api = "responses"',
|
|
25
|
+
"requires_openai_auth = true",
|
|
30
26
|
];
|
|
27
|
+
if (supportsWebsockets) lines.push("supports_websockets = true");
|
|
31
28
|
return lines.join("\n") + "\n";
|
|
32
29
|
}
|
|
33
30
|
|
|
@@ -53,6 +50,17 @@ function stripExistingModelProvider(content: string): string {
|
|
|
53
50
|
return out.join("\n");
|
|
54
51
|
}
|
|
55
52
|
|
|
53
|
+
function stripRootContextWindowOverrides(content: string): string {
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
56
|
+
return lines
|
|
57
|
+
.filter((line, i) => {
|
|
58
|
+
const isRoot = firstTable === -1 || i < firstTable;
|
|
59
|
+
return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
|
|
60
|
+
})
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
/**
|
|
57
65
|
* Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table
|
|
58
66
|
* header (TOML root keys must precede all tables). If there are no tables, append it to the root body.
|
|
@@ -70,16 +78,95 @@ function setRootModelProvider(content: string): string {
|
|
|
70
78
|
return lines.join("\n");
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
function
|
|
81
|
+
function readRootModelCatalogPath(content: string): string | null {
|
|
82
|
+
return readRootTomlString(content, "model_catalog_json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setRootModelCatalogPath(content: string, catalogPath: string): string {
|
|
86
|
+
if (readRootModelCatalogPath(content)) return content;
|
|
87
|
+
const lines = content.split("\n");
|
|
88
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
89
|
+
const key = `model_catalog_json = ${tomlString(catalogPath)}`;
|
|
90
|
+
if (firstTable === -1) {
|
|
91
|
+
return content.replace(/\n+$/, "") + "\n" + key + "\n";
|
|
92
|
+
}
|
|
93
|
+
let insertAt = firstTable;
|
|
94
|
+
while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
|
|
95
|
+
lines.splice(insertAt, 0, key);
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function removeProfileSection(content: string): string {
|
|
100
|
+
const lines = content.split("\n");
|
|
101
|
+
const filtered: string[] = [];
|
|
102
|
+
let inProfile = false;
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (line.trim() === "[profiles.opencodex]") {
|
|
105
|
+
inProfile = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (inProfile) {
|
|
109
|
+
if (line.startsWith("[") && line.trim() !== "[profiles.opencodex]") {
|
|
110
|
+
inProfile = false;
|
|
111
|
+
filtered.push(line);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
filtered.push(line);
|
|
116
|
+
}
|
|
117
|
+
return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeServiceTier(content: string): string {
|
|
121
|
+
return content.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, '$1"fast"');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ensureFastModeFeature(content: string): string {
|
|
125
|
+
const lines = content.split("\n");
|
|
126
|
+
const featuresStart = lines.findIndex(line => line.trim() === "[features]");
|
|
127
|
+
if (featuresStart === -1) {
|
|
128
|
+
return content.trimEnd() + "\n\n[features]\nfast_mode = true\n";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextTable = lines.findIndex((line, index) => index > featuresStart && /^\s*\[/.test(line));
|
|
132
|
+
const featuresEnd = nextTable === -1 ? lines.length : nextTable;
|
|
133
|
+
for (let i = featuresStart + 1; i < featuresEnd; i++) {
|
|
134
|
+
if (/^\s*fast_mode\s*=/.test(lines[i])) {
|
|
135
|
+
lines[i] = lines[i].replace(/^(\s*)fast_mode\s*=.*$/, "$1fast_mode = true");
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let insertAt = featuresEnd;
|
|
141
|
+
while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--;
|
|
142
|
+
lines.splice(insertAt, 0, "fast_mode = true");
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stripDefaultCatalogPath(content: string): string {
|
|
147
|
+
return content
|
|
148
|
+
.split("\n")
|
|
149
|
+
.filter(line => {
|
|
150
|
+
const m = line.match(/^\s*model_catalog_json\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
|
|
151
|
+
return !m || parseTomlString(m[1]) !== DEFAULT_CATALOG_PATH;
|
|
152
|
+
})
|
|
153
|
+
.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildProfileFile(port: number, catalogPath: string): string {
|
|
74
157
|
return [
|
|
75
158
|
"# OpenCodex proxy profile — use with: codex --profile opencodex",
|
|
76
159
|
`# Routes all model requests through the opencodex proxy at localhost:${port}`,
|
|
77
160
|
'model_provider = "opencodex"',
|
|
161
|
+
`model_catalog_json = ${tomlString(catalogPath)}`,
|
|
162
|
+
"",
|
|
163
|
+
"[features]",
|
|
164
|
+
"fast_mode = true",
|
|
78
165
|
"",
|
|
79
166
|
].join("\n");
|
|
80
167
|
}
|
|
81
168
|
|
|
82
|
-
export async function injectCodexConfig(port: number,
|
|
169
|
+
export async function injectCodexConfig(port: number, config?: OcxConfig): Promise<{ success: boolean; message: string }> {
|
|
83
170
|
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
84
171
|
return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
|
|
85
172
|
}
|
|
@@ -92,15 +179,22 @@ export async function injectCodexConfig(port: number, _config?: OcxConfig): Prom
|
|
|
92
179
|
if (content.includes("[model_providers.opencodex]")) {
|
|
93
180
|
content = removeOcxSection(content);
|
|
94
181
|
}
|
|
182
|
+
content = removeProfileSection(content);
|
|
95
183
|
content = stripExistingModelProvider(content);
|
|
184
|
+
content = stripRootContextWindowOverrides(content);
|
|
185
|
+
content = normalizeServiceTier(content);
|
|
186
|
+
content = ensureFastModeFeature(content);
|
|
187
|
+
|
|
188
|
+
const catalogPath = readRootModelCatalogPath(content) ?? DEFAULT_CATALOG_PATH;
|
|
189
|
+
content = setRootModelCatalogPath(content, catalogPath);
|
|
96
190
|
|
|
97
191
|
// 1) Root key BEFORE the first table header (must be a global, not nested under a table).
|
|
98
192
|
content = setRootModelProvider(content);
|
|
99
193
|
// 2) Provider table appended at EOF (position-independent).
|
|
100
|
-
content = content.trimEnd() + "\n" + buildProviderTableBlock(port);
|
|
194
|
+
content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}));
|
|
101
195
|
|
|
102
196
|
writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
|
|
103
|
-
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port), "utf-8");
|
|
197
|
+
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
|
|
104
198
|
|
|
105
199
|
return {
|
|
106
200
|
success: true,
|
|
@@ -141,9 +235,12 @@ export function stripOpencodexConfig(content: string): string {
|
|
|
141
235
|
if (out.includes("[model_providers.opencodex]")) {
|
|
142
236
|
out = removeOcxSection(out);
|
|
143
237
|
}
|
|
238
|
+
out = removeProfileSection(out);
|
|
144
239
|
// Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
|
|
145
240
|
// must match the detection regex above, or a detected line could survive un-removed.
|
|
146
241
|
out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
|
|
242
|
+
out = stripRootContextWindowOverrides(out);
|
|
243
|
+
out = stripDefaultCatalogPath(out);
|
|
147
244
|
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
148
245
|
}
|
|
149
246
|
|
|
@@ -159,6 +256,8 @@ export function removeCodexConfig(): { success: boolean; message: string } {
|
|
|
159
256
|
const had = hasOpencodexRouting(content);
|
|
160
257
|
if (had) {
|
|
161
258
|
atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
|
|
259
|
+
} else if (stripRootContextWindowOverrides(content) !== content) {
|
|
260
|
+
atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
|
|
162
261
|
}
|
|
163
262
|
if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
|
|
164
263
|
return {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
function resolveCodexHome(): string {
|
|
6
|
+
const raw = process.env.CODEX_HOME?.trim();
|
|
7
|
+
if (raw) {
|
|
8
|
+
const path = resolve(raw);
|
|
9
|
+
let stat;
|
|
10
|
+
try {
|
|
11
|
+
stat = statSync(path);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
14
|
+
throw new Error(`CODEX_HOME points to ${raw}, but that path could not be read: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
if (!stat.isDirectory()) {
|
|
17
|
+
throw new Error(`CODEX_HOME points to ${raw}, but that path is not a directory`);
|
|
18
|
+
}
|
|
19
|
+
return realpathSync.native(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return join(homedir(), ".codex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CODEX_HOME = resolveCodexHome();
|
|
26
|
+
export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
27
|
+
export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
|
|
28
|
+
export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
|
|
29
|
+
export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
|
|
30
|
+
|
|
31
|
+
export function tomlString(value: string): string {
|
|
32
|
+
return JSON.stringify(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseTomlString(raw: string): string {
|
|
36
|
+
if (raw.startsWith("\"")) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw) as string;
|
|
39
|
+
} catch {
|
|
40
|
+
return raw.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return raw.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readRootTomlString(content: string, key: string): string | null {
|
|
47
|
+
const lines = content.split("\n");
|
|
48
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
49
|
+
const rootLines = firstTable === -1 ? lines : lines.slice(0, firstTable);
|
|
50
|
+
for (const line of rootLines) {
|
|
51
|
+
const m = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(\"(?:\\\\.|[^\"])*\"|'[^']*')`));
|
|
52
|
+
if (m) return parseTomlString(m[1]);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveCodexConfigPath(path: string): string {
|
|
58
|
+
return isAbsolute(path) ? path : join(CODEX_HOME, path);
|
|
59
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -57,6 +57,10 @@ export function saveConfig(config: OcxConfig): void {
|
|
|
57
57
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
61
|
+
return config.websockets === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
export function getDefaultConfig(): OcxConfig {
|
|
61
65
|
// Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
|
|
62
66
|
// gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
|
|
@@ -72,6 +76,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
72
76
|
},
|
|
73
77
|
defaultProvider: "openai",
|
|
74
78
|
subagentModels: [...DEFAULT_SUBAGENT_MODELS],
|
|
79
|
+
websockets: false,
|
|
75
80
|
};
|
|
76
81
|
}
|
|
77
82
|
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Opt-in frame-drop visibility. The streaming path is intentionally quiet (no unconditional
|
|
2
|
+
// console output), so this no-ops unless OCX_DEBUG_FRAMES=1. Lets a malformed/chunk-split
|
|
3
|
+
// upstream frame be detected instead of silently truncating content.
|
|
4
|
+
const DEBUG_FRAMES = process.env.OCX_DEBUG_FRAMES === "1";
|
|
5
|
+
|
|
6
|
+
export function debugDroppedFrame(adapter: string, payload: string): void {
|
|
7
|
+
if (!DEBUG_FRAMES) return;
|
|
8
|
+
const preview = payload.length > 200 ? `${payload.slice(0, 200)}…` : payload;
|
|
9
|
+
console.error(`[ocx:frame-drop] ${adapter}: ${preview}`);
|
|
10
|
+
}
|