@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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DaRQZAM0.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.11",
3
+ "version": "2.6.13",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
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
@@ -396,6 +396,11 @@ switch (command) {
396
396
  case "status":
397
397
  await handleStatus();
398
398
  break;
399
+ case "doctor": {
400
+ const { runDoctor } = await import("./doctor");
401
+ await runDoctor();
402
+ break;
403
+ }
399
404
  case "ensure":
400
405
  await handleEnsure();
401
406
  break;
@@ -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
- // Personal and workspace subscriptions are separate duplicate buckets.
41
- // Within each bucket, keep the original ChatGPT account id + email collision guard.
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
 
@@ -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) setActiveCodexAccount(config, best);
271
- return best;
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
  }
@@ -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`, and numbered `1. ...` variants.
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 just a "Sources:" / "Source:" header (case-insensitive).
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 (/^\s*\**\s*sources?\s*:?\s*\**\s*$/i.test(lines[i])) { headerIdx = i; break; }
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 === "") continue;
60
- // Only consume list-ish lines that carry a URL; stop at the first non-source line so we don't
61
- // swallow trailing prose after the section.
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) break;
64
- const url = m[0].replace(/[).,]+$/, "");
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
- // Strip the Sources section (header + consumed lines) from the answer text.
74
- const stripped = lines.slice(0, headerIdx).join("\n").replace(/\s+$/, "");
75
- return { text: stripped, sources };
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. */