@bitkyc08/opencodex 2.6.0 → 2.6.1

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,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BS4X1QDi.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BmHrbTmO.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BwvDb198.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  writeRuntimePort,
18
18
  } from "./config";
19
19
  import { collectStatus } from "./cli-status";
20
+ import { installCrashGuards } from "./crash-guard";
20
21
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./cli-help";
21
22
  import { findAvailablePort, shouldPersistSelectedPort } from "./ports";
22
23
  import { killProxy } from "./process-control";
@@ -136,6 +137,9 @@ async function handleStart(options: { block?: boolean } = {}) {
136
137
  const port = await chooseListenPort(requestedPort);
137
138
 
138
139
  const server = startServer(port);
140
+ // A single request's streaming error must never crash the daemon serving every
141
+ // other Codex session — capture the full stack to crash.log and stay up.
142
+ installCrashGuards();
139
143
  writePid(process.pid);
140
144
 
141
145
  const config = loadConfig();
@@ -1,8 +1,13 @@
1
1
  import { getCodexAccountCredential } from "./codex-account-store";
2
2
  import { isAccountNeedsReauth } from "./codex-account-runtime-state";
3
+ import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./codex-main-account";
3
4
  import type { OcxConfig } from "./types";
4
5
 
5
6
  export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean {
7
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
8
+ // Main account: credential is the read-only ~/.codex/auth.json token (Option A).
9
+ return isMainAccountTokenLive() && !isAccountNeedsReauth(accountId);
10
+ }
6
11
  const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId);
7
12
  if (!exists) return false;
8
13
  if (isAccountNeedsReauth(accountId)) return false;
@@ -24,6 +24,7 @@ import {
24
24
  } from "./codex-quota";
25
25
  export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./codex-quota";
26
26
  import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
27
+ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./codex-main-account";
27
28
  import { maskEmail } from "./privacy";
28
29
  export { maskEmail } from "./privacy";
29
30
  import type { CodexAccount, OcxConfig } from "./types";
