@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.
- package/CHANGELOG.md +12 -0
- package/dist/auth/codexOAuth.d.ts +67 -0
- package/dist/auth/codexOAuth.js +202 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/browser/neurolink.min.js +419 -419
- package/dist/cli/commands/auth.d.ts +27 -8
- package/dist/cli/commands/auth.js +425 -6
- package/dist/cli/commands/proxy.js +230 -5
- package/dist/cli/factories/authCommandFactory.d.ts +8 -0
- package/dist/cli/factories/authCommandFactory.js +74 -1
- package/dist/lib/auth/codexOAuth.d.ts +67 -0
- package/dist/lib/auth/codexOAuth.js +203 -0
- package/dist/lib/auth/index.d.ts +1 -0
- package/dist/lib/auth/index.js +4 -0
- package/dist/lib/providers/openaiChatCompletionsBase.js +26 -14
- package/dist/lib/proxy/accountCooldown.js +35 -2
- package/dist/lib/proxy/accountQuota.d.ts +29 -3
- package/dist/lib/proxy/accountQuota.js +203 -12
- package/dist/lib/proxy/accountUsage.js +15 -2
- package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/lib/proxy/codexAccountUsage.js +174 -0
- package/dist/lib/proxy/proxyAnalysis.js +12 -1
- package/dist/lib/proxy/proxyConfig.js +24 -0
- package/dist/lib/proxy/routingEvidence.d.ts +12 -1
- package/dist/lib/proxy/routingEvidence.js +23 -0
- package/dist/lib/proxy/runtimeConfig.js +3 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
- package/dist/lib/types/cli.d.ts +7 -1
- package/dist/lib/types/codex.d.ts +95 -0
- package/dist/lib/types/codex.js +15 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/proxy.d.ts +83 -0
- package/dist/lib/types/subscription.d.ts +13 -0
- package/dist/providers/openaiChatCompletionsBase.js +26 -14
- package/dist/proxy/accountCooldown.js +35 -2
- package/dist/proxy/accountQuota.d.ts +29 -3
- package/dist/proxy/accountQuota.js +203 -12
- package/dist/proxy/accountUsage.js +15 -2
- package/dist/proxy/codexAccountUsage.d.ts +26 -0
- package/dist/proxy/codexAccountUsage.js +173 -0
- package/dist/proxy/proxyAnalysis.js +12 -1
- package/dist/proxy/proxyConfig.js +24 -0
- package/dist/proxy/routingEvidence.d.ts +12 -1
- package/dist/proxy/routingEvidence.js +23 -0
- package/dist/proxy/runtimeConfig.js +3 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
- package/dist/server/routes/claudeProxyRoutes.js +653 -72
- package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
- package/dist/server/routes/codexProxyRoutes.js +453 -0
- package/dist/types/cli.d.ts +7 -1
- package/dist/types/codex.d.ts +95 -0
- package/dist/types/codex.js +14 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +83 -0
- package/dist/types/subscription.d.ts +13 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
2270
|
-
|
|
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
|