@bitkyc08/opencodex 2.88.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 (205) 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-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.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.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -1089,7 +1089,11 @@ export interface OcxConfig {
1089
1089
  modelCacheTtlMs?: number;
1090
1090
  /** Evictable retained app-state budget in MiB. Default 256; valid 64..4096. */
1091
1091
  appOwnedMemoryBudgetMb?: number;
1092
- /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */
1092
+ /**
1093
+ * Anthropic prompt-cache retention: "short" = 5-min ephemeral, "long" = 1-hour extended,
1094
+ * "none" = disabled. Unset = "long" on first-party api.anthropic.com, "short" on
1095
+ * Anthropic-compatible endpoints (adapters/anthropic/cache-retention.ts).
1096
+ */
1093
1097
  cacheRetention?: "none" | "short" | "long";
1094
1098
  /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
1095
1099
  webSearchSidecar?: OcxWebSearchSidecarConfig;
@@ -1113,6 +1117,9 @@ export interface OcxConfig {
1113
1117
  codexAccounts?: CodexAccount[];
1114
1118
  /** Account ids administratively excluded from future pool selection until resumed. */
1115
1119
  pausedCodexAccountIds?: string[];
1120
+ /** Internal policy epochs; null marks malformed disk input and suppresses automatic pause. */
1121
+ accountAutoPausePolicyRevisions?: Record<string, string> | null;
1122
+ codexAccountPauseState?: Record<string, { revision: string; details?: import("./account-auto-pause").AccountPauseDetails }>;
1116
1123
  /**
1117
1124
  * Account ids, `__main__` included, allowed to keep serving from ChatGPT credits once one of
1118
1125
  * their usage windows is full. Every other account is skipped by selection at 100% until its
@@ -1293,6 +1300,13 @@ export interface OcxConfig {
1293
1300
  * that work today — on Azure and custom Responses gateways as well, whose limits are unknown.
1294
1301
  */
1295
1302
  maxUpstreamBodyBytes?: number;
1303
+ /**
1304
+ * Opt-in compression of native ChatGPT Responses HTTP request bodies (#7247): "gzip" or "zstd".
1305
+ * Omitted, "off", or any other value = identity, the default. Applies only at the final HTTP send
1306
+ * to chatgpt.com Codex responses/compact, to string bodies from 1 KiB to 16 MiB; WebSocket turns
1307
+ * and every other destination are unchanged. Compression runs synchronously on the event loop.
1308
+ */
1309
+ upstreamRequestCompression?: "off" | "gzip" | "zstd";
1296
1310
  /**
1297
1311
  * Opt-in ceiling, in bytes, on a decompressed INBOUND data-plane request body (#3573).
1298
1312
  *
@@ -1454,6 +1468,8 @@ export interface OcxComboConfig {
1454
1468
  nativeAlias?: boolean;
1455
1469
  /** Display-only label for the public catalog row. Required for native aliases. */
1456
1470
  displayName?: string;
1471
+ /** Display-only description for the public catalog row; unset keeps the generated text (#7229). */
1472
+ description?: string;
1457
1473
  /**
1458
1474
  * `strategy: "jev"` only: provider id of the decision service. Omitted or `"jev"` uses the
1459
1475
  * canonical TypeSafe endpoint; any other id must name a configured `adapter: "jev-decision"`
@@ -1469,6 +1485,10 @@ export interface OcxComboConfig {
1469
1485
  * whose first call may include a cold model load.
1470
1486
  */
1471
1487
  decisionTimeoutMs?: number;
1488
+ /** JEV-only advisory loaded quota evidence; off/unset leaves request bytes unchanged. */
1489
+ decisionQuotaSignals?: boolean | null;
1490
+ /** Partial 0..100 tier overrides; defaults limited 70/nearlyExhausted 90. null clears. */
1491
+ decisionQuotaTiers?: import("../combos/jev-quota-config").JevQuotaTiers | null;
1472
1492
  }
1473
1493
 
1474
1494
  export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude";
@@ -1757,6 +1777,8 @@ export interface CodexLowQuotaProtectionConfig {
1757
1777
  actions: {
1758
1778
  pause: boolean;
1759
1779
  notify: boolean;
1780
+ /** Opt-in: also hand each notice to this host's OS notifier (macOS, Linux). Requires notify. */
1781
+ desktop?: boolean;
1760
1782
  };
1761
1783
  windows: {
1762
1784
  short: boolean;
@@ -1,4 +1,5 @@
1
1
  import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./wire";
2
+ import type { AccountAutoPausePolicy, AccountPauseDetails } from "./account-auto-pause";
2
3
  import type { AnthropicAccountPoolConfig } from "./anthropic-account-pool";
3
4
 
4
5
  /**
@@ -448,8 +449,9 @@ export interface OcxProviderConfig {
448
449
  */
449
450
  preserveResponsesMessageMetadata?: boolean;
450
451
  /**
451
- * Opt in to lossy hosted-search history notes on ordinary canonical Codex Lite requests.
452
- * Default off; only literal true enables it, and non-Lite/native compact stay unchanged.
452
+ * Opt in to lossy hosted-search history notes on canonical Codex Lite ordinary turns and
453
+ * native v2 compaction triggers, even when search is declared. Default off; only literal true
454
+ * enables it. History without a search declaration is always projected, independent of this.
453
455
  */
454
456
  projectResponsesLiteHostedSearchHistory?: boolean;
455
457
  /**
@@ -567,7 +569,9 @@ export interface OcxProviderConfig {
567
569
  * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare
568
570
  * `apiKey` seeds a one-entry pool on first management touch.
569
571
  */
570
- apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>;
572
+ apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number; disabled?: boolean; pauseDetails?: AccountPauseDetails; pauseRevision?: string }>;
573
+ /** Explicit opt-in permanent account refusal policy; default off. */
574
+ accountAutoPause?: AccountAutoPausePolicy;
571
575
  /**
572
576
  * Optional proactive ordering for `apiKeyPool` when the committed key is already
573
577
  * cooling. Deliberately NOT named like the OAuth `accountPoolStrategy`: an API key
@@ -1110,9 +1114,9 @@ export interface OcxProviderConfig {
1110
1114
  googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
1111
1115
  /** Google tool-schema compatibility policy. Omitted preserves compatible report-only behavior. */
1112
1116
  googleToolSchemaPolicy?: "compatible" | "reject-lossy";
1113
- /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */
1117
+ /** Vertex AI GCP project id. API keys use it to select the project endpoint; ADC falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env. */
1114
1118
  project?: string;
1115
- /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */
1119
+ /** Vertex AI location, e.g. "us-central1" or "global". API-key project endpoints default to "global"; ADC falls back to GOOGLE_CLOUD_LOCATION env. */
1116
1120
  location?: string;
1117
1121
  /**
1118
1122
  * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent
@@ -46,6 +46,14 @@ export interface OcxReasoningReplayScopeRef {
46
46
  current?: Readonly<OcxReasoningReplayIdentity>;
47
47
  }
48
48
 
49
+ /** One replayed hosted search cell, rebuilt as the `web_search` call/result pair `callId`. */
50
+ export interface OcxReplayedWebSearchCell {
51
+ /** The client-visible cell id; absent when the cell carried no usable id (no recorded result). */
52
+ cellId?: string;
53
+ callId: string;
54
+ failed: boolean;
55
+ }
56
+
49
57
  export interface OcxParsedRequest {
50
58
  modelId: string;
51
59
  /** Client-facing model selector retained for Anthropic routes after wire-model normalization. */
@@ -152,6 +160,12 @@ export interface OcxParsedRequest {
152
160
  * executes searches via the gpt-5.6-luna sidecar (see src/web-search). Absent when not requested.
153
161
  */
154
162
  _webSearch?: Record<string, unknown>;
163
+ /**
164
+ * Replayed hosted `web_search_call` cells the parser rebuilt as `web_search` call/result pairs
165
+ * (src/responses/web-search-replay.ts). The web-search sidecar swaps in the result it recorded for
166
+ * a cell, or removes the pairs when it does not run (src/web-search/sidecar-search-replay.ts).
167
+ */
168
+ _replayedWebSearchCells?: OcxReplayedWebSearchCell[];
155
169
  /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */
156
170
  _imageGeneration?: { toolNames: Set<string>; originalTool?: Record<string, unknown> };
157
171
  /**
@@ -448,9 +462,10 @@ export type AdapterEvent =
448
462
  // "Searching the web" spinner, then `end` once it resolves. The bridge maps begin → an
449
463
  // output_item.added(in_progress) and end → the matching output_item.done(completed|failed) under
450
464
  // the SAME output index, so the activity animates instead of flashing completed instantly.
451
- | { type: "web_search_call_begin"; id: string }
465
+ // `itemId` lets the loop name the client-visible cell so it can record the result for replay.
466
+ | { type: "web_search_call_begin"; id: string; itemId?: string }
452
467
  | {
453
- type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[];
468
+ type: "web_search_call_end"; id: string; itemId?: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[];
454
469
  /** Anthropic's server-side call (adapters/anthropic.ts): its `server_tool_use` and result block, verbatim. */
455
470
  anthropic?: AnthropicServerBlocks;
456
471
  }
@@ -510,6 +525,8 @@ export interface OcxUrlCitation {
510
525
  * - `cachedInputTokens` is cache READ tokens only (a subset of `inputTokens`).
511
526
  * - `cacheReadInputTokens`/`cacheCreationInputTokens` carry the read/write split when
512
527
  * the provider reports both; reads mirror `cachedInputTokens`.
528
+ * - `cacheCreation1hInputTokens` is the 1-hour-TTL subset of `cacheCreationInputTokens`
529
+ * (Anthropic `cache_creation.ephemeral_1h_input_tokens`), priced at the 1h write tier.
513
530
  * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top.
514
531
  */
515
532
  export interface OcxUsage {
@@ -527,6 +544,7 @@ export interface OcxUsage {
527
544
  cachedInputTokens?: number;
528
545
  cacheReadInputTokens?: number;
529
546
  cacheCreationInputTokens?: number;
547
+ cacheCreation1hInputTokens?: number;
530
548
  reasoningOutputTokens?: number;
531
549
  estimated?: boolean;
532
550
  /**
package/src/types.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  export type {
42
42
  OcxReasoningReplayIdentity,
43
43
  OcxReasoningReplayScopeRef,
44
+ OcxReplayedWebSearchCell,
44
45
  OcxParsedRequest,
45
46
  OcxContext,
46
47
  OcxMessage,
package/src/usage/cost.ts CHANGED
@@ -65,6 +65,8 @@ export interface CostTokens {
65
65
  output: number;
66
66
  cacheRead: number;
67
67
  cacheWrite: number;
68
+ /** The 1-hour-TTL subset of `cacheWrite`, when the provider reports the split. */
69
+ cacheWrite1h?: number;
68
70
  }
69
71
 
70
72
  export interface CostBreakdown {
@@ -163,22 +165,35 @@ export function normalizeCostTokens(usage: OcxUsage): CostTokens | null {
163
165
  for (const cacheRead of candidates) {
164
166
  if (![input, output, cacheRead, cacheWrite].every(finiteNonNegative)) return null;
165
167
  if (cacheRead + cacheWrite > input) continue;
168
+ const cacheWrite1h = usage.cacheCreation1hInputTokens;
166
169
  return {
167
170
  input: Math.max(0, input - cacheRead - cacheWrite),
168
171
  output,
169
172
  cacheRead,
170
173
  cacheWrite,
174
+ ...(typeof cacheWrite1h === "number" && finiteNonNegative(cacheWrite1h) && cacheWrite1h > 0
175
+ ? { cacheWrite1h: Math.min(cacheWrite1h, cacheWrite) } : {}),
171
176
  };
172
177
  }
173
178
  return null;
174
179
  }
175
180
 
181
+ /**
182
+ * Price of a 1-hour cache write relative to the 5-minute rate that Cost4.cacheWrite records.
183
+ * Anthropic bills 5m writes at 1.25x and 1h writes at 2x base input, so 2 / 1.25. Scaling the
184
+ * 5m rate (rather than input) keeps every tier multiplier already applied to the Cost4 and keeps
185
+ * a free-write row (cacheWrite 0) free.
186
+ */
187
+ export const CACHE_WRITE_1H_OVER_5M = 2 / 1.25;
188
+
176
189
  /** jawcode unit convention: USD per 1M tokens (jawcode stats/db.ts calculateCatalogCost). */
177
190
  export function calculateCost(tokens: CostTokens, cost4: Cost4): CostBreakdown {
178
191
  const input = cost4.input * tokens.input / 1_000_000;
179
192
  const output = cost4.output * tokens.output / 1_000_000;
180
193
  const cacheRead = cost4.cacheRead * tokens.cacheRead / 1_000_000;
181
- const cacheWrite = cost4.cacheWrite * tokens.cacheWrite / 1_000_000;
194
+ const write1h = Math.min(tokens.cacheWrite1h ?? 0, tokens.cacheWrite);
195
+ const cacheWrite = (cost4.cacheWrite * (tokens.cacheWrite - write1h)
196
+ + cost4.cacheWrite * CACHE_WRITE_1H_OVER_5M * write1h) / 1_000_000;
182
197
  return { input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite };
183
198
  }
184
199
 
@@ -656,6 +671,7 @@ export function estimateComboCost(
656
671
  tokens.output += est.tokens.output;
657
672
  tokens.cacheRead += est.tokens.cacheRead;
658
673
  tokens.cacheWrite += est.tokens.cacheWrite;
674
+ if (est.tokens.cacheWrite1h) tokens.cacheWrite1h = (tokens.cacheWrite1h ?? 0) + est.tokens.cacheWrite1h;
659
675
  cost.input += est.cost.input;
660
676
  cost.output += est.cost.output;
661
677
  cost.cacheRead += est.cost.cacheRead;
@@ -125,7 +125,9 @@ const GLM_52: Cost4 = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0
125
125
  const GLM_53: Cost4 = { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 };
126
126
  const GLM_53_FLASH: Cost4 = { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 };
127
127
  const QWEN38_MAX: Cost4 = { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 };
128
- // Anthropic official list prices (USD / 1M tokens). Cache write uses the published 5-minute rate.
128
+ // Anthropic official list prices (USD / 1M tokens). Cache write uses the published 5-minute rate;
129
+ // writes the usage reports as 1-hour (cache_creation.ephemeral_1h_input_tokens) are priced at
130
+ // 2x base input through CACHE_WRITE_1H_OVER_5M in cost.ts.
129
131
  const CLAUDE_SONNET_46: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
130
132
  const CLAUDE_OPUS_46: Cost4 = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
131
133
  // Claude Fable 5.1: 10 / 50, 5m cache write 12.50. Cache hits are 0.025x base input
@@ -1,3 +1,4 @@
1
+ import { normalizeJevQuotaSummary, type JevQuotaDecisionSummary } from "../combos/jev-quota-config";
1
2
  import type { OcxComboDefaultEffort } from "../types";
2
3
  import type { PersistedUsageEntry } from "./log";
3
4
  import { usageDisplayTotalTokens } from "./totals";
@@ -39,6 +40,7 @@ export interface PersistedJevDecisionV1 {
39
40
  };
40
41
  gate: JevDecisionGate;
41
42
  latencyMs: number;
43
+ quota?: JevQuotaDecisionSummary;
42
44
  confidence?: number;
43
45
  chosenProbability?: number;
44
46
  usage?: {
@@ -182,6 +184,7 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis
182
184
  const confidence = probability(value.confidence);
183
185
  const chosenProbability = probability(value.chosenProbability);
184
186
  const usage = normalizedDecisionUsage(value.usage);
187
+ const quota = normalizeJevQuotaSummary(value.quota);
185
188
  const backend = value.backend === "typesafe" || value.backend === "systemone" || value.backend === "model"
186
189
  ? value.backend
187
190
  : undefined;
@@ -196,6 +199,7 @@ export function normalizePersistedJevDecision(value: unknown): PersistedJevDecis
196
199
  },
197
200
  gate: gate as JevDecisionGate,
198
201
  latencyMs,
202
+ ...(quota ? { quota } : {}),
199
203
  ...(confidence !== undefined ? { confidence } : {}),
200
204
  ...(chosenProbability !== undefined ? { chosenProbability } : {}),
201
205
  ...(usage ? { usage } : {}),
package/src/usage/log.ts CHANGED
@@ -581,6 +581,7 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined
581
581
  ...(typeof usage.cachedInputTokens === "number" ? { cachedInputTokens: usage.cachedInputTokens } : {}),
582
582
  ...(typeof usage.cacheReadInputTokens === "number" ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}),
583
583
  ...(typeof usage.cacheCreationInputTokens === "number" ? { cacheCreationInputTokens: usage.cacheCreationInputTokens } : {}),
584
+ ...(typeof usage.cacheCreation1hInputTokens === "number" ? { cacheCreation1hInputTokens: usage.cacheCreation1hInputTokens } : {}),
584
585
  ...(typeof usage.reasoningOutputTokens === "number" ? { reasoningOutputTokens: usage.reasoningOutputTokens } : {}),
585
586
  ...(isNonNegativeFiniteNumber(usage.providerCredits) ? { providerCredits: usage.providerCredits } : {}),
586
587
  ...(usage.estimated ? { estimated: true } : {}),
@@ -625,6 +626,7 @@ function normalizeAttemptUsage(raw: unknown): OcxUsage | null {
625
626
  "cachedInputTokens",
626
627
  "cacheReadInputTokens",
627
628
  "cacheCreationInputTokens",
629
+ "cacheCreation1hInputTokens",
628
630
  "reasoningOutputTokens",
629
631
  "providerCredits",
630
632
  ] as const) {
@@ -520,6 +520,8 @@ function projectedComboUsage(
520
520
  let cachedInputTokens = 0;
521
521
  let cacheReadInputTokens = 0;
522
522
  let cacheCreationInputTokens = 0;
523
+ let cacheCreation1hInputTokens = 0;
524
+ let hasCacheWrite1h = false;
523
525
  let reasoningOutputTokens = 0;
524
526
  let hasUsage = false;
525
527
  let hasCacheTelemetry = false;
@@ -540,6 +542,10 @@ function projectedComboUsage(
540
542
  cacheReadInputTokens += cache.read;
541
543
  }
542
544
  if (typeof cache.creation === "number") cacheCreationInputTokens += cache.creation;
545
+ if (typeof attempt.usage.cacheCreation1hInputTokens === "number") {
546
+ hasCacheWrite1h = true;
547
+ cacheCreation1hInputTokens += attempt.usage.cacheCreation1hInputTokens;
548
+ }
543
549
  if (typeof attempt.usage.reasoningOutputTokens === "number") {
544
550
  hasReasoningTelemetry = true;
545
551
  reasoningOutputTokens += attempt.usage.reasoningOutputTokens;
@@ -559,6 +565,7 @@ function projectedComboUsage(
559
565
  inputTokens,
560
566
  outputTokens,
561
567
  ...(hasCacheTelemetry ? { cachedInputTokens, cacheReadInputTokens, cacheCreationInputTokens } : {}),
568
+ ...(hasCacheWrite1h ? { cacheCreation1hInputTokens } : {}),
562
569
  ...(hasReasoningTelemetry ? { reasoningOutputTokens } : {}),
563
570
  ...(estimated ? { estimated: true } : {}),
564
571
  }
@@ -14,6 +14,7 @@ import { bridgeToResponsesSSE } from "../bridge";
14
14
  import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal";
15
15
  import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog";
16
16
  import { mergeUsage } from "../server/responses/empty-completion-guard";
17
+ import { markSidecarContextOverflow, sidecarLoopTerminalEvent, upstreamErrorCode, type SidecarLoopFailure } from "../server/responses/context-overflow";
17
18
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
18
19
  import { runAnthropicWebSearch } from "./anthropic-executor";
19
20
  import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor";
@@ -25,6 +26,7 @@ import { redactSecretString } from "../lib/redact";
25
26
  import { readBoundedResponseBody } from "../lib/bounded-body";
26
27
  import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
27
28
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
29
+ import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
28
30
  import {
29
31
  isTranslatorBudgetExceededError,
30
32
  TRANSLATOR_MAX_TURN_BYTES,
@@ -33,6 +35,7 @@ import {
33
35
  import { formatWebSearchResults } from "./format-result";
34
36
  import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "./progress-stream";
35
37
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
38
+ import { newSidecarSearchCellId, rememberSidecarSearch, restoreSidecarSearchResults } from "./sidecar-search-replay";
36
39
 
37
40
  const SSE_HEADERS = {
38
41
  "Content-Type": "text/event-stream",
@@ -284,12 +287,21 @@ function jsonError(status: number, message: string, event?: Extract<AdapterEvent
284
287
  /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
285
288
  * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
286
289
  class LoopError extends Error {
287
- constructor(readonly status: number, message: string, readonly event?: Extract<AdapterEvent, { type: "error" }>) {
290
+ constructor(
291
+ readonly status: number,
292
+ message: string,
293
+ readonly event?: Extract<AdapterEvent, { type: "error" }>,
294
+ readonly detail?: Pick<SidecarLoopFailure, "upstreamCode" | "nonReplayable">,
295
+ ) {
288
296
  super(message);
289
297
  this.name = "LoopError";
290
298
  }
291
299
  }
292
300
 
301
+ function loopFailure(e: LoopError): SidecarLoopFailure {
302
+ return { status: e.status, message: e.message, ...(e.event ? { event: e.event } : {}), ...e.detail };
303
+ }
304
+
293
305
  /**
294
306
  * Dependencies for one web-search loop iteration: parsed request, active adapter,
295
307
  * incoming metadata, and the configured search executor.
@@ -417,6 +429,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
417
429
  ? Math.floor(deps.stallTimeoutSec * 1000)
418
430
  : 300_000;
419
431
 
432
+ // Put recorded results back into replayed search pairs before the first iteration (#7269).
433
+ restoreSidecarSearchResults(parsed);
420
434
  const messages: OcxMessage[] = [...parsed.context.messages];
421
435
  const replaySnapshots = new Set<AnthropicReplaySnapshot>();
422
436
  const loopT0 = Date.now();
@@ -491,9 +505,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
491
505
  // through the adapter formatter or the generic transport error, which could expose its
492
506
  // raw message. A parent/client cancellation still owns the request lifecycle as 499.
493
507
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
494
- throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
508
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`, undefined,
509
+ { nonReplayable: isNonReplayableResponse(prepared.response) });
495
510
  }
496
511
  let formatted = "";
512
+ let upstreamCode: string | undefined;
497
513
  if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
498
514
  try {
499
515
  formatted = prepared.responseAdapter.formatErrorBody(
@@ -503,8 +519,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
503
519
  ).trim();
504
520
  } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
505
521
  }
522
+ if (body.displaySafe && !body.truncated) upstreamCode = upstreamErrorCode(body.text);
506
523
  const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
507
- throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
524
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`, undefined,
525
+ { upstreamCode, nonReplayable: isNonReplayableResponse(prepared.response) });
508
526
  }
509
527
  };
510
528
 
@@ -736,7 +754,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
736
754
  throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
737
755
  }
738
756
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
739
- if (error instanceof LoopError) throw error;
757
+ if (error instanceof LoopError || error instanceof RequestPacingQueueOverloadError) throw error;
740
758
  throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
741
759
  } finally {
742
760
  headerDeadline.clear();
@@ -834,7 +852,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
834
852
  if (terminal.code === "translation_buffer_limit") {
835
853
  throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
836
854
  }
837
- throw new LoopError(502, terminal.message);
855
+ // Keep an adapter's own overflow verdict; every other terminal stays a generic 502.
856
+ throw new LoopError(502, terminal.message, terminal.code === "context_length_exceeded" ? terminal : undefined);
838
857
  }
839
858
  deps.thinkingRecovery?.observeIteration(events);
840
859
  return { ...scanEventsForWebSearch(events), streamedPassthroughCount, events, anthropicReplay };
@@ -849,6 +868,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
849
868
  async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = [], appendAssistant = true): AsyncGenerator<AdapterEvent> {
850
869
  const results: { query: string; outcome: SidecarOutcome }[] = [];
851
870
  let beganCell = false;
871
+ const cellItemId = newSidecarSearchCellId();
852
872
  if (call.queries.length === 0) {
853
873
  // The model called web_search with neither query nor queries — count it against the budget
854
874
  // (loop-bounding) exactly as the old empty-query placeholder did, but emit no cell.
@@ -870,7 +890,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
870
890
  // Real sidecar search. Open the cell once, before the first real query runs.
871
891
  if (!beganCell) {
872
892
  beganCell = true;
873
- yield { type: "web_search_call_begin", id: call.id };
893
+ yield { type: "web_search_call_begin", id: call.id, itemId: cellItemId };
874
894
  }
875
895
  // F5: the anthropic sidecar authenticates with its own stored OAuth — it never touches the
876
896
  // ChatGPT forward headers and must NOT record a Codex/OpenAI pool outcome.
@@ -945,12 +965,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
945
965
  });
946
966
  // One aggregated tool result. isError only when EVERY query failed (a partial success is usable).
947
967
  const allFailed = results.every(r => !!r.outcome.error);
968
+ const resultText = formatWebSearchResults(results, !!parsed._structuredOutput);
948
969
  messages.push({
949
970
  role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
950
- content: formatWebSearchResults(results, !!parsed._structuredOutput),
971
+ content: resultText,
951
972
  isError: allFailed, timestamp: now,
952
973
  });
953
974
  if (beganCell) {
975
+ // The client sees only the cell; keep the result so its replay can carry it (#7269).
976
+ rememberSidecarSearch(parsed, cellItemId, { id: call.id, arguments: callArgs }, resultText);
954
977
  // The cell is "completed" if any query produced a usable result, else "failed". `queries`
955
978
  // carries every attempted query so Codex renders the native plural label.
956
979
  const anySuccess = results.some(r => !r.outcome.error);
@@ -966,7 +989,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
966
989
  }
967
990
  }
968
991
  yield {
969
- type: "web_search_call_end", id: call.id,
992
+ type: "web_search_call_end", id: call.id, itemId: cellItemId,
970
993
  queries: call.queries,
971
994
  status: anySuccess ? "completed" : "failed",
972
995
  ...(sources.length > 0 ? { sources } : {}),
@@ -982,7 +1005,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
982
1005
  firstPrepared = await prepareIterationDrained(false);
983
1006
  } catch (e) {
984
1007
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
985
- if (e instanceof LoopError) return jsonError(e.status, e.message, e.event);
1008
+ if (e instanceof LoopError) return markSidecarContextOverflow(jsonError(e.status, e.message, e.event), loopFailure(e));
986
1009
  if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
987
1010
  { type: "error", message: e.message, errorType: "rate_limit_error", code: e.code, retryable: false });
988
1011
  throw e;
@@ -1137,10 +1160,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1137
1160
  } else {
1138
1161
  if (e instanceof SendBudgetExhaustedError) yield { type: "error", status: 429,
1139
1162
  errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false };
1140
- else yield e instanceof LoopError && e.event ? e.event
1163
+ // A later iteration's overflow ends the turn as the main path's terminal
1164
+ // context_length_exceeded, so Codex compacts instead of resending (#7316).
1165
+ else yield (e instanceof LoopError ? sidecarLoopTerminalEvent(loopFailure(e)) : undefined)
1166
+ ?? (e instanceof LoopError && e.event ? e.event
1141
1167
  : e instanceof LoopError && deps.thinkingRecovery
1142
1168
  ? { type: "error", status: e.status, errorType: "upstream_error", message: e.message }
1143
- : { type: "error", message: e instanceof Error ? e.message : String(e) };
1169
+ : { type: "error", message: e instanceof Error ? e.message : String(e) });
1144
1170
  }
1145
1171
  return;
1146
1172
  }
@@ -308,9 +308,12 @@ export async function* parseStreamWithProgress(
308
308
  }
309
309
  if (event.type === "error") {
310
310
  if (event.usage) options.onErrorUsage?.(event.usage);
311
+ // An adapter's own overflow verdict keeps its typed event so the sidecar can end the
312
+ // turn as context_length_exceeded (#7316); other errors keep the plain-message shape.
313
+ const preserve = options.preserveAdapterError || event.code === "context_length_exceeded";
311
314
  fail(event.code === "translation_buffer_limit"
312
315
  ? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES)
313
- : options.preserveAdapterError ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message));
316
+ : preserve ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message));
314
317
  return;
315
318
  }
316
319
  replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true;
@@ -43,6 +43,7 @@ import {
43
43
  type TranslatorBudget,
44
44
  } from "../lib/translator-budget";
45
45
  import { jsonUtf8Bytes } from "../lib/json-byte-size";
46
+ import { newSidecarSearchCellId, rememberSidecarSearch, restoreSidecarSearchResults } from "./sidecar-search-replay";
46
47
 
47
48
  export interface RunTurnWebSearchDeps {
48
49
  parsed: OcxParsedRequest;
@@ -77,6 +78,8 @@ export interface RunTurnWebSearchDeps {
77
78
  * active: same request plus the synthetic tool so the model can ask to search.
78
79
  */
79
80
  export function runTurnWebSearchInitialParsed(parsed: OcxParsedRequest): OcxParsedRequest {
81
+ // Runs before the first dispatch: put recorded results back into replayed search pairs (#7269).
82
+ restoreSidecarSearchResults(parsed);
80
83
  return {
81
84
  ...parsed,
82
85
  context: {
@@ -296,6 +299,7 @@ export async function* runTurnWebSearchLoop(
296
299
  ): AsyncGenerator<AdapterEvent> {
297
300
  const results: { query: string; outcome: SidecarOutcome }[] = [];
298
301
  let beganCell = false;
302
+ const cellItemId = newSidecarSearchCellId();
299
303
  if (call.queries.length === 0) {
300
304
  searchesExecuted++;
301
305
  results.push({ query: "", outcome: { text: "", sources: [], error: "the model called web_search with an empty query" } });
@@ -318,7 +322,7 @@ export async function* runTurnWebSearchLoop(
318
322
  } else {
319
323
  if (!beganCell) {
320
324
  beganCell = true;
321
- yield { type: "web_search_call_begin", id: call.id };
325
+ yield { type: "web_search_call_begin", id: call.id, itemId: cellItemId };
322
326
  }
323
327
  if (abortSignal?.aborted) {
324
328
  yield { type: "web_search_call_end", id: call.id, queries: call.queries, status: "failed" };
@@ -358,12 +362,14 @@ export async function* runTurnWebSearchLoop(
358
362
  timestamp: now,
359
363
  });
360
364
  const allFailed = results.every(r => !!r.outcome.error);
365
+ const resultText = formatWebSearchResults(results, !!parsed._structuredOutput);
361
366
  appendMessage({
362
367
  role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
363
- content: formatWebSearchResults(results, !!parsed._structuredOutput),
368
+ content: resultText,
364
369
  isError: allFailed, timestamp: now,
365
370
  });
366
371
  if (beganCell) {
372
+ rememberSidecarSearch(parsed, cellItemId, { id: call.id, arguments: callArgs }, resultText);
367
373
  const anySuccess = results.some(r => !r.outcome.error);
368
374
  const sources: { url: string; title?: string }[] = [];
369
375
  const seenSrc = new Set<string>();
@@ -375,7 +381,7 @@ export async function* runTurnWebSearchLoop(
375
381
  }
376
382
  }
377
383
  yield {
378
- type: "web_search_call_end", id: call.id,
384
+ type: "web_search_call_end", id: call.id, itemId: cellItemId,
379
385
  queries: call.queries,
380
386
  status: anySuccess ? "completed" : "failed",
381
387
  ...(sources.length > 0 ? { sources } : {}),
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Replay memo for searches the web-search sidecar executed (#7269).
3
+ *
4
+ * The sidecar hands the model its search result inside the turn, but the caller only sees a hosted
5
+ * `web_search_call` cell without the result text. When the turn ends on a client tool call, the
6
+ * next request replays that cell, which the parser rebuilds as a `web_search` call/result pair
7
+ * with a fallback result (src/responses/web-search-replay.ts). This module records the real result
8
+ * under the cell id and puts it back into that pair, or removes the pairs when no sidecar runs:
9
+ * the synthetic `web_search` tool is then undeclared, and a call to it is an invalid history.
10
+ *
11
+ * Storage reuses the bounded, in-memory, never-logged store of the passthrough bridge
12
+ * (src/responses/bridge-search-replay-cache.ts) under a separate scope. Unlike a bridged
13
+ * destination call, the sidecar result does not belong to the serving model: the proxy ran it with
14
+ * its own backend. So the scope is the caller principal and conversation, which lets a model switch
15
+ * inside the same conversation keep the result, and fails closed without either.
16
+ */
17
+ import type { OcxMessage, OcxParsedRequest, OcxReasoningReplayScopeRef } from "../types";
18
+ import { peekBridgeSearchReplay, rememberBridgeSearchReplay } from "../responses/bridge-search-replay-cache";
19
+ import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
20
+
21
+ export function sidecarSearchReplayScope(scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
22
+ if (!scope?.clientPrincipalId || !scope.clientThreadId) return undefined;
23
+ return JSON.stringify(["web-search-sidecar", scope.clientPrincipalId, scope.clientThreadId]);
24
+ }
25
+
26
+ /** A fresh client-visible cell id the loop hands the bridge, so it can key the recorded result. */
27
+ export function newSidecarSearchCellId(): string {
28
+ return `ws_${crypto.randomUUID().replace(/-/g, "")}`;
29
+ }
30
+
31
+ export function rememberSidecarSearch(
32
+ parsed: Pick<OcxParsedRequest, "_reasoningReplayScope">,
33
+ cellItemId: string,
34
+ call: { id: string; arguments: Record<string, unknown> },
35
+ output: string,
36
+ ): void {
37
+ rememberBridgeSearchReplay(sidecarSearchReplayScope(parsed._reasoningReplayScope), cellItemId, {
38
+ callId: call.id,
39
+ name: WEB_SEARCH_TOOL_NAME,
40
+ argumentsText: JSON.stringify(call.arguments),
41
+ output,
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Swap the recorded result into every replayed search pair the parser rebuilt. A miss keeps the
47
+ * parser's fallback result; neither rerunning the search nor inventing a result is a recovery.
48
+ */
49
+ export function restoreSidecarSearchResults(parsed: OcxParsedRequest): void {
50
+ const cells = parsed._replayedWebSearchCells;
51
+ if (!cells?.length) return;
52
+ const scope = sidecarSearchReplayScope(parsed._reasoningReplayScope);
53
+ for (const cell of cells) {
54
+ const entry = cell.cellId ? peekBridgeSearchReplay(scope, cell.cellId) : undefined;
55
+ for (const message of parsed.context.messages) {
56
+ if (message.role === "toolResult" && message.toolCallId === cell.callId && message.toolName === WEB_SEARCH_TOOL_NAME) {
57
+ if (entry) message.content = entry.output;
58
+ if (cell.failed) message.isError = true;
59
+ } else if (entry && message.role === "assistant") {
60
+ for (const part of message.content) {
61
+ if (part.type !== "toolCall" || part.id !== cell.callId || part.name !== WEB_SEARCH_TOOL_NAME) continue;
62
+ try {
63
+ const args: unknown = JSON.parse(entry.argumentsText);
64
+ if (args && typeof args === "object" && !Array.isArray(args)) part.arguments = args as Record<string, unknown>;
65
+ } catch { /* keep the replayed arguments */ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Remove the rebuilt pairs when no sidecar plan resolved, restoring the history the routed
74
+ * provider saw before #7269. An assistant turn left holding only reasoning goes too, as the parser
75
+ * dropped reasoning that led straight to a hosted cell.
76
+ */
77
+ export function stripReplayedSearchPairs(parsed: OcxParsedRequest): void {
78
+ const cells = parsed._replayedWebSearchCells;
79
+ if (!cells?.length) return;
80
+ const ids = new Set(cells.map(cell => cell.callId));
81
+ const isPair = (part: { type: string; id?: string; name?: string }) =>
82
+ part.type === "toolCall" && part.name === WEB_SEARCH_TOOL_NAME && part.id !== undefined && ids.has(part.id);
83
+ const kept: OcxMessage[] = [];
84
+ for (const message of parsed.context.messages) {
85
+ if (message.role === "toolResult" && message.toolName === WEB_SEARCH_TOOL_NAME && ids.has(message.toolCallId)) continue;
86
+ if (message.role === "assistant" && message.content.some(isPair)) {
87
+ const content = message.content.filter(part => !isPair(part));
88
+ if (!content.some(part => part.type !== "thinking")) continue;
89
+ kept.push({ ...message, content });
90
+ continue;
91
+ }
92
+ kept.push(message);
93
+ }
94
+ parsed.context.messages = kept;
95
+ delete parsed._replayedWebSearchCells;
96
+ }