@@ -88,7 +89,7 @@ async function resolveResetCreditAuth(
88
89
  | { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
89
90
  | { ok: false; response: Response }
90
91
  > {
91
- if (accountId === "__main__") {
92
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
92
93
  const tokens = readCodexTokens();
93
94
  if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
94
95
  return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
@@ -202,6 +203,21 @@ async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: stri
202
203
  ts: Date.now(),
203
204
  };
204
205
  mainAccountCache = result;
206
+ // Mirror main quota + plan into the shared stores so the rotation engine can
207
+ // score and auto-switch the main account exactly like a pool account (Option A).
208
+ setMainAccountPlan(result.plan);
209
+ if (result.quota) {
210
+ updateAccountQuota(
211
+ MAIN_CODEX_ACCOUNT_ID,
212
+ result.quota.weeklyPercent,
213
+ result.quota.fiveHourPercent,
214
+ result.quota.weeklyResetAt,
215
+ result.quota.fiveHourResetAt,
216
+ result.quota.monthlyPercent,
217
+ result.quota.monthlyResetAt,
218
+ result.quota.resetCredits,
219
+ );
220
+ }
205
221
  return result;
206
222
  } catch {
207
223
  return { email: null, plan: null, quota: null };
@@ -265,7 +281,7 @@ export async function handleCodexAuthAPI(
265
281
  return poolAccountDto(a, quotaResult, !!cred);
266
282
  });
267
283
  const main = {
268
- id: "__main__",
284
+ id: MAIN_CODEX_ACCOUNT_ID,
269
285
  email: maskEmail(mainInfo.email) ?? "Codex App login",
270
286
  plan: mainInfo.plan,
271
287
  isMain: true,
@@ -329,7 +345,7 @@ export async function handleCodexAuthAPI(
329
345
  let body: { accountId: string | null };
330
346
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
331
347
  const runtimeConfig = getRuntimeConfig(config);
332
- if (body.accountId != null) {
348
+ if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
333
349
  const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId);
334
350
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
335
351
  }
@@ -6,6 +6,7 @@ import {
6
6
  } from "./codex-account-store";
7
7
  import { markAccountNeedsReauth } from "./codex-account-runtime-state";
8
8
  import { isCodexAccountUsable } from "./codex-account-usability";
9
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./codex-main-account";
9
10
  import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
10
11
  import type { OcxConfig, OcxProviderConfig } from "./types";
11
12
  import { FORWARD_HEADERS } from "./adapters/openai-responses";
@@ -18,6 +19,14 @@ export type CodexAuthContext =
18
19
  generation: number;
19
20
  accessToken: string;
20
21
  chatgptAccountId: string;
22
+ }
23
+ | {
24
+ // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
25
+ // (Option A). Distinct from "main" (passthrough fallback that forwards the client token).
26
+ kind: "main-pool";
27
+ accountId: string;
28
+ accessToken: string;
29
+ chatgptAccountId: string;
21
30
  };
22
31
 
23
32
  export type OcxRuntimeProviderConfig = OcxProviderConfig & {
@@ -70,6 +79,14 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
70
79
  const cooldownUntil = getCodexAccountCooldownUntil(accountId);
71
80
  if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
72
81
 
82
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
83
+ // Main account in rotation: inject the read-only auth.json token. If the token vanished
84
+ // since selection, fall back to passthrough rather than failing the request.
85
+ const token = getMainAccountToken();
86
+ if (!token) return { kind: "main", accountId: null };
87
+ return { kind: "main-pool", accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId };
88
+ }
89
+
73
90
  try {
74
91
  const token = await getValidCodexToken(accountId);
75
92
  return {
@@ -88,7 +105,7 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
88
105
  }
89
106
 
90
107
  export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
91
- if (ctx?.kind !== "pool") return;
108
+ if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
92
109
  const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
93
110
  if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
94
111
  }
@@ -97,7 +114,7 @@ export function applyCodexAuthContextToProvider(
97
114
  provider: OcxProviderConfig,
98
115
  ctx: CodexAuthContext,
99
116
  ): OcxRuntimeProviderConfig {
100
- if (ctx.kind !== "pool" || provider.authMode !== "forward") return provider;
117
+ if ((ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
101
118
  return {
102
119
  ...provider,
103
120
  _codexAccountOverride: {
@@ -114,7 +131,7 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte
114
131
  const value = headers.get(name);
115
132
  if (value) selected.set(name, value);
116
133
  }
117
- if (ctx.kind === "pool") {
134
+ if (ctx.kind === "pool" || ctx.kind === "main-pool") {
118
135
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
119
136
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
120
137
  }
@@ -123,6 +140,7 @@ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthConte
123
140
 
124
141
  export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean {
125
142
  if (ctx.kind === "main") return true;
143
+ if (ctx.kind === "main-pool") return isCodexAccountUsable(config, ctx.accountId);
126
144
  return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation);
127
145
  }
128
146
 
@@ -0,0 +1,46 @@
1
+ import { readCodexTokens } from "./codex-auth-collision";
2
+ import { decodeJwtPayload } from "./oauth/chatgpt";
3
+
4
+ /**
5
+ * Stable id under which the "main" Codex account (the Codex CLI login stored in
6
+ * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is
7
+ * NOT imported into the managed credential store (Option A): its token is read-only from
8
+ * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth
9
+ * notice (re-login via the Codex CLI) rather than a background refresh.
10
+ */
11
+ export const MAIN_CODEX_ACCOUNT_ID = "__main__";
12
+
13
+ /**
14
+ * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage
15
+ * fetch. Used by the rotation usage-score so go/free main accounts score on monthly
16
+ * percent, matching pool-account behavior.
17
+ */
18
+ let mainAccountPlan: string | null = null;
19
+
20
+ export function setMainAccountPlan(plan: string | null): void {
21
+ mainAccountPlan = plan;
22
+ }
23
+
24
+ export function getMainAccountPlan(): string | undefined {
25
+ return mainAccountPlan ?? undefined;
26
+ }
27
+
28
+ /** Read-only main account token from ~/.codex/auth.json, or null when not logged in. */
29
+ export function getMainAccountToken(): { accessToken: string; chatgptAccountId: string } | null {
30
+ const tokens = readCodexTokens();
31
+ if (!tokens?.access_token) return null;
32
+ return { accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
33
+ }
34
+
35
+ /**
36
+ * The main token is usable when it exists and — if its JWT carries a decodable `exp` — is
37
+ * not expired. When `exp` cannot be decoded we treat the token as live (best-effort); an
38
+ * actually-invalid token then surfaces via the upstream 401 → cooldown path.
39
+ */
40
+ export function isMainAccountTokenLive(now = Date.now()): boolean {
41
+ const tokens = readCodexTokens();
42
+ if (!tokens?.access_token) return false;
43
+ const payload = decodeJwtPayload(tokens.access_token);
44
+ const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined;
45
+ return exp === undefined || exp > now;
46
+ }
@@ -4,6 +4,7 @@ import { codexAccountLogLabel } from "./codex-account-label";
4
4
  import { isCodexAccountUsable } from "./codex-account-usability";
5
5
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./codex-account-runtime-state";
6
6
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./codex-quota";
7
+ import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./codex-main-account";
7
8
  import type { OcxConfig } from "./types";
8
9
 
9
10
  type ThreadAffinityEntry = {
@@ -43,6 +44,7 @@ export type CodexUpstreamOutcomeMeta = {
43
44
  };
44
45
 
45
46
  function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
47
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId);
46
48
  return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId);
47
49
  }
48
50
 
@@ -203,14 +205,26 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v
203
205
  }
204
206
 
205
207
  function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] {
206
- return (config.codexAccounts ?? [])
208
+ const ids = (config.codexAccounts ?? [])
207
209
  .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id))
208
210
  .filter(account => !isCodexAccountInCooldown(account.id, now))
209
211
  .filter(account => isCodexAccountUsable(config, account.id))
210
212
  .map(account => account.id);
213
+ // The main Codex account is not stored in config.codexAccounts; include it as a
214
+ // first-class rotation candidate when its read-only token is usable (Option A).
215
+ if (
216
+ excludeId !== MAIN_CODEX_ACCOUNT_ID
217
+ && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
218
+ && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now)
219
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
220
+ ) {
221
+ ids.unshift(MAIN_CODEX_ACCOUNT_ID);
222
+ }
223
+ return ids;
211
224
  }
