@bitkyc08/opencodex 2.5.6 → 2.6.0
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.ko.md +17 -6
- package/README.md +19 -7
- package/README.zh-CN.md +12 -3
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +88 -2
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-BS4X1QDi.js +9 -0
- package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +20 -6
- package/src/adapters/anthropic.ts +16 -5
- package/src/adapters/google.ts +9 -2
- package/src/adapters/openai-chat.ts +13 -5
- package/src/bun-runtime.ts +22 -1
- package/src/cli-help.ts +111 -0
- package/src/cli-status.ts +164 -0
- package/src/cli.ts +77 -186
- package/src/codex-account-store.ts +47 -8
- package/src/codex-auth-api.ts +111 -54
- package/src/codex-auth-collision.ts +5 -0
- package/src/codex-catalog.ts +24 -12
- package/src/codex-history-provider.ts +29 -13
- package/src/codex-inject.ts +46 -29
- package/src/codex-journal.ts +77 -13
- package/src/codex-quota.ts +11 -3
- package/src/codex-routing.ts +14 -4
- package/src/codex-shim.ts +71 -24
- package/src/codex-websocket-registry.ts +20 -4
- package/src/config.ts +138 -4
- package/src/init.ts +7 -2
- package/src/oauth/callback-server.ts +22 -15
- package/src/oauth/index.ts +18 -4
- package/src/oauth/login-cli.ts +8 -1
- package/src/oauth/store.ts +2 -1
- package/src/process-control.ts +36 -0
- package/src/provider-label.ts +8 -0
- package/src/responses/parser.ts +18 -1
- package/src/router.ts +61 -5
- package/src/server.ts +878 -94
- package/src/service-secrets.ts +6 -0
- package/src/service.ts +293 -28
- package/src/types.ts +26 -1
- package/src/update.ts +16 -9
- package/src/usage-debug.ts +65 -0
- package/src/usage-log.ts +62 -0
- package/src/usage-summary.ts +0 -0
- package/src/ws-bridge.ts +2 -2
- package/gui/README.md +0 -73
- package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/codex-auth-api.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota
|
|
|
26
26
|
import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
|
|
27
27
|
import { maskEmail } from "./privacy";
|
|
28
28
|
export { maskEmail } from "./privacy";
|
|
29
|
-
import type { OcxConfig } from "./types";
|
|
29
|
+
import type { CodexAccount, OcxConfig } from "./types";
|
|
30
30
|
|
|
31
31
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
32
32
|
return new Response(JSON.stringify(data), {
|
|
@@ -40,6 +40,92 @@ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
|
|
|
40
40
|
|
|
41
41
|
const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
|
|
42
42
|
|
|
43
|
+
function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
|
|
44
|
+
if (!ACCOUNT_ID_RE.test(accountId)) return null;
|
|
45
|
+
return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
|
|
49
|
+
const normalized = plan?.trim().toLowerCase();
|
|
50
|
+
return normalized === "go" || normalized === "free";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
54
|
+
quota: T,
|
|
55
|
+
plan: string | null | undefined,
|
|
56
|
+
): T {
|
|
57
|
+
if (!quota || !isThirtyDayOnlyPlan(plan)) return quota;
|
|
58
|
+
return {
|
|
59
|
+
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|
|
60
|
+
...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
|
|
61
|
+
...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
|
|
62
|
+
...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
|
|
63
|
+
} as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function poolAccountDto(
|
|
67
|
+
account: CodexAccount,
|
|
68
|
+
quotaResult: PoolQuotaResult,
|
|
69
|
+
hasCredential: boolean,
|
|
70
|
+
): Record<string, unknown> {
|
|
71
|
+
const quota = quotaForPlan(quotaResult.quota, account.plan);
|
|
72
|
+
return {
|
|
73
|
+
id: account.id,
|
|
74
|
+
email: maskEmail(account.email) ?? account.email,
|
|
75
|
+
...(account.plan !== undefined ? { plan: account.plan } : {}),
|
|
76
|
+
...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
|
|
77
|
+
isMain: false,
|
|
78
|
+
quota: quota ? { ...quota } : null,
|
|
79
|
+
needsReauth: !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id),
|
|
80
|
+
hasCredential,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveResetCreditAuth(
|
|
85
|
+
runtimeConfig: OcxConfig,
|
|
86
|
+
accountId: string,
|
|
87
|
+
): Promise<
|
|
88
|
+
| { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
|
|
89
|
+
| { ok: false; response: Response }
|
|
90
|
+
> {
|
|
91
|
+
if (accountId === "__main__") {
|
|
92
|
+
const tokens = readCodexTokens();
|
|
93
|
+
if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
|
|
94
|
+
return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
|
|
95
|
+
}
|
|
96
|
+
if (!ACCOUNT_ID_RE.test(accountId)) {
|
|
97
|
+
return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
|
|
98
|
+
}
|
|
99
|
+
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
100
|
+
return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
|
|
101
|
+
}
|
|
102
|
+
const cred = await getValidCodexToken(accountId);
|
|
103
|
+
return { ok: true, isMain: false, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
|
|
107
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
108
|
+
const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
|
|
109
|
+
const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
|
|
110
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
111
|
+
const credit = raw as Record<string, unknown>;
|
|
112
|
+
return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
|
|
113
|
+
? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
|
|
114
|
+
: [];
|
|
115
|
+
});
|
|
116
|
+
const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
|
|
117
|
+
?? obj.available_count;
|
|
118
|
+
return {
|
|
119
|
+
credits,
|
|
120
|
+
...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function safeResetCreditConsumeDto(input: unknown): { code: string } {
|
|
125
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
126
|
+
return { code: typeof obj.code === "string" ? obj.code : "unknown" };
|
|
127
|
+
}
|
|
128
|
+
|
|
43
129
|
export function isUnverifiedCodexImportEnabled(): boolean {
|
|
44
130
|
return process.env[MANUAL_IMPORT_ENV] === "1";
|
|
45
131
|
}
|
|
@@ -127,7 +213,7 @@ interface PoolQuotaResult {
|
|
|
127
213
|
needsReauth: boolean;
|
|
128
214
|
}
|
|
129
215
|
|
|
130
|
-
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): Promise<PoolQuotaResult> {
|
|
216
|
+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
|
|
131
217
|
const existing = getAccountQuota(accountId);
|
|
132
218
|
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
133
219
|
return { quota: existing, needsReauth: false };
|
|
@@ -140,7 +226,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): P
|
|
|
140
226
|
});
|
|
141
227
|
if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
|
|
142
228
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
143
|
-
const quota = parseUsageQuota(data);
|
|
229
|
+
const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
|
|
144
230
|
if (!quota) return { quota: existing ?? null, needsReauth: false };
|
|
145
231
|
updateAccountQuota(
|
|
146
232
|
accountId,
|
|
@@ -174,15 +260,9 @@ export async function handleCodexAuthAPI(
|
|
|
174
260
|
const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
175
261
|
const cred = getCodexAccountCredential(a.id);
|
|
176
262
|
const quotaResult = cred
|
|
177
|
-
? await fetchPoolAccountQuota(a.id, forceRefresh)
|
|
263
|
+
? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan)
|
|
178
264
|
: { quota: null, needsReauth: true };
|
|
179
|
-
return
|
|
180
|
-
...a,
|
|
181
|
-
email: maskEmail(a.email) ?? a.email,
|
|
182
|
-
quota: quotaResult.quota ? { ...quotaResult.quota } : null,
|
|
183
|
-
needsReauth: !cred || quotaResult.needsReauth || isAccountNeedsReauth(a.id),
|
|
184
|
-
hasCredential: !!cred,
|
|
185
|
-
};
|
|
265
|
+
return poolAccountDto(a, quotaResult, !!cred);
|
|
186
266
|
});
|
|
187
267
|
const main = {
|
|
188
268
|
id: "__main__",
|
|
@@ -190,7 +270,7 @@ export async function handleCodexAuthAPI(
|
|
|
190
270
|
plan: mainInfo.plan,
|
|
191
271
|
isMain: true,
|
|
192
272
|
hasCredential: true,
|
|
193
|
-
quota: mainInfo.quota ? { ...mainInfo.quota, updatedAt: Date.now() } : null,
|
|
273
|
+
quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
|
|
194
274
|
};
|
|
195
275
|
return jsonResponse({ accounts: [main, ...withQuota] });
|
|
196
276
|
}
|
|
@@ -301,39 +381,27 @@ export async function handleCodexAuthAPI(
|
|
|
301
381
|
const accountId = url.searchParams.get("accountId");
|
|
302
382
|
if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
303
383
|
|
|
304
|
-
const isMain = accountId === "__main__";
|
|
305
|
-
let accessToken: string;
|
|
306
|
-
let chatgptAccountId: string;
|
|
307
|
-
|
|
308
384
|
try {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (!tokens) return jsonResponse({ error: "Main Codex account not logged in" }, 401);
|
|
312
|
-
accessToken = tokens.access_token;
|
|
313
|
-
chatgptAccountId = tokens.account_id;
|
|
314
|
-
} else {
|
|
315
|
-
const cred = await getValidCodexToken(accountId);
|
|
316
|
-
accessToken = cred.accessToken;
|
|
317
|
-
chatgptAccountId = cred.chatgptAccountId;
|
|
318
|
-
}
|
|
385
|
+
const auth = await resolveResetCreditAuth(getRuntimeConfig(config), accountId);
|
|
386
|
+
if (!auth.ok) return auth.response;
|
|
319
387
|
|
|
320
388
|
const resp = await fetch(
|
|
321
389
|
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
|
|
322
390
|
{
|
|
323
391
|
headers: {
|
|
324
|
-
Authorization: `Bearer ${accessToken}`,
|
|
325
|
-
"ChatGPT-Account-Id": chatgptAccountId,
|
|
392
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
393
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
326
394
|
},
|
|
327
395
|
signal: AbortSignal.timeout(8000),
|
|
328
396
|
},
|
|
329
397
|
);
|
|
330
398
|
if (!resp.ok) {
|
|
331
|
-
|
|
332
|
-
return jsonResponse({ error: `Upstream error ${resp.status}
|
|
399
|
+
await resp.body?.cancel().catch(() => {});
|
|
400
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
333
401
|
}
|
|
334
|
-
return jsonResponse(await resp.json());
|
|
402
|
+
return jsonResponse(safeResetCreditsDto(await resp.json()));
|
|
335
403
|
} catch (e) {
|
|
336
|
-
return jsonResponse({ error:
|
|
404
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
|
|
337
405
|
}
|
|
338
406
|
}
|
|
339
407
|
|
|
@@ -341,21 +409,9 @@ export async function handleCodexAuthAPI(
|
|
|
341
409
|
const body = (await req.json().catch(() => ({}))) as { accountId?: string };
|
|
342
410
|
if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
343
411
|
|
|
344
|
-
const isMain = body.accountId === "__main__";
|
|
345
|
-
let accessToken: string;
|
|
346
|
-
let chatgptAccountId: string;
|
|
347
|
-
|
|
348
412
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (!tokens) return jsonResponse({ error: "Main Codex account not logged in" }, 401);
|
|
352
|
-
accessToken = tokens.access_token;
|
|
353
|
-
chatgptAccountId = tokens.account_id;
|
|
354
|
-
} else {
|
|
355
|
-
const cred = await getValidCodexToken(body.accountId);
|
|
356
|
-
accessToken = cred.accessToken;
|
|
357
|
-
chatgptAccountId = cred.chatgptAccountId;
|
|
358
|
-
}
|
|
413
|
+
const auth = await resolveResetCreditAuth(getRuntimeConfig(config), body.accountId);
|
|
414
|
+
if (!auth.ok) return auth.response;
|
|
359
415
|
|
|
360
416
|
const idempotencyKey = crypto.randomUUID();
|
|
361
417
|
const resp = await fetch(
|
|
@@ -363,8 +419,8 @@ export async function handleCodexAuthAPI(
|
|
|
363
419
|
{
|
|
364
420
|
method: "POST",
|
|
365
421
|
headers: {
|
|
366
|
-
Authorization: `Bearer ${accessToken}`,
|
|
367
|
-
"ChatGPT-Account-Id": chatgptAccountId,
|
|
422
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
423
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
368
424
|
"Content-Type": "application/json",
|
|
369
425
|
},
|
|
370
426
|
body: JSON.stringify({ redeem_request_id: idempotencyKey }),
|
|
@@ -372,20 +428,21 @@ export async function handleCodexAuthAPI(
|
|
|
372
428
|
},
|
|
373
429
|
);
|
|
374
430
|
if (!resp.ok) {
|
|
375
|
-
|
|
376
|
-
return jsonResponse({ error: `Upstream error ${resp.status}
|
|
431
|
+
await resp.body?.cancel().catch(() => {});
|
|
432
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
377
433
|
}
|
|
378
|
-
const result = (await resp.json())
|
|
434
|
+
const result = safeResetCreditConsumeDto(await resp.json());
|
|
379
435
|
if (result.code === "reset") {
|
|
380
|
-
if (isMain) {
|
|
436
|
+
if (auth.isMain) {
|
|
381
437
|
await fetchMainAccountInfo(true);
|
|
382
438
|
} else {
|
|
383
|
-
|
|
439
|
+
const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
|
|
440
|
+
await fetchPoolAccountQuota(body.accountId, true, account?.plan);
|
|
384
441
|
}
|
|
385
442
|
}
|
|
386
443
|
return jsonResponse(result);
|
|
387
444
|
} catch (e) {
|
|
388
|
-
return jsonResponse({ error:
|
|
445
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
|
|
389
446
|
}
|
|
390
447
|
}
|
|
391
448
|
|
|
@@ -44,6 +44,11 @@ export function checkAccountIdCollision(
|
|
|
44
44
|
email?: string | null,
|
|
45
45
|
plan?: string | null,
|
|
46
46
|
): { collision: true; reason: string } | { collision: false } {
|
|
47
|
+
const mainAccountId = getMainChatgptAccountId();
|
|
48
|
+
if (mainAccountId && mainAccountId === chatgptAccountId) {
|
|
49
|
+
return { collision: true, reason: "Account is already used by the main Codex login." };
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
const candidateEmail = normalizedEmail(email);
|
|
48
53
|
const candidateWorkspace = isWorkspacePlan(plan);
|
|
49
54
|
for (const account of loadConfig().codexAccounts ?? []) {
|
package/src/codex-catalog.ts
CHANGED
|
@@ -311,7 +311,7 @@ function loadCatalogForSync(path: string): RawCatalog | null {
|
|
|
311
311
|
const catalog = readCatalog(path);
|
|
312
312
|
if (catalog && findNativeTemplate(catalog)) return catalog;
|
|
313
313
|
return readCatalog(catalogBackupPathFor(path))
|
|
314
|
-
?? readCatalog(legacyCatalogBackupPath())
|
|
314
|
+
?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
|
|
315
315
|
?? readCatalog(CODEX_MODELS_CACHE_PATH)
|
|
316
316
|
?? materializeBundledCodexCatalog(path)
|
|
317
317
|
?? catalog;
|
|
@@ -332,8 +332,7 @@ function readCurrentCatalogOrCache(): RawCatalog | null {
|
|
|
332
332
|
export function loadCatalogTemplate(): RawEntry | null {
|
|
333
333
|
const catalogPath = readCodexCatalogPath();
|
|
334
334
|
const native = findNativeTemplate(readCatalog(catalogPath))
|
|
335
|
-
?? findNativeTemplate(
|
|
336
|
-
?? findNativeTemplate(readCatalog(legacyCatalogBackupPath()))
|
|
335
|
+
?? findNativeTemplate(readCatalogBackup(catalogPath))
|
|
337
336
|
?? findNativeTemplate(readCatalog(CODEX_MODELS_CACHE_PATH))
|
|
338
337
|
?? findNativeTemplate(loadBundledCodexCatalog());
|
|
339
338
|
return native ? JSON.parse(JSON.stringify(native)) : null;
|
|
@@ -463,7 +462,8 @@ export function listCatalogNativeSlugs(): string[] {
|
|
|
463
462
|
* (rather than the modified value left in the live catalog by a previous sync).
|
|
464
463
|
*/
|
|
465
464
|
function readCatalogBackup(catalogPath: string): RawCatalog | null {
|
|
466
|
-
return readCatalog(catalogBackupPathFor(catalogPath))
|
|
465
|
+
return readCatalog(catalogBackupPathFor(catalogPath))
|
|
466
|
+
?? (isDefaultCatalogPath(catalogPath) ? readCatalog(legacyCatalogBackupPath()) : null);
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
|
|
@@ -486,7 +486,7 @@ function ensureCatalogBackup(catalogPath: string, catalog: RawCatalog): void {
|
|
|
486
486
|
const dir = getConfigDir();
|
|
487
487
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
488
488
|
writePristineCatalogBackup(catalogBackupPathFor(catalogPath), catalogPath, catalog);
|
|
489
|
-
writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
|
|
489
|
+
if (isDefaultCatalogPath(catalogPath)) writePristineCatalogBackup(legacyCatalogBackupPath(), catalogPath, catalog);
|
|
490
490
|
}
|
|
491
491
|
|
|
492
492
|
function readNativeBaseline(catalogPath: string): Map<string, number> {
|
|
@@ -695,7 +695,6 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
695
695
|
const template = findNativeTemplate(catalog);
|
|
696
696
|
|
|
697
697
|
const goModels = await gatherRoutedModels(config);
|
|
698
|
-
if (goModels.length === 0) return { added: 0, path: catalogPath };
|
|
699
698
|
try {
|
|
700
699
|
// Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
|
|
701
700
|
// (later syncs would otherwise overwrite it with featured-modified priorities).
|
|
@@ -719,7 +718,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
719
718
|
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && !goIds.has(m.slug as string))
|
|
720
719
|
.map(m => {
|
|
721
720
|
const slug = m.slug as string;
|
|
722
|
-
const
|
|
721
|
+
const baselinePriority = baseline.get(slug) ?? (m.priority as number);
|
|
722
|
+
const priority = rank.has(slug)
|
|
723
|
+
? rank.get(slug)!
|
|
724
|
+
: featured.length > 0
|
|
725
|
+
? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100)
|
|
726
|
+
: baselinePriority;
|
|
723
727
|
return normalizeServiceTiers({ ...m, priority });
|
|
724
728
|
});
|
|
725
729
|
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
@@ -746,11 +750,19 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
|
|
|
746
750
|
const catalogPath = readCodexCatalogPath();
|
|
747
751
|
const catalog = readCatalog(catalogPath);
|
|
748
752
|
if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
|
|
749
|
-
const
|
|
750
|
-
if (
|
|
751
|
-
const removed =
|
|
752
|
-
|
|
753
|
-
|
|
753
|
+
const backup = readCatalogBackup(catalogPath);
|
|
754
|
+
if (backup && Array.isArray(backup.models)) {
|
|
755
|
+
const removed = (catalog.models ?? []).filter(m => typeof m.slug === "string" && m.slug.includes("/")).length;
|
|
756
|
+
const backupSlugs = new Set(backup.models.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
757
|
+
const userNativeAdditions = (catalog.models ?? []).filter(m =>
|
|
758
|
+
typeof m.slug === "string" && !m.slug.includes("/") && !backupSlugs.has(m.slug)
|
|
759
|
+
);
|
|
760
|
+
const restored = {
|
|
761
|
+
...backup,
|
|
762
|
+
models: [...backup.models, ...userNativeAdditions],
|
|
763
|
+
};
|
|
764
|
+
atomicWriteFile(catalogPath, JSON.stringify(restored, null, 2) + "\n");
|
|
765
|
+
return { removed, kept: restored.models.length, path: catalogPath };
|
|
754
766
|
}
|
|
755
767
|
const before = catalog.models.length;
|
|
756
768
|
const native = catalog.models.filter(m => !(typeof m.slug === "string" && m.slug.includes("/")));
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { Database } from "bun:sqlite";
|
|
4
5
|
import { CODEX_HOME } from "./codex-paths";
|
|
5
6
|
import { atomicWriteFile, getConfigDir } from "./config";
|
|
6
7
|
|
|
7
8
|
const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
|
|
8
|
-
|
|
9
|
+
function historyBackupPathFor(stateDbPath: string): string {
|
|
10
|
+
const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath);
|
|
11
|
+
const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
12
|
+
return join(getConfigDir(), `codex-history-backup-${id}.json`);
|
|
13
|
+
}
|
|
14
|
+
const HISTORY_BACKUP_PATH = historyBackupPathFor(STATE_DB_PATH);
|
|
9
15
|
const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
|
|
10
16
|
|
|
11
17
|
type CodexHistoryProvider = "openai" | "opencodex";
|
|
@@ -34,6 +40,7 @@ interface BackupEntry {
|
|
|
34
40
|
|
|
35
41
|
interface BackupManifest {
|
|
36
42
|
version: 1;
|
|
43
|
+
stateDbPath?: string;
|
|
37
44
|
entries: Record<string, BackupEntry>;
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -43,26 +50,35 @@ interface NativeRestoreTarget {
|
|
|
43
50
|
hasUserEvent: number;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
function
|
|
47
|
-
|
|
53
|
+
function samePath(a: string, b: string): boolean {
|
|
54
|
+
const left = resolve(a);
|
|
55
|
+
const right = resolve(b);
|
|
56
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBackup(path: string, stateDbPath?: string): BackupManifest {
|
|
60
|
+
if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
|
|
48
61
|
try {
|
|
49
62
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<BackupManifest>;
|
|
50
63
|
if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
|
|
51
|
-
return { version: 1, entries: {} };
|
|
64
|
+
return { version: 1, stateDbPath, entries: {} };
|
|
65
|
+
}
|
|
66
|
+
if (stateDbPath && typeof parsed.stateDbPath === "string" && !samePath(parsed.stateDbPath, stateDbPath)) {
|
|
67
|
+
return { version: 1, stateDbPath, entries: {} };
|
|
52
68
|
}
|
|
53
|
-
return { version: 1, entries: parsed.entries };
|
|
69
|
+
return { version: 1, stateDbPath: parsed.stateDbPath ?? stateDbPath, entries: parsed.entries };
|
|
54
70
|
} catch {
|
|
55
|
-
return { version: 1, entries: {} };
|
|
71
|
+
return { version: 1, stateDbPath, entries: {} };
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
74
|
|
|
59
|
-
function writeBackup(path: string, manifest: BackupManifest): void {
|
|
75
|
+
function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: string): void {
|
|
60
76
|
if (Object.keys(manifest.entries).length === 0) {
|
|
61
77
|
if (existsSync(path)) unlinkSync(path);
|
|
62
78
|
return;
|
|
63
79
|
}
|
|
64
80
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
65
|
-
atomicWriteFile(path, JSON.stringify(manifest, null, 2) + "\n");
|
|
81
|
+
atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void {
|
|
@@ -211,9 +227,9 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
|
|
|
211
227
|
`)
|
|
212
228
|
.all();
|
|
213
229
|
|
|
214
|
-
const manifest = readBackup(backupPath);
|
|
230
|
+
const manifest = readBackup(backupPath, stateDbPath);
|
|
215
231
|
for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row);
|
|
216
|
-
writeBackup(backupPath, manifest);
|
|
232
|
+
writeBackup(backupPath, manifest, stateDbPath);
|
|
217
233
|
|
|
218
234
|
let files = 0;
|
|
219
235
|
for (const row of openaiRows) {
|
|
@@ -262,7 +278,7 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
|
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult {
|
|
265
|
-
const manifest = readBackup(backupPath);
|
|
281
|
+
const manifest = readBackup(backupPath, stateDbPath);
|
|
266
282
|
const entries = Object.values(manifest.entries);
|
|
267
283
|
|
|
268
284
|
const db = new Database(stateDbPath);
|
|
@@ -296,7 +312,7 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
296
312
|
}
|
|
297
313
|
});
|
|
298
314
|
restore();
|
|
299
|
-
writeBackup(backupPath, { version: 1, entries: {} });
|
|
315
|
+
writeBackup(backupPath, { version: 1, stateDbPath, entries: {} }, stateDbPath);
|
|
300
316
|
const ejected = ejectRemainingOpencodexHistory(db);
|
|
301
317
|
return ejected.rows > 0
|
|
302
318
|
? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows }
|
package/src/codex-inject.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { atomicWriteFile, websocketsEnabled } from "./config";
|
|
3
|
-
import {
|
|
3
|
+
import { markJournalInjectedState, restoreJournalState, writeJournal } from "./codex-journal";
|
|
4
4
|
import { restoreCodexCatalog } from "./codex-catalog";
|
|
5
5
|
import { syncCodexHistoryProvider } from "./codex-history-provider";
|
|
6
6
|
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./codex-paths";
|
|
@@ -25,16 +25,36 @@ export interface InjectCodexOptions {
|
|
|
25
25
|
* whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
|
|
26
26
|
* never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
29
|
+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
|
|
30
|
+
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function providerBaseHost(hostname: string | undefined): string {
|
|
34
|
+
const trimmed = (hostname ?? "127.0.0.1").trim();
|
|
35
|
+
if (isLoopbackHostname(trimmed) || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "localhost";
|
|
36
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
|
37
|
+
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shouldInjectApiAuthHeader(config: Pick<OcxConfig, "hostname"> | undefined): boolean {
|
|
41
|
+
return !isLoopbackHostname(config?.hostname);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildProviderTableBlock(port: number, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
|
|
45
|
+
const host = providerBaseHost(hostname);
|
|
29
46
|
const lines = [
|
|
30
47
|
"",
|
|
31
48
|
OCX_SECTION_MARKER,
|
|
32
49
|
"[model_providers.opencodex]",
|
|
33
50
|
'name = "OpenCodex Proxy"',
|
|
34
|
-
`base_url = "http
|
|
51
|
+
`base_url = "http://${host}:${port}/v1"`,
|
|
35
52
|
'wire_api = "responses"',
|
|
36
53
|
"requires_openai_auth = true",
|
|
37
54
|
];
|
|
55
|
+
if (includeApiAuthHeader) {
|
|
56
|
+
lines.push('env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }');
|
|
57
|
+
}
|
|
38
58
|
if (supportsWebsockets) lines.push("supports_websockets = true");
|
|
39
59
|
return lines.join("\n") + "\n";
|
|
40
60
|
}
|
|
@@ -61,17 +81,6 @@ function stripExistingModelProvider(content: string): string {
|
|
|
61
81
|
return out.join("\n");
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
function stripRootContextWindowOverrides(content: string): string {
|
|
65
|
-
const lines = content.split("\n");
|
|
66
|
-
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
67
|
-
return lines
|
|
68
|
-
.filter((line, i) => {
|
|
69
|
-
const isRoot = firstTable === -1 || i < firstTable;
|
|
70
|
-
return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
|
|
71
|
-
})
|
|
72
|
-
.join("\n");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
84
|
function stripRootRoutedModel(content: string): string {
|
|
76
85
|
const lines = content.split("\n");
|
|
77
86
|
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
@@ -142,7 +151,7 @@ function removeProfileSection(content: string): string {
|
|
|
142
151
|
continue;
|
|
143
152
|
}
|
|
144
153
|
if (inProfile) {
|
|
145
|
-
if (line
|
|
154
|
+
if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") {
|
|
146
155
|
inProfile = false;
|
|
147
156
|
filtered.push(line);
|
|
148
157
|
}
|
|
@@ -193,14 +202,16 @@ function stripOpencodexCatalogPath(content: string): string {
|
|
|
193
202
|
.join("\n");
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
export function buildProfileFile(port: number, catalogPath?: string | null): string {
|
|
205
|
+
export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets = false, includeApiAuthHeader = false, hostname?: string): string {
|
|
206
|
+
const host = providerBaseHost(hostname);
|
|
197
207
|
const lines = [
|
|
198
208
|
"# OpenCodex proxy profile — use with: codex --profile opencodex",
|
|
199
|
-
`# Routes all model requests through the opencodex proxy at
|
|
209
|
+
`# Routes all model requests through the opencodex proxy at ${host}:${port}`,
|
|
200
210
|
'model_provider = "opencodex"',
|
|
201
211
|
];
|
|
202
212
|
if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
|
|
203
|
-
lines.push("", "[features]", "fast_mode = true"
|
|
213
|
+
lines.push("", "[features]", "fast_mode = true");
|
|
214
|
+
lines.push(buildProviderTableBlock(port, supportsWebsockets, includeApiAuthHeader, hostname).trimEnd(), "");
|
|
204
215
|
return lines.join("\n");
|
|
205
216
|
}
|
|
206
217
|
|
|
@@ -221,6 +232,7 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
221
232
|
return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
|
|
222
233
|
}
|
|
223
234
|
|
|
235
|
+
writeJournal();
|
|
224
236
|
let content = readFileSync(CODEX_CONFIG_PATH, "utf-8");
|
|
225
237
|
|
|
226
238
|
// Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every
|
|
@@ -231,7 +243,6 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
231
243
|
}
|
|
232
244
|
content = removeProfileSection(content);
|
|
233
245
|
content = stripExistingModelProvider(content);
|
|
234
|
-
content = stripRootContextWindowOverrides(content);
|
|
235
246
|
content = normalizeServiceTier(content);
|
|
236
247
|
content = ensureFastModeFeature(content);
|
|
237
248
|
|
|
@@ -241,10 +252,12 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
241
252
|
// 1) Root key BEFORE the first table header (must be a global, not nested under a table).
|
|
242
253
|
content = setRootModelProvider(content);
|
|
243
254
|
// 2) Provider table appended at EOF (position-independent).
|
|
244
|
-
content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}));
|
|
255
|
+
content = content.trimEnd() + "\n" + buildProviderTableBlock(port, websocketsEnabled(config ?? {}), shouldInjectApiAuthHeader(config), config?.hostname);
|
|
245
256
|
|
|
257
|
+
const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), shouldInjectApiAuthHeader(config), config?.hostname);
|
|
246
258
|
atomicWriteFile(CODEX_CONFIG_PATH, content);
|
|
247
|
-
atomicWriteFile(CODEX_PROFILE_PATH,
|
|
259
|
+
atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
|
|
260
|
+
markJournalInjectedState(content, profileContent);
|
|
248
261
|
const history = config?.syncResumeHistory !== false
|
|
249
262
|
? syncCodexHistoryProvider("opencodex")
|
|
250
263
|
: { rows: 0, files: 0 };
|
|
@@ -279,7 +292,7 @@ function removeOcxSection(content: string): string {
|
|
|
279
292
|
if (inOcxSection) {
|
|
280
293
|
// End the injected section at the next table header that ISN'T our own — exact match so a
|
|
281
294
|
// user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
|
|
282
|
-
if (line
|
|
295
|
+
if (/^\s*\[/.test(line) && line.trim() !== "[model_providers.opencodex]") {
|
|
283
296
|
inOcxSection = false;
|
|
284
297
|
filtered.push(line);
|
|
285
298
|
}
|
|
@@ -293,6 +306,7 @@ function removeOcxSection(content: string): string {
|
|
|
293
306
|
/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */
|
|
294
307
|
export function stripOpencodexConfig(content: string): string {
|
|
295
308
|
let out = content;
|
|
309
|
+
const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex";
|
|
296
310
|
if (out.includes("[model_providers.opencodex]")) {
|
|
297
311
|
out = removeOcxSection(out);
|
|
298
312
|
}
|
|
@@ -300,8 +314,7 @@ export function stripOpencodexConfig(content: string): string {
|
|
|
300
314
|
// Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
|
|
301
315
|
// must match the detection regex above, or a detected line could survive un-removed.
|
|
302
316
|
out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
|
|
303
|
-
out =
|
|
304
|
-
out = stripRootRoutedModel(out);
|
|
317
|
+
if (hadRootOcxProvider) out = stripRootRoutedModel(out);
|
|
305
318
|
out = stripOpencodexCatalogPath(out);
|
|
306
319
|
return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
307
320
|
}
|
|
@@ -310,7 +323,7 @@ function hasOpencodexRouting(content: string): boolean {
|
|
|
310
323
|
return content.includes("[model_providers.opencodex]") || /^\s*model_provider\s*=\s*"opencodex"/m.test(content);
|
|
311
324
|
}
|
|
312
325
|
|
|
313
|
-
export function removeCodexConfig(): { success: boolean; message: string } {
|
|
326
|
+
export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } {
|
|
314
327
|
if (!existsSync(CODEX_CONFIG_PATH)) {
|
|
315
328
|
return { success: false, message: "Codex config not found." };
|
|
316
329
|
}
|
|
@@ -321,10 +334,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
|
|
|
321
334
|
} else if (stripOpencodexConfig(content) !== content) {
|
|
322
335
|
atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
|
|
323
336
|
}
|
|
324
|
-
if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
|
|
337
|
+
if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
|
|
325
338
|
return {
|
|
326
339
|
success: true,
|
|
327
|
-
message: had
|
|
340
|
+
message: had
|
|
341
|
+
? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}`
|
|
342
|
+
: "opencodex not present in Codex config.",
|
|
328
343
|
};
|
|
329
344
|
}
|
|
330
345
|
|
|
@@ -334,10 +349,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
|
|
|
334
349
|
* handler, and `ocx restore`. Idempotent + atomic.
|
|
335
350
|
*/
|
|
336
351
|
export function restoreNativeCodex(): { success: boolean; message: string } {
|
|
337
|
-
const
|
|
352
|
+
const journal = restoreJournalState();
|
|
353
|
+
const cfg = journal.configRestored
|
|
354
|
+
? { success: true, message: "Codex config restored from opencodex journal." }
|
|
355
|
+
: removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
|
|
338
356
|
const cat = restoreCodexCatalog();
|
|
339
357
|
const history = syncCodexHistoryProvider("openai");
|
|
340
|
-
removeJournal();
|
|
341
358
|
const msg = cat.removed > 0
|
|
342
359
|
? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
|
|
343
360
|
: cfg.message;
|