@bitkyc08/opencodex 2.7.40-preview.20260725 → 2.7.41-preview.20260726

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.
Files changed (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-7RXBHYrB.js +65 -0
  4. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-BxQ8N_K5.js +0 -52
  110. package/gui/dist/assets/index-CMip1DzF.css +0 -1
package/src/update/job.ts CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
6
  import { killProxy } from "../lib/process-control";
7
- import { waitForPortAvailable } from "../server/ports";
7
+ import { reclaimListenPort } from "../server/port-reclaim";
8
8
  import { proxyIdentityAt } from "../server/proxy-liveness";
9
9
  import { isServiceInstalled } from "../service";
10
10
  import {
@@ -28,6 +28,8 @@ const UPDATE_TIMEOUT_MS = 180_000;
28
28
  const RESTART_TIMEOUT_MS = 60_000;
29
29
  const RESTART_HEALTH_TIMEOUT_MS = 15_000;
30
30
  const RESTART_STABILITY_WINDOW_MS = 15_000;
31
+ /** How long update restart waits for the captured port to become bindable after stop. */
32
+ export const RESTART_PORT_RECLAIM_MS = 30_000;
31
33
 
32
34
  export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
33
35
 
@@ -292,7 +294,7 @@ function spawnDetachedStart(job: UpdateJobState, installer: Installer, port?: nu
292
294
 
293
295
  /** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */
294
296
  export interface RestartIo {
295
- waitForPort?: typeof waitForPortAvailable;
297
+ waitForPort?: typeof reclaimListenPort;
296
298
  spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
297
299
  serviceInstalledFn?: () => boolean;
298
300
  probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
@@ -308,7 +310,7 @@ export interface RestartIo {
308
310
 
309
311
  async function restartAfterUpdate(
310
312
  job: UpdateJobState,
311
- captured?: { port: number; hostname: string },
313
+ captured?: { port: number; hostname: string; oldPid?: number },
312
314
  io: RestartIo = {},
313
315
  ): Promise<void> {
314
316
  const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
@@ -318,6 +320,9 @@ async function restartAfterUpdate(
318
320
  // port to wait on; config is only the cold-start fallback.
319
321
  const port = captured?.port ?? config.port ?? 10100;
320
322
  const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
323
+ const oldPid = typeof captured?.oldPid === "number" && captured.oldPid > 0
324
+ ? captured.oldPid
325
+ : undefined;
321
326
  let svcArgs: string[] | undefined;
322
327
  if (serviceInstalled) {
323
328
  try {
@@ -326,14 +331,21 @@ async function restartAfterUpdate(
326
331
  } catch { /* fallback to default service install */ }
327
332
  }
328
333
  const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
329
- const waitFn = io.waitForPort ?? waitForPortAvailable;
334
+ const waitFn = io.waitForPort ?? reclaimListenPort;
335
+ const reclaimOpts = {
336
+ timeoutMs: RESTART_PORT_RECLAIM_MS,
337
+ intervalMs: 100,
338
+ scanIntervalMs: 500,
339
+ killOcxHolders: oldPid != null,
340
+ onlyKillPids: oldPid != null ? [oldPid] : [],
341
+ };
330
342
 
331
343
  if (serviceInstalled) {
332
- // Stop-first update already unloaded the service; wait for the socket to drain,
333
- // then reinstall wrappers that bake `--port` via OCX_BAKE_PORT (PR #152 gap).
334
- const freed = await waitFn(port, hostname, { timeoutMs: 5_000, intervalMs: 25 });
344
+ // Stop-first update already unloaded the service; reclaim the socket (only the
345
+ // captured old PID when trusted), then reinstall wrappers that bake `--port`.
346
+ const freed = await waitFn(port, hostname, reclaimOpts);
335
347
  if (!freed) {
336
- updateJob(job, {}, `Port ${port} still busy after stop; reinstalling service with pinned --port ${port} anyway.`);
348
+ updateJob(job, {}, `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop reinstall may fail until the port is free.`);
337
349
  }
338
350
  const prevBake = process.env.OCX_BAKE_PORT;
339
351
  process.env.OCX_BAKE_PORT = String(Math.trunc(port));
@@ -365,12 +377,13 @@ async function restartAfterUpdate(
365
377
  updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
366
378
  killProxy(pid);
367
379
  }
368
- // The old socket can stay busy briefly after stop (Windows taskkill drain, or the
369
- // stop-first update path that already killed the proxy before we got here) wait
370
- // unconditionally on the captured port so the pinned start does not race the drain.
371
- const freed = await waitFn(port, hostname, { timeoutMs: 2_000, intervalMs: 25 });
380
+ // Reclaim the captured port before the pinned start. Spawning `--port` while the old
381
+ // socket is still busy is how Windows updates used to fail health checks (or hop).
382
+ // Only the trusted pre-update PID may be killed; never an arbitrary ocx listener.
383
+ const freed = await waitFn(port, hostname, reclaimOpts);
372
384
  if (!freed) {
373
- updateJob(job, {}, `Port ${port} still busy after stop; starting with --port ${port} anyway.`);
385
+ updateJob(job, {}, `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket); not starting on another port. Retry 'ocx start --port ${port}'.`);
386
+ return;
374
387
  }
375
388
  (io.spawnStart ?? spawnDetachedStart)(job, job.installer, port);
376
389
  }
@@ -378,7 +391,7 @@ async function restartAfterUpdate(
378
391
  /** Exposed for tests: drives the non-service restart path with injected io. */
379
392
  export function restartAfterUpdateForTests(
380
393
  job: UpdateJobState,
381
- captured: { port: number; hostname: string },
394
+ captured: { port: number; hostname: string; oldPid?: number },
382
395
  io: RestartIo,
383
396
  ): Promise<void> {
384
397
  return restartAfterUpdate(job, captured, io);
@@ -386,7 +399,8 @@ export function restartAfterUpdateForTests(
386
399
 
387
400
  function restartFailureHint(port: number): string {
388
401
  return `Update installed, but the restarted proxy did not stay healthy on port ${port}. `
389
- + "Try 'ocx start'. If the update log shows bun postinstall or EPERM warnings, "
402
+ + `Try 'ocx start --port ${port}'. `
403
+ + "If the update log shows bun postinstall or EPERM warnings, "
390
404
  + "reinstall with 'npm install -g --allow-scripts=bun @bitkyc08/opencodex'.";
391
405
  }
392
406
 
@@ -474,6 +488,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
474
488
  const captured = {
475
489
  port: runtimeTrusted ? rt.port : configPort,
476
490
  hostname: (runtimeTrusted ? rt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
491
+ ...(runtimeTrusted && livePid ? { oldPid: livePid } : {}),
477
492
  };
478
493
  let trayWasInstalled = false;
479
494
  let trayWasRunning = false;
package/src/usage/log.ts CHANGED
@@ -36,7 +36,7 @@ export interface PersistedUsageEntry {
36
36
  timestamp: number;
37
37
  provider: string;
38
38
  model: string;
39
- surface?: "claude";
39
+ surface?: "claude" | "claude-desktop" | "grok";
40
40
  resolvedModel?: string;
41
41
  requestedModel?: string;
42
42
  /** Reasoning effort / service-tier metadata for GUI Logs after restart. */
@@ -64,6 +64,23 @@ export interface PersistedUsageEntry {
64
64
  upstreamError?: string;
65
65
  }
66
66
 
67
+ const KNOWN_USAGE_SURFACES = new Set<NonNullable<PersistedUsageEntry["surface"]>>([
68
+ "claude",
69
+ "claude-desktop",
70
+ "grok",
71
+ ]);
72
+
73
+ /**
74
+ * The serializer guard for `surface`. Two failure modes shaped this: a literal
75
+ * whitelist ("claude" | "claude-desktop" only) silently dropped every NEW surface at
76
+ * write time, while a plain truthy spread would persist junk values from hand-edited
77
+ * logs. Membership in this set is the middle path: adding a surface here is one edit,
78
+ * and unknown values are still dropped.
79
+ */
80
+ export function isKnownUsageSurface(value: unknown): value is NonNullable<PersistedUsageEntry["surface"]> {
81
+ return typeof value === "string" && KNOWN_USAGE_SURFACES.has(value as NonNullable<PersistedUsageEntry["surface"]>);
82
+ }
83
+
67
84
  export function usageLogPath(): string {
68
85
  return join(getConfigDir(), "usage.jsonl");
69
86
  }
@@ -217,7 +234,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
217
234
  timestamp: entry.timestamp,
218
235
  provider: entry.provider,
219
236
  model: entry.model,
220
- ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
237
+ ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
221
238
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
222
239
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
223
240
  ...(typeof entry.requestedEffort === "string" && entry.requestedEffort
@@ -270,6 +287,145 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void {
270
287
  try { chmodSync(path, 0o600); } catch { /* best-effort on platforms that ignore chmod */ }
271
288
  }
272
289
 
290
+ export type UsageLogRevision = {
291
+ path: string;
292
+ dev: number;
293
+ ino: number;
294
+ birthtimeMs: number;
295
+ size: number;
296
+ mtimeMs: number;
297
+ ctimeMs: number;
298
+ };
299
+
300
+ let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
301
+ let managementUsageReadInflight: {
302
+ key: string;
303
+ promise: Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }>;
304
+ } | null = null;
305
+
306
+ /** Test-only observability for proving that unchanged prefixes are not reparsed. */
307
+ export function usageReadCacheStatsForTests(): Readonly<typeof usageReadCacheStats> {
308
+ return { ...usageReadCacheStats };
309
+ }
310
+
311
+ export function resetUsageReadCacheForTests(): void {
312
+ usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
313
+ managementUsageReadInflight = null;
314
+ }
315
+
316
+ function readExactly(fd: number, length: number, position: number): Buffer | null {
317
+ const output = Buffer.allocUnsafe(length);
318
+ let offset = 0;
319
+ while (offset < length) {
320
+ const read = readSync(fd, output, offset, length - offset, position + offset);
321
+ if (read === 0) return null;
322
+ offset += read;
323
+ }
324
+ return output;
325
+ }
326
+
327
+ function usageLogRevision(path: string, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
328
+ if (!stat.isFile()) throw new Error("usage log is not a regular file");
329
+ return {
330
+ path,
331
+ dev: Number(stat.dev),
332
+ ino: Number(stat.ino),
333
+ birthtimeMs: Number(stat.birthtimeMs),
334
+ size: Number(stat.size),
335
+ mtimeMs: Number(stat.mtimeMs),
336
+ ctimeMs: Number(stat.ctimeMs),
337
+ };
338
+ }
339
+
340
+ export function usageLogRevisionKey(revision: UsageLogRevision | null): string {
341
+ if (!revision) return "missing";
342
+ return [
343
+ revision.path,
344
+ revision.dev,
345
+ revision.ino,
346
+ revision.birthtimeMs,
347
+ revision.size,
348
+ revision.mtimeMs,
349
+ revision.ctimeMs,
350
+ ].join("\0");
351
+ }
352
+
353
+ export function currentUsageLogRevision(): UsageLogRevision | null {
354
+ const path = usageLogPath();
355
+ if (!existsSync(path)) return null;
356
+ let fd: number | undefined;
357
+ try {
358
+ fd = openSync(path, "r");
359
+ return usageLogRevision(path, fstatSync(fd));
360
+ } finally {
361
+ if (fd !== undefined) closeSync(fd);
362
+ }
363
+ }
364
+
365
+ async function parseUsageTextCooperatively(text: string): Promise<PersistedUsageEntry[]> {
366
+ const lines = text.split(/\r?\n/);
367
+ usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length;
368
+ const entries: PersistedUsageEntry[] = [];
369
+ const batchSize = 1_000;
370
+ for (let offset = 0; offset < lines.length; offset += batchSize) {
371
+ entries.push(...parseUsageLines(lines.slice(offset, offset + batchSize)));
372
+ if (offset + batchSize < lines.length) {
373
+ // JSON parsing dominates large-log startup. Yield between bounded batches so
374
+ // Bun can continue serving health and settings requests on the same thread.
375
+ await new Promise<void>(resolve => setTimeout(resolve, 0));
376
+ }
377
+ }
378
+ return entries;
379
+ }
380
+
381
+ async function readUsageEntriesFullCooperatively(
382
+ path: string,
383
+ ): Promise<{ entries: PersistedUsageEntry[]; revision: UsageLogRevision }> {
384
+ let fd: number | undefined;
385
+ try {
386
+ fd = openSync(path, "r");
387
+ const stat = fstatSync(fd);
388
+ const size = Number(stat.size);
389
+ const bytes = readExactly(fd, size, 0);
390
+ if (bytes === null) throw new Error("usage log changed while it was being read");
391
+ const entries = await parseUsageTextCooperatively(bytes.toString("utf-8"));
392
+ usageReadCacheStats.fullReads += 1;
393
+ return { entries, revision: usageLogRevision(path, stat) };
394
+ } finally {
395
+ if (fd !== undefined) closeSync(fd);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Management API reader: full parses yield between bounded batches and concurrent
401
+ * callers share work only when they observed the same exact file revision. Parsed rows
402
+ * are returned to the request and never retained in module state.
403
+ */
404
+ export async function readUsageSnapshotForManagement(): Promise<{
405
+ entries: PersistedUsageEntry[];
406
+ revision: UsageLogRevision | null;
407
+ }> {
408
+ const path = usageLogPath();
409
+ if (!existsSync(path)) return { entries: [], revision: null };
410
+ const observed = currentUsageLogRevision();
411
+ const key = usageLogRevisionKey(observed);
412
+ if (managementUsageReadInflight?.key === key) {
413
+ const shared = await managementUsageReadInflight.promise;
414
+ return { entries: shared.entries.slice(), revision: shared.revision };
415
+ }
416
+ const promise = readUsageEntriesFullCooperatively(path);
417
+ managementUsageReadInflight = { key, promise };
418
+ try {
419
+ return await promise;
420
+ } finally {
421
+ if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null;
422
+ }
423
+ }
424
+
425
+ export async function readUsageEntriesForManagement(): Promise<PersistedUsageEntry[]> {
426
+ return (await readUsageSnapshotForManagement()).entries;
427
+ }
428
+
273
429
  export function readUsageEntries(): PersistedUsageEntry[] {
274
430
  const path = usageLogPath();
275
431
  if (!existsSync(path)) return [];
@@ -5,7 +5,7 @@ import type { PersistedUsageEntry, UsageStatus } from "./log";
5
5
  import { estimateComboCost, estimateRequestCost, effectiveServiceTier } from "./cost";
6
6
 
7
7
  export type UsageRange = "7d" | "30d" | "all";
8
- export type UsageSurface = "all" | "codex" | "claude";
8
+ export type UsageSurface = "all" | "codex" | "claude" | "grok";
9
9
 
10
10
  export interface UsageSummaryTotals {
11
11
  requests: number;
@@ -98,7 +98,7 @@ export function parseRange(input: string | null | undefined): UsageRange {
98
98
  }
99
99
 
100
100
  export function parseUsageSurface(input: string | null | undefined): UsageSurface {
101
- if (input === "codex" || input === "claude") return input;
101
+ if (input === "codex" || input === "claude" || input === "grok") return input;
102
102
  return "all";
103
103
  }
104
104
 
@@ -491,8 +491,12 @@ export function summarizeUsage(
491
491
  const { since } = rangeWindow(range, now);
492
492
  const filteredEntries = entries.filter(entry => {
493
493
  if (since !== null && entry.timestamp < since) return false;
494
- if (surface === "claude") return entry.surface === "claude";
495
- if (surface === "codex") return entry.surface !== "claude";
494
+ if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop";
495
+ if (surface === "grok") return entry.surface === "grok";
496
+ // Codex = the historical unlabelled bucket. Before the grok tag existed every
497
+ // non-Claude turn landed here, and `surface !== "claude"` also swallowed
498
+ // claude-desktop — disjoint predicates fix both.
499
+ if (surface === "codex") return entry.surface === undefined;
496
500
  return true;
497
501
  });
498
502
  const totals = blankTotals();
@@ -1,5 +1,5 @@
1
1
  import type { ProviderAdapter } from "../adapters/base";
2
- import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent } from "../types";
2
+ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
4
  import { bridgeToResponsesSSE } from "../bridge";
5
5
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
@@ -189,6 +189,8 @@ export interface WebSearchLoopDeps {
189
189
  stallTimeoutSec?: number;
190
190
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
191
191
  onFirstOutput?: () => void;
192
+ /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
193
+ onUsage?: (usage: OcxUsage | undefined) => void;
192
194
  /**
193
195
  * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
194
196
  * or null when the pool is exhausted (same semantics as the normal routed path).
@@ -575,6 +577,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
575
577
  hideThinkingSummary: parsed.options.hideThinkingSummary,
576
578
  ...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
577
579
  ...(deps.onFirstOutput ? { onFirstOutput: deps.onFirstOutput } : {}),
580
+ ...(deps.onUsage ? { onUsage: deps.onUsage } : {}),
578
581
  },
579
582
  );
580
583
  return new Response(sse, { headers: SSE_HEADERS });