@bitkyc08/opencodex 2.5.5 → 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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-qkvcDJZw.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CJF4_jax.css">
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.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": "./src/cli.ts",
9
- "ocx": "./src/cli.ts"
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
- "bun": ">=1.1.0"
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 !== selected) {
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
- removePid(process.pid);
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();
@@ -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: JWT-derived account ID is authoritative; collision check
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, listCodexAccountIds } from "./codex-account-store";
4
+ import { getCodexAccountCredential } from "./codex-account-store";
5
5
  import { loadConfig } from "./config";
6
- import { extractAccountId, extractEmail } from "./oauth/chatgpt";
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 poolEmailForId(id: string): string | null {
43
- const account = (loadConfig().codexAccounts ?? []).find(a => a.id === id);
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
- // Business/Team members can share chatgpt_account_id, so require email match too.
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 mainId = getMainChatgptAccountId();
54
- const mainEmail = getMainChatgptEmail();
55
- if (mainId && mainId === chatgptAccountId && (!candidateEmail || !mainEmail || mainEmail === candidateEmail)) {
56
- return { collision: true, reason: "This account is your main Codex login. Use a different account for the pool." };
57
- }
58
- for (const poolId of listCodexAccountIds()) {
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 (${poolId}).` };
55
+ return { collision: true, reason: `Account is already in the pool (${account.id}).` };
63
56
  }
64
57
  }
65
58
  return { collision: false };
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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
- writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
246
- writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
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
- return { bun: process.execPath, cli: join(import.meta.dir, "cli.ts") };
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: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
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: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
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: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
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: { ...prov, apiKey: resolveEnvValue(prov.apiKey) },
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: { ...defaultProv, apiKey: resolveEnvValue(defaultProv.apiKey) },
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
- if (!GUI_DIST) return null;
148
+ const guiDist = findGuiDist();
149
+ if (!guiDist) return null;
151
150
  const filePath = pathname === "/" || pathname === ""
152
- ? join(GUI_DIST, "index.html")
153
- : join(GUI_DIST, pathname);
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(GUI_DIST, "index.html");
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
- // process.execPath = the bun binary; cli.ts sits next to this module.
20
- return { bun: process.execPath, cli: join(import.meta.dir, "cli.ts") };
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
- if (process.platform === "win32") {
61
- try {
62
- const { installCodexShim } = await import("./codex-shim");
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(" ")}`);