212
225
 
213
226
  function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
227
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
214
228
  return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan;
215
229
  }
216
230
 
@@ -387,6 +401,10 @@ export function recordCodexUpstreamOutcome(
387
401
 
388
402
  export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
389
403
  if (!accountId) return providerName;
404
+ // The main Codex login participates in rotation as "main-pool" (MAIN_CODEX_ACCOUNT_ID) but is the
405
+ // same physical account as the "main" passthrough (null accountId). Log both under the base provider
406
+ // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`.
407
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName;
390
408
  const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId);
391
409
  return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName;
392
410
  }
@@ -5,7 +5,7 @@ const socketsByAccount = new Map<string, Set<ServerWebSocket<WsData>>>();
5
5
 
6
6
  function trackedAccountId(ws: ServerWebSocket<WsData>): string | null {
7
7
  const ctx = ws.data.authContext;
8
- return ctx?.kind === "pool" ? ctx.accountId : null;
8
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.accountId : null;
9
9
  }
10
10
 
11
11
  function addSocketForAccount(accountId: string | null, ws: ServerWebSocket<WsData>): void {
@@ -0,0 +1,82 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfigDir } from "./config";
4
+
5
+ /**
6
+ * Process-level safety net for the long-running proxy daemon.
7
+ *
8
+ * A single request can trigger an async error inside a Bun.serve streaming
9
+ * handler (e.g. a ReadableStream `start(controller)` callback hitting an
10
+ * unexpected upstream response shape). Without a handler, Bun's default
11
+ * behaviour prints the raw error — shown as `(function (controller, error)
12
+ * {"use strict"; ... TypeError: null is not an object` — and can tear down
13
+ * the whole proxy, killing every other in-flight Codex session.
14
+ *
15
+ * We must NOT let one bad stream crash the daemon. These handlers:
16
+ * 1. Append the full error + stack to `<configDir>/crash.log` so the exact
17
+ * fault (with the JSC `(evaluating 'x.y')` clause and file:line) is
18
+ * captured for a precise root-cause fix.
19
+ * 2. Keep the process alive — the failed request is already isolated by
20
+ * Bun.serve; surviving is strictly better than terminating.
21
+ */
22
+
23
+ let installed = false;
24
+
25
+ function crashLogPath(): string {
26
+ const dir = getConfigDir();
27
+ try {
28
+ mkdirSync(dir, { recursive: true });
29
+ } catch {
30
+ /* best-effort: directory usually already exists */
31
+ }
32
+ return join(dir, "crash.log");
33
+ }
34
+
35
+ function formatError(kind: string, err: unknown): string {
36
+ const ts = new Date().toISOString();
37
+ const detail =
38
+ err instanceof Error
39
+ ? `${err.name}: ${err.message}\n${err.stack ?? "(no stack)"}`
40
+ : typeof err === "object"
41
+ ? safeStringify(err)
42
+ : String(err);
43
+ return `\n[${ts}] ${kind}\n${detail}\n`;
44
+ }
45
+
46
+ function safeStringify(value: unknown): string {
47
+ try {
48
+ return JSON.stringify(value, null, 2) ?? String(value);
49
+ } catch {
50
+ return String(value);
51
+ }
52
+ }
53
+
54
+ function record(kind: string, err: unknown): void {
55
+ const line = formatError(kind, err);
56
+ // Always surface to stderr so foreground `ocx start` users still see it,
57
+ // then persist for later diagnosis.
58
+ console.error(`⚠️ ${kind} (proxy stayed up; logged to crash.log)`);
59
+ console.error(line.trimStart());
60
+ try {
61
+ appendFileSync(crashLogPath(), line);
62
+ } catch {
63
+ /* logging must never throw */
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Register global handlers that keep the proxy alive and capture full stacks.
69
+ * Idempotent: safe to call more than once.
70
+ */
71
+ export function installCrashGuards(): void {
72
+ if (installed) return;
73
+ installed = true;
74
+
75
+ process.on("unhandledRejection", reason => {
76
+ record("unhandledRejection", reason);
77
+ });
78
+
79
+ process.on("uncaughtException", err => {
80
+ record("uncaughtException", err);
81
+ });
82
+ }
@@ -4,5 +4,9 @@ export function baseProviderLabel(provider: string): string {
4
4
  const cut = provider.lastIndexOf("-");
5
5
  if (cut <= 0) return provider;
6
6
  const suffix = provider.slice(cut + 1);
7
+ // `-main` is the legacy log label for the main Codex account (MAIN_CODEX_ACCOUNT_ID). New entries
8
+ // log under the base provider name, but historical `<provider>-main` entries must still collapse so
9
+ // the usage table aggregates the main account into a single row instead of a stray split.
10
+ if (suffix === "main") return provider.slice(0, cut);
7
11
  return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? provider.slice(0, cut) : provider;
8
12
  }
package/src/server.ts CHANGED
@@ -298,16 +298,22 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
298
298
  }
299
299
 
300
300
  function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
301
- return authCtx.kind === "pool"
301
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool"
302
302
  ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
303
303
  : undefined;
304
304
  }
305
305
 
306
+ /** Account id to attribute log labels / upstream outcomes to (pool + rotation-injected main). */
307
+ function codexLogAccountId(authCtx: CodexAuthContext): string | null {
308
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
309
+ }
310
+
306
311
  function usesCodexForwardPoolAuth(
307
312
  authCtx: CodexAuthContext,
308
313
  provider: OcxProviderConfig,
309
- ): authCtx is Extract<CodexAuthContext, { kind: "pool" }> {
310
- return authCtx.kind === "pool" && provider.authMode === "forward" && provider.adapter === "openai-responses";
314
+ ): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
315
+ return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
316
+ && provider.authMode === "forward" && provider.adapter === "openai-responses";
311
317
  }
312
318
 
313
319
  function codexForwardTerminalOutcomeRecorder(
@@ -399,7 +405,7 @@ async function handleResponses(
399
405
  return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
400
406
  }
401
407
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx);
402
- logCtx.provider = formatCodexProviderForLog(route.providerName, authCtx.kind === "pool" ? authCtx.accountId : null, config);
408
+ logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
403
409
 
404
410
  // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
405
411
  // existing openai-chat / anthropic adapters authenticate with no change.
Binary file