@bitkyc08/opencodex 0.1.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/LICENSE +21 -0
- package/README.ko.md +164 -0
- package/README.md +165 -0
- package/README.zh-CN.md +162 -0
- package/gui/README.md +73 -0
- package/gui/dist/assets/index-C1wlp1SM.css +1 -0
- package/gui/dist/assets/index-C9y3iMF1.js +9 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +15 -0
- package/gui/dist/logo.png +0 -0
- package/package.json +56 -0
- package/scripts/postinstall.mjs +57 -0
- package/src/adapters/anthropic.ts +306 -0
- package/src/adapters/azure.ts +31 -0
- package/src/adapters/base.ts +20 -0
- package/src/adapters/google.ts +195 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/openai-chat.ts +265 -0
- package/src/adapters/openai-responses.ts +43 -0
- package/src/bridge.ts +296 -0
- package/src/cli.ts +183 -0
- package/src/codex-catalog.ts +318 -0
- package/src/codex-inject.ts +186 -0
- package/src/config.ts +108 -0
- package/src/index.ts +20 -0
- package/src/init.ts +163 -0
- package/src/model-cache.ts +42 -0
- package/src/oauth/anthropic.ts +151 -0
- package/src/oauth/callback-server.ts +249 -0
- package/src/oauth/index.ts +235 -0
- package/src/oauth/key-providers.ts +126 -0
- package/src/oauth/kimi.ts +160 -0
- package/src/oauth/local-token-detect.ts +71 -0
- package/src/oauth/login-cli.ts +90 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +39 -0
- package/src/oauth/types.ts +22 -0
- package/src/oauth/xai.ts +234 -0
- package/src/responses/parser.ts +402 -0
- package/src/responses/schema.ts +145 -0
- package/src/router.ts +86 -0
- package/src/server.ts +522 -0
- package/src/service.ts +130 -0
- package/src/star-prompt.ts +50 -0
- package/src/types.ts +228 -0
- package/src/update.ts +64 -0
- package/src/vision/describe.ts +98 -0
- package/src/vision/index.ts +141 -0
- package/src/web-search/executor.ts +75 -0
- package/src/web-search/format-result.ts +45 -0
- package/src/web-search/index.ts +62 -0
- package/src/web-search/loop.ts +188 -0
- package/src/web-search/parse.ts +128 -0
- package/src/web-search/synthetic-tool.ts +42 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { restoreNativeCodex } from "./codex-inject";
|
|
3
|
+
import { loadConfig, readPid, removePid, writePid } from "./config";
|
|
4
|
+
import { serviceCommand } from "./service";
|
|
5
|
+
import { startServer } from "./server";
|
|
6
|
+
import { maybeShowStarPrompt } from "./star-prompt";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const command = args[0];
|
|
10
|
+
|
|
11
|
+
function printUsage() {
|
|
12
|
+
console.log(`opencodex (ocx) — Universal provider proxy for Codex
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
ocx init Interactive setup (provider + Codex config injection)
|
|
16
|
+
ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
|
|
17
|
+
ocx stop Stop the proxy AND restore native Codex (plain codex works again)
|
|
18
|
+
ocx restore Restore native Codex without stopping (alias: eject)
|
|
19
|
+
ocx service <sub> Run as a background service (install|start|stop|status|uninstall)
|
|
20
|
+
ocx sync Fetch models from providers and inject into Codex config
|
|
21
|
+
ocx status Check proxy server status
|
|
22
|
+
ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
|
|
23
|
+
ocx logout <provider> Remove a stored OAuth login
|
|
24
|
+
ocx update Update opencodex to the latest published version
|
|
25
|
+
ocx help Show this help message
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
ocx init Set up provider and inject into Codex
|
|
29
|
+
ocx start Start on default port (10100)
|
|
30
|
+
ocx start --port 8080 Start on custom port
|
|
31
|
+
ocx sync Sync available models to Codex`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function syncModelsToCodex(port?: number) {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const p = port ?? config.port ?? 10100;
|
|
37
|
+
const { injectCodexConfig } = await import("./codex-inject");
|
|
38
|
+
const result = await injectCodexConfig(p, config);
|
|
39
|
+
try {
|
|
40
|
+
const { syncCatalogModels } = await import("./codex-catalog");
|
|
41
|
+
const cat = await syncCatalogModels(config);
|
|
42
|
+
if (cat.added > 0) console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error("catalog sync skipped:", e instanceof Error ? e.message : String(e));
|
|
45
|
+
}
|
|
46
|
+
console.log(result.message);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleStart() {
|
|
51
|
+
const existingPid = readPid();
|
|
52
|
+
if (existingPid) {
|
|
53
|
+
console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let port: number | undefined;
|
|
58
|
+
const portIdx = args.indexOf("--port");
|
|
59
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
60
|
+
port = parseInt(args[portIdx + 1], 10);
|
|
61
|
+
if (isNaN(port)) {
|
|
62
|
+
console.error("Invalid port number");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const server = startServer(port);
|
|
68
|
+
writePid(process.pid);
|
|
69
|
+
|
|
70
|
+
void maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
|
|
71
|
+
syncModelsToCodex(port).catch(() => {});
|
|
72
|
+
|
|
73
|
+
const shutdown = () => {
|
|
74
|
+
console.log("\n🛑 Shutting down opencodex proxy...");
|
|
75
|
+
server.stop(true);
|
|
76
|
+
removePid();
|
|
77
|
+
// Under the service (OCX_SERVICE), a restart re-injects on start — don't churn Codex config.
|
|
78
|
+
// `ocx service stop/uninstall` restore explicitly.
|
|
79
|
+
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
|
|
80
|
+
process.exit(0);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
process.on("SIGINT", shutdown);
|
|
84
|
+
process.on("SIGTERM", shutdown);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleStop() {
|
|
88
|
+
const pid = readPid();
|
|
89
|
+
if (pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, "SIGTERM");
|
|
92
|
+
console.log(`✅ Proxy (PID ${pid}) stopped.`);
|
|
93
|
+
} catch {
|
|
94
|
+
console.log("Proxy process not found.");
|
|
95
|
+
}
|
|
96
|
+
removePid();
|
|
97
|
+
} else {
|
|
98
|
+
console.log("No running proxy found.");
|
|
99
|
+
}
|
|
100
|
+
// Recover native Codex so plain `codex` keeps working while the proxy is down.
|
|
101
|
+
const r = restoreNativeCodex();
|
|
102
|
+
console.log(`↩️ ${r.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleStatus() {
|
|
106
|
+
const pid = readPid();
|
|
107
|
+
if (pid) {
|
|
108
|
+
console.log(`✅ Proxy running (PID ${pid})`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log("❌ Proxy not running");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
switch (command) {
|
|
115
|
+
case "init": {
|
|
116
|
+
const { runInit } = await import("./init");
|
|
117
|
+
await runInit();
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "start":
|
|
121
|
+
handleStart();
|
|
122
|
+
break;
|
|
123
|
+
case "stop":
|
|
124
|
+
handleStop();
|
|
125
|
+
break;
|
|
126
|
+
case "restore":
|
|
127
|
+
case "eject": {
|
|
128
|
+
const r = restoreNativeCodex();
|
|
129
|
+
console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`);
|
|
130
|
+
console.log("Plain `codex` now runs natively (no proxy).");
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "status":
|
|
134
|
+
handleStatus();
|
|
135
|
+
break;
|
|
136
|
+
case "login": {
|
|
137
|
+
const { handleLogin } = await import("./oauth/login-cli");
|
|
138
|
+
await handleLogin(args[1]);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "logout": {
|
|
142
|
+
const { removeCredential } = await import("./oauth/store");
|
|
143
|
+
const name = (args[1] ?? "").trim().toLowerCase();
|
|
144
|
+
removeCredential(name);
|
|
145
|
+
console.log(`Logged out of ${name || "(none)"}.`);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "sync": {
|
|
149
|
+
await syncModelsToCodex();
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "gui": {
|
|
153
|
+
const cfg = await import("./config");
|
|
154
|
+
const config = cfg.loadConfig();
|
|
155
|
+
const guiUrl = `http://localhost:${config.port}`;
|
|
156
|
+
if (!cfg.readPid()) {
|
|
157
|
+
console.log("Proxy not running. Starting...");
|
|
158
|
+
handleStart();
|
|
159
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
160
|
+
}
|
|
161
|
+
console.log(`Opening ${guiUrl}`);
|
|
162
|
+
(await import("node:child_process")).exec(`open ${guiUrl}`);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "service":
|
|
166
|
+
serviceCommand(args[1]);
|
|
167
|
+
break;
|
|
168
|
+
case "update": {
|
|
169
|
+
const { runUpdate } = await import("./update");
|
|
170
|
+
runUpdate();
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case "help":
|
|
174
|
+
case "--help":
|
|
175
|
+
case "-h":
|
|
176
|
+
case undefined:
|
|
177
|
+
printUsage();
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
console.error(`Unknown command: ${command}`);
|
|
181
|
+
printUsage();
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { atomicWriteFile } from "./config";
|
|
5
|
+
import { DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, setCached } from "./model-cache";
|
|
6
|
+
import { buildModelsRequest, resolveModelsAuthToken } from "./oauth/index";
|
|
7
|
+
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
|
|
10
|
+
const DEFAULT_CATALOG_PATH = join(homedir(), ".codex", "opencodex-catalog.json");
|
|
11
|
+
const OCX_DIR = join(homedir(), ".opencodex");
|
|
12
|
+
const CATALOG_BACKUP_PATH = join(OCX_DIR, "catalog-backup.json");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native OpenAI / Codex models served via ChatGPT OAuth passthrough — FALLBACK only. The ChatGPT
|
|
16
|
+
* backend has no `GET /models`, so the real set is read from the live Codex catalog (the slugs Codex
|
|
17
|
+
* itself ships for the installed version) via nativeOpenAiSlugs(); this static list is used only when
|
|
18
|
+
* no catalog is present. Keep it to ids ChatGPT actually accepts — advertising a phantom (e.g. an
|
|
19
|
+
* old `gpt-5.2`/`gpt-5.3-codex` that a newer Codex dropped) makes it 400 "model is not supported".
|
|
20
|
+
*/
|
|
21
|
+
export const NATIVE_OPENAI_MODELS = [
|
|
22
|
+
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The native (passthrough) OpenAI slugs to advertise — the LIVE Codex catalog's own bare slugs when
|
|
27
|
+
* available (always-latest: matches exactly what the installed Codex supports), else the static
|
|
28
|
+
* fallback above. Single source for the /v1/models native list and the subagent-default seed.
|
|
29
|
+
*/
|
|
30
|
+
export function nativeOpenAiSlugs(): string[] {
|
|
31
|
+
const live = listCatalogNativeSlugs();
|
|
32
|
+
return live.length > 0 ? live : NATIVE_OPENAI_MODELS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CatalogModel { id: string; provider: string; owned_by?: string; }
|
|
36
|
+
type RawEntry = Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/** Resolve the `model_catalog_json` path from Codex config.toml, else the default. */
|
|
39
|
+
export function readCodexCatalogPath(): string {
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(CODEX_CONFIG_PATH)) {
|
|
42
|
+
const toml = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
43
|
+
const m = toml.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
|
|
44
|
+
if (m) return m[1];
|
|
45
|
+
}
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
return DEFAULT_CATALOG_PATH;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readCatalog(path: string): { models?: RawEntry[]; [k: string]: unknown } | null {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path)) return null;
|
|
53
|
+
const cat = JSON.parse(readFileSync(path, "utf-8"));
|
|
54
|
+
return (cat && Array.isArray(cat.models)) ? cat : null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A full native entry from the on-disk catalog, used as a clone template so injected
|
|
60
|
+
* entries carry EVERY field Codex's strict parser requires (e.g. `base_instructions`).
|
|
61
|
+
* Returns a deep copy, or null if no catalog/native entry exists.
|
|
62
|
+
*/
|
|
63
|
+
export function loadCatalogTemplate(): RawEntry | null {
|
|
64
|
+
const cat = readCatalog(readCodexCatalogPath());
|
|
65
|
+
const native = cat?.models?.find(
|
|
66
|
+
m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
|
|
67
|
+
);
|
|
68
|
+
return native ? JSON.parse(JSON.stringify(native)) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The reasoning ladder advertised for routed models in Codex's picker: low → medium → high → xhigh.
|
|
73
|
+
* This matches Codex's NATIVE catalog exactly — Codex's strict parser rejects an unknown effort like
|
|
74
|
+
* `max`, so it must not be advertised here. (Previously routed models were clamped down to
|
|
75
|
+
* low/medium/high, which dropped the `xhigh` that Codex does support.)
|
|
76
|
+
*/
|
|
77
|
+
const ROUTED_REASONING_LEVELS: { effort: string; description: string }[] = [
|
|
78
|
+
{ effort: "low", description: "Fast responses with lighter reasoning" },
|
|
79
|
+
{ effort: "medium", description: "Balances speed and reasoning depth" },
|
|
80
|
+
{ effort: "high", description: "Greater reasoning depth for complex problems" },
|
|
81
|
+
{ effort: "xhigh", description: "Extended reasoning for the hardest problems" },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function deriveEntry(template: RawEntry | null, slug: string, desc: string, priority: number): RawEntry {
|
|
85
|
+
if (template) {
|
|
86
|
+
const e = JSON.parse(JSON.stringify(template)) as RawEntry;
|
|
87
|
+
e.slug = slug;
|
|
88
|
+
e.display_name = slug;
|
|
89
|
+
e.description = desc;
|
|
90
|
+
e.priority = priority;
|
|
91
|
+
e.visibility = "list";
|
|
92
|
+
if ("upgrade" in e) e.upgrade = null;
|
|
93
|
+
delete e.availability_nux; // don't replay another model's "now available" NUX
|
|
94
|
+
// Routed (namespaced) models inherit the gpt template — correct its OpenAI/GPT identity
|
|
95
|
+
// and advertise the reasoning ladder Codex accepts (low/medium/high/xhigh).
|
|
96
|
+
if (slug.includes("/")) {
|
|
97
|
+
const modelName = slug.slice(slug.indexOf("/") + 1);
|
|
98
|
+
if (typeof e.base_instructions === "string") {
|
|
99
|
+
e.base_instructions = e.base_instructions.replace(
|
|
100
|
+
"You are Codex, a coding agent based on GPT-5.",
|
|
101
|
+
`You are a coding agent powered by the ${modelName} model, served through the opencodex proxy. Do not claim to be GPT-5 or made by OpenAI.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// Reuse the template's level objects where they exist (correct shape/fields), synthesize the rest.
|
|
105
|
+
const byEffort = new Map(
|
|
106
|
+
(Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels : [])
|
|
107
|
+
.map((l: { effort?: string }) => [l.effort, l]),
|
|
108
|
+
);
|
|
109
|
+
e.supported_reasoning_levels = ROUTED_REASONING_LEVELS.map(l => byEffort.get(l.effort) ?? { ...l });
|
|
110
|
+
e.default_reasoning_level = "medium";
|
|
111
|
+
}
|
|
112
|
+
return e;
|
|
113
|
+
}
|
|
114
|
+
// Fallback when no template is available (best-effort; strict parser may need more).
|
|
115
|
+
return {
|
|
116
|
+
slug, display_name: slug, description: desc,
|
|
117
|
+
default_reasoning_level: "medium",
|
|
118
|
+
supported_reasoning_levels: ROUTED_REASONING_LEVELS.map(l => ({ ...l })),
|
|
119
|
+
shell_type: "shell_command", visibility: "list", supported_in_api: true,
|
|
120
|
+
priority, base_instructions: "You are a helpful coding assistant.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Single source of truth for Codex-catalog-shaped entries, reused by both the on-disk
|
|
126
|
+
* catalog sync and the proxy `/v1/models?client_version` branch.
|
|
127
|
+
* Native gpt slugs stay bare; routed models are namespaced `<provider>/<model>`.
|
|
128
|
+
*/
|
|
129
|
+
export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[], goModels: CatalogModel[], featured?: string[]): RawEntry[] {
|
|
130
|
+
// Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible
|
|
131
|
+
// models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog
|
|
132
|
+
// ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so
|
|
133
|
+
// it sorts to the front. This works for native gpt slugs AND routed slugs alike.
|
|
134
|
+
const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
|
|
135
|
+
const out: RawEntry[] = [];
|
|
136
|
+
for (const slug of gptSlugs) {
|
|
137
|
+
const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9);
|
|
138
|
+
if (rank.has(slug)) e.priority = rank.get(slug)!;
|
|
139
|
+
out.push(e);
|
|
140
|
+
}
|
|
141
|
+
for (const m of goModels) {
|
|
142
|
+
const slug = `${m.provider}/${m.id}`;
|
|
143
|
+
const e = deriveEntry(template, slug, `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, 5);
|
|
144
|
+
if (rank.has(slug)) e.priority = rank.get(slug)!;
|
|
145
|
+
out.push(e);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
|
|
151
|
+
export function listCatalogNativeSlugs(): string[] {
|
|
152
|
+
const cat = readCatalog(readCodexCatalogPath());
|
|
153
|
+
return (cat?.models ?? [])
|
|
154
|
+
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
|
|
155
|
+
.map(m => m.slug as string);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Native-model priority baseline read from the PRISTINE backup, so featuring stays reversible:
|
|
160
|
+
* a featured native gets its low rank, and un-featuring restores its original catalog priority
|
|
161
|
+
* (rather than the modified value left in the live catalog by a previous sync).
|
|
162
|
+
*/
|
|
163
|
+
function readNativeBaseline(): Map<string, number> {
|
|
164
|
+
const backup = readCatalog(CATALOG_BACKUP_PATH);
|
|
165
|
+
const out = new Map<string, number>();
|
|
166
|
+
for (const e of backup?.models ?? []) {
|
|
167
|
+
if (typeof e.slug === "string" && !e.slug.includes("/") && typeof e.priority === "number") {
|
|
168
|
+
out.set(e.slug, e.priority);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Fetch a provider's `/models` (openai-chat style) with a TTL cache + stale fallback. Skips
|
|
176
|
+
* forward-auth providers. Fresh cache → no network; live fetch → cache the merged result;
|
|
177
|
+
* fetch failure → last-known-good cache (so a provider blip doesn't drop its models), else the
|
|
178
|
+
* static config list. This is the per-provider half of jawcode's "always latest" resolver.
|
|
179
|
+
*/
|
|
180
|
+
async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number): Promise<CatalogModel[]> {
|
|
181
|
+
if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
|
|
182
|
+
const apiKey = await resolveModelsAuthToken(name, prov);
|
|
183
|
+
if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
|
|
184
|
+
const fresh = getFreshCached(name, ttlMs);
|
|
185
|
+
if (fresh) return fresh; // dedups Codex's frequent /v1/models polling within the TTL
|
|
186
|
+
const configured: CatalogModel[] = (prov.models ?? []).map(id => ({ id, provider: name }));
|
|
187
|
+
const { url, headers } = buildModelsRequest(prov, apiKey);
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
|
190
|
+
if (!res.ok) return getStaleCached(name) ?? configured;
|
|
191
|
+
const json = await res.json() as { data?: { id: string; owned_by?: string }[] };
|
|
192
|
+
const live = (json.data ?? []).map(m => ({ id: m.id, provider: name, owned_by: m.owned_by }));
|
|
193
|
+
const liveIds = new Set(live.map(m => m.id));
|
|
194
|
+
// Merge explicit config additions (e.g. a model not in the provider's /models, like a new endpoint).
|
|
195
|
+
const merged = [...live, ...configured.filter(m => !liveIds.has(m.id))];
|
|
196
|
+
setCached(name, merged);
|
|
197
|
+
return merged;
|
|
198
|
+
} catch {
|
|
199
|
+
return getStaleCached(name) ?? configured;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Gather routed (non-forward) provider models across the config — the single source of truth for
|
|
205
|
+
* the live model list, used by both the on-disk catalog sync and the proxy's /api/* + /v1/models
|
|
206
|
+
* endpoints. Providers are fetched in parallel; the result is sorted (provider, then id) for a
|
|
207
|
+
* stable listing. TTL comes from `config.modelCacheTtlMs` (default 5 min).
|
|
208
|
+
*/
|
|
209
|
+
export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
210
|
+
const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
|
|
211
|
+
const lists = await Promise.all(
|
|
212
|
+
Object.entries(config.providers).map(([name, prov]) => fetchProviderModels(name, prov, ttlMs)),
|
|
213
|
+
);
|
|
214
|
+
const all = lists.flat();
|
|
215
|
+
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
216
|
+
return all;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reorder routed models so the configured subagent picks come FIRST (in the chosen order).
|
|
221
|
+
* Codex's spawn_agent advertises only the first 5 routed catalog entries, so putting the chosen
|
|
222
|
+
* ones first makes exactly them appear as overrides. Non-featured keep their relative order (stable
|
|
223
|
+
* sort) and stay visibility:"list" — so they remain in the main /model picker and callable by name.
|
|
224
|
+
*/
|
|
225
|
+
export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {
|
|
226
|
+
if (!featured || featured.length === 0) return goModels;
|
|
227
|
+
const rank = new Map(featured.map((id, i) => [id, i]));
|
|
228
|
+
const keyOf = (m: CatalogModel) => `${m.provider}/${m.id}`;
|
|
229
|
+
return [...goModels].sort((a, b) => {
|
|
230
|
+
const ra = rank.has(keyOf(a)) ? rank.get(keyOf(a))! : Number.MAX_SAFE_INTEGER;
|
|
231
|
+
const rb = rank.has(keyOf(b)) ? rank.get(keyOf(b))! : Number.MAX_SAFE_INTEGER;
|
|
232
|
+
return ra - rb;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Merge namespaced routed-model entries into the on-disk Codex catalog.
|
|
238
|
+
* Idempotent + non-destructive:
|
|
239
|
+
* - native entries (slug without "/") are preserved untouched,
|
|
240
|
+
* - previously injected entries (slug containing "/") are dropped and re-added,
|
|
241
|
+
* - each injected entry is CLONED from a native template so it has all required fields,
|
|
242
|
+
* - the catalog is backed up to ~/.opencodex/catalog-backup.json before writing.
|
|
243
|
+
* No-op if the catalog file does not exist.
|
|
244
|
+
*/
|
|
245
|
+
export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
|
|
246
|
+
const catalogPath = readCodexCatalogPath();
|
|
247
|
+
const catalog = readCatalog(catalogPath);
|
|
248
|
+
if (!catalog) return { added: 0, path: catalogPath };
|
|
249
|
+
|
|
250
|
+
const template = (catalog.models ?? []).find(
|
|
251
|
+
m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m,
|
|
252
|
+
) ?? null;
|
|
253
|
+
|
|
254
|
+
const goModels = await gatherRoutedModels(config);
|
|
255
|
+
if (goModels.length === 0) return { added: 0, path: catalogPath };
|
|
256
|
+
|
|
257
|
+
// Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
|
|
258
|
+
// by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
|
|
259
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
260
|
+
const enabledGo = goModels.filter(m => !disabled.has(`${m.provider}/${m.id}`));
|
|
261
|
+
const featured = config.subagentModels ?? [];
|
|
262
|
+
const rank = new Map(featured.map((slug, i) => [slug, i] as const));
|
|
263
|
+
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
264
|
+
const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured);
|
|
265
|
+
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields, but drop bare
|
|
266
|
+
// duplicates of routed models (replaced by namespaced entries) + any prior "/" entries. Re-derive
|
|
267
|
+
// each native's priority from the pristine baseline so featuring a native is reversible.
|
|
268
|
+
const baseline = readNativeBaseline();
|
|
269
|
+
const goIds = new Set(enabledGo.map(m => m.id));
|
|
270
|
+
const native = (catalog.models ?? [])
|
|
271
|
+
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && !goIds.has(m.slug as string))
|
|
272
|
+
.map(m => {
|
|
273
|
+
const slug = m.slug as string;
|
|
274
|
+
const priority = rank.has(slug) ? rank.get(slug)! : (baseline.get(slug) ?? (m.priority as number));
|
|
275
|
+
return { ...m, priority };
|
|
276
|
+
});
|
|
277
|
+
catalog.models = [...native, ...goEntries];
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
if (!existsSync(OCX_DIR)) mkdirSync(OCX_DIR, { recursive: true });
|
|
281
|
+
// Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
|
|
282
|
+
// (later syncs would otherwise overwrite it with featured-modified priorities).
|
|
283
|
+
if (!existsSync(CATALOG_BACKUP_PATH)) copyFileSync(catalogPath, CATALOG_BACKUP_PATH);
|
|
284
|
+
} catch { /* backup best-effort */ }
|
|
285
|
+
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
286
|
+
return { added: goEntries.length, path: catalogPath };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Restore the Codex catalog to native-only by dropping every opencodex-injected
|
|
291
|
+
* "<provider>/<model>" entry (those route through the proxy). Native gpt/codex slugs (no "/")
|
|
292
|
+
* are kept, so plain `codex` works when the proxy is stopped. Idempotent; no-op if nothing injected.
|
|
293
|
+
*/
|
|
294
|
+
export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
|
|
295
|
+
const catalogPath = readCodexCatalogPath();
|
|
296
|
+
const catalog = readCatalog(catalogPath);
|
|
297
|
+
if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
|
|
298
|
+
const before = catalog.models.length;
|
|
299
|
+
const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
|
|
300
|
+
const removed = before - native.length;
|
|
301
|
+
if (removed > 0) {
|
|
302
|
+
catalog.models = native;
|
|
303
|
+
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
|
304
|
+
}
|
|
305
|
+
return { removed, kept: native.length, path: catalogPath };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete Codex's models cache (~/.codex/models_cache.json) so the next turn re-fetches /v1/models.
|
|
310
|
+
* Codex caches the model list for 5 min (DEFAULT_MODEL_CACHE_TTL); invalidating makes catalog edits
|
|
311
|
+
* (enable/disable, subagent reorder) apply on the next turn instead of waiting for the TTL.
|
|
312
|
+
*/
|
|
313
|
+
export function invalidateCodexModelsCache(): void {
|
|
314
|
+
try {
|
|
315
|
+
const p = join(homedir(), ".codex", "models_cache.json");
|
|
316
|
+
if (existsSync(p)) unlinkSync(p);
|
|
317
|
+
} catch { /* best-effort */ }
|
|
318
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { atomicWriteFile } from "./config";
|
|
5
|
+
import { restoreCodexCatalog } from "./codex-catalog";
|
|
6
|
+
import type { OcxConfig } from "./types";
|
|
7
|
+
|
|
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
|
+
const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is
|
|
16
|
+
* safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here —
|
|
17
|
+
* it must live at the document root (before any table header) and is set separately by
|
|
18
|
+
* setRootModelProvider(). Appending the bare key at EOF was the original bug: it nested under
|
|
19
|
+
* whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
|
|
20
|
+
* never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
|
|
21
|
+
*/
|
|
22
|
+
function buildProviderTableBlock(port: number): string {
|
|
23
|
+
const lines = [
|
|
24
|
+
"",
|
|
25
|
+
OCX_SECTION_MARKER,
|
|
26
|
+
"[model_providers.opencodex]",
|
|
27
|
+
'name = "OpenCodex Proxy"',
|
|
28
|
+
`base_url = "http://localhost:${port}/v1"`,
|
|
29
|
+
'wire_api = "responses"',
|
|
30
|
+
];
|
|
31
|
+
return lines.join("\n") + "\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip every existing `model_provider` line that we must not duplicate: any line set to
|
|
36
|
+
* "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any
|
|
37
|
+
* ROOT-level model_provider (before the first table) of any value, since we override the global.
|
|
38
|
+
* A `model_provider` legitimately inside a user table/profile with a non-opencodex value is left
|
|
39
|
+
* untouched.
|
|
40
|
+
*/
|
|
41
|
+
function stripExistingModelProvider(content: string): string {
|
|
42
|
+
const lines = content.split("\n");
|
|
43
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
lines.forEach((line, i) => {
|
|
46
|
+
if (/^\s*model_provider\s*=/.test(line)) {
|
|
47
|
+
const isOurs = /^\s*model_provider\s*=\s*"opencodex"\s*$/.test(line);
|
|
48
|
+
const isRoot = firstTable === -1 || i < firstTable;
|
|
49
|
+
if (isOurs || isRoot) return; // drop it
|
|
50
|
+
}
|
|
51
|
+
out.push(line);
|
|
52
|
+
});
|
|
53
|
+
return out.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table
|
|
58
|
+
* header (TOML root keys must precede all tables). If there are no tables, append it to the root body.
|
|
59
|
+
*/
|
|
60
|
+
function setRootModelProvider(content: string): string {
|
|
61
|
+
const lines = content.split("\n");
|
|
62
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
63
|
+
const key = 'model_provider = "opencodex"';
|
|
64
|
+
if (firstTable === -1) {
|
|
65
|
+
return content.replace(/\n+$/, "") + "\n" + key + "\n";
|
|
66
|
+
}
|
|
67
|
+
let insertAt = firstTable;
|
|
68
|
+
while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--;
|
|
69
|
+
lines.splice(insertAt, 0, key);
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildProfileFile(port: number): string {
|
|
74
|
+
return [
|
|
75
|
+
"# OpenCodex proxy profile — use with: codex --profile opencodex",
|
|
76
|
+
`# Routes all model requests through the opencodex proxy at localhost:${port}`,
|
|
77
|
+
'model_provider = "opencodex"',
|
|
78
|
+
"",
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function injectCodexConfig(port: number, _config?: OcxConfig): Promise<{ success: boolean; message: string }> {
|
|
83
|
+
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
84
|
+
return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let content = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
88
|
+
|
|
89
|
+
// Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every
|
|
90
|
+
// stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy
|
|
91
|
+
// table-nested key behind.
|
|
92
|
+
if (content.includes("[model_providers.opencodex]")) {
|
|
93
|
+
content = removeOcxSection(content);
|
|
94
|
+
}
|
|
95
|
+
content = stripExistingModelProvider(content);
|
|
96
|
+
|
|
97
|
+
// 1) Root key BEFORE the first table header (must be a global, not nested under a table).
|
|
98
|
+
content = setRootModelProvider(content);
|
|
99
|
+
// 2) Provider table appended at EOF (position-independent).
|
|
100
|
+
content = content.trimEnd() + "\n" + buildProviderTableBlock(port);
|
|
101
|
+
|
|
102
|
+
writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
|
|
103
|
+
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port), "utf-8");
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
success: true,
|
|
107
|
+
message: `Injected opencodex as default provider into Codex config.\n` +
|
|
108
|
+
` All models now route through opencodex proxy (like OpenRouter).\n` +
|
|
109
|
+
` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
|
|
110
|
+
` Custom models route to their configured providers.\n` +
|
|
111
|
+
` Fallback: codex --profile opencodex (same behavior)`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeOcxSection(content: string): string {
|
|
116
|
+
const lines = content.split("\n");
|
|
117
|
+
const filtered: string[] = [];
|
|
118
|
+
let inOcxSection = false;
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.includes(OCX_SECTION_MARKER) || line.trim() === "[model_providers.opencodex]") {
|
|
121
|
+
inOcxSection = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (inOcxSection) {
|
|
125
|
+
// End the injected section at the next table header that ISN'T our own — exact match so a
|
|
126
|
+
// user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
|
|
127
|
+
if (line.startsWith("[") && line.trim() !== "[model_providers.opencodex]") {
|
|
128
|
+
inOcxSection = false;
|
|
129
|
+
filtered.push(line);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
filtered.push(line);
|
|
134
|
+
}
|
|
135
|
+
return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
|
|
139
|
+
export function stripOpencodexConfig(content: string): string {
|
|
140
|
+
let out = content;
|
|
141
|
+
if (out.includes("[model_providers.opencodex]")) {
|
|
142
|
+
out = removeOcxSection(out);
|
|
143
|
+
}
|
|
144
|
+
// Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
|
|
145
|
+
// must match the detection regex above, or a detected line could survive un-removed.
|
|
146
|
+
out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
|
|
147
|
+
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasOpencodexRouting(content: string): boolean {
|
|
151
|
+
return content.includes("[model_providers.opencodex]") || /^\s*model_provider\s*=\s*"opencodex"/m.test(content);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function removeCodexConfig(): { success: boolean; message: string } {
|
|
155
|
+
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
156
|
+
return { success: false, message: "Codex config not found." };
|
|
157
|
+
}
|
|
158
|
+
const content = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
159
|
+
const had = hasOpencodexRouting(content);
|
|
160
|
+
if (had) {
|
|
161
|
+
atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
|
|
162
|
+
}
|
|
163
|
+
if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
message: had ? "Removed opencodex routing from Codex config + profile." : "opencodex not present in Codex config.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Recover native Codex: strip opencodex from config.toml AND drop proxy-routed catalog entries,
|
|
172
|
+
* so plain `codex` works when the proxy is stopped. Called by `ocx stop`, the proxy shutdown
|
|
173
|
+
* handler, and `ocx restore`. Idempotent + atomic.
|
|
174
|
+
*/
|
|
175
|
+
export function restoreNativeCodex(): { success: boolean; message: string } {
|
|
176
|
+
const cfg = removeCodexConfig();
|
|
177
|
+
const cat = restoreCodexCatalog();
|
|
178
|
+
const msg = cat.removed > 0
|
|
179
|
+
? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
|
|
180
|
+
: cfg.message;
|
|
181
|
+
return { success: cfg.success, message: msg };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getCodexConfigPath(): string {
|
|
185
|
+
return CODEX_CONFIG_PATH;
|
|
186
|
+
}
|