@bitkyc08/opencodex 2.6.11 → 2.6.13
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 +135 -35
- package/README.md +6 -0
- package/README.zh-CN.md +147 -26
- package/gui/dist/assets/{index-DaRQZAM0.js → index-BTTqyZ-C.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli-help.ts +2 -0
- package/src/cli.ts +5 -0
- package/src/codex-auth-api.ts +51 -0
- package/src/codex-auth-collision.ts +3 -7
- package/src/codex-auth-context.ts +11 -0
- package/src/codex-routing.ts +64 -2
- package/src/doctor.ts +173 -0
- package/src/server.ts +8 -0
- package/src/web-search/parse.ts +52 -15
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BTTqyZ-C.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-DIBiVVC0.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
package/src/cli-help.ts
CHANGED
|
@@ -44,6 +44,7 @@ const helpEntries: Record<string, HelpEntry> = {
|
|
|
44
44
|
sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
|
|
45
45
|
"sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
|
|
46
46
|
status: { usage: "ocx status", summary: "Check proxy server status." },
|
|
47
|
+
doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
|
|
47
48
|
login: { usage: "ocx login <provider>", summary: "OAuth or API-key login for a provider." },
|
|
48
49
|
logout: { usage: "ocx logout <provider>", summary: "Remove a stored provider login." },
|
|
49
50
|
gui: { usage: "ocx gui", summary: "Open the opencodex dashboard." },
|
|
@@ -80,6 +81,7 @@ Usage:
|
|
|
80
81
|
ocx sync Fetch models from providers and inject into Codex config
|
|
81
82
|
ocx sync-cache Refresh Codex's model cache from the active catalog
|
|
82
83
|
ocx status Check proxy server status
|
|
84
|
+
ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
|
|
83
85
|
ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
|
|
84
86
|
ocx logout <provider> Remove a stored OAuth login
|
|
85
87
|
ocx gui Open the opencodex dashboard
|
package/src/cli.ts
CHANGED
package/src/codex-auth-api.ts
CHANGED
|
@@ -262,6 +262,57 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
let primeInFlight: Promise<void> | null = null;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Best-effort prime of pool-account (and main) quota so the rotation engine has
|
|
269
|
+
* real usage scores instead of leaving every account at the unknown sentinel.
|
|
270
|
+
*
|
|
271
|
+
* Quota is otherwise populated only from live upstream headers (an idle pool
|
|
272
|
+
* account never serves traffic, so it never gets scored) or from the dashboard
|
|
273
|
+
* WHAM fetch (a CLI-only user never opens it). Without priming, every account
|
|
274
|
+
* stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
|
|
275
|
+
* and lazily before routing when the active account is unknown.
|
|
276
|
+
*
|
|
277
|
+
* Single-flight: concurrent callers share one pass instead of stampeding N WHAM
|
|
278
|
+
* fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
|
|
279
|
+
* cost, so the worst case is one WHAM call per account per TTL window. Failures
|
|
280
|
+
* are swallowed: a blocked WSL network must never crash startup or a request.
|
|
281
|
+
*/
|
|
282
|
+
export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
|
|
283
|
+
if (primeInFlight) return primeInFlight;
|
|
284
|
+
primeInFlight = (async () => {
|
|
285
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
286
|
+
const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
|
|
287
|
+
const stale = pool.filter(a => {
|
|
288
|
+
const q = getAccountQuota(a.id);
|
|
289
|
+
return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
290
|
+
});
|
|
291
|
+
const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
|
|
292
|
+
try {
|
|
293
|
+
await Promise.allSettled([
|
|
294
|
+
primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
|
|
295
|
+
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
296
|
+
if (!getCodexAccountCredential(a.id)) return;
|
|
297
|
+
await fetchPoolAccountQuota(a.id, false, a.plan);
|
|
298
|
+
}),
|
|
299
|
+
]);
|
|
300
|
+
} catch {
|
|
301
|
+
// Priming is best-effort; never propagate.
|
|
302
|
+
}
|
|
303
|
+
if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
|
|
304
|
+
console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
|
|
305
|
+
}
|
|
306
|
+
})().finally(() => { primeInFlight = null; });
|
|
307
|
+
return primeInFlight;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Test-only: drop any in-flight prime pass so a leaked single-flight promise
|
|
311
|
+
* from another suite cannot coalesce into the next prime. */
|
|
312
|
+
export function clearCodexQuotaPrimeState(): void {
|
|
313
|
+
primeInFlight = null;
|
|
314
|
+
}
|
|
315
|
+
|
|
265
316
|
export async function handleCodexAuthAPI(
|
|
266
317
|
req: Request,
|
|
267
318
|
url: URL,
|
|
@@ -37,18 +37,14 @@ function isWorkspacePlan(plan: string | undefined | null): boolean {
|
|
|
37
37
|
return !!plan && /team|business|enterprise|workspace|edu/i.test(plan);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// Main login and managed pool accounts are separate duplicate buckets.
|
|
41
|
+
// Inside the pool, personal and workspace subscriptions are also separate buckets.
|
|
42
|
+
// Within each pool bucket, keep the original ChatGPT account id + email collision guard.
|
|
42
43
|
export function checkAccountIdCollision(
|
|
43
44
|
chatgptAccountId: string,
|
|
44
45
|
email?: string | null,
|
|
45
46
|
plan?: string | null,
|
|
46
47
|
): { 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
|
-
|
|
52
48
|
const candidateEmail = normalizedEmail(email);
|
|
53
49
|
const candidateWorkspace = isWorkspacePlan(plan);
|
|
54
50
|
for (const account of loadConfig().codexAccounts ?? []) {
|
|
@@ -8,6 +8,7 @@ import { markAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
|
8
8
|
import { isCodexAccountUsable } from "./codex-account-usability";
|
|
9
9
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./codex-main-account";
|
|
10
10
|
import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
|
|
11
|
+
import { getAccountQuota } from "./codex-quota";
|
|
11
12
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
12
13
|
import { FORWARD_HEADERS } from "./adapters/openai-responses";
|
|
13
14
|
|
|
@@ -76,6 +77,16 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
|
|
|
76
77
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
77
78
|
const accountId = resolution.status === "selected" ? resolution.accountId : null;
|
|
78
79
|
if (!accountId) return { kind: "main", accountId: null };
|
|
80
|
+
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
81
|
+
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
82
|
+
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
83
|
+
// blocks the current request, and the helper's single-flight guard collapses
|
|
84
|
+
// repeated triggers into one pass.
|
|
85
|
+
if (!getAccountQuota(accountId)) {
|
|
86
|
+
import("./codex-auth-api")
|
|
87
|
+
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
}
|
|
79
90
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId);
|
|
80
91
|
if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
81
92
|
|
package/src/codex-routing.ts
CHANGED
|
@@ -12,6 +12,9 @@ type ThreadAffinityEntry = {
|
|
|
12
12
|
generation: number;
|
|
13
13
|
createdAt: number;
|
|
14
14
|
lastUsedAt: number;
|
|
15
|
+
// Last time the bound account's quota threshold was re-evaluated for this
|
|
16
|
+
// thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
|
|
17
|
+
lastReevalAt: number;
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export type CodexThreadResolution =
|
|
@@ -32,6 +35,9 @@ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
|
32
35
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
33
36
|
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
34
37
|
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
38
|
+
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
39
|
+
// Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
|
|
40
|
+
export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
|
|
35
41
|
|
|
36
42
|
const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
37
43
|
|
|
@@ -200,6 +206,7 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v
|
|
|
200
206
|
generation: record.generation,
|
|
201
207
|
createdAt: previous?.createdAt ?? now,
|
|
202
208
|
lastUsedAt: now,
|
|
209
|
+
lastReevalAt: now,
|
|
203
210
|
});
|
|
204
211
|
pruneLruThreadAffinities();
|
|
205
212
|
}
|
|
@@ -260,6 +267,20 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
|
260
267
|
saveConfig(config);
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
function isUnknownUsage(usage: number): boolean {
|
|
271
|
+
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Round-robin among eligible unknown-quota candidates. `getEligiblePoolAccounts`
|
|
275
|
+
// already returns a deterministic order (config order, main unshifted first) and
|
|
276
|
+
// excludes the active id, so taking the first eligible unknown is a stable rotation
|
|
277
|
+
// without any new per-account state.
|
|
278
|
+
function pickNextUnknownAccount(config: OcxConfig, active: string, now: number): string | null {
|
|
279
|
+
const eligible = getEligiblePoolAccounts(config, active, now)
|
|
280
|
+
.filter(id => isUnknownUsage(computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id))));
|
|
281
|
+
return eligible.length > 0 ? eligible[0]! : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
263
284
|
function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
|
|
264
285
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
265
286
|
if (threshold <= 0) return active;
|
|
@@ -267,8 +288,27 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
|
|
|
267
288
|
const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
|
|
268
289
|
if (activeUsage < threshold) return active;
|
|
269
290
|
const best = pickLowerUsageAccount(config, active, activeUsage, now);
|
|
270
|
-
if (best !== active)
|
|
271
|
-
|
|
291
|
+
if (best !== active) {
|
|
292
|
+
setActiveCodexAccount(config, best);
|
|
293
|
+
return best;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Deadlock guard: active is over threshold but no candidate scored strictly
|
|
297
|
+
// lower. When the active itself is unknown, every candidate is likely unknown
|
|
298
|
+
// too (100 < 100 never fires), which pins the pool to one account whose real
|
|
299
|
+
// usage we cannot see (e.g. quota never primed on WSL). Rotate to the next
|
|
300
|
+
// eligible unknown so rotation is not stuck; known-but-saturated accounts are
|
|
301
|
+
// intentionally left alone so a genuinely hot pool stays visible.
|
|
302
|
+
if (isUnknownUsage(activeUsage)) {
|
|
303
|
+
const next = pickNextUnknownAccount(config, active, now);
|
|
304
|
+
if (next) {
|
|
305
|
+
console.warn(`[codex-routing] quota unknown for active "${active}"; rotating to "${next}" (all candidates unknown, threshold=${threshold})`);
|
|
306
|
+
setActiveCodexAccount(config, next);
|
|
307
|
+
return next;
|
|
308
|
+
}
|
|
309
|
+
console.warn(`[codex-routing] quota unknown for active "${active}" and no eligible rotation target; staying put`);
|
|
310
|
+
}
|
|
311
|
+
return active;
|
|
272
312
|
}
|
|
273
313
|
|
|
274
314
|
function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
|
|
@@ -314,6 +354,28 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
314
354
|
&& isCodexAccountSelectable(config, entry.accountId, now)
|
|
315
355
|
) {
|
|
316
356
|
entry.lastUsedAt = now;
|
|
357
|
+
// Periodic quota re-eval: a long-lived bound thread must still switch when
|
|
358
|
+
// it crosses autoSwitchThreshold and a strictly-cooler account exists.
|
|
359
|
+
// Without this the reuse branch returns before applyQuotaAutoSwitch and the
|
|
360
|
+
// thread stays pinned for the full idle TTL (the WSL "never switches" report).
|
|
361
|
+
if (now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
|
|
362
|
+
entry.lastReevalAt = now;
|
|
363
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
364
|
+
if (threshold > 0) {
|
|
365
|
+
const usage = computeCodexUsageScore(
|
|
366
|
+
getAccountQuota(entry.accountId),
|
|
367
|
+
getPoolAccountPlan(config, entry.accountId),
|
|
368
|
+
);
|
|
369
|
+
if (usage >= threshold) {
|
|
370
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
|
|
371
|
+
if (best !== entry.accountId) {
|
|
372
|
+
setActiveCodexAccount(config, best);
|
|
373
|
+
bindThreadAffinity(threadId, best, now); // rebinds + resets clocks
|
|
374
|
+
return { status: "selected", accountId: best };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
317
379
|
return { status: "selected", accountId: entry.accountId };
|
|
318
380
|
}
|
|
319
381
|
threadAccountMap.delete(threadId);
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ocx doctor` - read-only environment diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* Explains WHY ChatGPT quota may never populate (and thus why account
|
|
5
|
+
* auto-switch can appear stuck), especially on WSL2 where outbound fetch to
|
|
6
|
+
* chatgpt.com can be blocked by NAT/DNS/VPN/proxy differences. Observe-only:
|
|
7
|
+
* it never sets proxy env, relocates state dirs, mutates quota, or changes
|
|
8
|
+
* networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { getConfigDir, getConfigPath } from "./config";
|
|
14
|
+
import { readCodexTokens } from "./codex-auth-collision";
|
|
15
|
+
|
|
16
|
+
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
17
|
+
const PROBE_TIMEOUT_MS = 8000;
|
|
18
|
+
|
|
19
|
+
export type PathRow = { label: string; path: string; exists: boolean };
|
|
20
|
+
|
|
21
|
+
export function resolveCodexHomeDir(): string {
|
|
22
|
+
const raw = process.env["CODEX_HOME"]?.trim();
|
|
23
|
+
return raw ? resolve(raw) : join(homedir(), ".codex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function collectPaths(): PathRow[] {
|
|
27
|
+
const codexHome = resolveCodexHomeDir();
|
|
28
|
+
const opencodexHome = getConfigDir();
|
|
29
|
+
return [
|
|
30
|
+
{ label: "CODEX_HOME", path: codexHome, exists: existsSync(codexHome) },
|
|
31
|
+
{ label: "CODEX_HOME/auth.json", path: join(codexHome, "auth.json"), exists: existsSync(join(codexHome, "auth.json")) },
|
|
32
|
+
{ label: "OPENCODEX_HOME", path: opencodexHome, exists: existsSync(opencodexHome) },
|
|
33
|
+
{ label: "OPENCODEX_HOME/config.json", path: getConfigPath(), exists: existsSync(getConfigPath()) },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FsTypeInfo = { fstype: string; mount: string; isDrvfs: boolean; isMntDrive: boolean };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse `/proc/mounts`-shaped content and return the longest mount-point prefix
|
|
41
|
+
* covering `path`. `mountsContent` is injectable for testing; in production the
|
|
42
|
+
* caller passes the real file (or null off-Linux -> "n/a").
|
|
43
|
+
*/
|
|
44
|
+
export function detectFsType(path: string, mountsContent: string | null): FsTypeInfo {
|
|
45
|
+
const isMntDrive = /^\/mnt\/[a-z]\//i.test(path) || /^\/mnt\/[a-z]$/i.test(path);
|
|
46
|
+
if (!mountsContent) {
|
|
47
|
+
return { fstype: "n/a", mount: "", isDrvfs: false, isMntDrive };
|
|
48
|
+
}
|
|
49
|
+
let best: { mount: string; fstype: string } | null = null;
|
|
50
|
+
for (const line of mountsContent.split("\n")) {
|
|
51
|
+
const parts = line.split(/\s+/);
|
|
52
|
+
if (parts.length < 3) continue;
|
|
53
|
+
const mount = parts[1]!;
|
|
54
|
+
const fstype = parts[2]!;
|
|
55
|
+
if (path === mount || path.startsWith(mount.endsWith("/") ? mount : `${mount}/`) || mount === "/") {
|
|
56
|
+
if (!best || mount.length > best.mount.length) best = { mount, fstype };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const fstype = best?.fstype ?? "unknown";
|
|
60
|
+
return {
|
|
61
|
+
fstype,
|
|
62
|
+
mount: best?.mount ?? "",
|
|
63
|
+
isDrvfs: fstype === "drvfs" || fstype === "9p",
|
|
64
|
+
isMntDrive,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readMounts(): string | null {
|
|
69
|
+
try {
|
|
70
|
+
return process.platform === "linux" ? readFileSync("/proc/mounts", "utf-8") : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
|
|
77
|
+
|
|
78
|
+
export type ProxyEnvRow = { key: string; present: boolean };
|
|
79
|
+
|
|
80
|
+
/** Report only presence/absence of proxy env vars - never the value (it may
|
|
81
|
+
* embed credentials). Checks both upper- and lower-case forms. */
|
|
82
|
+
export function collectProxyEnv(): ProxyEnvRow[] {
|
|
83
|
+
return PROXY_KEYS.map(key => ({
|
|
84
|
+
key,
|
|
85
|
+
present: !!(process.env[key]?.trim() || process.env[key.toLowerCase()]?.trim()),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type WhamProbeResult = {
|
|
90
|
+
ok: boolean;
|
|
91
|
+
status: number | null;
|
|
92
|
+
durationMs: number;
|
|
93
|
+
classification: "ok" | "timeout" | "connect_error" | string;
|
|
94
|
+
authenticated: boolean;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replicate the runtime WHAM fetch shape (same URL, 8s timeout, main-token
|
|
99
|
+
* headers when present) so the probe fails exactly where the real path fails.
|
|
100
|
+
* `fetchImpl` is injectable for testing.
|
|
101
|
+
*/
|
|
102
|
+
export async function probeWham(fetchImpl: typeof fetch = fetch): Promise<WhamProbeResult> {
|
|
103
|
+
const tokens = readCodexTokens();
|
|
104
|
+
const headers: Record<string, string> = {};
|
|
105
|
+
if (tokens) {
|
|
106
|
+
headers.Authorization = `Bearer ${tokens.access_token}`;
|
|
107
|
+
headers["ChatGPT-Account-Id"] = tokens.account_id;
|
|
108
|
+
}
|
|
109
|
+
const start = performance.now();
|
|
110
|
+
try {
|
|
111
|
+
const resp = await fetchImpl(WHAM_USAGE_URL, { headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
112
|
+
const durationMs = Math.round(performance.now() - start);
|
|
113
|
+
return {
|
|
114
|
+
ok: resp.ok,
|
|
115
|
+
status: resp.status,
|
|
116
|
+
durationMs,
|
|
117
|
+
classification: resp.ok ? "ok" : `http_${resp.status}`,
|
|
118
|
+
authenticated: !!tokens,
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const durationMs = Math.round(performance.now() - start);
|
|
122
|
+
const name = err instanceof Error ? err.name : String(err);
|
|
123
|
+
const classification = name === "TimeoutError" || name === "AbortError"
|
|
124
|
+
? "timeout"
|
|
125
|
+
: "connect_error";
|
|
126
|
+
return { ok: false, status: null, durationMs, classification, authenticated: !!tokens };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runDoctor(): Promise<void> {
|
|
131
|
+
console.log("opencodex doctor\n");
|
|
132
|
+
|
|
133
|
+
const paths = collectPaths();
|
|
134
|
+
const mounts = readMounts();
|
|
135
|
+
console.log("Paths");
|
|
136
|
+
for (const row of paths) {
|
|
137
|
+
const fs = detectFsType(row.path, mounts);
|
|
138
|
+
const flags = [fs.fstype !== "n/a" ? `fs=${fs.fstype}` : null, fs.isDrvfs || fs.isMntDrive ? "WSL /mnt drive" : null]
|
|
139
|
+
.filter(Boolean).join(", ");
|
|
140
|
+
console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log("\nProxy env (presence only)");
|
|
144
|
+
for (const row of collectProxyEnv()) {
|
|
145
|
+
console.log(` ${row.present ? "set " : "unset "} ${row.key}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log("\nWHAM reachability");
|
|
149
|
+
const probe = await probeWham();
|
|
150
|
+
const detail = probe.status !== null ? `status=${probe.status}` : `error=${probe.classification}`;
|
|
151
|
+
console.log(` ${probe.ok ? "ok " : "-- "} ${WHAM_USAGE_URL}`);
|
|
152
|
+
console.log(` ${detail}, ${probe.durationMs}ms, ${probe.authenticated ? "authenticated" : "unauthenticated"}`);
|
|
153
|
+
|
|
154
|
+
// Hints, not fixes.
|
|
155
|
+
const hints: string[] = [];
|
|
156
|
+
const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
|
|
157
|
+
const noProxy = collectProxyEnv().every(p => !p.present);
|
|
158
|
+
if (anyDrvfs) {
|
|
159
|
+
hints.push("State dir is on a Windows-mounted (/mnt) drive. Prefer the Linux home (~) under WSL for token/lock reliability.");
|
|
160
|
+
}
|
|
161
|
+
if (!probe.ok) {
|
|
162
|
+
if (probe.classification === "timeout" || probe.classification === "connect_error") {
|
|
163
|
+
hints.push("WHAM probe could not reach chatgpt.com. On WSL2 this is often NAT/DNS/VPN. Quota cannot prime, so auto-switch stays on unknown scores.");
|
|
164
|
+
if (noProxy) {
|
|
165
|
+
hints.push("No *_PROXY env is set in this WSL process. If Windows uses a proxy/VPN, set HTTP(S)_PROXY here or enable WSL autoProxy so Bun fetch can reach the network.");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (hints.length > 0) {
|
|
170
|
+
console.log("\nHints");
|
|
171
|
+
for (const h of hints) console.log(` - ${h}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -2310,5 +2310,13 @@ export function startServer(port?: number) {
|
|
|
2310
2310
|
console.log(` GET /api/* → management API`);
|
|
2311
2311
|
console.log(` GET / → GUI dashboard`);
|
|
2312
2312
|
|
|
2313
|
+
// Prime pool-account quota in the background so the rotation engine has real
|
|
2314
|
+
// usage scores from the first routing decision, even when the dashboard is
|
|
2315
|
+
// never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
|
|
2316
|
+
// listener, and a blocked network silently no-ops (see Phase 30 diagnostics).
|
|
2317
|
+
import("./codex-auth-api")
|
|
2318
|
+
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "startup"))
|
|
2319
|
+
.catch(() => {});
|
|
2320
|
+
|
|
2313
2321
|
return server;
|
|
2314
2322
|
}
|
package/src/web-search/parse.ts
CHANGED
|
@@ -41,38 +41,75 @@ function collectAnnotation(ann: AnnotationLike | undefined, sources: WebSearchSo
|
|
|
41
41
|
* answer text with that section stripped so the tool_result renderer doesn't double-print sources.
|
|
42
42
|
*
|
|
43
43
|
* Handles the per-line forms seen from the backend: `- title: url`, `- title (url)`,
|
|
44
|
-
* `- [title](url)`, `- <url>`, `- url`,
|
|
44
|
+
* `- [title](url)`, `- <url>`, `- url`, numbered `1. ...` variants, a markdown-prefixed header
|
|
45
|
+
* (`### Sources:`, `**Sources**`), a title line whose URL sits on the FOLLOWING line, and trailing
|
|
46
|
+
* URL punctuation (`;`, `,`, `)`, `]`, `.`). Prose that follows the source list is preserved.
|
|
45
47
|
*/
|
|
46
|
-
const URL_RE = /https?:\/\/[^\s<>()\]]+/;
|
|
48
|
+
const URL_RE = /https?:\/\/[^\s<>()\[\]]+/;
|
|
49
|
+
// A "Sources:" / "Source:" header, allowing markdown prefixes (#, *, -, >) and bold/italic wrappers.
|
|
50
|
+
const SOURCES_HEADER_RE = /^\s*(?:#{1,6}\s*)?[-*>\s]*\**\s*sources?\s*\**\s*:?\s*\**\s*$/i;
|
|
51
|
+
|
|
52
|
+
/** Trim wrapping/trailing noise from a captured URL: angle brackets, then trailing punctuation. */
|
|
53
|
+
function cleanUrl(url: string): string {
|
|
54
|
+
return url.replace(/^<+/, "").replace(/[)>\].,;:]+$/, "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Derive a human title from the list-item text preceding the URL (strip markers, md link, seps). */
|
|
58
|
+
function cleanTitle(prefix: string): string {
|
|
59
|
+
let title = prefix.replace(/^[-*>\d.)\s]+/, "").trim();
|
|
60
|
+
// `[title](` from a markdown link, or a leading `[`.
|
|
61
|
+
title = title.replace(/^\[/, "").replace(/\]\(?$/, "").replace(/[:\-—(<]\s*$/, "").trim();
|
|
62
|
+
return title;
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
function extractTrailingSources(text: string): { text: string; sources: WebSearchSource[] } {
|
|
48
66
|
const lines = text.split("\n");
|
|
49
|
-
// Find the LAST line that is
|
|
67
|
+
// Find the LAST line that is a "Sources:" header (markdown prefixes allowed).
|
|
50
68
|
let headerIdx = -1;
|
|
51
69
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
52
|
-
if (
|
|
70
|
+
if (SOURCES_HEADER_RE.test(lines[i])) { headerIdx = i; break; }
|
|
53
71
|
}
|
|
54
72
|
if (headerIdx === -1) return { text, sources: [] };
|
|
55
73
|
const sources: WebSearchSource[] = [];
|
|
56
74
|
const seen = new Set<string>();
|
|
75
|
+
// Track the last line index actually consumed as part of the source list so trailing prose after
|
|
76
|
+
// the list survives (we strip the header through the last consumed source line, not to EOF).
|
|
77
|
+
let lastConsumed = headerIdx;
|
|
78
|
+
// A title line whose URL is expected on a following line (multiline entry).
|
|
79
|
+
let pendingTitle: string | null = null;
|
|
57
80
|
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
58
81
|
const raw = lines[i].trim();
|
|
59
|
-
if (raw === "")
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
if (raw === "") {
|
|
83
|
+
// Blank line between header and first entry is fine; a blank AFTER entries ends the list.
|
|
84
|
+
if (sources.length > 0 || pendingTitle !== null) break;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
62
87
|
const m = raw.match(URL_RE);
|
|
63
|
-
if (!m)
|
|
64
|
-
|
|
88
|
+
if (!m) {
|
|
89
|
+
// A list-ish line with no URL may be a title whose URL is on the next line. Only treat it as a
|
|
90
|
+
// pending title when it looks like a list item; otherwise it's prose → stop.
|
|
91
|
+
if (/^[-*>\d.)]/.test(raw) || pendingTitle === null) {
|
|
92
|
+
if (/^[-*>\d.)]/.test(raw)) { pendingTitle = raw; lastConsumed = i; continue; }
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const url = cleanUrl(m[0]);
|
|
97
|
+
if (!url) { break; }
|
|
98
|
+
lastConsumed = i;
|
|
99
|
+
// Title: text before the URL on this line, else a buffered title from a preceding line.
|
|
100
|
+
const inlinePrefix = raw.slice(0, m.index);
|
|
101
|
+
const title = cleanTitle(inlinePrefix) || (pendingTitle ? cleanTitle(pendingTitle) : "");
|
|
102
|
+
pendingTitle = null;
|
|
65
103
|
if (seen.has(url)) continue;
|
|
66
104
|
seen.add(url);
|
|
67
|
-
// Derive a title from the text before the URL: strip list markers, [md](), and separators.
|
|
68
|
-
let title = raw.slice(0, m.index).replace(/^[-*\d.)\s]+/, "").trim();
|
|
69
|
-
title = title.replace(/^\[/, "").replace(/\]\(?$/, "").replace(/[:\-—(]\s*$/, "").trim();
|
|
70
105
|
sources.push(title ? { url, title } : { url });
|
|
71
106
|
}
|
|
72
107
|
if (sources.length === 0) return { text, sources: [] };
|
|
73
|
-
//
|
|
74
|
-
const
|
|
75
|
-
|
|
108
|
+
// Keep text before the header AND any prose after the consumed source lines.
|
|
109
|
+
const before = lines.slice(0, headerIdx).join("\n").replace(/\s+$/, "");
|
|
110
|
+
const after = lines.slice(lastConsumed + 1).join("\n").replace(/^\s+/, "");
|
|
111
|
+
const body = after ? (before ? `${before}\n\n${after}` : after) : before;
|
|
112
|
+
return { text: body, sources };
|
|
76
113
|
}
|
|
77
114
|
|
|
78
115
|
/** Pull final text + url_citation sources from a completed Responses `output[]` array. */
|