@juspay/neurolink 11.2.0 → 11.2.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/codexOAuth.d.ts +67 -0
  3. package/dist/auth/codexOAuth.js +202 -0
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.js +4 -0
  6. package/dist/browser/neurolink.min.js +419 -419
  7. package/dist/cli/commands/auth.d.ts +27 -8
  8. package/dist/cli/commands/auth.js +425 -6
  9. package/dist/cli/commands/proxy.js +230 -5
  10. package/dist/cli/factories/authCommandFactory.d.ts +8 -0
  11. package/dist/cli/factories/authCommandFactory.js +74 -1
  12. package/dist/lib/auth/codexOAuth.d.ts +67 -0
  13. package/dist/lib/auth/codexOAuth.js +203 -0
  14. package/dist/lib/auth/index.d.ts +1 -0
  15. package/dist/lib/auth/index.js +4 -0
  16. package/dist/lib/providers/openaiChatCompletionsBase.js +26 -14
  17. package/dist/lib/proxy/accountCooldown.js +35 -2
  18. package/dist/lib/proxy/accountQuota.d.ts +29 -3
  19. package/dist/lib/proxy/accountQuota.js +203 -12
  20. package/dist/lib/proxy/accountUsage.js +15 -2
  21. package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
  22. package/dist/lib/proxy/codexAccountUsage.js +174 -0
  23. package/dist/lib/proxy/proxyAnalysis.js +12 -1
  24. package/dist/lib/proxy/proxyConfig.js +24 -0
  25. package/dist/lib/proxy/routingEvidence.d.ts +12 -1
  26. package/dist/lib/proxy/routingEvidence.js +23 -0
  27. package/dist/lib/proxy/runtimeConfig.js +3 -0
  28. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
  29. package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
  30. package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
  31. package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
  32. package/dist/lib/types/cli.d.ts +7 -1
  33. package/dist/lib/types/codex.d.ts +95 -0
  34. package/dist/lib/types/codex.js +15 -0
  35. package/dist/lib/types/index.d.ts +1 -0
  36. package/dist/lib/types/index.js +1 -0
  37. package/dist/lib/types/proxy.d.ts +83 -0
  38. package/dist/lib/types/subscription.d.ts +13 -0
  39. package/dist/providers/openaiChatCompletionsBase.js +26 -14
  40. package/dist/proxy/accountCooldown.js +35 -2
  41. package/dist/proxy/accountQuota.d.ts +29 -3
  42. package/dist/proxy/accountQuota.js +203 -12
  43. package/dist/proxy/accountUsage.js +15 -2
  44. package/dist/proxy/codexAccountUsage.d.ts +26 -0
  45. package/dist/proxy/codexAccountUsage.js +173 -0
  46. package/dist/proxy/proxyAnalysis.js +12 -1
  47. package/dist/proxy/proxyConfig.js +24 -0
  48. package/dist/proxy/routingEvidence.d.ts +12 -1
  49. package/dist/proxy/routingEvidence.js +23 -0
  50. package/dist/proxy/runtimeConfig.js +3 -0
  51. package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
  52. package/dist/server/routes/claudeProxyRoutes.js +653 -72
  53. package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
  54. package/dist/server/routes/codexProxyRoutes.js +453 -0
  55. package/dist/types/cli.d.ts +7 -1
  56. package/dist/types/codex.d.ts +95 -0
  57. package/dist/types/codex.js +14 -0
  58. package/dist/types/index.d.ts +1 -0
  59. package/dist/types/index.js +1 -0
  60. package/dist/types/proxy.d.ts +83 -0
  61. package/dist/types/subscription.d.ts +13 -0
  62. package/package.json +3 -1
@@ -625,6 +625,190 @@ async function clearOpenCodeProxySettings(expectedBaseUrl) {
625
625
  fs.writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2));
626
626
  return hadNeurolink;
627
627
  }
