@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI ChatGPT) Proxy Routes
|
|
3
|
+
*
|
|
4
|
+
* Exposes `POST /backend-api/codex/responses` — the ChatGPT-backend Responses
|
|
5
|
+
* API endpoint the Codex CLI talks to — and pools it across multiple ChatGPT
|
|
6
|
+
* OAuth accounts, mirroring the Anthropic Claude pool engine.
|
|
7
|
+
*
|
|
8
|
+
* A Codex CLI configured with `model_providers.<name>.base_url` pointing at this
|
|
9
|
+
* proxy sends its own OAuth token; the proxy strips it, selects a pooled
|
|
10
|
+
* `codex:*` account, attaches that account's Bearer + matching chatgpt-account-id,
|
|
11
|
+
* forwards to https://chatgpt.com/backend-api/codex, and relays the SSE stream.
|
|
12
|
+
* On quota exhaustion (429 / usage limit) the account is cooled and the next
|
|
13
|
+
* account is tried — so the user never has to switch accounts by hand.
|
|
14
|
+
*
|
|
15
|
+
* This engine is deliberately leaner than claudeProxyRoutes.ts: it reuses the
|
|
16
|
+
* shared cooldown/quota persistence (keyed by the `codex:` account key so it
|
|
17
|
+
* never collides with anthropic entries) and does pre-commit rotation only, not
|
|
18
|
+
* the full transient-budget / admission machinery.
|
|
19
|
+
*/
|
|
20
|
+
import type { AccountCoolingReason, AccountQuota, CodexRuntimeAccount, RouteGroup } from "../../types/index.js";
|
|
21
|
+
/** Refresh an account's token at most once at a time. */
|
|
22
|
+
declare function refreshCodexTokenOnce(key: string, refreshToken: string): Promise<{
|
|
23
|
+
accessToken: string;
|
|
24
|
+
refreshToken: string;
|
|
25
|
+
expiresAt?: number;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Load the Codex account pool from the token store, refreshing expired OAuth
|
|
29
|
+
* tokens and hydrating cooldown + quota state from disk.
|
|
30
|
+
*/
|
|
31
|
+
declare function loadCodexProxyAccounts(): Promise<CodexRuntimeAccount[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Order accounts fill-first by quota: eligible (not cooling) first, then least
|
|
34
|
+
* session utilization, treating unknown quota as "probe first" so a fresh
|
|
35
|
+
* account gets observed rather than starved.
|
|
36
|
+
*/
|
|
37
|
+
declare function orderCodexAccounts(accounts: CodexRuntimeAccount[], now: number): CodexRuntimeAccount[];
|
|
38
|
+
/** Build the upstream request headers, replacing client auth with the account's. */
|
|
39
|
+
declare function buildCodexUpstreamHeaders(clientHeaders: Record<string, string>, account: CodexRuntimeAccount): Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* Classify a 429 into a cooldown plan. Codex reports primary (session) and
|
|
42
|
+
* secondary (weekly) windows; a rejected window cools until its reset, a plain
|
|
43
|
+
* burst limit cools briefly and clamps to 15 min.
|
|
44
|
+
*/
|
|
45
|
+
declare function planCodexCooldown(quota: AccountQuota | null, retryAfterMs: number, now: number): {
|
|
46
|
+
coolingUntil: number;
|
|
47
|
+
reason: AccountCoolingReason;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Create Codex proxy routes.
|
|
51
|
+
*
|
|
52
|
+
* @param basePath - Base path prefix (default "").
|
|
53
|
+
* @returns RouteGroup with the Codex backend Responses endpoint.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createCodexProxyRoutes(basePath?: string): RouteGroup;
|
|
56
|
+
export declare const __testHooks: {
|
|
57
|
+
loadCodexProxyAccounts: typeof loadCodexProxyAccounts;
|
|
58
|
+
orderCodexAccounts: typeof orderCodexAccounts;
|
|
59
|
+
buildCodexUpstreamHeaders: typeof buildCodexUpstreamHeaders;
|
|
60
|
+
planCodexCooldown: typeof planCodexCooldown;
|
|
61
|
+
refreshCodexTokenOnce: typeof refreshCodexTokenOnce;
|
|
62
|
+
codexRefreshInFlightSize: () => number;
|
|
63
|
+
};
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI ChatGPT) Proxy Routes
|
|
3
|
+
*
|
|
4
|
+
* Exposes `POST /backend-api/codex/responses` — the ChatGPT-backend Responses
|
|
5
|
+
* API endpoint the Codex CLI talks to — and pools it across multiple ChatGPT
|
|
6
|
+
* OAuth accounts, mirroring the Anthropic Claude pool engine.
|
|
7
|
+
*
|
|
8
|
+
* A Codex CLI configured with `model_providers.<name>.base_url` pointing at this
|
|
9
|
+
* proxy sends its own OAuth token; the proxy strips it, selects a pooled
|
|
10
|
+
* `codex:*` account, attaches that account's Bearer + matching chatgpt-account-id,
|
|
11
|
+
* forwards to https://chatgpt.com/backend-api/codex, and relays the SSE stream.
|
|
12
|
+
* On quota exhaustion (429 / usage limit) the account is cooled and the next
|
|
13
|
+
* account is tried — so the user never has to switch accounts by hand.
|
|
14
|
+
*
|
|
15
|
+
* This engine is deliberately leaner than claudeProxyRoutes.ts: it reuses the
|
|
16
|
+
* shared cooldown/quota persistence (keyed by the `codex:` account key so it
|
|
17
|
+
* never collides with anthropic entries) and does pre-commit rotation only, not
|
|
18
|
+
* the full transient-budget / admission machinery.
|
|
19
|
+
*/
|
|
20
|
+
import { tokenStore } from "../../auth/tokenStore.js";
|
|
21
|
+
import { CODEX_ORIGINATOR, CODEX_RESPONSES_URL, CODEX_USER_AGENT, codexTokenNeedsRefresh, isPermanentCodexRefreshFailure, refreshCodexToken, resolveCodexAccountId, } from "../../auth/codexOAuth.js";
|
|
22
|
+
import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
|
|
23
|
+
import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.js";
|
|
24
|
+
import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
|
|
25
|
+
import { logRequest } from "../../proxy/requestLogger.js";
|
|
26
|
+
import { parseRetryAfterMs } from "../../proxy/routingPolicy.js";
|
|
27
|
+
import { sanitizeForLog } from "../../utils/logSanitize.js";
|
|
28
|
+
import { logger } from "../../utils/logger.js";
|
|
29
|
+
const CODEX_UPSTREAM_TIMEOUT_MS = 15 * 60 * 1000; // 15 min, matches Claude path
|
|
30
|
+
const DEFAULT_TRANSIENT_COOLDOWN_MS = 60_000;
|
|
31
|
+
const MAX_TRANSIENT_COOLDOWN_MS = 15 * 60 * 1000;
|
|
32
|
+
/** Brief park after a refresh attempt that never reached a verdict. */
|
|
33
|
+
const CODEX_AUTH_COOLDOWN_MS = 60_000;
|
|
34
|
+
/**
|
|
35
|
+
* In-flight proactive refreshes, keyed by account.
|
|
36
|
+
*
|
|
37
|
+
* The pool is rebuilt per request with no shared state, so without this every
|
|
38
|
+
* concurrent request for the same account fires its own refresh. OpenAI rotates
|
|
39
|
+
* the refresh token on each call, so those attempts invalidate one another — the
|
|
40
|
+
* losers then see a rejected grant and, via the 401 path, can disable an account
|
|
41
|
+
* that is perfectly healthy.
|
|
42
|
+
*/
|
|
43
|
+
const codexRefreshInFlight = new Map();
|
|
44
|
+
/** Refresh an account's token at most once at a time. */
|
|
45
|
+
async function refreshCodexTokenOnce(key, refreshToken) {
|
|
46
|
+
const existing = codexRefreshInFlight.get(key);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return existing;
|
|
49
|
+
}
|
|
50
|
+
const pending = (async () => {
|
|
51
|
+
// Re-read the stored token instead of trusting the caller's snapshot. A
|
|
52
|
+
// request that captured the pool just before a previous refresh completed
|
|
53
|
+
// holds a token that has since been rotated; using it would spend a real
|
|
54
|
+
// attempt on a grant the server has already invalidated.
|
|
55
|
+
const latest = await tokenStore.peekTokens(key).catch(() => null);
|
|
56
|
+
const current = latest?.refreshToken ?? refreshToken;
|
|
57
|
+
const refreshed = await refreshCodexToken(current);
|
|
58
|
+
return {
|
|
59
|
+
accessToken: refreshed.accessToken,
|
|
60
|
+
refreshToken: refreshed.refreshToken ?? current,
|
|
61
|
+
expiresAt: refreshed.expiresAt,
|
|
62
|
+
};
|
|
63
|
+
})().finally(() => {
|
|
64
|
+
codexRefreshInFlight.delete(key);
|
|
65
|
+
});
|
|
66
|
+
codexRefreshInFlight.set(key, pending);
|
|
67
|
+
return pending;
|
|
68
|
+
}
|
|
69
|
+
// Headers we never forward upstream (hop-by-hop, client creds, or things we
|
|
70
|
+
// re-derive). The client's own auth is replaced with the pooled account's.
|
|
71
|
+
const BLOCKED_UPSTREAM_HEADERS = new Set([
|
|
72
|
+
"host",
|
|
73
|
+
"connection",
|
|
74
|
+
"content-length",
|
|
75
|
+
"transfer-encoding",
|
|
76
|
+
"cookie",
|
|
77
|
+
"proxy-authorization",
|
|
78
|
+
"authorization",
|
|
79
|
+
"x-api-key",
|
|
80
|
+
"chatgpt-account-id",
|
|
81
|
+
"accept-encoding",
|
|
82
|
+
]);
|
|
83
|
+
/** Build a Codex error body as a Response with the intended status. */
|
|
84
|
+
function buildCodexErrorResponse(status, message) {
|
|
85
|
+
return new Response(JSON.stringify({ error: { type: "proxy_error", message } }), { status, headers: { "content-type": "application/json" } });
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load the Codex account pool from the token store, refreshing expired OAuth
|
|
89
|
+
* tokens and hydrating cooldown + quota state from disk.
|
|
90
|
+
*/
|
|
91
|
+
async function loadCodexProxyAccounts() {
|
|
92
|
+
const keys = await tokenStore.listByPrefix(CODEX_ACCOUNT_PREFIX);
|
|
93
|
+
const [cooldowns, quotas] = await Promise.all([
|
|
94
|
+
loadAccountCooldowns(),
|
|
95
|
+
loadAccountQuotas(),
|
|
96
|
+
]);
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const accounts = [];
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
if (await tokenStore.isDisabled(key)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const tokens = await tokenStore.loadTokens(key);
|
|
104
|
+
if (!tokens || tokens.tokenType !== "Bearer") {
|
|
105
|
+
// Only OAuth (Bearer) accounts can serve the ChatGPT backend.
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let accessToken = tokens.accessToken;
|
|
109
|
+
let expiresAt = tokens.expiresAt;
|
|
110
|
+
if (codexTokenNeedsRefresh(expiresAt) && tokens.refreshToken) {
|
|
111
|
+
try {
|
|
112
|
+
const refreshed = await refreshCodexTokenOnce(key, tokens.refreshToken);
|
|
113
|
+
accessToken = refreshed.accessToken;
|
|
114
|
+
expiresAt = refreshed.expiresAt ?? expiresAt;
|
|
115
|
+
await tokenStore.saveTokens(key, {
|
|
116
|
+
accessToken,
|
|
117
|
+
refreshToken: refreshed.refreshToken,
|
|
118
|
+
expiresAt: expiresAt ?? Date.now() + 3_600_000,
|
|
119
|
+
tokenType: "Bearer",
|
|
120
|
+
scope: tokens.scope,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// Keep the stale token; a 401 upstream will trigger rotation.
|
|
125
|
+
logger.debug(`Codex proactive refresh failed for ${key.slice(CODEX_ACCOUNT_PREFIX.length)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const cooldown = cooldowns[key];
|
|
129
|
+
const cooling = cooldown && cooldown.coolingUntil > now;
|
|
130
|
+
accounts.push({
|
|
131
|
+
key,
|
|
132
|
+
label: key.slice(CODEX_ACCOUNT_PREFIX.length) || key,
|
|
133
|
+
token: accessToken,
|
|
134
|
+
refreshToken: tokens.refreshToken,
|
|
135
|
+
expiresAt,
|
|
136
|
+
accountId: resolveCodexAccountId(accessToken),
|
|
137
|
+
quota: quotas[key],
|
|
138
|
+
coolingUntil: cooling ? cooldown.coolingUntil : undefined,
|
|
139
|
+
coolingReason: cooldown?.reason,
|
|
140
|
+
// Kept even once expired: an account only reaches the request loop when it
|
|
141
|
+
// is NOT cooling, so this is the only handle the success path has for
|
|
142
|
+
// deleting the spent record. Without it they accumulate forever.
|
|
143
|
+
expiredCooldownUntil: cooldown && !cooling ? cooldown.coolingUntil : undefined,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return accounts;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Order accounts fill-first by quota: eligible (not cooling) first, then least
|
|
150
|
+
* session utilization, treating unknown quota as "probe first" so a fresh
|
|
151
|
+
* account gets observed rather than starved.
|
|
152
|
+
*/
|
|
153
|
+
function orderCodexAccounts(accounts, now) {
|
|
154
|
+
return [...accounts].sort((a, b) => {
|
|
155
|
+
const aCooling = a.coolingUntil !== undefined && a.coolingUntil > now;
|
|
156
|
+
const bCooling = b.coolingUntil !== undefined && b.coolingUntil > now;
|
|
157
|
+
if (aCooling !== bCooling) {
|
|
158
|
+
return aCooling ? 1 : -1;
|
|
159
|
+
}
|
|
160
|
+
const aUsed = a.quota ? a.quota.sessionUsed : -1;
|
|
161
|
+
const bUsed = b.quota ? b.quota.sessionUsed : -1;
|
|
162
|
+
return aUsed - bUsed;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Build the upstream request headers, replacing client auth with the account's. */
|
|
166
|
+
function buildCodexUpstreamHeaders(clientHeaders, account) {
|
|
167
|
+
const headers = {};
|
|
168
|
+
for (const [name, value] of Object.entries(clientHeaders)) {
|
|
169
|
+
const lower = name.toLowerCase();
|
|
170
|
+
if (BLOCKED_UPSTREAM_HEADERS.has(lower)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
headers[lower] = value;
|
|
174
|
+
}
|
|
175
|
+
headers.authorization = `Bearer ${account.token}`;
|
|
176
|
+
const accountId = resolveCodexAccountId(account.token, account.accountId);
|
|
177
|
+
if (accountId) {
|
|
178
|
+
headers["chatgpt-account-id"] = accountId;
|
|
179
|
+
}
|
|
180
|
+
headers["content-type"] = "application/json";
|
|
181
|
+
if (!headers["user-agent"]) {
|
|
182
|
+
headers["user-agent"] = CODEX_USER_AGENT;
|
|
183
|
+
}
|
|
184
|
+
if (!headers.originator) {
|
|
185
|
+
headers.originator = CODEX_ORIGINATOR;
|
|
186
|
+
}
|
|
187
|
+
if (!headers.accept) {
|
|
188
|
+
headers.accept = "text/event-stream";
|
|
189
|
+
}
|
|
190
|
+
return headers;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Classify a 429 into a cooldown plan. Codex reports primary (session) and
|
|
194
|
+
* secondary (weekly) windows; a rejected window cools until its reset, a plain
|
|
195
|
+
* burst limit cools briefly and clamps to 15 min.
|
|
196
|
+
*/
|
|
197
|
+
function planCodexCooldown(quota, retryAfterMs, now) {
|
|
198
|
+
// A reported reset can already be in the past — a stale header, or a clock
|
|
199
|
+
// skew. Taken literally the account is eligible again immediately and the
|
|
200
|
+
// pool re-sends to something the provider just rejected.
|
|
201
|
+
const floor = now + DEFAULT_TRANSIENT_COOLDOWN_MS;
|
|
202
|
+
if (quota) {
|
|
203
|
+
if (quota.weeklyStatus === "rejected" && quota.weeklyResetAt > 0) {
|
|
204
|
+
return {
|
|
205
|
+
coolingUntil: Math.max(quota.weeklyResetAt * 1000, floor),
|
|
206
|
+
reason: "weekly",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (quota.sessionStatus === "rejected" && quota.sessionResetAt > 0) {
|
|
210
|
+
return {
|
|
211
|
+
coolingUntil: Math.max(quota.sessionResetAt * 1000, floor),
|
|
212
|
+
reason: "session",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const delay = Math.min(MAX_TRANSIENT_COOLDOWN_MS, Math.max(retryAfterMs, DEFAULT_TRANSIENT_COOLDOWN_MS));
|
|
217
|
+
return { coolingUntil: now + delay, reason: "transient" };
|
|
218
|
+
}
|
|
219
|
+
/** Set the x-neurolink-* attribution headers on the context. */
|
|
220
|
+
function publishCodexHeaders(ctx, account, attempt, quota) {
|
|
221
|
+
if (!ctx.responseHeaders) {
|
|
222
|
+
ctx.responseHeaders = {};
|
|
223
|
+
}
|
|
224
|
+
ctx.responseHeaders["x-neurolink-account"] = account.label;
|
|
225
|
+
ctx.responseHeaders["x-neurolink-account-type"] = "codex-oauth";
|
|
226
|
+
ctx.responseHeaders["x-neurolink-served-by"] = "codex";
|
|
227
|
+
ctx.responseHeaders["x-neurolink-attempt"] = String(attempt);
|
|
228
|
+
ctx.responseHeaders["x-neurolink-quota-source"] = quota ? "live" : "none";
|
|
229
|
+
if (quota) {
|
|
230
|
+
ctx.responseHeaders["x-neurolink-quota-session-left-pct"] = String(Math.round((1 - quota.sessionUsed) * 100));
|
|
231
|
+
ctx.responseHeaders["x-neurolink-weekly-left-pct"] = String(Math.round((1 - quota.weeklyUsed) * 100));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Core pooled handler for POST /backend-api/codex/responses. */
|
|
235
|
+
async function handleCodexResponsesRequest(ctx) {
|
|
236
|
+
const requestStartTime = Date.now();
|
|
237
|
+
const body = ctx.body ?? {};
|
|
238
|
+
const bodyStr = JSON.stringify(body);
|
|
239
|
+
const model = typeof body.model === "string"
|
|
240
|
+
? body.model
|
|
241
|
+
: "-";
|
|
242
|
+
const writeLog = (account, responseStatus, extra = {}) => logRequest({
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
requestId: ctx.requestId,
|
|
245
|
+
method: ctx.method,
|
|
246
|
+
path: ctx.path,
|
|
247
|
+
model,
|
|
248
|
+
stream: true,
|
|
249
|
+
toolCount: Array.isArray(body.tools)
|
|
250
|
+
? body.tools.length
|
|
251
|
+
: 0,
|
|
252
|
+
account,
|
|
253
|
+
accountType: "codex-oauth",
|
|
254
|
+
responseStatus,
|
|
255
|
+
responseTimeMs: Date.now() - requestStartTime,
|
|
256
|
+
...extra,
|
|
257
|
+
});
|
|
258
|
+
const accounts = await loadCodexProxyAccounts();
|
|
259
|
+
if (accounts.length === 0) {
|
|
260
|
+
await writeLog("", 401, {
|
|
261
|
+
errorType: "no_accounts",
|
|
262
|
+
errorMessage: "No Codex accounts",
|
|
263
|
+
});
|
|
264
|
+
return buildCodexErrorResponse(401, "No Codex accounts configured. Run `neurolink auth login codex`.");
|
|
265
|
+
}
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const ordered = orderCodexAccounts(accounts, now);
|
|
268
|
+
const eligible = ordered.filter((a) => !(a.coolingUntil !== undefined && a.coolingUntil > now));
|
|
269
|
+
if (eligible.length === 0) {
|
|
270
|
+
// Every account is cooling; surface the soonest recovery as retry-after.
|
|
271
|
+
const soonest = ordered.reduce((min, a) => {
|
|
272
|
+
if (a.coolingUntil === undefined) {
|
|
273
|
+
return min;
|
|
274
|
+
}
|
|
275
|
+
return min === undefined ? a.coolingUntil : Math.min(min, a.coolingUntil);
|
|
276
|
+
}, undefined);
|
|
277
|
+
const retryAfterSec = soonest
|
|
278
|
+
? Math.max(1, Math.ceil((soonest - now) / 1000))
|
|
279
|
+
: 60;
|
|
280
|
+
await writeLog("", 429, {
|
|
281
|
+
errorType: "all_accounts_cooling",
|
|
282
|
+
errorMessage: "All Codex accounts are rate-limited",
|
|
283
|
+
});
|
|
284
|
+
return new Response(JSON.stringify({
|
|
285
|
+
error: {
|
|
286
|
+
type: "rate_limit_error",
|
|
287
|
+
message: "All Codex accounts are currently rate-limited",
|
|
288
|
+
},
|
|
289
|
+
}), {
|
|
290
|
+
status: 429,
|
|
291
|
+
headers: {
|
|
292
|
+
"content-type": "application/json",
|
|
293
|
+
"retry-after": String(retryAfterSec),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
let attempt = 0;
|
|
298
|
+
let lastErrorMessage = "All Codex accounts failed";
|
|
299
|
+
let lastErrorStatus = 502;
|
|
300
|
+
for (const account of eligible) {
|
|
301
|
+
attempt += 1;
|
|
302
|
+
let authRetried = false;
|
|
303
|
+
// Same-account loop only re-runs once, for a post-401 token refresh.
|
|
304
|
+
for (;;) {
|
|
305
|
+
let upstream;
|
|
306
|
+
try {
|
|
307
|
+
upstream = await fetch(CODEX_RESPONSES_URL, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: buildCodexUpstreamHeaders(ctx.headers, account),
|
|
310
|
+
body: bodyStr,
|
|
311
|
+
signal: AbortSignal.timeout(CODEX_UPSTREAM_TIMEOUT_MS),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
// A transport failure message is derived from local state — resolved
|
|
316
|
+
// hostnames, socket paths, Node internals — and says nothing the caller
|
|
317
|
+
// can act on. Keep the detail in the log and return a fixed string, so
|
|
318
|
+
// internal topology never reaches the client.
|
|
319
|
+
logger.debug(`Codex upstream fetch failed (${account.label}): ${sanitizeForLog(error instanceof Error ? error.message : String(error))}`);
|
|
320
|
+
lastErrorMessage = "Codex upstream request failed";
|
|
321
|
+
lastErrorStatus = 502;
|
|
322
|
+
break; // rotate to next account
|
|
323
|
+
}
|
|
324
|
+
if (upstream.ok) {
|
|
325
|
+
const quota = parseCodexRateLimitHeaders(upstream.headers);
|
|
326
|
+
if (quota) {
|
|
327
|
+
saveAccountQuota(account.key, quota).catch(() => undefined);
|
|
328
|
+
}
|
|
329
|
+
// A prior cooldown that has expired is cleared on success. The
|
|
330
|
+
// compare-and-swap guards against wiping a longer cooldown that another
|
|
331
|
+
// in-flight request set while this one was upstream.
|
|
332
|
+
if (account.expiredCooldownUntil !== undefined) {
|
|
333
|
+
clearAccountCooldown(account.key, account.expiredCooldownUntil).catch(() => undefined);
|
|
334
|
+
}
|
|
335
|
+
publishCodexHeaders(ctx, account, attempt, quota);
|
|
336
|
+
await writeLog(account.label, upstream.status);
|
|
337
|
+
const headers = {
|
|
338
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
339
|
+
"cache-control": "no-cache",
|
|
340
|
+
connection: "keep-alive",
|
|
341
|
+
...(ctx.responseHeaders ?? {}),
|
|
342
|
+
};
|
|
343
|
+
return new Response(upstream.body, {
|
|
344
|
+
status: upstream.status,
|
|
345
|
+
headers,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const errText = await upstream.text().catch(() => "");
|
|
349
|
+
// 401/403 → try a forced token refresh once, then rotate.
|
|
350
|
+
if ((upstream.status === 401 || upstream.status === 403) &&
|
|
351
|
+
!authRetried &&
|
|
352
|
+
account.refreshToken) {
|
|
353
|
+
authRetried = true;
|
|
354
|
+
const staleTokens = {
|
|
355
|
+
accessToken: account.token,
|
|
356
|
+
refreshToken: account.refreshToken,
|
|
357
|
+
expiresAt: account.expiresAt ?? 0,
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
const refreshed = await refreshCodexToken(account.refreshToken);
|
|
361
|
+
account.token = refreshed.accessToken;
|
|
362
|
+
account.refreshToken = refreshed.refreshToken ?? account.refreshToken;
|
|
363
|
+
account.expiresAt = refreshed.expiresAt ?? account.expiresAt;
|
|
364
|
+
account.accountId = resolveCodexAccountId(refreshed.accessToken);
|
|
365
|
+
await tokenStore.saveTokens(account.key, {
|
|
366
|
+
accessToken: account.token,
|
|
367
|
+
refreshToken: account.refreshToken,
|
|
368
|
+
expiresAt: account.expiresAt ?? Date.now() + 3_600_000,
|
|
369
|
+
tokenType: "Bearer",
|
|
370
|
+
});
|
|
371
|
+
continue; // retry same account with the fresh token
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (isPermanentCodexRefreshFailure(error)) {
|
|
375
|
+
// Compare-and-swap: the pool is rebuilt per request with no shared
|
|
376
|
+
// state, so a concurrent request may already have rotated this
|
|
377
|
+
// credential. Disabling unconditionally would kill the account that
|
|
378
|
+
// the other request just healed.
|
|
379
|
+
const disabled = await tokenStore.markDisabledIfCurrent(account.key, staleTokens, "refresh_invalid");
|
|
380
|
+
if (disabled) {
|
|
381
|
+
logger.always(`[proxy] codex account=${account.label} disabled until re-authentication. Run: neurolink auth login codex --label ${account.label}`);
|
|
382
|
+
}
|
|
383
|
+
lastErrorStatus = 401;
|
|
384
|
+
lastErrorMessage = "Codex token refresh failed; re-login required";
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
// No verdict on the credential — cool briefly and try the next
|
|
388
|
+
// account, so a 5xx or a timeout cannot cost the user a login.
|
|
389
|
+
await saveAccountCooldown(account.key, Date.now() + CODEX_AUTH_COOLDOWN_MS, "auth").catch(() => undefined);
|
|
390
|
+
logger.debug(`[proxy] codex account=${account.label} refresh failed transiently; cooling and rotating`);
|
|
391
|
+
lastErrorStatus = 503;
|
|
392
|
+
lastErrorMessage = "Codex token refresh temporarily unavailable";
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// 429 → cooldown + rotate.
|
|
397
|
+
if (upstream.status === 429) {
|
|
398
|
+
const quota = parseCodexRateLimitHeaders(upstream.headers);
|
|
399
|
+
if (quota) {
|
|
400
|
+
saveAccountQuota(account.key, quota).catch(() => undefined);
|
|
401
|
+
}
|
|
402
|
+
const retryAfterMs = parseRetryAfterMs(upstream.headers.get("retry-after"));
|
|
403
|
+
const plan = planCodexCooldown(quota, retryAfterMs, Date.now());
|
|
404
|
+
await saveAccountCooldown(account.key, plan.coolingUntil, plan.reason).catch(() => undefined);
|
|
405
|
+
lastErrorStatus = 429;
|
|
406
|
+
lastErrorMessage = "Codex account rate-limited";
|
|
407
|
+
break; // rotate
|
|
408
|
+
}
|
|
409
|
+
// Other non-ok → record and rotate.
|
|
410
|
+
if (upstream.status === 401 || upstream.status === 403) {
|
|
411
|
+
// Reached only when the account has no refresh token to retry with, so
|
|
412
|
+
// it will fail identically on the next request. Park it briefly instead
|
|
413
|
+
// of letting it stay first in line with unknown quota.
|
|
414
|
+
await saveAccountCooldown(account.key, Date.now() + CODEX_AUTH_COOLDOWN_MS, "auth").catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
lastErrorStatus = upstream.status >= 500 ? 502 : upstream.status;
|
|
417
|
+
lastErrorMessage = sanitizeForLog(errText).slice(0, 200) || "Codex error";
|
|
418
|
+
break; // rotate
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await writeLog("", lastErrorStatus, {
|
|
422
|
+
errorType: "all_accounts_failed",
|
|
423
|
+
errorMessage: lastErrorMessage,
|
|
424
|
+
});
|
|
425
|
+
return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Create Codex proxy routes.
|
|
429
|
+
*
|
|
430
|
+
* @param basePath - Base path prefix (default "").
|
|
431
|
+
* @returns RouteGroup with the Codex backend Responses endpoint.
|
|
432
|
+
*/
|
|
433
|
+
export function createCodexProxyRoutes(basePath = "") {
|
|
434
|
+
return {
|
|
435
|
+
prefix: `${basePath}/backend-api/codex`,
|
|
436
|
+
routes: [
|
|
437
|
+
{
|
|
438
|
+
method: "POST",
|
|
439
|
+
path: `${basePath}/backend-api/codex/responses`,
|
|
440
|
+
description: "Codex ChatGPT-backend Responses API (account pool)",
|
|
441
|
+
handler: (ctx) => handleCodexResponsesRequest(ctx),
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
export const __testHooks = {
|
|
447
|
+
loadCodexProxyAccounts,
|
|
448
|
+
orderCodexAccounts,
|
|
449
|
+
buildCodexUpstreamHeaders,
|
|
450
|
+
planCodexCooldown,
|
|
451
|
+
refreshCodexTokenOnce,
|
|
452
|
+
codexRefreshInFlightSize: () => codexRefreshInFlight.size,
|
|
453
|
+
};
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -956,6 +956,12 @@ export type AuthCommandArgs = BaseCommandArgs & {
|
|
|
956
956
|
config?: string;
|
|
957
957
|
/** Email passed to `auth set-primary <email>` */
|
|
958
958
|
email?: string;
|
|
959
|
+
/** Why an account is being disabled, recorded by `auth disable` */
|
|
960
|
+
reason?: string;
|
|
961
|
+
/** Subcommand verb for `auth cooldown <action>` */
|
|
962
|
+
action?: string;
|
|
963
|
+
/** `auth cooldown clear --all` */
|
|
964
|
+
all?: boolean;
|
|
959
965
|
/** Yargs positional arguments */
|
|
960
966
|
_?: (string | number)[];
|
|
961
967
|
};
|
|
@@ -1211,7 +1217,7 @@ export type ProviderSetupConfig = {
|
|
|
1211
1217
|
isReconfiguring?: boolean;
|
|
1212
1218
|
};
|
|
1213
1219
|
/** Providers supported by the `neurolink auth` command. */
|
|
1214
|
-
export type SupportedProvider = "anthropic";
|
|
1220
|
+
export type SupportedProvider = "anthropic" | "codex";
|
|
1215
1221
|
/** Arguments for `neurolink autoresearch init`. */
|
|
1216
1222
|
export type AutoresearchInitArgs = {
|
|
1217
1223
|
repoPath: string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI ChatGPT subscription) proxy types.
|
|
3
|
+
*
|
|
4
|
+
* Codex authenticates with a ChatGPT account over OAuth and talks to the
|
|
5
|
+
* ChatGPT backend Responses API (https://chatgpt.com/backend-api/codex).
|
|
6
|
+
* These types describe the on-disk `~/.codex/auth.json` shape we import from,
|
|
7
|
+
* the OAuth refresh contract, and the usage/rate-limit payloads we normalise
|
|
8
|
+
* into the shared AccountQuota model.
|
|
9
|
+
*
|
|
10
|
+
* Naming: all exported names carry the `Codex` prefix (rule 9). Codex quota is
|
|
11
|
+
* stored through the same AccountQuota shape as Anthropic — its primary window
|
|
12
|
+
* maps onto the session fields and its secondary window onto the weekly fields.
|
|
13
|
+
*/
|
|
14
|
+
import type { AccountCoolingReason, AccountQuota } from "./proxy.js";
|
|
15
|
+
/** Token block inside `~/.codex/auth.json`. */
|
|
16
|
+
export type CodexAuthFileTokens = {
|
|
17
|
+
id_token?: string;
|
|
18
|
+
access_token: string;
|
|
19
|
+
refresh_token?: string;
|
|
20
|
+
account_id?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Shape of `~/.codex/auth.json` written by the Codex CLI. */
|
|
23
|
+
export type CodexAuthFile = {
|
|
24
|
+
auth_mode?: string;
|
|
25
|
+
OPENAI_API_KEY?: string | null;
|
|
26
|
+
tokens?: CodexAuthFileTokens;
|
|
27
|
+
last_refresh?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Result of importing a Codex credential (from auth.json or the OAuth flow). */
|
|
30
|
+
export type CodexImportedCredential = {
|
|
31
|
+
accessToken: string;
|
|
32
|
+
refreshToken?: string;
|
|
33
|
+
idToken?: string;
|
|
34
|
+
/** ChatGPT account id (from auth.json or decoded from the access token). */
|
|
35
|
+
accountId?: string;
|
|
36
|
+
/** Epoch ms when the access token expires (decoded from the JWT `exp`). */
|
|
37
|
+
expiresAt?: number;
|
|
38
|
+
/** ChatGPT plan type decoded from the token, for display only. */
|
|
39
|
+
planType?: string;
|
|
40
|
+
/** Account email decoded from the id token, for the account label. */
|
|
41
|
+
email?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Raw OpenAI OAuth token endpoint response. */
|
|
44
|
+
export type CodexTokenResponse = {
|
|
45
|
+
access_token?: string;
|
|
46
|
+
refresh_token?: string;
|
|
47
|
+
id_token?: string;
|
|
48
|
+
token_type?: string;
|
|
49
|
+
expires_in?: number;
|
|
50
|
+
};
|
|
51
|
+
/** One rate-limit window as reported by the Codex backend (primary/secondary). */
|
|
52
|
+
export type CodexRateLimitWindow = {
|
|
53
|
+
used_percent?: number | null;
|
|
54
|
+
window_minutes?: number | null;
|
|
55
|
+
resets_in_seconds?: number | null;
|
|
56
|
+
/** Seconds until reset. Observed alias of `resets_in_seconds` on some
|
|
57
|
+
* responses; accepted defensively so a cooldown lands on the real reset
|
|
58
|
+
* instead of degrading to the transient ceiling. */
|
|
59
|
+
reset_after?: number | null;
|
|
60
|
+
resets_at?: number | null;
|
|
61
|
+
};
|
|
62
|
+
/** Codex rate-limit block: a primary (short) and secondary (long) window. */
|
|
63
|
+
export type CodexRateLimits = {
|
|
64
|
+
primary?: CodexRateLimitWindow | null;
|
|
65
|
+
secondary?: CodexRateLimitWindow | null;
|
|
66
|
+
};
|
|
67
|
+
/** Loose shape of the Codex usage endpoint response. */
|
|
68
|
+
export type CodexUsageResponse = {
|
|
69
|
+
rate_limits?: CodexRateLimits | null;
|
|
70
|
+
plan_type?: string | null;
|
|
71
|
+
};
|
|
72
|
+
/** Result of a single Codex usage fetch. */
|
|
73
|
+
export type CodexUsageFetchResult = {
|
|
74
|
+
ok: true;
|
|
75
|
+
quota: AccountQuota;
|
|
76
|
+
} | {
|
|
77
|
+
ok: false;
|
|
78
|
+
reason: "not_oauth" | "auth" | "http" | "network" | "parse";
|
|
79
|
+
};
|
|
80
|
+
/** A Codex account with its runtime cooldown/quota state hydrated from disk. */
|
|
81
|
+
export type CodexRuntimeAccount = {
|
|
82
|
+
key: string;
|
|
83
|
+
label: string;
|
|
84
|
+
token: string;
|
|
85
|
+
refreshToken?: string;
|
|
86
|
+
expiresAt?: number;
|
|
87
|
+
accountId?: string;
|
|
88
|
+
quota?: AccountQuota;
|
|
89
|
+
coolingUntil?: number;
|
|
90
|
+
coolingReason?: AccountCoolingReason;
|
|
91
|
+
/** A persisted cooldown whose window has already passed. Present only when the
|
|
92
|
+
* account is therefore eligible again, so the success path can delete the
|
|
93
|
+
* spent record — nothing else ever reaps it. */
|
|
94
|
+
expiredCooldownUntil?: number;
|
|
95
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex (OpenAI ChatGPT subscription) proxy types.
|
|
3
|
+
*
|
|
4
|
+
* Codex authenticates with a ChatGPT account over OAuth and talks to the
|
|
5
|
+
* ChatGPT backend Responses API (https://chatgpt.com/backend-api/codex).
|
|
6
|
+
* These types describe the on-disk `~/.codex/auth.json` shape we import from,
|
|
7
|
+
* the OAuth refresh contract, and the usage/rate-limit payloads we normalise
|
|
8
|
+
* into the shared AccountQuota model.
|
|
9
|
+
*
|
|
10
|
+
* Naming: all exported names carry the `Codex` prefix (rule 9). Codex quota is
|
|
11
|
+
* stored through the same AccountQuota shape as Anthropic — its primary window
|
|
12
|
+
* maps onto the session fields and its secondary window onto the weekly fields.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./autoresearch.js";
|
|
|
12
12
|
export * from "./circuitBreakerErrors.js";
|
|
13
13
|
export * from "./cli.js";
|
|
14
14
|
export * from "./client.js";
|
|
15
|
+
export * from "./codex.js";
|
|
15
16
|
export * from "./common.js";
|
|
16
17
|
export * from "./config.js";
|
|
17
18
|
export * from "./context.js";
|
package/dist/types/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./autoresearch.js";
|
|
|
13
13
|
export * from "./circuitBreakerErrors.js";
|
|
14
14
|
export * from "./cli.js";
|
|
15
15
|
export * from "./client.js";
|
|
16
|
+
export * from "./codex.js";
|
|
16
17
|
export * from "./common.js";
|
|
17
18
|
export * from "./config.js";
|
|
18
19
|
export * from "./context.js";
|