@bitkyc08/opencodex 2.1.8 → 2.1.10
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/gui/dist/assets/{index-DalshCSi.js → index-3RZw8J9v.js} +1 -1
- package/gui/dist/assets/index-xJdeQjzZ.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/anthropic.ts +29 -6
- package/src/adapters/openai-responses.ts +12 -0
- package/src/bridge.ts +21 -1
- package/src/codex-account-label.ts +34 -0
- package/src/codex-account-lifecycle.ts +21 -0
- package/src/codex-account-runtime-state.ts +13 -0
- package/src/codex-account-store.ts +355 -0
- package/src/codex-account-usability.ts +10 -0
- package/src/codex-auth-api.ts +446 -0
- package/src/codex-auth-collision.ts +66 -0
- package/src/codex-auth-context.ts +136 -0
- package/src/codex-catalog.ts +8 -2
- package/src/codex-quota.ts +130 -0
- package/src/codex-routing.ts +382 -0
- package/src/codex-websocket-registry.ts +57 -0
- package/src/config.ts +86 -26
- package/src/debug.ts +5 -4
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/index.ts +35 -7
- package/src/oauth/store.ts +9 -5
- package/src/privacy.ts +11 -0
- package/src/router.ts +1 -1
- package/src/server.ts +360 -23
- package/src/types.ts +32 -0
- package/src/vision/describe.ts +7 -3
- package/src/vision/index.ts +7 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/index.ts +3 -1
- package/src/web-search/loop.ts +6 -5
- package/src/ws-bridge.ts +56 -10
- package/gui/dist/assets/index-dCS-lwCM.css +0 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getCodexAccountCredential } from "./codex-account-store";
|
|
2
|
+
import { isAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
3
|
+
import type { OcxConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean {
|
|
6
|
+
const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId);
|
|
7
|
+
if (!exists) return false;
|
|
8
|
+
if (isAccountNeedsReauth(accountId)) return false;
|
|
9
|
+
return !!getCodexAccountCredential(accountId);
|
|
10
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from "./config";
|
|
2
|
+
import { withCodexAccountLogLabel } from "./codex-account-label";
|
|
3
|
+
import {
|
|
4
|
+
getCodexAccountCredential,
|
|
5
|
+
getValidCodexToken,
|
|
6
|
+
saveCodexAccountCredential,
|
|
7
|
+
CodexCredentialGenerationConflictError,
|
|
8
|
+
CodexCredentialRefreshLockTimeoutError,
|
|
9
|
+
TokenRefreshError,
|
|
10
|
+
} from "./codex-account-store";
|
|
11
|
+
import { deleteCodexAccount } from "./codex-account-lifecycle";
|
|
12
|
+
import { checkAccountIdCollision, readCodexTokens } from "./codex-auth-collision";
|
|
13
|
+
export { checkAccountIdCollision, getMainChatgptAccountId } from "./codex-auth-collision";
|
|
14
|
+
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
15
|
+
import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
16
|
+
import {
|
|
17
|
+
clearAccountQuota,
|
|
18
|
+
getAccountQuota,
|
|
19
|
+
listAccountQuotas,
|
|
20
|
+
parseUsageQuota,
|
|
21
|
+
updateAccountQuota,
|
|
22
|
+
type StoredAccountQuota,
|
|
23
|
+
type WhamUsageResponse,
|
|
24
|
+
} from "./codex-quota";
|
|
25
|
+
export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./codex-quota";
|
|
26
|
+
import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
|
|
27
|
+
import { maskEmail } from "./privacy";
|
|
28
|
+
export { maskEmail } from "./privacy";
|
|
29
|
+
import type { OcxConfig } from "./types";
|
|
30
|
+
|
|
31
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
32
|
+
return new Response(JSON.stringify(data), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
39
|
+
const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
|
|
40
|
+
|
|
41
|
+
const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
|
|
42
|
+
|
|
43
|
+
export function isUnverifiedCodexImportEnabled(): boolean {
|
|
44
|
+
return process.env[MANUAL_IMPORT_ENV] === "1";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function manualImportDisabledResponse(): Response {
|
|
48
|
+
return jsonResponse({
|
|
49
|
+
error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
|
|
50
|
+
code: "manual_import_disabled",
|
|
51
|
+
}, 403);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
|
|
55
|
+
if (!flowId) return;
|
|
56
|
+
codexAuthLoginState.set(flowId, { status: "error", error, doneAt: Date.now() });
|
|
57
|
+
setTimeout(() => codexAuthLoginState.delete(flowId), 30_000);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
|
|
61
|
+
const MAIN_CACHE_TTL = 5 * 60_000;
|
|
62
|
+
const POOL_CACHE_TTL = 5 * 60_000;
|
|
63
|
+
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
64
|
+
|
|
65
|
+
function isRuntimeConfig(config: OcxConfig): boolean {
|
|
66
|
+
return !!config && typeof config === "object" && !!config.providers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getRuntimeConfig(config: OcxConfig): OcxConfig {
|
|
70
|
+
return isRuntimeConfig(config) ? config : loadConfig();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
|
|
74
|
+
saveConfig(nextConfig);
|
|
75
|
+
if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
|
|
76
|
+
for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
|
|
77
|
+
delete sourceConfig[key];
|
|
78
|
+
}
|
|
79
|
+
Object.assign(sourceConfig, nextConfig);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function mapWithConcurrency<T, R>(
|
|
83
|
+
items: T[],
|
|
84
|
+
concurrency: number,
|
|
85
|
+
mapper: (item: T) => Promise<R>,
|
|
86
|
+
): Promise<R[]> {
|
|
87
|
+
const results = new Array<R>(items.length);
|
|
88
|
+
let next = 0;
|
|
89
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
90
|
+
while (next < items.length) {
|
|
91
|
+
const index = next++;
|
|
92
|
+
results[index] = await mapper(items[index]!);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await Promise.all(workers);
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null }> {
|
|
100
|
+
if (!forceRefresh && mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) {
|
|
101
|
+
return mainAccountCache;
|
|
102
|
+
}
|
|
103
|
+
const tokens = readCodexTokens();
|
|
104
|
+
if (!tokens) return { email: null, plan: null, quota: null };
|
|
105
|
+
try {
|
|
106
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
107
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
108
|
+
signal: AbortSignal.timeout(8000),
|
|
109
|
+
});
|
|
110
|
+
if (!resp.ok) return { email: null, plan: null, quota: null };
|
|
111
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
112
|
+
const result = {
|
|
113
|
+
email: data.email ?? null,
|
|
114
|
+
plan: data.plan_type ?? null,
|
|
115
|
+
quota: parseUsageQuota(data),
|
|
116
|
+
ts: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
mainAccountCache = result;
|
|
119
|
+
return result;
|
|
120
|
+
} catch {
|
|
121
|
+
return { email: null, plan: null, quota: null };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface PoolQuotaResult {
|
|
126
|
+
quota: StoredAccountQuota | null;
|
|
127
|
+
needsReauth: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): Promise<PoolQuotaResult> {
|
|
131
|
+
const existing = getAccountQuota(accountId);
|
|
132
|
+
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
133
|
+
return { quota: existing, needsReauth: false };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId);
|
|
137
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
138
|
+
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
139
|
+
signal: AbortSignal.timeout(8000),
|
|
140
|
+
});
|
|
141
|
+
if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
|
|
142
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
143
|
+
const quota = parseUsageQuota(data);
|
|
144
|
+
if (!quota) return { quota: existing ?? null, needsReauth: false };
|
|
145
|
+
updateAccountQuota(
|
|
146
|
+
accountId,
|
|
147
|
+
quota.weeklyPercent,
|
|
148
|
+
quota.fiveHourPercent,
|
|
149
|
+
quota.weeklyResetAt,
|
|
150
|
+
quota.fiveHourResetAt,
|
|
151
|
+
quota.monthlyPercent,
|
|
152
|
+
quota.monthlyResetAt,
|
|
153
|
+
);
|
|
154
|
+
return { quota: getAccountQuota(accountId), needsReauth: false };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
|
|
157
|
+
if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
|
|
158
|
+
return { quota: existing ?? null, needsReauth: false };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function handleCodexAuthAPI(
|
|
163
|
+
req: Request,
|
|
164
|
+
url: URL,
|
|
165
|
+
config: OcxConfig,
|
|
166
|
+
): Promise<Response | null> {
|
|
167
|
+
|
|
168
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
|
|
169
|
+
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
170
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
171
|
+
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
|
|
172
|
+
const mainInfo = await fetchMainAccountInfo(forceRefresh);
|
|
173
|
+
const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
174
|
+
const cred = getCodexAccountCredential(a.id);
|
|
175
|
+
const quotaResult = cred
|
|
176
|
+
? await fetchPoolAccountQuota(a.id, forceRefresh)
|
|
177
|
+
: { quota: null, needsReauth: true };
|
|
178
|
+
return {
|
|
179
|
+
...a,
|
|
180
|
+
email: maskEmail(a.email) ?? a.email,
|
|
181
|
+
quota: quotaResult.quota ? { ...quotaResult.quota } : null,
|
|
182
|
+
needsReauth: !cred || quotaResult.needsReauth || isAccountNeedsReauth(a.id),
|
|
183
|
+
hasCredential: !!cred,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
const main = {
|
|
187
|
+
id: "__main__",
|
|
188
|
+
email: maskEmail(mainInfo.email) ?? "Codex App login",
|
|
189
|
+
plan: mainInfo.plan,
|
|
190
|
+
isMain: true,
|
|
191
|
+
hasCredential: true,
|
|
192
|
+
quota: mainInfo.quota ? { ...mainInfo.quota, updatedAt: Date.now() } : null,
|
|
193
|
+
};
|
|
194
|
+
return jsonResponse({ accounts: [main, ...withQuota] });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
|
|
198
|
+
if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse();
|
|
199
|
+
|
|
200
|
+
let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string };
|
|
201
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
202
|
+
if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
|
|
203
|
+
return jsonResponse({ error: "Missing required fields" }, 400);
|
|
204
|
+
}
|
|
205
|
+
if (!ACCOUNT_ID_RE.test(body.id)) {
|
|
206
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
207
|
+
}
|
|
208
|
+
if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
|
|
209
|
+
return jsonResponse({ error: "Input too large" }, 400);
|
|
210
|
+
}
|
|
211
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
212
|
+
const accounts = runtimeConfig.codexAccounts ?? [];
|
|
213
|
+
if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) {
|
|
214
|
+
return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400);
|
|
215
|
+
}
|
|
216
|
+
// 1.1: JWT-derived account ID is authoritative; collision check
|
|
217
|
+
const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
|
|
218
|
+
const collision = checkAccountIdCollision(derivedAccountId, body.email);
|
|
219
|
+
if (collision.collision) {
|
|
220
|
+
return jsonResponse({ error: collision.reason }, 400);
|
|
221
|
+
}
|
|
222
|
+
// 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour
|
|
223
|
+
const payload = decodeJwtPayload(body.accessToken);
|
|
224
|
+
const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
|
|
225
|
+
saveCodexAccountCredential(body.id, {
|
|
226
|
+
accessToken: body.accessToken,
|
|
227
|
+
refreshToken: body.refreshToken,
|
|
228
|
+
expiresAt: exp,
|
|
229
|
+
chatgptAccountId: derivedAccountId,
|
|
230
|
+
});
|
|
231
|
+
clearAccountNeedsReauth(body.id);
|
|
232
|
+
accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
|
|
233
|
+
runtimeConfig.codexAccounts = accounts;
|
|
234
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
235
|
+
return jsonResponse({ ok: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
|
|
239
|
+
const id = url.searchParams.get("id");
|
|
240
|
+
if (!id) return jsonResponse({ error: "Missing id" }, 400);
|
|
241
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
242
|
+
deleteCodexAccount(runtimeConfig, id);
|
|
243
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
244
|
+
return jsonResponse({ ok: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
|
|
248
|
+
let body: { accountId: string | null };
|
|
249
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
250
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
251
|
+
if (body.accountId != null) {
|
|
252
|
+
const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId);
|
|
253
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
254
|
+
}
|
|
255
|
+
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
256
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
257
|
+
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
|
|
261
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
262
|
+
return jsonResponse({
|
|
263
|
+
activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? null,
|
|
264
|
+
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
265
|
+
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
|
|
270
|
+
let body: { threshold: number };
|
|
271
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
272
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
|
|
273
|
+
return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
|
|
274
|
+
}
|
|
275
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
276
|
+
runtimeConfig.autoSwitchThreshold = body.threshold;
|
|
277
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
278
|
+
return jsonResponse({ ok: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
|
|
282
|
+
let body: { threshold: number };
|
|
283
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
284
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
|
|
285
|
+
return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
|
|
286
|
+
}
|
|
287
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
288
|
+
runtimeConfig.upstreamFailoverThreshold = body.threshold;
|
|
289
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
290
|
+
return jsonResponse({ ok: true });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
|
|
294
|
+
const quotas: Record<string, unknown> = {};
|
|
295
|
+
for (const [id, q] of listAccountQuotas()) quotas[id] = q;
|
|
296
|
+
return jsonResponse({ quotas });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
|
|
300
|
+
const body = (await req.json().catch(() => ({}))) as { id?: string };
|
|
301
|
+
const requestedAccountId = body.id?.trim();
|
|
302
|
+
if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) {
|
|
303
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
304
|
+
}
|
|
305
|
+
const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
|
|
306
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
307
|
+
if ((runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || getCodexAccountCredential(accountId)) {
|
|
308
|
+
return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400);
|
|
309
|
+
}
|
|
310
|
+
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
311
|
+
try {
|
|
312
|
+
const { startLoginFlow, getLoginStatus } = await import("./oauth/index");
|
|
313
|
+
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
314
|
+
|
|
315
|
+
(async () => {
|
|
316
|
+
let completed = false;
|
|
317
|
+
for (let i = 0; i < 150; i++) {
|
|
318
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
319
|
+
const st = getLoginStatus("chatgpt");
|
|
320
|
+
if (st.done && st.loggedIn) {
|
|
321
|
+
const { getCredential } = await import("./oauth/store");
|
|
322
|
+
const cred = getCredential("chatgpt");
|
|
323
|
+
if (cred) {
|
|
324
|
+
// 1.2: account-ID-based collision check (JWT-derived, not email)
|
|
325
|
+
const oauthAccountId = cred.accountId;
|
|
326
|
+
if (!oauthAccountId) {
|
|
327
|
+
codexAuthLoginState.set(flowId, {
|
|
328
|
+
status: "error",
|
|
329
|
+
error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
|
|
330
|
+
doneAt: Date.now(),
|
|
331
|
+
});
|
|
332
|
+
completed = true;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
const collision = checkAccountIdCollision(oauthAccountId, cred.email);
|
|
336
|
+
if (collision.collision) {
|
|
337
|
+
codexAuthLoginState.set(flowId, {
|
|
338
|
+
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
339
|
+
});
|
|
340
|
+
completed = true;
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let email = cred.email || accountId;
|
|
345
|
+
let plan: string | undefined;
|
|
346
|
+
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
347
|
+
try {
|
|
348
|
+
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
349
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
350
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
351
|
+
signal: AbortSignal.timeout(8000),
|
|
352
|
+
});
|
|
353
|
+
if (resp.ok) {
|
|
354
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
355
|
+
email = data.email ?? email;
|
|
356
|
+
plan = data.plan_type ?? undefined;
|
|
357
|
+
quota = parseUsageQuota(data);
|
|
358
|
+
}
|
|
359
|
+
} catch { /* wham fetch is non-blocking */ }
|
|
360
|
+
|
|
361
|
+
saveCodexAccountCredential(accountId, {
|
|
362
|
+
accessToken: cred.access,
|
|
363
|
+
refreshToken: cred.refresh,
|
|
364
|
+
expiresAt: cred.expires,
|
|
365
|
+
chatgptAccountId: oauthAccountId,
|
|
366
|
+
});
|
|
367
|
+
clearAccountNeedsReauth(accountId);
|
|
368
|
+
if (quota) {
|
|
369
|
+
updateAccountQuota(
|
|
370
|
+
accountId,
|
|
371
|
+
quota.weeklyPercent,
|
|
372
|
+
quota.fiveHourPercent,
|
|
373
|
+
quota.weeklyResetAt,
|
|
374
|
+
quota.fiveHourResetAt,
|
|
375
|
+
quota.monthlyPercent,
|
|
376
|
+
quota.monthlyResetAt,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const latestConfig = getRuntimeConfig(config);
|
|
381
|
+
const accounts = latestConfig.codexAccounts ?? [];
|
|
382
|
+
if (!accounts.find(a => a.id === accountId)) {
|
|
383
|
+
accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
|
|
384
|
+
latestConfig.codexAccounts = accounts;
|
|
385
|
+
saveRuntimeConfig(config, latestConfig);
|
|
386
|
+
}
|
|
387
|
+
codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() });
|
|
388
|
+
completed = true;
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
if (st.done && st.error) {
|
|
393
|
+
codexAuthLoginState.set(flowId, { status: "error", error: st.error, doneAt: Date.now() });
|
|
394
|
+
completed = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!completed) {
|
|
399
|
+
codexAuthLoginState.set(flowId, {
|
|
400
|
+
status: "error",
|
|
401
|
+
error: "Login timed out before OAuth completed.",
|
|
402
|
+
doneAt: Date.now(),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
406
|
+
setTimeout(() => codexAuthLoginState.delete(flowId), 300_000);
|
|
407
|
+
})();
|
|
408
|
+
|
|
409
|
+
codexAuthLoginState.set(flowId, { status: "pending" });
|
|
410
|
+
return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
413
|
+
if (msg.includes("already in progress")) {
|
|
414
|
+
return jsonResponse({ error: msg, status: "pending" }, 409);
|
|
415
|
+
}
|
|
416
|
+
return jsonResponse({ error: msg }, 500);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
|
|
421
|
+
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
|
|
422
|
+
const { cancelLoginFlow } = await import("./oauth/index");
|
|
423
|
+
const cancelled = cancelLoginFlow("chatgpt");
|
|
424
|
+
expireCodexAuthFlow(body.flowId ?? null);
|
|
425
|
+
return jsonResponse({ ok: true, cancelled });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
|
|
429
|
+
const flowId = url.searchParams.get("flowId");
|
|
430
|
+
const accountId = url.searchParams.get("accountId")?.trim();
|
|
431
|
+
if (flowId) {
|
|
432
|
+
const st = codexAuthLoginState.get(flowId);
|
|
433
|
+
if (!st && accountId && getCodexAccountCredential(accountId)) {
|
|
434
|
+
return jsonResponse({ status: "done", accountId });
|
|
435
|
+
}
|
|
436
|
+
return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
|
|
437
|
+
}
|
|
438
|
+
// Legacy fallback: return latest pending flow
|
|
439
|
+
for (const [, st] of codexAuthLoginState) {
|
|
440
|
+
if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
|
|
441
|
+
}
|
|
442
|
+
return jsonResponse({ status: "idle" });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { getCodexAccountCredential, listCodexAccountIds } from "./codex-account-store";
|
|
5
|
+
import { loadConfig } from "./config";
|
|
6
|
+
import { extractAccountId, extractEmail } from "./oauth/chatgpt";
|
|
7
|
+
|
|
8
|
+
export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
|
|
9
|
+
try {
|
|
10
|
+
const codexHome = process.env["CODEX_HOME"] || join(os.homedir(), ".codex");
|
|
11
|
+
const authPath = join(codexHome, "auth.json");
|
|
12
|
+
if (!existsSync(authPath)) return null;
|
|
13
|
+
const j = JSON.parse(readFileSync(authPath, "utf-8")) as {
|
|
14
|
+
tokens?: { access_token?: string; account_id?: string; id_token?: string };
|
|
15
|
+
};
|
|
16
|
+
if (!j?.tokens?.access_token) return null;
|
|
17
|
+
return {
|
|
18
|
+
access_token: j.tokens.access_token,
|
|
19
|
+
account_id: j.tokens.account_id ?? "",
|
|
20
|
+
id_token: j.tokens.id_token,
|
|
21
|
+
};
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getMainChatgptAccountId(): string | null {
|
|
26
|
+
const tokens = readCodexTokens();
|
|
27
|
+
if (!tokens) return null;
|
|
28
|
+
return extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getMainChatgptEmail(): string | null {
|
|
32
|
+
const tokens = readCodexTokens();
|
|
33
|
+
if (!tokens) return null;
|
|
34
|
+
return extractEmail(tokens.id_token, tokens.access_token) ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizedEmail(email: string | undefined | null): string | null {
|
|
38
|
+
const trimmed = email?.trim().toLowerCase();
|
|
39
|
+
return trimmed || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function poolEmailForId(id: string): string | null {
|
|
43
|
+
const account = (loadConfig().codexAccounts ?? []).find(a => a.id === id);
|
|
44
|
+
return normalizedEmail(account?.email);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Business/Team members can share chatgpt_account_id, so require email match too.
|
|
48
|
+
export function checkAccountIdCollision(
|
|
49
|
+
chatgptAccountId: string,
|
|
50
|
+
email?: string | null,
|
|
51
|
+
): { collision: true; reason: string } | { collision: false } {
|
|
52
|
+
const candidateEmail = normalizedEmail(email);
|
|
53
|
+
const mainId = getMainChatgptAccountId();
|
|
54
|
+
const mainEmail = getMainChatgptEmail();
|
|
55
|
+
if (mainId && mainId === chatgptAccountId && (!candidateEmail || !mainEmail || mainEmail === candidateEmail)) {
|
|
56
|
+
return { collision: true, reason: "This account is your main Codex login. Use a different account for the pool." };
|
|
57
|
+
}
|
|
58
|
+
for (const poolId of listCodexAccountIds()) {
|
|
59
|
+
const cred = getCodexAccountCredential(poolId);
|
|
60
|
+
const poolEmail = poolEmailForId(poolId);
|
|
61
|
+
if (cred && cred.chatgptAccountId === chatgptAccountId && (!candidateEmail || !poolEmail || poolEmail === candidateEmail)) {
|
|
62
|
+
return { collision: true, reason: `Account is already in the pool (${poolId}).` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { collision: false };
|
|
66
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CodexCredentialGenerationConflictError,
|
|
3
|
+
CodexCredentialRefreshLockTimeoutError,
|
|
4
|
+
getValidCodexToken,
|
|
5
|
+
isCodexAccountGenerationLive,
|
|
6
|
+
} from "./codex-account-store";
|
|
7
|
+
import { markAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
8
|
+
import { isCodexAccountUsable } from "./codex-account-usability";
|
|
9
|
+
import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
|
|
10
|
+
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
11
|
+
import { FORWARD_HEADERS } from "./adapters/openai-responses";
|
|
12
|
+
|
|
13
|
+
export type CodexAuthContext =
|
|
14
|
+
| { kind: "main"; accountId: null }
|
|
15
|
+
| {
|
|
16
|
+
kind: "pool";
|
|
17
|
+
accountId: string;
|
|
18
|
+
generation: number;
|
|
19
|
+
accessToken: string;
|
|
20
|
+
chatgptAccountId: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type OcxRuntimeProviderConfig = OcxProviderConfig & {
|
|
24
|
+
_codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
|
|
25
|
+
_codexAccountRequired?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class CodexAuthContextError extends Error {
|
|
29
|
+
accountId: string;
|
|
30
|
+
|
|
31
|
+
constructor(accountId: string, cause: unknown) {
|
|
32
|
+
super("Codex pool account auth failed", { cause });
|
|
33
|
+
this.name = "CodexAuthContextError";
|
|
34
|
+
this.accountId = accountId;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CodexAccountCooldownError extends Error {
|
|
39
|
+
accountId: string;
|
|
40
|
+
cooldownUntil: number;
|
|
41
|
+
|
|
42
|
+
constructor(accountId: string, cooldownUntil: number) {
|
|
43
|
+
super("Selected Codex account is cooling down");
|
|
44
|
+
this.name = "CodexAccountCooldownError";
|
|
45
|
+
this.accountId = accountId;
|
|
46
|
+
this.cooldownUntil = cooldownUntil;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class CodexThreadAffinityExpiredError extends Error {
|
|
51
|
+
accountId: string;
|
|
52
|
+
|
|
53
|
+
constructor(accountId: string) {
|
|
54
|
+
super("Codex thread account affinity expired");
|
|
55
|
+
this.name = "CodexThreadAffinityExpiredError";
|
|
56
|
+
this.accountId = accountId;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
61
|
+
return !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolveCodexAuthContext(headers: Headers, config: OcxConfig): Promise<CodexAuthContext> {
|
|
65
|
+
const threadId = headers.get("x-codex-parent-thread-id");
|
|
66
|
+
const resolution = resolveCodexAccountForThreadDetailed(threadId, config);
|
|
67
|
+
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
68
|
+
const accountId = resolution.status === "selected" ? resolution.accountId : null;
|
|
69
|
+
if (!accountId) return { kind: "main", accountId: null };
|
|
70
|
+
const cooldownUntil = getCodexAccountCooldownUntil(accountId);
|
|
71
|
+
if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const token = await getValidCodexToken(accountId);
|
|
75
|
+
return {
|
|
76
|
+
kind: "pool",
|
|
77
|
+
accountId,
|
|
78
|
+
generation: token.generation,
|
|
79
|
+
accessToken: token.accessToken,
|
|
80
|
+
chatgptAccountId: token.chatgptAccountId,
|
|
81
|
+
};
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
84
|
+
markAccountNeedsReauth(accountId);
|
|
85
|
+
}
|
|
86
|
+
throw new CodexAuthContextError(accountId, cause);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
|
|
91
|
+
if (ctx?.kind !== "pool") return;
|
|
92
|
+
const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
|
|
93
|
+
if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyCodexAuthContextToProvider(
|
|
97
|
+
provider: OcxProviderConfig,
|
|
98
|
+
ctx: CodexAuthContext,
|
|
99
|
+
): OcxRuntimeProviderConfig {
|
|
100
|
+
if (ctx.kind !== "pool" || provider.authMode !== "forward") return provider;
|
|
101
|
+
return {
|
|
102
|
+
...provider,
|
|
103
|
+
_codexAccountOverride: {
|
|
104
|
+
accessToken: ctx.accessToken,
|
|
105
|
+
chatgptAccountId: ctx.chatgptAccountId,
|
|
106
|
+
},
|
|
107
|
+
_codexAccountRequired: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
|
|
112
|
+
const selected = new Headers();
|
|
113
|
+
for (const name of FORWARD_HEADERS) {
|
|
114
|
+
const value = headers.get(name);
|
|
115
|
+
if (value) selected.set(name, value);
|
|
116
|
+
}
|
|
117
|
+
if (ctx.kind === "pool") {
|
|
118
|
+
selected.set("authorization", `Bearer ${ctx.accessToken}`);
|
|
119
|
+
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
|
|
120
|
+
}
|
|
121
|
+
return selected;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean {
|
|
125
|
+
if (ctx.kind === "main") return true;
|
|
126
|
+
return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function stripCodexRuntimeProviderFields(provider: OcxProviderConfig): OcxProviderConfig {
|
|
130
|
+
const {
|
|
131
|
+
_codexAccountOverride: _override,
|
|
132
|
+
_codexAccountRequired: _required,
|
|
133
|
+
...safeProvider
|
|
134
|
+
} = provider as OcxRuntimeProviderConfig;
|
|
135
|
+
return safeProvider;
|
|
136
|
+
}
|
package/src/codex-catalog.ts
CHANGED
|
@@ -596,7 +596,13 @@ export function invalidateCodexModelsCache(): void {
|
|
|
596
596
|
try {
|
|
597
597
|
const catalogPath = readCodexCatalogPath();
|
|
598
598
|
if (!existsSync(catalogPath)) return;
|
|
599
|
-
const catalog = readFileSync(catalogPath, "utf8");
|
|
600
|
-
|
|
599
|
+
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
600
|
+
const models = catalog.models ?? catalog;
|
|
601
|
+
const wrapper = {
|
|
602
|
+
fetched_at: "2000-01-01T00:00:00Z",
|
|
603
|
+
client_version: "0.0.0",
|
|
604
|
+
models,
|
|
605
|
+
};
|
|
606
|
+
atomicWriteFile(CODEX_MODELS_CACHE_PATH, JSON.stringify(wrapper, null, 2) + "\n");
|
|
601
607
|
} catch { /* best-effort */ }
|
|
602
608
|
}
|