628
+ // =============================================================================
629
+ // CODEX (ChatGPT) AUTO-CONFIGURATION
630
+ // =============================================================================
631
+ //
632
+ // Points the Codex CLI at the proxy by managing `~/.codex/config.toml`:
633
+ // - appends a marker-delimited `[model_providers.neurolink]` table
634
+ // - flips the top-level `model_provider` to "neurolink"
635
+ // The original `model_provider` value is snapshotted to a sidecar JSON so the
636
+ // restore works even across process crashes. All edits are guarded and wrapped
637
+ // so a failure never aborts proxy start/stop. Codex talks the Responses API to
638
+ // the proxy's /backend-api/codex path.
639
+ const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
640
+ const CODEX_SNAPSHOT_PATH = join(homedir(), ".neurolink", "codex-proxy-snapshot.json");
641
+ const CODEX_BLOCK_BEGIN = "# >>> neurolink-proxy (managed) >>>";
642
+ const CODEX_BLOCK_END = "# <<< neurolink-proxy (managed) <<<";
643
+ const CODEX_PROVIDER_LINE_RE = /^[ \t]*model_provider[ \t]*=.*$/m;
644
+ const CODEX_MODEL_LINE_RE = /^[ \t]*model[ \t]*=.*$/m;
645
+ /** Strip any previously-managed block + our injected provider line. */
646
+ function stripCodexManagedConfig(text) {
647
+ const blockRe = new RegExp(`\\n?${escapeRegExp(CODEX_BLOCK_BEGIN)}[\\s\\S]*?${escapeRegExp(CODEX_BLOCK_END)}\\n?`, "g");
648
+ let out = text.replace(blockRe, "\n");
649
+ // Remove our injected selector line (only the exact "neurolink" one).
650
+ out = out.replace(/^[ \t]*model_provider[ \t]*=[ \t]*"neurolink"[ \t]*$\n?/m, "");
651
+ return out;
652
+ }
653
+ function escapeRegExp(value) {
654
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
655
+ }
656
+ /**
657
+ * Apply a top-level TOML key edit, confined to the document preamble.
658
+ *
659
+ * In TOML every key after a `[table]` header belongs to that table. A
660
+ * document-wide regex therefore happily rewrites `model_provider` inside
661
+ * `[model_providers.foo]`, which sets a table key instead of the top-level
662
+ * selector: Codex keeps using its original provider while the command reports
663
+ * success. Only the text before the first header can hold top-level keys.
664
+ */
665
+ function editTomlPreamble(text, edit) {
666
+ const headerMatch = /^[ \t]*\[/m.exec(text);
667
+ const boundary = headerMatch ? headerMatch.index : text.length;
668
+ const preamble = text.slice(0, boundary);
669
+ const edited = edit(preamble);
670
+ return edited === null ? text : edited + text.slice(boundary);
671
+ }
672
+ function buildCodexProviderBlock(baseUrl) {
673
+ return [
674
+ CODEX_BLOCK_BEGIN,
675
+ "[model_providers.neurolink]",
676
+ 'name = "NeuroLink Proxy"',
677
+ `base_url = "${baseUrl}/backend-api/codex"`,
678
+ 'wire_api = "responses"',
679
+ "requires_openai_auth = true",
680
+ CODEX_BLOCK_END,
681
+ "",
682
+ ].join("\n");
683
+ }
684
+ async function setCodexProxySettings(baseUrl) {
685
+ try {
686
+ const fs = await import("fs");
687
+ if (!fs.existsSync(CODEX_CONFIG_PATH)) {
688
+ // Codex not installed / never configured — skip silently.
689
+ return false;
690
+ }
691
+ const original = fs.readFileSync(CODEX_CONFIG_PATH, "utf8");
692
+ // Snapshot the user's original selector once (survives crashes/restarts).
693
+ if (!fs.existsSync(CODEX_SNAPSHOT_PATH)) {
694
+ // Same preamble boundary the edit paths use. Matching document-wide would
695
+ // capture a `model_provider` belonging to a table — a legacy
696
+ // `[profiles.<name>]` block, say — and the restore would then write that
697
+ // profile's provider back as the top-level selector.
698
+ let providerMatch = null;
699
+ editTomlPreamble(original, (preamble) => {
700
+ providerMatch = preamble.match(CODEX_PROVIDER_LINE_RE);
701
+ return null;
702
+ });
703
+ // Ignore a stale managed selector line if present in the original.
704
+ const originalProviderLine = providerMatch && !/"neurolink"/.test(providerMatch[0])
705
+ ? providerMatch[0]
706
+ : null;
707
+ fs.mkdirSync(join(homedir(), ".neurolink"), { recursive: true });
708
+ fs.writeFileSync(CODEX_SNAPSHOT_PATH, JSON.stringify({ originalProviderLine }, null, 2), { mode: 0o600 });
709
+ }
710
+ let text = stripCodexManagedConfig(original);
711
+ // Set the selector: replace an existing top-level model_provider or insert
712
+ // one right after the top-level `model = ...` line (stays before any table).
713
+ let selectorPlaced = false;
714
+ text = editTomlPreamble(text, (preamble) => {
715
+ if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
716
+ selectorPlaced = true;
717
+ return preamble.replace(CODEX_PROVIDER_LINE_RE, 'model_provider = "neurolink"');
718
+ }
719
+ if (CODEX_MODEL_LINE_RE.test(preamble)) {
720
+ selectorPlaced = true;
721
+ return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\nmodel_provider = "neurolink"`);
722
+ }
723
+ return null;
724
+ });
725
+ if (!selectorPlaced) {
726
+ text = `model_provider = "neurolink"\n${text}`;
727
+ }
728
+ const trimmed = text.replace(/\s*$/, "\n");
729
+ fs.writeFileSync(CODEX_CONFIG_PATH, `${trimmed}\n${buildCodexProviderBlock(baseUrl)}`);
730
+ return true;
731
+ }
732
+ catch (error) {
733
+ logger.debug(`[proxy] Codex client config not updated: ${error instanceof Error ? error.message : String(error)}`);
734
+ return false;
735
+ }
736
+ }
737
+ async function clearCodexProxySettings(expectedBaseUrl) {
738
+ try {
739
+ const fs = await import("fs");
740
+ if (!fs.existsSync(CODEX_CONFIG_PATH)) {
741
+ return false;
742
+ }
743
+ const original = fs.readFileSync(CODEX_CONFIG_PATH, "utf8");
744
+ // The managed block records its owner in `base_url`. Without this check a
745
+ // second proxy shutting down on another port would strip the block that the
746
+ // still-running proxy installed, and restore the snapshot selector on top —
747
+ // silently taking Codex off the live proxy. Mirrors the Claude and OpenCode
748
+ // clear paths.
749
+ if (expectedBaseUrl) {
750
+ const ownerMatch = original.match(new RegExp(`${escapeRegExp(CODEX_BLOCK_BEGIN)}[\\s\\S]*?base_url\\s*=\\s*"([^"]*)"`));
751
+ if (ownerMatch &&
752
+ ownerMatch[1] !== `${expectedBaseUrl}/backend-api/codex`) {
753
+ return false;
754
+ }
755
+ }
756
+ let text = stripCodexManagedConfig(original);
757
+ // Restore the user's original selector line if we snapshotted one.
758
+ let restored = null;
759
+ if (fs.existsSync(CODEX_SNAPSHOT_PATH)) {
760
+ try {
761
+ const snap = JSON.parse(fs.readFileSync(CODEX_SNAPSHOT_PATH, "utf8"));
762
+ restored = snap.originalProviderLine ?? null;
763
+ }
764
+ catch {
765
+ restored = null;
766
+ }
767
+ }
768
+ if (restored) {
769
+ const restoredLine = restored;
770
+ let restorePlaced = false;
771
+ text = editTomlPreamble(text, (preamble) => {
772
+ if (CODEX_PROVIDER_LINE_RE.test(preamble)) {
773
+ restorePlaced = true;
774
+ return preamble.replace(CODEX_PROVIDER_LINE_RE, restoredLine);
775
+ }
776
+ if (CODEX_MODEL_LINE_RE.test(preamble)) {
777
+ restorePlaced = true;
778
+ return preamble.replace(CODEX_MODEL_LINE_RE, (line) => `${line}\n${restoredLine}`);
779
+ }
780
+ return null;
781
+ });
782
+ if (!restorePlaced) {
783
+ text = `${restoredLine}\n${text}`;
784
+ }
785
+ }
786
+ if (text === original) {
787
+ // Nothing managed remains, so the snapshot can no longer describe the
788
+ // user's current selector. Keeping it would let a later clear restore a
789
+ // value the user has since changed by hand.
790
+ try {
791
+ fs.rmSync(CODEX_SNAPSHOT_PATH, { force: true });
792
+ }
793
+ catch {
794
+ // best-effort
795
+ }
796
+ return false;
797
+ }
798
+ fs.writeFileSync(CODEX_CONFIG_PATH, text.replace(/\s*$/, "\n"));
799
+ try {
800
+ fs.rmSync(CODEX_SNAPSHOT_PATH, { force: true });
801
+ }
802
+ catch {
803
+ // best-effort
804
+ }
805
+ return true;
806
+ }
807
+ catch (error) {
808
+ logger.debug(`[proxy] Codex client config not cleared: ${error instanceof Error ? error.message : String(error)}`);
809
+ return false;
810
+ }
811
+ }
628
812
  export async function probeProxyHealth(host, port, timeoutMs) {
629
813
  const startedAt = Date.now();
630
814
  try {
@@ -1217,7 +1401,7 @@ async function createProxyNeurolinkRuntime(logsDir) {
1217
1401
  };
1218
1402
  }
1219
1403
  function registerProxyRequestTracking(app, requestMetadata, readiness) {
1220
- app.use("/v1/*", async (c, next) => {
1404
+ const trackingHandler = async (c, next) => {
1221
1405
  const startedMonotonicMs = performance.now();
1222
1406
  const contentLengthHeader = c.req.raw.headers.get("content-length");
1223
1407
  const rawContentLength = contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader);
@@ -1336,11 +1520,16 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
1336
1520
  });
1337
1521
  throw error;
1338
1522
  }
1339
- });
1523
+ };
1524
+ // Cover both the Anthropic (/v1/*) and Codex (/backend-api/*) inbound paths so
1525
+ // drain/reject, lifecycle logging, and concurrency accounting apply to both.
1526
+ app.use("/v1/*", trackingHandler);
1527
+ app.use("/backend-api/*", trackingHandler);
1340
1528
  }
