@bitkyc08/opencodex 2.87.0 → 2.89.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.
Files changed (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -46,6 +46,7 @@ export function nativeReasoningTagMatches(expected: string | undefined, presente
46
46
  const MAX_TOTAL_BYTES = 256 * 1024;
47
47
  const TTL_MS = 60 * 60 * 1000;
48
48
  const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
49
+ const MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS = 256;
49
50
  const replayIdentityKey = randomBytes(32);
50
51
  const CREDENTIAL_HEADER_NAMES = new Set([
51
52
  "authorization",
@@ -78,8 +79,14 @@ interface ServingIdentityEntry {
78
79
  interface OpaqueBlobRejectionEntry {
79
80
  bytes: number;
80
81
  at: number;
82
+ /** Undefined retains the legacy blanket rejection; only payload digests are stored here. */
83
+ fingerprints?: ReadonlySet<string>;
81
84
  }
82
85
 
86
+ export type ReasoningReplayOpaqueBlobRejection =
87
+ | { mode: "blanket" }
88
+ | { mode: "selective"; fingerprints: ReadonlySet<string> };
89
+
83
90
  const entries = new Map<string, CacheEntry>();
84
91
  const servingIdentities = new Map<string, ServingIdentityEntry>();
85
92
  const opaqueBlobRejections = new Map<string, OpaqueBlobRejectionEntry>();
@@ -310,18 +317,57 @@ export function reasoningReplayOpaqueBlobRejectionMemoized(
310
317
  return opaqueBlobRejections.has(key);
311
318
  }
312
319
 
320
+ /** Undefined means no live memo; a selective result owns a defensive copy of its digests. */
321
+ export function getReasoningReplayOpaqueBlobRejection(
322
+ scope: OcxReasoningReplayScopeRef | undefined,
323
+ ): ReasoningReplayOpaqueBlobRejection | undefined {
324
+ const key = opaqueBlobRejectionKeyFor(scope);
325
+ if (!key) return undefined;
326
+ sweepExpiredOpaqueBlobRejections(now());
327
+ const entry = opaqueBlobRejections.get(key);
328
+ return entry === undefined ? undefined : entry.fingerprints === undefined ? { mode: "blanket" }
329
+ : { mode: "selective", fingerprints: new Set(entry.fingerprints) };
330
+ }
331
+
332
+ /** Incomplete, invalid or unbounded enumerations cannot safely narrow the legacy rejection. */
333
+ function boundedRejectionFingerprints(fingerprints: ReadonlySet<string> | undefined): Set<string> | undefined {
334
+ if (fingerprints === undefined) return undefined;
335
+ const copy = new Set<string>();
336
+ try {
337
+ // The intrinsic validates a real Set without trusting an overridden iterator or size getter.
338
+ for (const value of Set.prototype.values.call(fingerprints) as IterableIterator<unknown>) {
339
+ if (typeof value !== "string" || value.length !== 64 || !/^[a-f0-9]{64}$/.test(value)
340
+ || copy.size >= MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) return undefined;
341
+ copy.add(value);
342
+ }
343
+ } catch { return undefined; }
344
+ return copy.size > 0 ? copy : undefined;
345
+ }
346
+
313
347
  /** Record only after a blobless retry succeeded for this durable serving identity. */
314
348
  export function rememberReasoningReplayOpaqueBlobRejection(
315
349
  scope: OcxReasoningReplayScopeRef | undefined,
350
+ fingerprints?: ReadonlySet<string>,
316
351
  ): void {
317
352
  const key = opaqueBlobRejectionKeyFor(scope);
318
353
  if (!key) return;
319
- const bytes = Buffer.byteLength(key, "utf8");
320
- if (bytes > MAX_TOTAL_BYTES) return;
354
+ const keyBytes = Buffer.byteLength(key, "utf8");
355
+ if (keyBytes > MAX_TOTAL_BYTES) return;
321
356
  const at = now();
322
357
  sweepExpiredOpaqueBlobRejections(at);
323
- if (opaqueBlobRejections.has(key)) deleteOpaqueBlobRejection(key);
324
- opaqueBlobRejections.set(key, { bytes, at });
358
+ const previous = opaqueBlobRejections.get(key);
359
+ let merged = previous && previous.fingerprints === undefined ? undefined : boundedRejectionFingerprints(fingerprints);
360
+ if (merged && previous?.fingerprints) {
361
+ for (const fingerprint of previous.fingerprints) {
362
+ merged.add(fingerprint);
363
+ if (merged.size > MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) { merged = undefined; break; }
364
+ }
365
+ }
366
+ // A near-limit key can still retain its existing blanket memo if the digest set will not fit.
367
+ if (merged && keyBytes + merged.size * 64 > MAX_TOTAL_BYTES) merged = undefined;
368
+ const bytes = keyBytes + (merged?.size ?? 0) * 64;
369
+ if (previous) deleteOpaqueBlobRejection(key);
370
+ opaqueBlobRejections.set(key, { bytes, at, ...(merged ? { fingerprints: merged } : {}) });
325
371
  opaqueBlobRejectionTotalBytes += bytes;
326
372
  while (
327
373
  (opaqueBlobRejectionTotalBytes > MAX_TOTAL_BYTES || opaqueBlobRejections.size > MAX_ENTRIES)
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Parse-time rebuild of replayed hosted `web_search_call` cells for routed providers (#7269).
3
+ *
4
+ * Codex replays the cell it was shown, which carries the query and at most a source list, never the
5
+ * result text. Dropping the cell made a routed model forget it had searched: after a client tool
6
+ * call ended the turn, the next request carried no trace of the search, so the model searched and
7
+ * ran its tools again in a loop.
8
+ *
9
+ * When search is declared, the parser rewrites each cell into the `web_search` function call and
10
+ * output the routed model made, at the raw-input level, so the ordinary call/output handling owns
11
+ * turn batching and reasoning attachment. The call takes the cell's place; its output is deferred to
12
+ * the end of the contiguous run of call/output items that follows, so a cell inside a parallel
13
+ * client batch never splits it. The output is a fallback built from the cell alone; the web-search
14
+ * sidecar swaps in the result it recorded for the cell, and removes the pairs again when it does not
15
+ * run, because the synthetic tool is then undeclared (src/web-search/sidecar-search-replay.ts).
16
+ */
17
+ import { isObj } from "./parser-content";
18
+ import { WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
19
+ import type { OcxReplayedWebSearchCell } from "../types";
20
+
21
+ const MAX_FIELD_CHARS = 2048;
22
+ const MAX_LIST_ENTRIES = 20;
23
+ const CELL_ID = /^[A-Za-z0-9_-]{1,128}$/;
24
+
25
+ /** Call items of a batch; a call after the batch's first output starts the next assistant turn. */
26
+ const CALL_TYPES = new Set(["function_call", "custom_tool_call", "local_shell_call", "tool_search_call"]);
27
+ /** Output items of a batch. Anything that is neither a call nor an output ends the batch. */
28
+ const OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output", "local_shell_call_output", "tool_search_output"]);
29
+
30
+ function capped(value: string): string {
31
+ return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}…`;
32
+ }
33
+
34
+ function queriesOf(action: Record<string, unknown>): string[] {
35
+ const queries = Array.isArray(action.queries)
36
+ ? action.queries.filter((query): query is string => typeof query === "string" && query.trim() !== "")
37
+ : [];
38
+ if (queries.length === 0 && typeof action.query === "string" && action.query.trim() !== "") queries.push(action.query);
39
+ return queries.slice(0, MAX_LIST_ENTRIES).map(capped);
40
+ }
41
+
42
+ function sourcesOf(item: Record<string, unknown>, action: Record<string, unknown>): string[] {
43
+ const lines: string[] = [];
44
+ const seen = new Set<string>();
45
+ for (const list of [item.sources, action.sources]) {
46
+ if (!Array.isArray(list)) continue;
47
+ for (const source of list) {
48
+ if (lines.length >= MAX_LIST_ENTRIES) return lines;
49
+ if (!isObj(source) || typeof source.url !== "string" || seen.has(source.url)) continue;
50
+ seen.add(source.url);
51
+ lines.push(typeof source.title === "string" && source.title.trim() !== ""
52
+ ? `- ${capped(source.title)} <${capped(source.url)}>`
53
+ : `- <${capped(source.url)}>`);
54
+ }
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ /** The call arguments and fallback result text for one replayed cell. */
60
+ export function replayedWebSearchContent(item: Record<string, unknown>): { arguments: Record<string, unknown>; result: string; failed: boolean } {
61
+ const action = isObj(item.action) ? item.action : {};
62
+ const failed = item.status === "failed";
63
+ const queries = queriesOf(action);
64
+ const lines: string[] = [];
65
+ if (action.type === "open_page" && typeof action.url === "string") {
66
+ lines.push(`Earlier page open: <${capped(action.url)}>.`);
67
+ } else if (action.type === "find_in_page" && typeof action.url === "string") {
68
+ lines.push(`Earlier find in page <${capped(action.url)}>${typeof action.pattern === "string" ? ` for "${capped(action.pattern)}"` : ""}.`);
69
+ } else {
70
+ lines.push(queries.length > 0 ? `Earlier web search for: ${queries.map(query => `"${query}"`).join(", ")}.` : "Earlier web search.");
71
+ }
72
+ if (failed) {
73
+ lines.push("This search failed and returned no results.");
74
+ } else {
75
+ lines.push("The result text of this search is not available in the replayed conversation history.");
76
+ const sources = sourcesOf(item, action);
77
+ if (sources.length > 0) lines.push("Sources it returned (titles and URLs only, untrusted):", ...sources);
78
+ }
79
+ return { arguments: queries.length > 1 ? { queries } : { query: queries[0] ?? "" }, result: lines.join("\n"), failed };
80
+ }
81
+
82
+ export interface RewrittenWebSearchInput {
83
+ items: unknown[];
84
+ /** Original input index per rewritten item, for boundaries measured on the caller's input. */
85
+ sourceIndexes: number[];
86
+ cells: OcxReplayedWebSearchCell[];
87
+ }
88
+
89
+ /** Rewrite replayed cells into web_search call/output pairs; undefined when the input has none. */
90
+ export function rewriteReplayedWebSearchCells(input: readonly unknown[]): RewrittenWebSearchInput | undefined {
91
+ if (!input.some(item => isObj(item) && item.type === "web_search_call")) return undefined;
92
+ // A synthetic call id must not collide with any client call id, nor with another cell's.
93
+ const taken = new Set(input.flatMap(item => isObj(item) && typeof item.call_id === "string" ? [item.call_id] : []));
94
+ const allocate = (base: string): string => {
95
+ let id = base;
96
+ for (let n = 1; taken.has(id); n++) id = `${base}_${n}`;
97
+ taken.add(id);
98
+ return id;
99
+ };
100
+ const items: unknown[] = [];
101
+ const sourceIndexes: number[] = [];
102
+ const cells: OcxReplayedWebSearchCell[] = [];
103
+ const seenCells = new Set<string>();
104
+ let pending: Array<{ item: unknown; sourceIndex: number }> = [];
105
+ // True once the current batch reached its outputs: the next call opens a new assistant turn.
106
+ let outputPhase = false;
107
+ const flush = () => {
108
+ for (const entry of pending) { items.push(entry.item); sourceIndexes.push(entry.sourceIndex); }
109
+ pending = [];
110
+ outputPhase = false;
111
+ };
112
+ input.forEach((item, sourceIndex) => {
113
+ const type = isObj(item) ? item.type : undefined;
114
+ if (isObj(item) && type === "web_search_call") {
115
+ const cellId = typeof item.id === "string" && CELL_ID.test(item.id) ? item.id : undefined;
116
+ // The same cell replayed twice is one search.
117
+ if (cellId && seenCells.has(cellId)) return;
118
+ if (cellId) seenCells.add(cellId);
119
+ if (outputPhase) flush();
120
+ const content = replayedWebSearchContent(item);
121
+ const callId = allocate(cellId ?? "ws_replay");
122
+ cells.push({ ...(cellId ? { cellId } : {}), callId, failed: content.failed });
123
+ items.push({ type: "function_call", call_id: callId, name: WEB_SEARCH_TOOL_NAME, arguments: JSON.stringify(content.arguments) });
124
+ sourceIndexes.push(sourceIndex);
125
+ pending.push({ item: { type: "function_call_output", call_id: callId, output: content.result }, sourceIndex });
126
+ return;
127
+ }
128
+ if (typeof type === "string" && OUTPUT_TYPES.has(type)) {
129
+ outputPhase = true;
130
+ } else if (!(typeof type === "string" && CALL_TYPES.has(type)) || outputPhase) {
131
+ flush();
132
+ }
133
+ items.push(item);
134
+ sourceIndexes.push(sourceIndex);
135
+ });
136
+ flush();
137
+ return { items, sourceIndexes, cells };
138
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { PersistedUsageEntry } from "../usage/log";
15
- import { estimateRequestCost, serviceTierContext } from "../usage/cost";
15
+ import { computeEntryCost } from "../usage/summary";
16
16
  import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
17
17
 
18
18
  export const ANALYTICS_MAX_ROWS = 50_000;
@@ -122,6 +122,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([
122
122
  "oauth-account-403",
123
123
  "anthropic-oauth-429",
124
124
  "oauth-account-429",
125
+ "oauth-account-402",
125
126
  ]);
126
127
 
127
128
  function percentile(sorted: number[], p: number): number | undefined {
@@ -158,19 +159,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number):
158
159
  });
159
160
  }
160
161
 
161
- function successCostUsd(
162
- row: Pick<ScannedRow, "provider" | "model">,
163
- entry: PersistedUsageEntry,
164
- ): number | null {
165
- if (!entry.usage) return null;
166
- const estimate = estimateRequestCost({
167
- provider: row.provider,
168
- model: row.model,
169
- usage: entry.usage,
170
- usageStatus: entry.usageStatus,
171
- serviceTier: serviceTierContext(entry),
172
- });
173
- return estimate ? estimate.cost.total : null;
162
+ /** Same attempt-aware pricing as /api/usage, so a refused retry before a measured send is priced. */
163
+ function successCostUsd(entry: PersistedUsageEntry): number | null {
164
+ const { isPriced, costTotal } = computeEntryCost(entry);
165
+ return isPriced ? costTotal : null;
174
166
  }
175
167
 
176
168
  export async function computeRoutingAnalytics(
@@ -245,7 +237,7 @@ export async function computeRoutingAnalytics(
245
237
  if (kind === "success") {
246
238
  const entry = parseEntry(row.rowJson);
247
239
  if (entry) {
248
- rowCostUsd = successCostUsd(row, entry);
240
+ rowCostUsd = successCostUsd(entry);
249
241
  if (rowCostUsd !== null) {
250
242
  costTotalUsd += rowCostUsd;
251
243
  costCount += 1;
@@ -4,6 +4,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types";
4
4
  import { PROVIDER_REGISTRY } from "../../providers/registry";
5
5
  import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier";
6
6
  import { resolveProviderAuthTransport } from "../../providers/fastwire";
7
+ import { defaultAnthropicCacheRetention } from "../../adapters/anthropic/cache-retention";
7
8
  import { localFingerprint } from "../../lab/digest";
8
9
  import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
9
10
 
@@ -237,7 +238,9 @@ export function resolveProductionBehaviorValues(
237
238
  }),
238
239
  "tools.builtinNameEscaping": behaviorRow("provider_config", effective.escapeBuiltinToolNames === true),
239
240
  "cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true),
240
- "cache.retention": behaviorRow("global_config", config.cacheRetention ?? "short"),
241
+ // The wire TTL, not just the setting: an omitted value means 1h on first-party Anthropic.
242
+ "cache.retention": behaviorRow("global_config", config.cacheRetention
243
+ ?? (adapter === "anthropic" ? defaultAnthropicCacheRetention(effective) : "short")),
241
244
  "anthropic.eofPolicy": behaviorRow("provider_config", effective.anthropicEofTolerance === true ? "tolerant" : "strict"),
242
245
  "openai-chat.eofPolicy": behaviorRow("provider_config", effective.openaiChatEofTolerance === true ? "tolerant" : "strict"),
243
246
  "google.mode": behaviorRow("provider_config", effective.googleMode ?? null),
@@ -40,6 +40,7 @@ import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
40
40
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
41
41
  import { providerConfigSeed } from "../providers/derive";
42
42
  import { anthropicOAuthInstanceConfigError } from "../config/schema/anthropic-account-pool";
43
+ import { accountAutoPausePolicySchema } from "../config/schema/account-auto-pause";
43
44
  import type { OcxConfig, OcxProviderConfig } from "../types";
44
45
  import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
45
46
  import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget";
@@ -740,6 +741,9 @@ export function providerManagementConfigError(
740
741
  return "provider must be a plain object";
741
742
  }
742
743
  const raw = provider as Record<string, unknown>;
744
+ if (raw.accountAutoPause !== undefined && !accountAutoPausePolicySchema.safeParse(raw.accountAutoPause).success) {
745
+ return "accountAutoPause must be an object with optional boolean enabled and integer consecutiveFailures (1-100)";
746
+ }
743
747
  const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers);
744
748
  if (contextTiersError) return contextTiersError;
745
749
  const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities);
@@ -796,6 +800,8 @@ export function providerManagementConfigError(
796
800
  // sends recover, not what the canonical forward seed is. Validated below
797
801
  // (retryOnResetPolicyConfigError).
798
802
  delete canonicalCandidate.retryOnReset;
803
+ // Validated account eligibility policy is independent of the canonical transport.
804
+ delete canonicalCandidate.accountAutoPause;
799
805
  // Context windows are the same kind of user-owned overlay as requestPacing: the operator
800
806
  // narrowing what their own native rows advertise. They can only ever LOWER the measured
801
807
  // window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy
@@ -1087,6 +1093,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1087
1093
  apiKeyPool: "redacted",
1088
1094
  // Ordering preference only; it names no key material, so an editor may read and set it.
1089
1095
  apiKeyPoolStrategy: "editor",
1096
+ accountAutoPause: "editor",
1090
1097
  apiKeySelectionRevision: "runtime",
1091
1098
  _apiKeyAttempt: "runtime",
1092
1099
  defaultModel: "editor",
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig, StorageCleanupPolicy } from "../types";
2
2
  import { registerCodexLowQuotaProtection, type LowQuotaRegistration } from "../codex/low-quota-protection";
3
+ import { resolveLowQuotaDesktopSink } from "../codex/low-quota-desktop-notifier";
3
4
  import type { LowQuotaEvent } from "../codex/low-quota-events";
4
5
  import { startStateStoreSweeper } from "../lib/state-store-sweeper";
5
6
  import {
@@ -209,7 +210,7 @@ export function acquireServerBackgroundLifecycle(
209
210
  fanoutRevoked: false,
210
211
  applyPolicy,
211
212
  resources: acquireServerResourceOwner(),
212
- lowQuota: registerCodexLowQuotaProtection(config),
213
+ lowQuota: registerCodexLowQuotaProtection(config, { desktopSink: () => resolveLowQuotaDesktopSink() }),
213
214
  };
214
215
  try {
215
216
  if (!processLoops) {
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause";
1
2
  import { createPhysicalSendReporter } from "../lib/request-execution-budget";
2
3
  import { createInferenceSendBudget } from "./inference/context";
3
4
  import { observeAdvisorNativeReasoning } from "./inference/advisor-diagnostics";
@@ -64,11 +65,10 @@ import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
64
65
  import { stampApiKeyAccountLabel } from "../providers/label";
65
66
  import { distributedKeyPhysicalFetch, distributedKeyResponseOwner } from "../providers/api-key-response-ownership";
66
67
  import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
67
- import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
68
68
  import type { OcxProviderTransport } from "../providers/xai-transport";
69
69
  import type { RouteResult } from "../router";
70
70
  import type { OcxConfig, OcxProviderConfig } from "../types";
71
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
71
+ import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
72
72
  import { isLocalUpstream } from "../lib/local-upstream";
73
73
  import { linkAbortSignal } from "./responses";
74
74
  import {
@@ -449,7 +449,7 @@ export async function runNativeChatAttempt(
449
449
  let responseOwnsDistributedKey = true;
450
450
  const send = async (
451
451
  request: AdapterRequest,
452
- recovery?: "rate-limit-429" | "key-429" | "key-401" | UpstreamSendRecovery,
452
+ recovery?: "rate-limit-429" | "key-401" | "key-429" | UpstreamSendRecovery,
453
453
  singleShot = false,
454
454
  ): Promise<Response> => {
455
455
  try {
@@ -516,6 +516,7 @@ export async function runNativeChatAttempt(
516
516
  const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
517
517
  poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
518
518
  }), execution.comboDispatchPermit);
519
+ const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: undefined }, headers);
519
520
  let dispatched: Response;
520
521
  try {
521
522
  // Capture this request's spend policy at its first physical boundary, including
@@ -538,6 +539,7 @@ export async function runNativeChatAttempt(
538
539
  request.url,
539
540
  applyUpstreamRecoveryInit({
540
541
  ...init, method: request.method, headers, body: request.body,
542
+ ...physicalSendObserverInit(observeAutoPause, request.url),
541
543
  }, transportRecovery),
542
544
  // Reselection can replace the provider transport and the wire shape, so the
543
545
  // egress route is bound to the provider this send actually uses. Omitting it
@@ -552,7 +554,7 @@ export async function runNativeChatAttempt(
552
554
  responseOwnsDistributedKey = !activeProvider._apiKeyAttempt?.distributed
553
555
  || distributedKeyResponseOwner(dispatched, config, route.providerName) !== null;
554
556
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
555
- return dispatched;
557
+ return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
556
558
  },
557
559
  }),
558
560
  );
@@ -585,6 +587,23 @@ export async function runNativeChatAttempt(
585
587
  response = await send(activeRequest);
586
588
  const retryPolicy = rateLimitRetryPolicyFor(activeProvider);
587
589
  let retries = 0;
590
+ let autoPauseMoves = 0;
591
+ for (;;) {
592
+ const priorResponse = response;
593
+ for (; wasAccountAutoPaused(response) && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && transientSendAvailable(); autoPauseMoves++) {
594
+ const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
595
+ const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
596
+ if (!next) break;
597
+ try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
598
+ activeProvider = next;
599
+ stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
600
+ activeAdapter = createOpenAIChatAdapter(activeProvider);
601
+ releaseRetainedRequest();
602
+ activeRequest = buildActiveRequest();
603
+ retainRequest(activeRequest);
604
+ response = await send(activeRequest, "key-401");
605
+ }
606
+
588
607
  while (
589
608
  response.status === 429
590
609
  // A 429 this proxy synthesized for a refused reset replay is not a provider rate
@@ -632,6 +651,10 @@ export async function runNativeChatAttempt(
632
651
  retainRequest(activeRequest);
633
652
  response = await send(activeRequest, failureStatus === 401 ? "key-401" : "key-429");
634
653
  }
654
+ if (response !== priorResponse && wasAccountAutoPaused(response)) continue;
655
+ break;
656
+ }
657
+
635
658
  } catch (error) {
636
659
  releaseRetainedRequest();
637
660
  cleanupAbort();
@@ -694,20 +717,12 @@ export async function runNativeChatAttempt(
694
717
  && (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage))
695
718
  ? upstreamMessage
696
719
  : detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
697
- // Zen's keyless free tier refuses the request outright rather than rate-limiting it, and
698
- // the raw `MissingSessionID` tells a user nothing about why or what to do (#4121).
699
- const clientMessage = enrichOpenCodeZenFreeTierMessage(message, {
700
- providerName: route.providerName,
701
- baseUrl: route.provider.baseUrl,
702
- adapter: route.provider.adapter,
703
- upstreamErrorType: upstreamType,
704
- });
705
720
  const classified = classifyError(
706
721
  response.status,
707
722
  upstreamType ?? (response.status === 401 ? "authentication_error"
708
723
  : response.status === 429 ? "rate_limit_error"
709
724
  : response.status >= 500 ? "server_error" : "invalid_request_error"),
710
- clientMessage,
725
+ message,
711
726
  );
712
727
  // The verdict is read once, from the response that carries it and from the code a
713
728
  // re-wrapped body kept -- never from the status, which a real rate limit shares.
@@ -33,7 +33,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl
33
33
  import { recordDesktopRequest } from "../claude/desktop-health";
34
34
  import { stripOneMillionMarker } from "../claude/context-windows";
35
35
  import { captureClaudeInbound } from "../claude/inbound-debug";
36
- import { declaredAnthropicServerTools } from "../claude/inbound-content-options";
36
+ import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options";
37
37
  import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
38
38
  import { classifyInterceptClient } from "../claude/intercept/client-class";
39
39
  import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
@@ -1110,7 +1110,8 @@ async function handleClaudeMessagesWithBudget(
1110
1110
  const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish;
1111
1111
  const upstream = await handleResponses(internalReq, {
1112
1112
  ...buildClaudeReplayConfig(config),
1113
- // Operator config wins; otherwise the caller's own 1h breakpoints keep 1h here.
1113
+ // Operator config wins; otherwise the caller's explicit TTLs (all 1h, or a pinned 5m) carry
1114
+ // over, and TTL-less breakpoints take the destination default.
1114
1115
  cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody),
1115
1116
  }, logCtx, {
1116
1117
  // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
@@ -1133,6 +1134,7 @@ async function handleClaudeMessagesWithBudget(
1133
1134
  nativeReasoningMint,
1134
1135
  // Out of body like the replay above: the core hands these to an Anthropic destination only.
1135
1136
  anthropicServerTools: declaredAnthropicServerTools(anthropicBody),
1137
+ anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody),
1136
1138
  claudeGoAffinity: { sessionLane: claudeGoSessionLane },
1137
1139
  claudeNativeSessionId,
1138
1140
  stripClaudeMainAuthForNoncanonicalForward: true,
@@ -40,8 +40,9 @@ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
40
40
  *
41
41
  * What must never happen is neither: an immortal socket plus a destination that silently accepts
42
42
  * the orphaned delta. `tests/responses/ws-endpoint.test.ts` holds exactly that pair together.
43
- * Raising the timeout off 0 is still worth doing for its own reasons (a dead peer holds a socket
44
- * forever today), and Bun caps the value at 255 seconds, well inside the bound below.
43
+ * Responses admission separately reclaims its least recently used idle connection under
44
+ * capacity pressure. That is not a time limit, so expired-reference validation remains required.
45
+ * Keeping this shared timeout at 0 also preserves quiet live/voice sessions.
45
46
  */
46
47
  export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
47
48
  /** Ceiling a finite websocket idle timeout must stay under, in seconds. See above. */
@@ -17,6 +17,7 @@ import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor
17
17
  import { linkManagementRouteAllowed } from "../../link/management-routes";
18
18
  import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
19
19
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
20
+ import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
20
21
  export { LINK_INGRESS_HOSTNAME } from "./link-listener";
21
22
  import type { ServerIngress } from "./serve-options";
22
23
 
@@ -51,6 +52,7 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
51
52
  const claudeIntercept: ClaudeInterceptLifecycle<T> = createClaudeInterceptLifecycle<T>();
52
53
  const linkListener: LinkListenerLifecycle<T> = createLinkListenerLifecycle<T>(linkDeps);
53
54
  let activeConfig: OcxConfig | undefined;
55
+ let stopJevQuotaActivation: (() => void) | undefined;
54
56
  const supervisor = createLinkSupervisor({
55
57
  apiKeys: () => activeConfig?.apiKeys ?? [],
56
58
  revokeApiKey: id => {
@@ -114,6 +116,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
114
116
  maxRequestBodySize: ctx.maxRequestBodySize,
115
117
  dispatch: ctx.dispatch,
116
118
  });
119
+ // Last, after every listener started: JEV advisory quota follows the live config while serving.
120
+ stopJevQuotaActivation = startJevQuotaActivation(ctx.config);
117
121
  },
118
122
  ensureStarted: () => linkListener.ensureStarted(),
119
123
  close: () => linkListener.close(),
@@ -132,6 +136,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
132
136
  try { await claudeIntercept.stop(); } catch (error) { failure ??= error; }
133
137
  unregisterSupervisorAdmission?.();
134
138
  unregisterSupervisorAdmission = undefined;
139
+ stopJevQuotaActivation?.();
140
+ stopJevQuotaActivation = undefined;
135
141
  if (failure) throw failure;
136
142
  },
137
143
  };
@@ -1618,11 +1618,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1618
1618
  // Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
1619
1619
  // the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
1620
1620
  const sessionReq = withCallerSessionIdentity(req, admission);
1621
- return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors(
1622
- await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1623
- req,
1624
- policy,
1625
- ), { requestId, start, logCtx });
1621
+ // Only expose an id when the handler or a logged refusal owns its history row.
1622
+ let ownsRequestLog = false;
1623
+ const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => {
1624
+ ownsRequestLog = true;
1625
+ return withCors(
1626
+ await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1627
+ req,
1628
+ policy,
1629
+ );
1630
+ }, { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } });
1631
+ return ownsRequestLog ? withRequestLogId(response, requestId) : response;
1626
1632
  }
1627
1633
 
1628
1634
  if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
@@ -89,6 +89,7 @@ import {
89
89
  import type { ServeOptionsContext } from "./serve-options";
90
90
  import type { RequestMetricsRecorder } from "../request-metrics";
91
91
  import { resolveInboundBodyLimitBytes } from "../request-decompress";
92
+ import { responseToolOwnership } from "../responses/response-tool-ownership";
92
93
 
93
94
  /**
94
95
  * The WebSocket half of the Bun.serve options, split out of serve-options.ts to keep that file
@@ -184,6 +185,8 @@ export function createWebsocketHandler(
184
185
  }
185
186
  return;
186
187
  }
188
+ if (ws.data.responsesActivity?.closing) return;
189
+ ws.data.responsesActivity?.touch();
187
190
  const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
188
191
  if (rawBytes > MAX_WS_FRAME_BYTES) {
189
192
  sendJsonFrame(ws, buildWsErrorFrame(413, {
@@ -223,6 +226,7 @@ export function createWebsocketHandler(
223
226
  if (frame.type === "response.inject") {
224
227
  if (!ws.data.nativeControl?.inject) throw new NativeSteeringError("injection_not_supported", "Native injection is disabled or unavailable on this route.");
225
228
  ws.data.nativeControl.inject(frame);
229
+ ws.data.responsesActivity?.acceptResults(frame.input);
226
230
  return;
227
231
  }
228
232
  if (frame.type === "response.steer") {
@@ -230,7 +234,10 @@ export function createWebsocketHandler(
230
234
  ws.data.nativeControl.steer(frame);
231
235
  return;
232
236
  }
233
- if (ws.data.nativeControl?.continue(frame)) return;
237
+ if (ws.data.nativeControl?.continue(frame)) {
238
+ ws.data.responsesActivity?.acceptResults(frame.input);
239
+ return;
240
+ }
234
241
  } catch (error) {
235
242
  sendJsonFrame(ws, buildWsErrorFrame(400, {
236
243
  type: "invalid_request_error",
@@ -294,6 +301,7 @@ export function createWebsocketHandler(
294
301
  }
295
302
 
296
303
  // Only a genuinely admitted turn may receive steering or continuations.
304
+ const finishActivity = ws.data.responsesActivity?.beginTurn(frame.input);
297
305
  ws.data.nativeControl = nativeControl;
298
306
  const payload: Record<string, unknown> = { ...frame };
299
307
  delete payload.type;
@@ -346,11 +354,16 @@ export function createWebsocketHandler(
346
354
  terminalRecorder = recorder;
347
355
  },
348
356
  });
357
+ const observeLog = nativeControl?.relayActive
358
+ ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
359
+ : (payload: string) => inspectResponseLogSsePayload(logCtx, payload);
360
+ const providerExecuted = responseToolOwnership(response);
349
361
  await sendResponseToWebSocket(ws, response, isCurrent, {
350
362
  untilEof: nativeControl?.relayActive === true,
351
- onSsePayload: nativeControl?.relayActive
352
- ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
353
- : payload => inspectResponseLogSsePayload(logCtx, payload),
363
+ onSsePayload: payload => {
364
+ ws.data.responsesActivity?.observePayload(payload, providerExecuted);
365
+ observeLog(payload);
366
+ },
354
367
  onTerminal: status => {
355
368
  terminalRecorder?.(status, logCtx.terminalHttpStatus);
356
369
  finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
@@ -392,6 +405,7 @@ export function createWebsocketHandler(
392
405
  /* socket already gone or send dropped */
393
406
  }
394
407
  } finally {
408
+ finishActivity?.();
395
409
  turnAdmissionLease.release();
396
410
  if (ws.data.nativeControl === nativeControl) ws.data.nativeControl = undefined;
397
411
  if (!logged && turnAbort.signal.aborted) finalizeLog(499);