@bitkyc08/opencodex 2.5.5-preview.2 → 2.5.6
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 +6 -10
- package/README.md +9 -10
- package/README.zh-CN.md +6 -10
- package/bin/ocx.mjs +85 -0
- package/gui/dist/assets/index-CKqUwc02.css +1 -0
- package/gui/dist/assets/index-CSUvRNAX.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +9 -4
- package/src/bun-runtime.ts +64 -0
- package/src/cli.ts +19 -4
- package/src/codex-auth-api.ts +11 -11
- package/src/codex-auth-collision.ts +14 -21
- package/src/codex-inject.ts +5 -3
- package/src/codex-journal.ts +63 -0
- package/src/codex-shim.ts +9 -1
- package/src/ports.ts +7 -0
- package/src/router.ts +42 -5
- package/src/server.ts +26 -6
- package/src/service.ts +10 -2
- package/src/update.ts +13 -5
- package/gui/dist/assets/index-CJF4_jax.css +0 -1
- package/gui/dist/assets/index-mmIPacc3.js +0 -9
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-CSUvRNAX.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CKqUwc02.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitkyc08/opencodex",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.6",
|
|
4
4
|
"description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"opencodex": "./
|
|
9
|
-
"ocx": "./
|
|
8
|
+
"opencodex": "./bin/ocx.mjs",
|
|
9
|
+
"ocx": "./bin/ocx.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
+
"bin",
|
|
12
13
|
"src",
|
|
13
14
|
"gui/dist",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE"
|
|
16
17
|
],
|
|
17
18
|
"engines": {
|
|
18
|
-
"
|
|
19
|
+
"node": ">=18"
|
|
19
20
|
},
|
|
21
|
+
"trustedDependencies": [
|
|
22
|
+
"bun"
|
|
23
|
+
],
|
|
20
24
|
"scripts": {
|
|
21
25
|
"dev": "bun run src/cli.ts start",
|
|
22
26
|
"start": "bun run src/cli.ts start",
|
|
@@ -30,6 +34,7 @@
|
|
|
30
34
|
"release:watch": "bun scripts/release.ts watch"
|
|
31
35
|
},
|
|
32
36
|
"dependencies": {
|
|
37
|
+
"bun": "1.3.14",
|
|
33
38
|
"zod": "^4.0.0"
|
|
34
39
|
},
|
|
35
40
|
"devDependencies": {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled Bun runtime resolution.
|
|
3
|
+
*
|
|
4
|
+
* opencodex ships the Bun runtime via the `bun` npm dependency (esbuild-style:
|
|
5
|
+
* a tiny main package + platform-specific `@oven/bun-*` optionalDependencies,
|
|
6
|
+
* finalized by the package's own postinstall `node install.js`). The npm `bin`
|
|
7
|
+
* launcher (bin/ocx.mjs) and the durable service/shim integrations both need a
|
|
8
|
+
* stable path to that binary. This module is the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* In a from-source dev checkout the `bun` dependency may be absent; callers fall
|
|
11
|
+
* back to `process.execPath` (which is itself Bun when run via `bun src/cli.ts`).
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { existsSync, statSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
// The `bun` package leaves a tiny ASCII placeholder at bin/bun.exe until its
|
|
20
|
+
// postinstall downloads the real ~60MB binary; reject the stub by size so we
|
|
21
|
+
// never bake a non-executable path into durable artifacts.
|
|
22
|
+
const REAL_BUN_MIN_BYTES = 1_000_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True only for a real, downloaded Bun binary — not the ~450-byte ASCII
|
|
26
|
+
* placeholder stub left by `--ignore-scripts` / pnpm. A size gate cleanly
|
|
27
|
+
* separates the two on every platform (real binary is tens of MB).
|
|
28
|
+
*/
|
|
29
|
+
export function isRealBunBinary(path: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
return existsSync(path) && statSync(path).size >= REAL_BUN_MIN_BYTES;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path to the bundled Bun binary, or null if the `bun` dependency is
|
|
39
|
+
* not installed/resolvable (or only the un-downloaded placeholder is present).
|
|
40
|
+
* The npm `bun` package ships the binary as `bin/bun.exe` on every platform;
|
|
41
|
+
* we also probe `bin/bun` for forward compatibility.
|
|
42
|
+
*/
|
|
43
|
+
export function bundledBunPath(): string | null {
|
|
44
|
+
try {
|
|
45
|
+
const bunDir = dirname(require.resolve("bun/package.json"));
|
|
46
|
+
for (const name of ["bun.exe", "bun"]) {
|
|
47
|
+
const p = join(bunDir, "bin", name);
|
|
48
|
+
if (isRealBunBinary(p)) return p;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
|
|
58
|
+
* the Codex auto-start shim). Prefer the bundled binary — it lives under the
|
|
59
|
+
* npm global prefix and survives across `ocx update` — and fall back to the
|
|
60
|
+
* current runtime, which is Bun when launched normally.
|
|
61
|
+
*/
|
|
62
|
+
export function durableBunPath(): string {
|
|
63
|
+
return bundledBunPath() ?? process.execPath;
|
|
64
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
3
3
|
import { rmSync } from "node:fs";
|
|
4
4
|
import { restoreNativeCodex } from "./codex-inject";
|
|
5
5
|
import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
|
|
6
|
+
import { writeJournal, reconcileJournal } from "./codex-journal";
|
|
6
7
|
import { codexAutoStartEnabled, getConfigDir, getConfigPath, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
|
|
7
|
-
import { findAvailablePort } from "./ports";
|
|
8
|
+
import { findAvailablePort, shouldPersistSelectedPort } from "./ports";
|
|
8
9
|
import { serviceCommand, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
|
|
9
10
|
import { drainAndShutdown, startServer } from "./server";
|
|
10
11
|
import { maybeShowStarPrompt } from "./star-prompt";
|
|
@@ -176,7 +177,7 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
176
177
|
if (selected !== preferred) {
|
|
177
178
|
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
178
179
|
}
|
|
179
|
-
if (config.port
|
|
180
|
+
if (shouldPersistSelectedPort(config.port, selected, preferred)) {
|
|
180
181
|
config.port = selected;
|
|
181
182
|
saveConfig(config);
|
|
182
183
|
}
|
|
@@ -184,6 +185,7 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
async function handleStart(options: { block?: boolean } = {}) {
|
|
188
|
+
reconcileJournal();
|
|
187
189
|
const existingPid = readPid();
|
|
188
190
|
if (existingPid) {
|
|
189
191
|
const config = loadConfig();
|
|
@@ -199,20 +201,30 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
199
201
|
|
|
200
202
|
const server = startServer(port);
|
|
201
203
|
writePid(process.pid);
|
|
204
|
+
writeJournal();
|
|
202
205
|
|
|
203
206
|
const config = loadConfig();
|
|
207
|
+
|
|
208
|
+
let cleaned = false;
|
|
209
|
+
const syncCleanup = () => {
|
|
210
|
+
if (cleaned) return;
|
|
211
|
+
cleaned = true;
|
|
212
|
+
removePid(process.pid);
|
|
213
|
+
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
|
|
214
|
+
};
|
|
215
|
+
|
|
204
216
|
const shutdown = () => {
|
|
205
217
|
console.log("\n🛑 Shutting down opencodex proxy...");
|
|
206
218
|
void (async () => {
|
|
207
219
|
await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000);
|
|
208
|
-
|
|
209
|
-
if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } }
|
|
220
|
+
syncCleanup();
|
|
210
221
|
process.exit(0);
|
|
211
222
|
})();
|
|
212
223
|
};
|
|
213
224
|
|
|
214
225
|
process.on("SIGINT", shutdown);
|
|
215
226
|
process.on("SIGTERM", shutdown);
|
|
227
|
+
process.on("exit", syncCleanup);
|
|
216
228
|
|
|
217
229
|
await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
|
|
218
230
|
await syncModelsToCodex(port).catch(() => {});
|
|
@@ -223,6 +235,7 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
async function handleEnsure() {
|
|
238
|
+
reconcileJournal();
|
|
226
239
|
let config = loadConfig();
|
|
227
240
|
if (!codexAutoStartEnabled(config)) {
|
|
228
241
|
console.log("Codex autostart is disabled.");
|
|
@@ -239,6 +252,7 @@ async function handleEnsure() {
|
|
|
239
252
|
const child = spawn(process.execPath, [process.argv[1], "start"], {
|
|
240
253
|
detached: true,
|
|
241
254
|
stdio: "ignore",
|
|
255
|
+
windowsHide: true,
|
|
242
256
|
env: { ...process.env, OCX_SERVICE: "1" },
|
|
243
257
|
});
|
|
244
258
|
child.unref();
|
|
@@ -489,6 +503,7 @@ switch (command) {
|
|
|
489
503
|
const child = spawn(process.execPath, [process.argv[1], "start"], {
|
|
490
504
|
detached: true,
|
|
491
505
|
stdio: "ignore",
|
|
506
|
+
windowsHide: true,
|
|
492
507
|
env: process.env,
|
|
493
508
|
});
|
|
494
509
|
child.unref();
|
package/src/codex-auth-api.ts
CHANGED
|
@@ -214,9 +214,9 @@ export async function handleCodexAuthAPI(
|
|
|
214
214
|
if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) {
|
|
215
215
|
return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400);
|
|
216
216
|
}
|
|
217
|
-
// 1.1:
|
|
217
|
+
// 1.1: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
218
218
|
const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
|
|
219
|
-
const collision = checkAccountIdCollision(derivedAccountId, body.email);
|
|
219
|
+
const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan);
|
|
220
220
|
if (collision.collision) {
|
|
221
221
|
return jsonResponse({ error: collision.reason }, 400);
|
|
222
222
|
}
|
|
@@ -414,7 +414,6 @@ export async function handleCodexAuthAPI(
|
|
|
414
414
|
const { getCredential } = await import("./oauth/store");
|
|
415
415
|
const cred = getCredential("chatgpt");
|
|
416
416
|
if (cred) {
|
|
417
|
-
// 1.2: account-ID-based collision check (JWT-derived, not email)
|
|
418
417
|
const oauthAccountId = cred.accountId;
|
|
419
418
|
if (!oauthAccountId) {
|
|
420
419
|
codexAuthLoginState.set(flowId, {
|
|
@@ -425,14 +424,6 @@ export async function handleCodexAuthAPI(
|
|
|
425
424
|
completed = true;
|
|
426
425
|
break;
|
|
427
426
|
}
|
|
428
|
-
const collision = checkAccountIdCollision(oauthAccountId, cred.email);
|
|
429
|
-
if (collision.collision) {
|
|
430
|
-
codexAuthLoginState.set(flowId, {
|
|
431
|
-
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
432
|
-
});
|
|
433
|
-
completed = true;
|
|
434
|
-
break;
|
|
435
|
-
}
|
|
436
427
|
|
|
437
428
|
let email = cred.email || accountId;
|
|
438
429
|
let plan: string | undefined;
|
|
@@ -450,6 +441,15 @@ export async function handleCodexAuthAPI(
|
|
|
450
441
|
quota = parseUsageQuota(data);
|
|
451
442
|
}
|
|
452
443
|
} catch { /* wham fetch is non-blocking */ }
|
|
444
|
+
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
445
|
+
const collision = checkAccountIdCollision(oauthAccountId, email, plan);
|
|
446
|
+
if (collision.collision) {
|
|
447
|
+
codexAuthLoginState.set(flowId, {
|
|
448
|
+
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
449
|
+
});
|
|
450
|
+
completed = true;
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
453
|
|
|
454
454
|
saveCodexAccountCredential(accountId, {
|
|
455
455
|
accessToken: cred.access,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
-
import { getCodexAccountCredential
|
|
4
|
+
import { getCodexAccountCredential } from "./codex-account-store";
|
|
5
5
|
import { loadConfig } from "./config";
|
|
6
|
-
import { extractAccountId
|
|
6
|
+
import { extractAccountId } from "./oauth/chatgpt";
|
|
7
7
|
|
|
8
8
|
export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
|
|
9
9
|
try {
|
|
@@ -28,38 +28,31 @@ export function getMainChatgptAccountId(): string | null {
|
|
|
28
28
|
return extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function getMainChatgptEmail(): string | null {
|
|
32
|
-
const tokens = readCodexTokens();
|
|
33
|
-
if (!tokens) return null;
|
|
34
|
-
return extractEmail(tokens.id_token, tokens.access_token) ?? null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
31
|
function normalizedEmail(email: string | undefined | null): string | null {
|
|
38
32
|
const trimmed = email?.trim().toLowerCase();
|
|
39
33
|
return trimmed || null;
|
|
40
34
|
}
|
|
41
35
|
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
return normalizedEmail(account?.email);
|
|
36
|
+
function isWorkspacePlan(plan: string | undefined | null): boolean {
|
|
37
|
+
return !!plan && /team|business|enterprise|workspace|edu/i.test(plan);
|
|
45
38
|
}
|
|
46
39
|
|
|
47
|
-
//
|
|
40
|
+
// Personal and workspace subscriptions are separate duplicate buckets.
|
|
41
|
+
// Within each bucket, keep the original ChatGPT account id + email collision guard.
|
|
48
42
|
export function checkAccountIdCollision(
|
|
49
43
|
chatgptAccountId: string,
|
|
50
44
|
email?: string | null,
|
|
45
|
+
plan?: string | null,
|
|
51
46
|
): { collision: true; reason: string } | { collision: false } {
|
|
52
47
|
const candidateEmail = normalizedEmail(email);
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const cred = getCodexAccountCredential(poolId);
|
|
60
|
-
const poolEmail = poolEmailForId(poolId);
|
|
48
|
+
const candidateWorkspace = isWorkspacePlan(plan);
|
|
49
|
+
for (const account of loadConfig().codexAccounts ?? []) {
|
|
50
|
+
if (account.isMain) continue;
|
|
51
|
+
if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue;
|
|
52
|
+
const cred = getCodexAccountCredential(account.id);
|
|
53
|
+
const poolEmail = normalizedEmail(account.email);
|
|
61
54
|
if (cred && cred.chatgptAccountId === chatgptAccountId && (!candidateEmail || !poolEmail || poolEmail === candidateEmail)) {
|
|
62
|
-
return { collision: true, reason: `Account is already in the pool (${
|
|
55
|
+
return { collision: true, reason: `Account is already in the pool (${account.id}).` };
|
|
63
56
|
}
|
|
64
57
|
}
|
|
65
58
|
return { collision: false };
|
package/src/codex-inject.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync, unlinkSync
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { atomicWriteFile, websocketsEnabled } from "./config";
|
|
3
|
+
import { removeJournal } from "./codex-journal";
|
|
3
4
|
import { restoreCodexCatalog } from "./codex-catalog";
|
|
4
5
|
import { syncCodexHistoryProvider } from "./codex-history-provider";
|
|
5
6
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./codex-paths";
|
|
@@ -242,8 +243,8 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
242
243
|
// 2) Provider table appended at EOF (position-independent).
|
|
243
244
|
content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}));
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
atomicWriteFile(CODEX_CONFIG_PATH, content);
|
|
247
|
+
atomicWriteFile(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath));
|
|
247
248
|
const history = config?.syncResumeHistory !== false
|
|
248
249
|
? syncCodexHistoryProvider("opencodex")
|
|
249
250
|
: { rows: 0, files: 0 };
|
|
@@ -336,6 +337,7 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
|
|
|
336
337
|
const cfg = removeCodexConfig();
|
|
337
338
|
const cat = restoreCodexCatalog();
|
|
338
339
|
const history = syncCodexHistoryProvider("openai");
|
|
340
|
+
removeJournal();
|
|
339
341
|
const msg = cat.removed > 0
|
|
340
342
|
? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
|
|
341
343
|
: cfg.message;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "./config";
|
|
4
|
+
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./codex-paths";
|
|
5
|
+
|
|
6
|
+
const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json");
|
|
7
|
+
|
|
8
|
+
interface Journal {
|
|
9
|
+
version: 1;
|
|
10
|
+
originalConfig: string;
|
|
11
|
+
originalProfile: string | null;
|
|
12
|
+
pid: number;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeJournal(): void {
|
|
17
|
+
if (!existsSync(CODEX_CONFIG_PATH)) return;
|
|
18
|
+
const config = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
19
|
+
const profile = existsSync(CODEX_PROFILE_PATH)
|
|
20
|
+
? readFileSync(CODEX_PROFILE_PATH, "utf-8")
|
|
21
|
+
: null;
|
|
22
|
+
const journal: Journal = {
|
|
23
|
+
version: 1,
|
|
24
|
+
originalConfig: Buffer.from(config).toString("base64"),
|
|
25
|
+
originalProfile: profile ? Buffer.from(profile).toString("base64") : null,
|
|
26
|
+
pid: process.pid,
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function removeJournal(): void {
|
|
33
|
+
try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function reconcileJournal(): boolean {
|
|
37
|
+
if (!existsSync(JOURNAL_PATH)) return false;
|
|
38
|
+
let journal: Journal;
|
|
39
|
+
try {
|
|
40
|
+
journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8"));
|
|
41
|
+
if (journal.version !== 1) throw new Error("unknown version");
|
|
42
|
+
} catch {
|
|
43
|
+
removeJournal();
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
process.kill(journal.pid, 0);
|
|
48
|
+
return false;
|
|
49
|
+
} catch (e: unknown) {
|
|
50
|
+
if ((e as NodeJS.ErrnoException).code === "EPERM") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8"));
|
|
55
|
+
if (journal.originalProfile !== null) {
|
|
56
|
+
atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8"));
|
|
57
|
+
} else if (existsSync(CODEX_PROFILE_PATH)) {
|
|
58
|
+
try { unlinkSync(CODEX_PROFILE_PATH); } catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
removeJournal();
|
|
61
|
+
console.error(`⚠️ Previous session (PID ${journal.pid}) did not shut down cleanly. Codex config restored from journal.`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
package/src/codex-shim.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { delimiter, dirname, extname, join } from "node:path";
|
|
2
2
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { getConfigDir } from "./config";
|
|
4
|
+
import { durableBunPath } from "./bun-runtime";
|
|
4
5
|
|
|
5
6
|
const SHIM_MARKER = "opencodex codex autostart shim";
|
|
6
7
|
const CODEX_INTERNAL_COMMANDS = [
|
|
@@ -43,7 +44,9 @@ interface ShimFileState {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function cliEntry(): { bun: string; cli: string } {
|
|
46
|
-
|
|
47
|
+
// Bundled Bun path (survives `ocx update`); all three shim builders
|
|
48
|
+
// (Unix / Windows cmd / Windows PowerShell) receive it via this entry.
|
|
49
|
+
return { bun: durableBunPath(), cli: join(import.meta.dir, "cli.ts") };
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
function commandNames(name: string): string[] {
|
|
@@ -291,6 +294,11 @@ export function uninstallCodexShim(): { removed: boolean; message: string } {
|
|
|
291
294
|
return { removed: true, message: `Codex autostart shim removed. Restored ${files.map(f => f.originalPath).join(", ")}.` };
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
/** True if a Codex autostart shim is currently installed (state file present). */
|
|
298
|
+
export function isCodexShimInstalled(): boolean {
|
|
299
|
+
return readState() !== null;
|
|
300
|
+
}
|
|
301
|
+
|
|
294
302
|
export function codexShimStatus(): string {
|
|
295
303
|
const state = readState();
|
|
296
304
|
if (!state) return "Codex autostart shim is not installed.";
|
package/src/ports.ts
CHANGED
|
@@ -28,3 +28,10 @@ export async function findAvailablePort(preferredPort: number, hostname = "127.0
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export function shouldPersistSelectedPort(
|
|
32
|
+
configPort: number | undefined,
|
|
33
|
+
selectedPort: number,
|
|
34
|
+
preferredPort: number,
|
|
35
|
+
): boolean {
|
|
36
|
+
return selectedPort === preferredPort && configPort !== selectedPort;
|
|
37
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
2
2
|
import { resolveEnvValue } from "./config";
|
|
3
|
+
import { PROVIDER_REGISTRY } from "./providers/registry";
|
|
3
4
|
|
|
4
5
|
interface RouteResult {
|
|
5
6
|
providerName: string;
|
|
@@ -19,6 +20,42 @@ const MODEL_PROVIDER_PATTERNS: Record<string, string[]> = {
|
|
|
19
20
|
],
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
// Merge registry-default effort maps under user values so persisted built-in provider configs
|
|
24
|
+
// that predate reasoningEffortMap/modelReasoningEffortMap still get correct wire translations
|
|
25
|
+
// (e.g. ollama-cloud xhigh -> max) without a disk migration. User overrides win per-key.
|
|
26
|
+
function mergeRecord(
|
|
27
|
+
seed: Record<string, string> | undefined,
|
|
28
|
+
user: Record<string, string> | undefined,
|
|
29
|
+
): Record<string, string> | undefined {
|
|
30
|
+
if (!seed && !user) return undefined;
|
|
31
|
+
return { ...(seed ?? {}), ...(user ?? {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeNestedRecord(
|
|
35
|
+
seed: Record<string, Record<string, string>> | undefined,
|
|
36
|
+
user: Record<string, Record<string, string>> | undefined,
|
|
37
|
+
): Record<string, Record<string, string>> | undefined {
|
|
38
|
+
if (!seed && !user) return undefined;
|
|
39
|
+
const out: Record<string, Record<string, string>> = {};
|
|
40
|
+
for (const [key, value] of Object.entries(seed ?? {})) out[key] = { ...value };
|
|
41
|
+
for (const [key, value] of Object.entries(user ?? {})) out[key] = { ...(out[key] ?? {}), ...value };
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
46
|
+
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
47
|
+
if (!registryEntry) return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
|
|
48
|
+
const reasoningEffortMap = mergeRecord(registryEntry.reasoningEffortMap, provider.reasoningEffortMap);
|
|
49
|
+
const modelReasoningEffortMap = mergeNestedRecord(registryEntry.modelReasoningEffortMap, provider.modelReasoningEffortMap);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...provider,
|
|
53
|
+
apiKey: resolveEnvValue(provider.apiKey),
|
|
54
|
+
...(reasoningEffortMap ? { reasoningEffortMap } : {}),
|
|
55
|
+
...(modelReasoningEffortMap ? { modelReasoningEffortMap } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
23
60
|
// 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4-pro").
|
|
24
61
|
// Only triggers when the prefix matches a CONFIGURED provider, so genuine
|
|
@@ -31,7 +68,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
31
68
|
if (prov) {
|
|
32
69
|
return {
|
|
33
70
|
providerName: provName,
|
|
34
|
-
provider:
|
|
71
|
+
provider: routedProviderConfig(provName, prov),
|
|
35
72
|
modelId: modelId.slice(slash + 1),
|
|
36
73
|
};
|
|
37
74
|
}
|
|
@@ -41,7 +78,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
41
78
|
if (prov.defaultModel === modelId) {
|
|
42
79
|
return {
|
|
43
80
|
providerName: provName,
|
|
44
|
-
provider:
|
|
81
|
+
provider: routedProviderConfig(provName, prov),
|
|
45
82
|
modelId,
|
|
46
83
|
};
|
|
47
84
|
}
|
|
@@ -51,7 +88,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
51
88
|
if (prov.models && Array.isArray(prov.models) && (prov.models as string[]).includes(modelId)) {
|
|
52
89
|
return {
|
|
53
90
|
providerName: provName,
|
|
54
|
-
provider:
|
|
91
|
+
provider: routedProviderConfig(provName, prov),
|
|
55
92
|
modelId,
|
|
56
93
|
};
|
|
57
94
|
}
|
|
@@ -66,7 +103,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
66
103
|
const [provName, prov] = matchingProvider;
|
|
67
104
|
return {
|
|
68
105
|
providerName: provName,
|
|
69
|
-
provider:
|
|
106
|
+
provider: routedProviderConfig(provName, prov),
|
|
70
107
|
modelId,
|
|
71
108
|
};
|
|
72
109
|
}
|
|
@@ -77,7 +114,7 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
|
|
|
77
114
|
if (defaultProv) {
|
|
78
115
|
return {
|
|
79
116
|
providerName: config.defaultProvider,
|
|
80
|
-
provider:
|
|
117
|
+
provider: routedProviderConfig(config.defaultProvider, defaultProv),
|
|
81
118
|
modelId,
|
|
82
119
|
};
|
|
83
120
|
}
|
package/src/server.ts
CHANGED
|
@@ -144,17 +144,16 @@ function findGuiDist(): string | null {
|
|
|
144
144
|
return null;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
const GUI_DIST = findGuiDist();
|
|
148
|
-
|
|
149
147
|
function serveGuiFile(pathname: string): Response | null {
|
|
150
|
-
|
|
148
|
+
const guiDist = findGuiDist();
|
|
149
|
+
if (!guiDist) return null;
|
|
151
150
|
const filePath = pathname === "/" || pathname === ""
|
|
152
|
-
? join(
|
|
153
|
-
: join(
|
|
151
|
+
? join(guiDist, "index.html")
|
|
152
|
+
: join(guiDist, pathname);
|
|
154
153
|
|
|
155
154
|
if (!existsSync(filePath)) {
|
|
156
155
|
if (!extname(pathname)) {
|
|
157
|
-
const indexPath = join(
|
|
156
|
+
const indexPath = join(guiDist, "index.html");
|
|
158
157
|
if (existsSync(indexPath)) {
|
|
159
158
|
return new Response(Bun.file(indexPath), {
|
|
160
159
|
headers: { "Content-Type": "text/html" },
|
|
@@ -171,6 +170,24 @@ function serveGuiFile(pathname: string): Response | null {
|
|
|
171
170
|
});
|
|
172
171
|
}
|
|
173
172
|
|
|
173
|
+
export function rootFallbackPayload() {
|
|
174
|
+
return {
|
|
175
|
+
status: "ok",
|
|
176
|
+
service: "opencodex",
|
|
177
|
+
version: VERSION,
|
|
178
|
+
dashboard: {
|
|
179
|
+
available: false,
|
|
180
|
+
reason: "GUI build not found. Run `bun run build:gui` from the opencodex repo, or use `ocx gui` from a packaged install.",
|
|
181
|
+
},
|
|
182
|
+
endpoints: {
|
|
183
|
+
health: "/healthz",
|
|
184
|
+
models: "/v1/models",
|
|
185
|
+
responses: "/v1/responses",
|
|
186
|
+
management: "/api/*",
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
|
|
175
192
|
"opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3", "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]),
|
|
176
193
|
};
|
|
@@ -1290,6 +1307,9 @@ export function startServer(port?: number) {
|
|
|
1290
1307
|
|
|
1291
1308
|
const guiFile = serveGuiFile(url.pathname);
|
|
1292
1309
|
if (guiFile) return guiFile;
|
|
1310
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
1311
|
+
return jsonResponse(rootFallbackPayload());
|
|
1312
|
+
}
|
|
1293
1313
|
|
|
1294
1314
|
return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
|
|
1295
1315
|
},
|
package/src/service.ts
CHANGED
|
@@ -11,13 +11,16 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { getConfigDir } from "./config";
|
|
13
13
|
import { restoreNativeCodex } from "./codex-inject";
|
|
14
|
+
import { durableBunPath } from "./bun-runtime";
|
|
14
15
|
|
|
15
16
|
const LABEL = "com.opencodex.proxy";
|
|
16
17
|
const TASK = "opencodex-proxy";
|
|
17
18
|
|
|
18
19
|
function cliEntry(): { bun: string; cli: string } {
|
|
19
|
-
//
|
|
20
|
-
|
|
20
|
+
// Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
|
|
21
|
+
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
22
|
+
// standalone Bun is later removed. cli.ts sits next to this module.
|
|
23
|
+
return { bun: durableBunPath(), cli: join(import.meta.dir, "cli.ts") };
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function plistPath(): string {
|
|
@@ -300,6 +303,11 @@ export function uninstallServiceIfInstalled(): boolean {
|
|
|
300
303
|
return false;
|
|
301
304
|
}
|
|
302
305
|
|
|
306
|
+
/** True if a background service (launchd/systemd/Task Scheduler) is installed. */
|
|
307
|
+
export function isServiceInstalled(): boolean {
|
|
308
|
+
return serviceStatusSummary().startsWith("installed");
|
|
309
|
+
}
|
|
310
|
+
|
|
303
311
|
export function serviceStatusSummary(): string {
|
|
304
312
|
if (process.platform === "darwin") {
|
|
305
313
|
if (!existsSync(plistPath())) return "not installed";
|
package/src/update.ts
CHANGED
|
@@ -57,15 +57,23 @@ export async function runUpdate(): Promise<void> {
|
|
|
57
57
|
const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });
|
|
58
58
|
if (r.status === 0) {
|
|
59
59
|
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Re-bake the bundled Bun path into the Codex autostart shim on every
|
|
61
|
+
// platform when one is installed (refresh-only; never installs fresh).
|
|
62
|
+
try {
|
|
63
|
+
const { isCodexShimInstalled, installCodexShim } = await import("./codex-shim");
|
|
64
|
+
if (isCodexShimInstalled()) {
|
|
63
65
|
const result = installCodexShim();
|
|
64
66
|
if (result.installed) console.log(`🔧 ${result.message}`);
|
|
65
|
-
} catch (e) {
|
|
66
|
-
console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
|
|
67
67
|
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
|
|
68
70
|
}
|
|
71
|
+
// The launchd/systemd/Task Scheduler service bakes an absolute Bun path;
|
|
72
|
+
// advise refreshing it (reinstall is heavier, so we don't auto-run it).
|
|
73
|
+
try {
|
|
74
|
+
const { isServiceInstalled } = await import("./service");
|
|
75
|
+
if (isServiceInstalled()) console.log("Service detected — refresh its baked path: ocx service install");
|
|
76
|
+
} catch { /* best-effort advisory */ }
|
|
69
77
|
console.log("Restart the proxy: ocx stop && ocx start");
|
|
70
78
|
} else {
|
|
71
79
|
console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
|