1341
1529
  export async function createProxyStartApp(params) {
1342
1530
  const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
1343
1531
  const { createOpenAIProxyRoutes } = await import("../../lib/server/routes/openaiProxyRoutes.js");
1532
+ const { createCodexProxyRoutes } = await import("../../lib/server/routes/codexProxyRoutes.js");
1344
1533
  const { logBodyCapture, logRequest } = await import("../../lib/proxy/requestLogger.js");
1345
1534
  const { recordFinalError } = await import("../../lib/proxy/usageStats.js");
1346
1535
  const { Hono } = await import("hono");
@@ -1461,7 +1650,12 @@ export async function createProxyStartApp(params) {
1461
1650
  }
1462
1651
  : params.accountAllowlist);
1463
1652
  const openaiRouteGroup = createOpenAIProxyRoutes(params.modelRouter, "", params.port, runtimeConfigProvider);
1464
- const allProxyRoutes = [...routeGroup.routes, ...openaiRouteGroup.routes];
1653
+ const codexRouteGroup = createCodexProxyRoutes("");
1654
+ const allProxyRoutes = [
1655
+ ...routeGroup.routes,
1656
+ ...openaiRouteGroup.routes,
1657
+ ...codexRouteGroup.routes,
1658
+ ];
1465
1659
  for (const route of allProxyRoutes) {
1466
1660
  const method = route.method.toLowerCase();
1467
1661
  app[method](route.path, async (c) => {
@@ -2265,9 +2459,16 @@ function registerProxyShutdownHandlers(params) {
2265
2459
  catch {
2266
2460
  // non-fatal
2267
2461
  }
2462
+ const shutdownHost = params.host === "0.0.0.0" ? "localhost" : params.host;
2463
+ const shutdownBaseUrl = `http://${shutdownHost}:${params.port}`;
2268
2464
  try {
2269
- const shutdownHost = params.host === "0.0.0.0" ? "localhost" : params.host;
2270
- await clearOpenCodeProxySettings(`http://${shutdownHost}:${params.port}/v1`);
2465
+ await clearOpenCodeProxySettings(`${shutdownBaseUrl}/v1`);
2466
+ }
2467
+ catch {
2468
+ // non-fatal
2469
+ }
2470
+ try {
2471
+ await clearCodexProxySettings(shutdownBaseUrl);
2271
2472
  }
2272
2473
  catch {
2273
2474
  // non-fatal
@@ -2516,6 +2717,16 @@ async function startProxyRuntime(params) {
2516
2717
  logger.debug("[proxy] Failed to auto-configure OpenCode: " +
2517
2718
  (error instanceof Error ? error.message : String(error)));
2518
2719
  }
2720
+ try {
2721
+ if (await setCodexProxySettings(url)) {
2722
+ logger.always(chalk.green(" ✓ Auto-configured Codex settings"));
2723
+ logger.always(chalk.dim(" Restart Codex to connect through proxy"));
2724
+ }
2725
+ }
2726
+ catch (error) {
2727
+ logger.debug("[proxy] Failed to auto-configure Codex: " +
2728
+ (error instanceof Error ? error.message : String(error)));
2729
+ }
2519
2730
  }
2520
2731
  else {
2521
2732
  logger.always(chalk.dim(" ⊘ Dev mode: skipping client auto-configuration"));
@@ -3826,6 +4037,12 @@ export const proxyGuardCommand = {
3826
4037
  catch {
3827
4038
  // non-fatal
3828
4039
  }
4040
+ try {
4041
+ await clearCodexProxySettings(expectedBaseUrl);
4042
+ }
4043
+ catch {
4044
+ // non-fatal
4045
+ }
3829
4046
  const state = loadProxyState();
3830
4047
  if (state &&
3831
4048
  state.host === host &&
@@ -3944,6 +4161,14 @@ export const proxySetupCommand = {
3944
4161
  catch (e) {
3945
4162
  console.info(chalk.yellow(` ⚠ Could not auto-configure OpenCode: ${e instanceof Error ? e.message : String(e)}`));
3946
4163
  }
4164
+ try {
4165
+ if (await setCodexProxySettings(url)) {
4166
+ console.info(chalk.green(" ✓ Codex configured"));
4167
+ }
4168
+ }
4169
+ catch (e) {
4170
+ console.info(chalk.yellow(` ⚠ Could not auto-configure Codex: ${e instanceof Error ? e.message : String(e)}`));
4171
+ }
3947
4172
  // Done!
3948
4173
  console.info("");
3949
4174
  console.info(chalk.bold.green("Setup complete!"));
@@ -56,6 +56,14 @@ export declare class AuthCommandFactory {
56
56
  * Build options for enable subcommand
57
57
  */
58
58
  private static buildEnableOptions;
59
+ /**
60
+ * Build options for disable subcommand
61
+ */
62
+ private static buildDisableOptions;
63
+ /**
64
+ * Build options for the cooldown subcommands
65
+ */
66
+ private static buildCooldownClearOptions;
59
67
  /**
60
68
  * Build options for set-primary subcommand
61
69
  */
@@ -9,7 +9,7 @@
9
9
  /**
10
10
  * Supported providers for authentication
11
11
  */
12
- const SUPPORTED_PROVIDERS = ["anthropic"];
12
+ const SUPPORTED_PROVIDERS = ["anthropic", "codex"];
13
13
  /**
14
14
  * Auth Command Factory
15
15
  *
@@ -63,6 +63,33 @@ export class AuthCommandFactory {
63
63
  .command("enable <account>", "Re-enable a previously disabled account", (yargs) => this.buildEnableOptions(yargs), async (argv) => {
64
64
  const { handleEnable } = await import("../commands/auth.js");
65
65
  await handleEnable(argv);
66
+ })
67
+ .command("disable <account>", "Take an account out of the proxy pool until re-enabled", (yargs) => this.buildDisableOptions(yargs), async (argv) => {
68
+ const { handleDisable } = await import("../commands/auth.js");
69
+ await handleDisable(argv);
70
+ })
71
+ .command("cooldown <action> [account]", "Inspect or clear per-account rate-limit cooldowns", (yargs) => this.buildCooldownClearOptions(yargs.positional("action", {
72
+ type: "string",
73
+ choices: ["list", "clear"],
74
+ description: "list cooldowns, or clear one/all",
75
+ demandOption: true,
76
+ })), async (argv) => {
77
+ const { handleCooldown } = await import("../commands/auth.js");
78
+ await handleCooldown(argv);
79
+ })
80
+ .command("overage [action]", "Show or set whether the pool may spend paid extra usage", (yargs) => yargs
81
+ .positional("action", {
82
+ type: "string",
83
+ choices: ["status", "auto", "always", "never"],
84
+ default: "status",
85
+ description: "Policy to apply, or 'status' to show it",
86
+ })
87
+ .option("config", {
88
+ type: "string",
89
+ description: "Path to the proxy config YAML",
90
+ }), async (argv) => {
91
+ const { handleOverage } = await import("../commands/auth.js");
92
+ await handleOverage(argv);
66
93
  })
67
94
  .command("set-primary <email>", "Set the proxy's primary (home) Anthropic account", (yargs) => this.buildSetPrimaryOptions(yargs), async (argv) => {
68
95
  const { handleSetPrimary } = await import("../commands/auth.js");
@@ -278,6 +305,52 @@ export class AuthCommandFactory {
278
305
  })
279
306
  .example("$0 auth enable anthropic:1-VjRIq", "Re-enable a disabled account");
280
307
  }
308
+ /**
309
+ * Build options for disable subcommand
310
+ */
311
+ static buildDisableOptions(yargs) {
312
+ return yargs
313
+ .positional("account", {
314
+ type: "string",
315
+ description: "Account key to disable (e.g., anthropic:1-VjRIq)",
316
+ demandOption: true,
317
+ })
318
+ .option("reason", {
319
+ type: "string",
320
+ description: "Why the account is being disabled (shown in auth list)",
321
+ })
322
+ .example("$0 auth disable anthropic:1-VjRIq", "Take an account out of the proxy pool");
323
+ }
324
+ /**
325
+ * Build options for the cooldown subcommands
326
+ */
327
+ static buildCooldownClearOptions(yargs) {
328
+ return (yargs
329
+ .positional("account", {
330
+ type: "string",
331
+ description: "Account key whose cooldown should be cleared",
332
+ })
333
+ .option("all", {
334
+ type: "boolean",
335
+ default: false,
336
+ description: "Clear cooldowns for every account",
337
+ })
338
+ .example("$0 auth cooldown clear anthropic:1-VjRIq", "Return a parked account to the pool immediately")
339
+ // Reject a scope the handler cannot honour rather than silently picking
340
+ // one. `--all` beats a named account there, so `clear <account> --all`
341
+ // would wipe every cooldown while reading as a single-account command.
342
+ .check((argv) => {
343
+ const action = String(argv.action ?? "");
344
+ const account = argv.account ? String(argv.account) : undefined;
345
+ if (action === "clear" && account && argv.all === true) {
346
+ throw new Error("Pass an account or --all, not both: --all clears every cooldown.");
347
+ }
348
+ if (action === "list" && (account || argv.all === true)) {
349
+ throw new Error("`auth cooldown list` takes no account and no --all; it lists every cooling account.");
350
+ }
351
+ return true;
352
+ }));
353
+ }
281
354
  /**
282
355
  * Build options for set-primary subcommand
283
356
  */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Codex (OpenAI ChatGPT) OAuth + identity helpers.
3
+ *
4
+ * Mirrors the role of `anthropicOAuth.ts` for the Codex pool engine. Codex signs
5
+ * in with a ChatGPT account (PKCE OAuth against auth.openai.com) and talks to the
6
+ * ChatGPT backend Responses API. The proxy pools multiple ChatGPT accounts, so it
7
+ * needs to: import an existing `~/.codex/auth.json`, refresh access tokens, and
8
+ * derive the per-account `chatgpt-account-id` the backend requires.
9
+ *
10
+ * Constants verified against Codex CLI 0.144.4.
11
+ */
12
+ import type { CodexImportedCredential } from "../types/index.js";
13
+ export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
14
+ export declare const CODEX_AUTH_URL = "https://auth.openai.com/oauth/authorize";
15
+ export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
16
+ export declare const CODEX_REVOKE_URL = "https://auth.openai.com/oauth/revoke";
17
+ export declare const CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback";
18
+ export declare const CODEX_DEFAULT_SCOPES: readonly ["openid", "profile", "email", "offline_access"];
19
+ export declare const CODEX_BACKEND_BASE_URL = "https://chatgpt.com/backend-api/codex";
20
+ export declare const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
21
+ export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/codex/usage";
22
+ export declare const CODEX_VERSION = "0.144.4";
23
+ export declare const CODEX_USER_AGENT = "codex_cli_rs/0.144.4 (external; neurolink-proxy)";
24
+ export declare const CODEX_ORIGINATOR = "codex_cli_rs";
25
+ /** Decode the ChatGPT account id, plan type, and expiry from an access token. */
26
+ export declare function decodeCodexAccessToken(accessToken: string): {
27
+ accountId?: string;
28
+ planType?: string;
29
+ expiresAt?: number;
30
+ };
31
+ /** Decode the account email from an id token (best-effort, for the label). */
32
+ export declare function decodeCodexEmail(idToken?: string): string | undefined;
33
+ /**
34
+ * The chatgpt-account-id the backend requires for a given access token.
35
+ * Prefer the value stored on the account; fall back to decoding the JWT so a
36
+ * rotated token always carries a coherent account id.
37
+ */
38
+ export declare function resolveCodexAccountId(accessToken: string, storedAccountId?: string): string | undefined;
39
+ /**
40
+ * Import an existing Codex credential from `~/.codex/auth.json` (or a custom
41
+ * path). This is the verified, robust login path: the user logs into Codex
42
+ * normally, then imports the current account into the proxy pool.
43
+ */
44
+ export declare function importCodexAuthFile(authFilePath?: string): Promise<CodexImportedCredential>;
45
+ /**
46
+ * Refresh a Codex access token. Verified: POST form-urlencoded to
47
+ * auth.openai.com/oauth/token with grant_type=refresh_token and the Codex
48
+ * client id. Returns a fresh credential (access + rotated refresh token).
49
+ */
50
+ export declare function refreshCodexToken(refreshToken: string, options?: {
51
+ timeoutMs?: number;
52
+ }): Promise<CodexImportedCredential>;
53
+ /**
54
+ * Whether a {@link refreshCodexToken} failure means the refresh token itself is
55
+ * no longer usable, as opposed to the request never getting a verdict.
56
+ *
57
+ * Only the authorization server rejecting the grant is permanent. A 5xx, a
58
+ * timeout, or a DNS failure says nothing about the credential, and treating
59
+ * those as permanent disables a working account — which `auth cleanup` then
60
+ * deletes.
61
+ */
62
+ export declare function isPermanentCodexRefreshFailure(error: unknown): boolean;
63
+ /**
64
+ * Whether an imported credential's access token is expired (or within the
65
+ * buffer window) and should be refreshed before use.
66
+ */
67
+ export declare function codexTokenNeedsRefresh(expiresAt: number | undefined, bufferMs?: number): boolean;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Codex (OpenAI ChatGPT) OAuth + identity helpers.
3
+ *
4
+ * Mirrors the role of `anthropicOAuth.ts` for the Codex pool engine. Codex signs
5
+ * in with a ChatGPT account (PKCE OAuth against auth.openai.com) and talks to the
6
+ * ChatGPT backend Responses API. The proxy pools multiple ChatGPT accounts, so it
7
+ * needs to: import an existing `~/.codex/auth.json`, refresh access tokens, and
8
+ * derive the per-account `chatgpt-account-id` the backend requires.
9
+ *
10
+ * Constants verified against Codex CLI 0.144.4.
11
+ */
12
+ import { readFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { logger } from "../utils/logger.js";
16
+ // OAuth client + endpoints (Codex CLI values).
17
+ export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
18
+ export const CODEX_AUTH_URL = "https://auth.openai.com/oauth/authorize";
19
+ export const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
20
+ export const CODEX_REVOKE_URL = "https://auth.openai.com/oauth/revoke";
21
+ export const CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback";
22
+ export const CODEX_DEFAULT_SCOPES = [
23
+ "openid",
24
+ "profile",
25
+ "email",
26
+ "offline_access",
27
+ ];
28
+ // Upstream backend (ChatGPT subscription path).
29
+ export const CODEX_BACKEND_BASE_URL = "https://chatgpt.com/backend-api/codex";
30
+ export const CODEX_RESPONSES_URL = `${CODEX_BACKEND_BASE_URL}/responses`;
31
+ export const CODEX_USAGE_URL = `${CODEX_BACKEND_BASE_URL}/usage`;
32
+ // Client fingerprint. The real Codex CLI sends these; the proxy defaults them
33
+ // only when a non-Codex client omits them.
34
+ export const CODEX_VERSION = "0.144.4";
35
+ export const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_VERSION} (external; neurolink-proxy)`;
36
+ export const CODEX_ORIGINATOR = "codex_cli_rs";
37
+ const CODEX_AUTH_FILE = join(homedir(), ".codex", "auth.json");
38
+ /** Base64url-decode a JWT segment into a JSON object (claims only, no verify). */
39
+ function decodeJwtSegment(segment) {
40
+ try {
41
+ const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
42
+ const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
43
+ const parsed = JSON.parse(json);
44
+ return parsed && typeof parsed === "object"
45
+ ? parsed
46
+ : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function getOpenAiAuthClaim(claims) {
53
+ if (!claims) {
54
+ return null;
55
+ }
56
+ const auth = claims["https://api.openai.com/auth"];
57
+ return auth && typeof auth === "object"
58
+ ? auth
59
+ : null;
60
+ }
61
+ /** Decode the ChatGPT account id, plan type, and expiry from an access token. */
62
+ export function decodeCodexAccessToken(accessToken) {
63
+ const parts = accessToken.split(".");
64
+ if (parts.length !== 3) {
65
+ return {};
66
+ }
67
+ const claims = decodeJwtSegment(parts[1]);
68
+ const authClaim = getOpenAiAuthClaim(claims);
69
+ const accountId = typeof authClaim?.chatgpt_account_id === "string"
70
+ ? authClaim.chatgpt_account_id
71
+ : undefined;
72
+ const planType = typeof authClaim?.chatgpt_plan_type === "string"
73
+ ? authClaim.chatgpt_plan_type
74
+ : undefined;
75
+ const exp = typeof claims?.exp === "number" ? claims.exp * 1000 : undefined;
76
+ return { accountId, planType, expiresAt: exp };
77
+ }
78
+ /** Decode the account email from an id token (best-effort, for the label). */
79
+ export function decodeCodexEmail(idToken) {
80
+ if (!idToken) {
81
+ return undefined;
82
+ }
83
+ const parts = idToken.split(".");
84
+ if (parts.length !== 3) {
85
+ return undefined;
86
+ }
87
+ const claims = decodeJwtSegment(parts[1]);
88
+ return typeof claims?.email === "string" ? claims.email : undefined;
89
+ }
90
+ /**
91
+ * The chatgpt-account-id the backend requires for a given access token.
92
+ * Prefer the value stored on the account; fall back to decoding the JWT so a
93
+ * rotated token always carries a coherent account id.
94
+ */
95
+ export function resolveCodexAccountId(accessToken, storedAccountId) {
96
+ if (storedAccountId && storedAccountId.length > 0) {
97
+ return storedAccountId;
98
+ }
99
+ return decodeCodexAccessToken(accessToken).accountId;
100
+ }
101
+ /** Build a CodexImportedCredential from raw token material. */
102
+ function buildImportedCredential(tokens) {
103
+ const decoded = decodeCodexAccessToken(tokens.access_token);
104
+ return {
105
+ accessToken: tokens.access_token,
106
+ refreshToken: tokens.refresh_token,
107
+ idToken: tokens.id_token,
108
+ accountId: tokens.account_id ?? decoded.accountId,
109
+ expiresAt: decoded.expiresAt,
110
+ planType: decoded.planType,
111
+ email: decodeCodexEmail(tokens.id_token),
112
+ };
113
+ }
114
+ /**
115
+ * Import an existing Codex credential from `~/.codex/auth.json` (or a custom
116
+ * path). This is the verified, robust login path: the user logs into Codex
117
+ * normally, then imports the current account into the proxy pool.
118
+ */
119
+ export async function importCodexAuthFile(authFilePath = CODEX_AUTH_FILE) {
120
+ const raw = await readFile(authFilePath, "utf8");
121
+ const parsed = JSON.parse(raw);
122
+ const tokens = parsed.tokens;
123
+ if (!tokens || typeof tokens.access_token !== "string") {
124
+ throw new Error(`No ChatGPT OAuth tokens found in ${authFilePath}. Run \`codex login\` first.`);
125
+ }
126
+ if (parsed.auth_mode && parsed.auth_mode !== "chatgpt") {
127
+ throw new Error(`Codex auth mode is "${parsed.auth_mode}", expected "chatgpt". Only ChatGPT subscription login can be pooled.`);
128
+ }
129
+ return buildImportedCredential({
130
+ access_token: tokens.access_token,
131
+ refresh_token: tokens.refresh_token,
132
+ id_token: tokens.id_token,
133
+ account_id: tokens.account_id,
134
+ });
135
+ }
136
+ /**
137
+ * Refresh a Codex access token. Verified: POST form-urlencoded to
138
+ * auth.openai.com/oauth/token with grant_type=refresh_token and the Codex
139
+ * client id. Returns a fresh credential (access + rotated refresh token).
140
+ */
141
+ export async function refreshCodexToken(refreshToken, options = {}) {
142
+ const body = new URLSearchParams({
143
+ grant_type: "refresh_token",
144
+ refresh_token: refreshToken,
145
+ client_id: CODEX_CLIENT_ID,
146
+ scope: CODEX_DEFAULT_SCOPES.join(" "),
147
+ });
148
+ const response = await fetch(CODEX_TOKEN_URL, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/x-www-form-urlencoded",
152
+ Accept: "application/json",
153
+ "User-Agent": CODEX_USER_AGENT,
154
+ },
155
+ body: body.toString(),
156
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
157
+ });
158
+ if (!response.ok) {
159
+ const text = await response.text().catch(() => "");
160
+ throw Object.assign(new Error(`Codex token refresh failed: ${response.status} ${text.slice(0, 200)}`), { status: response.status });
161
+ }
162
+ const data = (await response.json());
163
+ if (!data.access_token) {
164
+ throw new Error("Codex token refresh returned no access_token");
165
+ }
166
+ logger.debug("Codex token refreshed");
167
+ return buildImportedCredential({
168
+ access_token: data.access_token,
169
+ // OpenAI rotates the refresh token; fall back to the old one if omitted.
170
+ refresh_token: data.refresh_token ?? refreshToken,
171
+ id_token: data.id_token,
172
+ });
173
+ }
174
+ /**
175
+ * Whether a {@link refreshCodexToken} failure means the refresh token itself is
176
+ * no longer usable, as opposed to the request never getting a verdict.
177
+ *
178
+ * Only the authorization server rejecting the grant is permanent. A 5xx, a
179
+ * timeout, or a DNS failure says nothing about the credential, and treating
180
+ * those as permanent disables a working account — which `auth cleanup` then
181
+ * deletes.
182
+ */
183
+ export function isPermanentCodexRefreshFailure(error) {
184
+ const status = error?.status;
185
+ if (typeof status !== "number" || status < 400 || status >= 500) {
186
+ return false;
187
+ }
188
+ // 408 and 429 are a timeout and a throttle. Neither is the grant being
189
+ // rejected, and treating them as permanent disables the account — which
190
+ // `auth cleanup` then deletes.
191
+ return status !== 408 && status !== 429;
192
+ }
193
+ /**
194
+ * Whether an imported credential's access token is expired (or within the
195
+ * buffer window) and should be refreshed before use.
196
+ */
197
+ export function codexTokenNeedsRefresh(expiresAt, bufferMs = 5 * 60 * 1000) {
198
+ if (!expiresAt) {
199
+ return false;
200
+ }
201
+ return expiresAt - bufferMs <= Date.now();
202
+ }
203
+ //# sourceMappingURL=codexOAuth.js.map