codex-rotating-proxy 0.1.0 → 0.1.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/README.md +13 -10
- package/dist/cli.js +273 -0
- package/dist/config.js +90 -0
- package/dist/login.js +217 -0
- package/dist/pool.js +112 -0
- package/dist/server.js +303 -0
- package/dist/translate.js +248 -0
- package/package.json +20 -5
- package/bin/codex-proxy +0 -10
- package/src/cli.ts +0 -307
- package/src/config.ts +0 -113
- package/src/login.ts +0 -261
- package/src/pool.ts +0 -136
- package/src/server.ts +0 -247
package/src/login.ts
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
-
import { exec } from "node:child_process";
|
|
4
|
-
import { addAccount, type Account } from "./config.ts";
|
|
5
|
-
|
|
6
|
-
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
7
|
-
const ISSUER = "https://auth.openai.com";
|
|
8
|
-
const AUTHORIZE_URL = `${ISSUER}/oauth/authorize`;
|
|
9
|
-
const TOKEN_URL = `${ISSUER}/oauth/token`;
|
|
10
|
-
const SCOPES = "openid profile email offline_access";
|
|
11
|
-
const REFRESH_SCOPES = "openid profile email";
|
|
12
|
-
|
|
13
|
-
// ── PKCE ────────────────────────────────────────────────────────
|
|
14
|
-
function generatePKCE(): { verifier: string; challenge: string } {
|
|
15
|
-
const verifier = randomBytes(64).toString("base64url");
|
|
16
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
17
|
-
return { verifier, challenge };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// ── Login flow ──────────────────────────────────────────────────
|
|
21
|
-
export async function loginFlow(accountName?: string): Promise<void> {
|
|
22
|
-
const { verifier, challenge } = generatePKCE();
|
|
23
|
-
const state = randomBytes(32).toString("base64url");
|
|
24
|
-
|
|
25
|
-
return new Promise<void>((resolve, reject) => {
|
|
26
|
-
let port: number = 1455;
|
|
27
|
-
|
|
28
|
-
const server = createServer(async (req, res) => {
|
|
29
|
-
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
30
|
-
|
|
31
|
-
if (url.pathname !== "/auth/callback") {
|
|
32
|
-
res.writeHead(404);
|
|
33
|
-
res.end();
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const error = url.searchParams.get("error");
|
|
38
|
-
if (error) {
|
|
39
|
-
const desc = url.searchParams.get("error_description") ?? error;
|
|
40
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
41
|
-
res.end(resultPage(false, desc));
|
|
42
|
-
finish(reject, server, new Error(desc));
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (url.searchParams.get("state") !== state) {
|
|
47
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
48
|
-
res.end(resultPage(false, "State mismatch — try again."));
|
|
49
|
-
finish(reject, server, new Error("state mismatch"));
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const code = url.searchParams.get("code")!;
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
// Step 1: exchange authorization code for tokens
|
|
57
|
-
const tokens = await tokenRequest({
|
|
58
|
-
grant_type: "authorization_code",
|
|
59
|
-
code,
|
|
60
|
-
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
61
|
-
client_id: CLIENT_ID,
|
|
62
|
-
code_verifier: verifier,
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Step 2: exchange id_token for an OpenAI API key
|
|
66
|
-
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
67
|
-
|
|
68
|
-
// Parse JWT for display info + account ID
|
|
69
|
-
const claims = parseJwt(tokens.id_token);
|
|
70
|
-
const email = (claims.email as string) ?? "unknown";
|
|
71
|
-
const authClaims = (claims["https://api.openai.com/auth"] ?? {}) as Record<string, string>;
|
|
72
|
-
const accountId = authClaims.chatgpt_account_id;
|
|
73
|
-
const name = accountName || email.split("@")[0];
|
|
74
|
-
|
|
75
|
-
addAccount({
|
|
76
|
-
name,
|
|
77
|
-
token: apiKey,
|
|
78
|
-
refreshToken: tokens.refresh_token,
|
|
79
|
-
accountId,
|
|
80
|
-
addedAt: new Date().toISOString(),
|
|
81
|
-
lastRefresh: new Date().toISOString(),
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
85
|
-
res.end(resultPage(true, `Connected as "${name}" (${email})`));
|
|
86
|
-
|
|
87
|
-
console.log(`\x1b[32m✓\x1b[0m Connected "${name}" (${email})`);
|
|
88
|
-
finish(resolve, server);
|
|
89
|
-
} catch (err: any) {
|
|
90
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
91
|
-
res.end(resultPage(false, err.message));
|
|
92
|
-
finish(reject, server, err);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// Use port 1455 — same as official Codex CLI (registered redirect_uri)
|
|
97
|
-
server.listen(1455, () => {
|
|
98
|
-
port = 1455;
|
|
99
|
-
|
|
100
|
-
const params = new URLSearchParams({
|
|
101
|
-
response_type: "code",
|
|
102
|
-
client_id: CLIENT_ID,
|
|
103
|
-
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
104
|
-
scope: SCOPES,
|
|
105
|
-
code_challenge: challenge,
|
|
106
|
-
code_challenge_method: "S256",
|
|
107
|
-
state,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const authUrl = `${AUTHORIZE_URL}?${params}`;
|
|
111
|
-
|
|
112
|
-
console.log(" Opening browser to sign in with OpenAI...");
|
|
113
|
-
console.log(
|
|
114
|
-
` If it doesn't open, visit:\n \x1b[36m${authUrl}\x1b[0m\n`
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
openBrowser(authUrl);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
121
|
-
if (err.code === "EADDRINUSE") {
|
|
122
|
-
console.error(
|
|
123
|
-
"\x1b[31mPort 1455 is in use (maybe Codex CLI is running?).\x1b[0m"
|
|
124
|
-
);
|
|
125
|
-
console.error("Close it and try again.");
|
|
126
|
-
}
|
|
127
|
-
reject(err);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Token refresh ───────────────────────────────────────────────
|
|
133
|
-
export async function refreshAccount(
|
|
134
|
-
account: Account
|
|
135
|
-
): Promise<string | null> {
|
|
136
|
-
if (!account.refreshToken) return null;
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
const tokens = await tokenRequest({
|
|
140
|
-
client_id: CLIENT_ID,
|
|
141
|
-
grant_type: "refresh_token",
|
|
142
|
-
refresh_token: account.refreshToken,
|
|
143
|
-
scope: REFRESH_SCOPES,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
147
|
-
|
|
148
|
-
addAccount({
|
|
149
|
-
...account,
|
|
150
|
-
token: apiKey,
|
|
151
|
-
refreshToken: tokens.refresh_token,
|
|
152
|
-
lastRefresh: new Date().toISOString(),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
return apiKey;
|
|
156
|
-
} catch {
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Helpers ─────────────────────────────────────────────────────
|
|
162
|
-
async function tokenRequest(
|
|
163
|
-
params: Record<string, string>
|
|
164
|
-
): Promise<{ id_token: string; access_token: string; refresh_token: string }> {
|
|
165
|
-
const res = await fetch(TOKEN_URL, {
|
|
166
|
-
method: "POST",
|
|
167
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
168
|
-
body: new URLSearchParams(params).toString(),
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
if (!res.ok) {
|
|
172
|
-
const text = await res.text();
|
|
173
|
-
throw new Error(`Token request failed (${res.status}): ${text}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return res.json() as any;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function exchangeForApiKey(idToken: string): Promise<string> {
|
|
180
|
-
const res = await tokenRequest({
|
|
181
|
-
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
182
|
-
requested_token: "openai-api-key",
|
|
183
|
-
subject_token: idToken,
|
|
184
|
-
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
|
|
185
|
-
client_id: CLIENT_ID,
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
return res.access_token;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function parseJwt(token: string): Record<string, unknown> {
|
|
192
|
-
const payload = token.split(".")[1];
|
|
193
|
-
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function openBrowser(url: string): void {
|
|
197
|
-
const cmd =
|
|
198
|
-
process.platform === "darwin"
|
|
199
|
-
? `open "${url}"`
|
|
200
|
-
: process.platform === "win32"
|
|
201
|
-
? `start "" "${url}"`
|
|
202
|
-
: `xdg-open "${url}"`;
|
|
203
|
-
exec(cmd, () => {});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function finish(
|
|
207
|
-
cb: (value?: any) => void,
|
|
208
|
-
server: ReturnType<typeof createServer>,
|
|
209
|
-
err?: Error
|
|
210
|
-
): void {
|
|
211
|
-
setTimeout(() => {
|
|
212
|
-
server.close();
|
|
213
|
-
if (err) cb(err);
|
|
214
|
-
else cb();
|
|
215
|
-
}, 500);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ── Callback result page ────────────────────────────────────────
|
|
219
|
-
function resultPage(success: boolean, message: string): string {
|
|
220
|
-
const icon = success ? "✓" : "✗";
|
|
221
|
-
const color = success ? "#3fb950" : "#f85149";
|
|
222
|
-
const title = success ? "Connected!" : "Something went wrong";
|
|
223
|
-
const sub = success
|
|
224
|
-
? "You can close this tab and return to your terminal."
|
|
225
|
-
: "Check your terminal for details.";
|
|
226
|
-
|
|
227
|
-
return `<!DOCTYPE html>
|
|
228
|
-
<html lang="en">
|
|
229
|
-
<head>
|
|
230
|
-
<meta charset="utf-8">
|
|
231
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
232
|
-
<title>codex-proxy</title>
|
|
233
|
-
<style>
|
|
234
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
235
|
-
body {
|
|
236
|
-
background: #0d1117; color: #c9d1d9;
|
|
237
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
238
|
-
display: flex; justify-content: center; align-items: center;
|
|
239
|
-
min-height: 100vh; padding: 1rem;
|
|
240
|
-
}
|
|
241
|
-
.card {
|
|
242
|
-
background: #161b22; border: 1px solid #30363d;
|
|
243
|
-
border-radius: 12px; padding: 2.5rem;
|
|
244
|
-
max-width: 420px; width: 100%; text-align: center;
|
|
245
|
-
}
|
|
246
|
-
.icon { font-size: 3rem; color: ${color}; margin-bottom: 0.75rem; }
|
|
247
|
-
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; color: ${color}; }
|
|
248
|
-
.msg { color: #c9d1d9; margin-bottom: 1rem; font-size: 0.95rem; }
|
|
249
|
-
.sub { color: #8b949e; font-size: 0.85rem; }
|
|
250
|
-
</style>
|
|
251
|
-
</head>
|
|
252
|
-
<body>
|
|
253
|
-
<div class="card">
|
|
254
|
-
<div class="icon">${icon}</div>
|
|
255
|
-
<h1>${title}</h1>
|
|
256
|
-
<p class="msg">${message}</p>
|
|
257
|
-
<p class="sub">${sub}</p>
|
|
258
|
-
</div>
|
|
259
|
-
</body>
|
|
260
|
-
</html>`;
|
|
261
|
-
}
|
package/src/pool.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import type { Account } from "./config.ts";
|
|
2
|
-
|
|
3
|
-
interface AccountState {
|
|
4
|
-
account: Account;
|
|
5
|
-
status: "ready" | "cooldown";
|
|
6
|
-
cooldownUntil: number;
|
|
7
|
-
totalRequests: number;
|
|
8
|
-
errors: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class AccountPool {
|
|
12
|
-
private states: AccountState[];
|
|
13
|
-
private index = 0;
|
|
14
|
-
private cooldownMs: number;
|
|
15
|
-
|
|
16
|
-
constructor(accounts: Account[], cooldownMinutes: number) {
|
|
17
|
-
this.cooldownMs = cooldownMinutes * 60_000;
|
|
18
|
-
this.states = accounts.map((account) => ({
|
|
19
|
-
account,
|
|
20
|
-
status: "ready" as const,
|
|
21
|
-
cooldownUntil: 0,
|
|
22
|
-
totalRequests: 0,
|
|
23
|
-
errors: 0,
|
|
24
|
-
}));
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
get size(): number {
|
|
28
|
-
return this.states.length;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Sticky — stays on current account until it hits a limit, then rotates. */
|
|
32
|
-
getNext(): { account: Account; name: string } | null {
|
|
33
|
-
const now = Date.now();
|
|
34
|
-
|
|
35
|
-
for (const s of this.states) {
|
|
36
|
-
if (s.status === "cooldown" && now >= s.cooldownUntil) {
|
|
37
|
-
s.status = "ready";
|
|
38
|
-
log("green", `↩ ${s.account.name} back from cooldown`);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Prefer current (sticky)
|
|
43
|
-
if (this.states[this.index]?.status === "ready") {
|
|
44
|
-
this.states[this.index].totalRequests++;
|
|
45
|
-
return {
|
|
46
|
-
account: this.states[this.index].account,
|
|
47
|
-
name: this.states[this.index].account.name,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Find next ready
|
|
52
|
-
for (let i = 1; i < this.states.length; i++) {
|
|
53
|
-
const idx = (this.index + i) % this.states.length;
|
|
54
|
-
if (this.states[idx].status === "ready") {
|
|
55
|
-
this.index = idx;
|
|
56
|
-
this.states[idx].totalRequests++;
|
|
57
|
-
return {
|
|
58
|
-
account: this.states[idx].account,
|
|
59
|
-
name: this.states[idx].account.name,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
markCooldown(name: string): void {
|
|
68
|
-
const state = this.states.find((s) => s.account.name === name);
|
|
69
|
-
if (!state) return;
|
|
70
|
-
state.status = "cooldown";
|
|
71
|
-
state.cooldownUntil = Date.now() + this.cooldownMs;
|
|
72
|
-
state.errors++;
|
|
73
|
-
const idx = this.states.indexOf(state);
|
|
74
|
-
this.index = (idx + 1) % this.states.length;
|
|
75
|
-
log(
|
|
76
|
-
"yellow",
|
|
77
|
-
`⏸ ${name} → cooldown for ${Math.round(this.cooldownMs / 60_000)}m`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
reload(accounts: Account[]): void {
|
|
82
|
-
// Preserve state for existing accounts, add new ones
|
|
83
|
-
const oldMap = new Map(this.states.map((s) => [s.account.name, s]));
|
|
84
|
-
this.states = accounts.map((account) => {
|
|
85
|
-
const existing = oldMap.get(account.name);
|
|
86
|
-
if (existing) {
|
|
87
|
-
existing.account = account; // update token
|
|
88
|
-
return existing;
|
|
89
|
-
}
|
|
90
|
-
return {
|
|
91
|
-
account,
|
|
92
|
-
status: "ready" as const,
|
|
93
|
-
cooldownUntil: 0,
|
|
94
|
-
totalRequests: 0,
|
|
95
|
-
errors: 0,
|
|
96
|
-
};
|
|
97
|
-
});
|
|
98
|
-
if (this.index >= this.states.length) this.index = 0;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
updateToken(name: string, newToken: string): void {
|
|
102
|
-
const state = this.states.find((s) => s.account.name === name);
|
|
103
|
-
if (state) state.account.token = newToken;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
getStatus(): object[] {
|
|
107
|
-
const now = Date.now();
|
|
108
|
-
return this.states.map((s, i) => ({
|
|
109
|
-
name: s.account.name,
|
|
110
|
-
active: i === this.index && s.status === "ready",
|
|
111
|
-
status: s.status,
|
|
112
|
-
cooldownRemaining:
|
|
113
|
-
s.status === "cooldown"
|
|
114
|
-
? Math.max(0, Math.ceil((s.cooldownUntil - now) / 60_000)) + "m"
|
|
115
|
-
: null,
|
|
116
|
-
totalRequests: s.totalRequests,
|
|
117
|
-
errors: s.errors,
|
|
118
|
-
}));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ── Logging ─────────────────────────────────────────────────────
|
|
123
|
-
const C: Record<string, string> = {
|
|
124
|
-
red: "\x1b[31m",
|
|
125
|
-
green: "\x1b[32m",
|
|
126
|
-
yellow: "\x1b[33m",
|
|
127
|
-
cyan: "\x1b[36m",
|
|
128
|
-
dim: "\x1b[2m",
|
|
129
|
-
reset: "\x1b[0m",
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
export function log(color: string, msg: string): void {
|
|
133
|
-
const c = C[color] ?? "";
|
|
134
|
-
const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
135
|
-
console.log(`${C.dim}${ts}${C.reset} ${c}${msg}${C.reset}`);
|
|
136
|
-
}
|
package/src/server.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
-
import { execSync } from "node:child_process";
|
|
3
|
-
import { type as osType, release, arch } from "node:os";
|
|
4
|
-
import { getAccounts, getSettings, writePid, removePid, type Account } from "./config.ts";
|
|
5
|
-
import { refreshAccount } from "./login.ts";
|
|
6
|
-
import { AccountPool, log } from "./pool.ts";
|
|
7
|
-
|
|
8
|
-
const ROTATE_ON = new Set([429, 402]);
|
|
9
|
-
const STRIP_REQ = new Set([
|
|
10
|
-
"host", "authorization", "connection", "content-length",
|
|
11
|
-
"user-agent", "originator",
|
|
12
|
-
]);
|
|
13
|
-
const STRIP_RES = new Set(["transfer-encoding", "connection"]);
|
|
14
|
-
|
|
15
|
-
// ── Codex-style User-Agent ──────────────────────────────────────
|
|
16
|
-
// Map TERM_PROGRAM values to Codex CLI terminal tokens
|
|
17
|
-
const TERMINAL_MAP: Record<string, string> = {
|
|
18
|
-
"iterm.app": "iterm2",
|
|
19
|
-
"iterm": "iterm2",
|
|
20
|
-
"apple_terminal": "apple-terminal",
|
|
21
|
-
"terminal": "apple-terminal",
|
|
22
|
-
"warpterminal": "warp",
|
|
23
|
-
"wezterm": "wezterm",
|
|
24
|
-
"vscode": "vscode",
|
|
25
|
-
"ghostty": "ghostty",
|
|
26
|
-
"alacritty": "alacritty",
|
|
27
|
-
"kitty": "kitty",
|
|
28
|
-
"konsole": "konsole",
|
|
29
|
-
"gnome-terminal": "gnome-terminal",
|
|
30
|
-
"windows terminal": "windows-terminal",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
function buildUserAgent(): string {
|
|
34
|
-
let os = osType();
|
|
35
|
-
let ver = release();
|
|
36
|
-
if (os === "Darwin") {
|
|
37
|
-
os = "Mac OS";
|
|
38
|
-
try { ver = execSync("sw_vers -productVersion", { encoding: "utf-8" }).trim(); } catch {}
|
|
39
|
-
}
|
|
40
|
-
const raw = (process.env.TERM_PROGRAM ?? "").toLowerCase().replace(/\.app$/i, "");
|
|
41
|
-
const terminal = TERMINAL_MAP[raw] ?? (raw || "unknown-terminal");
|
|
42
|
-
return `codex_cli_rs/0.1.0 (${os} ${ver}; ${arch()}) ${terminal}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const CODEX_USER_AGENT = buildUserAgent();
|
|
46
|
-
|
|
47
|
-
function codexHeaders(account: Account): Record<string, string> {
|
|
48
|
-
const h: Record<string, string> = {
|
|
49
|
-
"user-agent": CODEX_USER_AGENT,
|
|
50
|
-
"originator": "codex_cli_rs",
|
|
51
|
-
};
|
|
52
|
-
if (account.accountId) {
|
|
53
|
-
h["chatgpt-account-id"] = account.accountId;
|
|
54
|
-
}
|
|
55
|
-
return h;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function startProxy(): void {
|
|
59
|
-
const settings = getSettings();
|
|
60
|
-
const accounts = getAccounts();
|
|
61
|
-
const upstream = settings.upstream.replace(/\/$/, "");
|
|
62
|
-
|
|
63
|
-
if (accounts.length === 0) {
|
|
64
|
-
console.error(
|
|
65
|
-
"\x1b[31mNo accounts configured. Run `codex-proxy login` first.\x1b[0m"
|
|
66
|
-
);
|
|
67
|
-
process.exit(1);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const pool = new AccountPool(accounts, settings.cooldownMinutes);
|
|
71
|
-
|
|
72
|
-
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
73
|
-
const url = new URL(req.url ?? "/", `http://localhost:${settings.port}`);
|
|
74
|
-
|
|
75
|
-
// ── Internal endpoints ────────────────────────────────────
|
|
76
|
-
if (url.pathname === "/_status") {
|
|
77
|
-
json(res, 200, { accounts: pool.getStatus() });
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (url.pathname === "/_reload") {
|
|
82
|
-
pool.reload(getAccounts());
|
|
83
|
-
log("green", "↻ accounts reloaded");
|
|
84
|
-
json(res, 200, { ok: true, accounts: pool.size });
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ── Buffer body for retries ───────────────────────────────
|
|
89
|
-
const chunks: Buffer[] = [];
|
|
90
|
-
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
91
|
-
let body = chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
92
|
-
|
|
93
|
-
// ── Forward headers ───────────────────────────────────────
|
|
94
|
-
const fwdHeaders: Record<string, string> = {};
|
|
95
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
96
|
-
if (v && !STRIP_REQ.has(k.toLowerCase())) {
|
|
97
|
-
fwdHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── Try each account ──────────────────────────────────────
|
|
102
|
-
for (let attempt = 0; attempt < pool.size; attempt++) {
|
|
103
|
-
const entry = pool.getNext();
|
|
104
|
-
if (!entry) break;
|
|
105
|
-
|
|
106
|
-
const target = `${upstream}${url.pathname}${url.search}`;
|
|
107
|
-
log("cyan", `→ ${req.method} ${url.pathname} via ${entry.name}`);
|
|
108
|
-
|
|
109
|
-
// Inner loop: try once, and if 401 + refreshable, refresh and retry
|
|
110
|
-
let currentToken = entry.account.token;
|
|
111
|
-
for (let retry = 0; retry < 2; retry++) {
|
|
112
|
-
try {
|
|
113
|
-
const fetchRes = await fetch(target, {
|
|
114
|
-
method: req.method,
|
|
115
|
-
headers: {
|
|
116
|
-
...fwdHeaders,
|
|
117
|
-
...codexHeaders(entry.account),
|
|
118
|
-
authorization: `Bearer ${currentToken}`,
|
|
119
|
-
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
120
|
-
},
|
|
121
|
-
body,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// ── 401: try token refresh before rotating ────────
|
|
125
|
-
if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
|
|
126
|
-
await fetchRes.text();
|
|
127
|
-
log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
|
|
128
|
-
const newToken = await refreshAccount(entry.account);
|
|
129
|
-
if (newToken) {
|
|
130
|
-
currentToken = newToken;
|
|
131
|
-
entry.account.token = newToken;
|
|
132
|
-
pool.updateToken(entry.name, newToken);
|
|
133
|
-
log("green", `✓ ${entry.name} token refreshed`);
|
|
134
|
-
continue; // retry inner loop
|
|
135
|
-
}
|
|
136
|
-
log("red", `✗ ${entry.name} refresh failed — rotating`);
|
|
137
|
-
pool.markCooldown(entry.name);
|
|
138
|
-
break; // move to next account
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── Rate limit / quota → rotate ───────────────────
|
|
142
|
-
if (ROTATE_ON.has(fetchRes.status)) {
|
|
143
|
-
await fetchRes.text();
|
|
144
|
-
log("red", `✗ ${entry.name} hit ${fetchRes.status} — rotating`);
|
|
145
|
-
pool.markCooldown(entry.name);
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (fetchRes.status === 403) {
|
|
150
|
-
const text = await fetchRes.text();
|
|
151
|
-
if (/quota|limit|exceeded|rate/i.test(text)) {
|
|
152
|
-
log("red", `✗ ${entry.name} 403 quota — rotating`);
|
|
153
|
-
pool.markCooldown(entry.name);
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
log("yellow", `✗ 403 (not quota) — forwarding`);
|
|
157
|
-
forward(res, 403, fetchRes.headers, text);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Stream response back ──────────────────────────
|
|
162
|
-
log("green", `✓ ${fetchRes.status}`);
|
|
163
|
-
const resHeaders: Record<string, string> = {};
|
|
164
|
-
fetchRes.headers.forEach((v, k) => {
|
|
165
|
-
if (!STRIP_RES.has(k.toLowerCase())) resHeaders[k] = v;
|
|
166
|
-
});
|
|
167
|
-
res.writeHead(fetchRes.status, resHeaders);
|
|
168
|
-
|
|
169
|
-
if (fetchRes.body) {
|
|
170
|
-
const reader = (fetchRes.body as ReadableStream<Uint8Array>).getReader();
|
|
171
|
-
try {
|
|
172
|
-
while (true) {
|
|
173
|
-
const { done, value } = await reader.read();
|
|
174
|
-
if (done) break;
|
|
175
|
-
res.write(value);
|
|
176
|
-
}
|
|
177
|
-
} catch {}
|
|
178
|
-
res.end();
|
|
179
|
-
} else {
|
|
180
|
-
res.end();
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
} catch (err) {
|
|
184
|
-
log("red", `✗ ${entry.name} network error: ${err}`);
|
|
185
|
-
pool.markCooldown(entry.name);
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
log("red", "✗ all accounts exhausted");
|
|
192
|
-
json(res, 503, {
|
|
193
|
-
error: {
|
|
194
|
-
message: "All accounts exhausted. Check /_status for cooldown times.",
|
|
195
|
-
type: "proxy_error",
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// ── Lifecycle ─────────────────────────────────────────────────
|
|
201
|
-
writePid(process.pid);
|
|
202
|
-
|
|
203
|
-
const shutdown = () => {
|
|
204
|
-
log("yellow", "shutting down...");
|
|
205
|
-
removePid();
|
|
206
|
-
server.close(() => process.exit(0));
|
|
207
|
-
setTimeout(() => process.exit(0), 3000);
|
|
208
|
-
};
|
|
209
|
-
process.on("SIGTERM", shutdown);
|
|
210
|
-
process.on("SIGINT", shutdown);
|
|
211
|
-
|
|
212
|
-
server.listen(settings.port, () => {
|
|
213
|
-
console.log();
|
|
214
|
-
console.log(" \x1b[36mcodex-proxy\x1b[0m");
|
|
215
|
-
console.log(` upstream ${upstream}`);
|
|
216
|
-
console.log(` port ${settings.port}`);
|
|
217
|
-
console.log(` accounts ${accounts.map((a) => a.name).join(", ")}`);
|
|
218
|
-
console.log(` cooldown ${settings.cooldownMinutes}m`);
|
|
219
|
-
console.log();
|
|
220
|
-
log("green", `listening on http://localhost:${settings.port}`);
|
|
221
|
-
console.log();
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function json(res: ServerResponse, status: number, data: unknown): void {
|
|
226
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
227
|
-
res.end(JSON.stringify(data, null, 2));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function forward(
|
|
231
|
-
res: ServerResponse,
|
|
232
|
-
status: number,
|
|
233
|
-
headers: Headers,
|
|
234
|
-
body: string
|
|
235
|
-
): void {
|
|
236
|
-
const h: Record<string, string> = {};
|
|
237
|
-
headers.forEach((v, k) => {
|
|
238
|
-
if (!STRIP_RES.has(k.toLowerCase())) h[k] = v;
|
|
239
|
-
});
|
|
240
|
-
res.writeHead(status, h);
|
|
241
|
-
res.end(body);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Allow running directly for daemon mode
|
|
245
|
-
if (process.env.CODEX_PROXY_DAEMON === "1") {
|
|
246
|
-
startProxy();
|
|
247
|
-
}
|