@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -1,13 +1,15 @@
1
1
  import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
2
- import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage } from "../types";
2
+ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
3
3
  import { namespacedToolName } from "../types";
4
+ import type { AttemptRecoveryKind } from "../usage/log";
4
5
  import { bridgeToResponsesSSE } from "../bridge";
5
6
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
6
7
  import { runAnthropicWebSearch } from "./anthropic-executor";
7
8
  import { clearableDeadline } from "../lib/abort";
8
9
  import { redactSecretString } from "../lib/redact";
9
10
  import { readBoundedResponseBody } from "../lib/bounded-body";
10
- import { fetchWithResetRetry } from "../lib/upstream-retry";
11
+ import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
12
+ import { rateLimitRetryDelayMs } from "../providers/key-failover";
11
13
  import {
12
14
  isTranslatorBudgetExceededError,
13
15
  TRANSLATOR_MAX_TURN_BYTES,
@@ -61,40 +63,68 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): {
61
63
  calls: WebSearchCall[];
62
64
  passthrough: AdapterEvent[];
63
65
  hasRealToolCall: boolean;
66
+ hasMalformedToolCall: boolean;
64
67
  } {
65
68
  const calls: WebSearchCall[] = [];
66
69
  const passthrough: AdapterEvent[] = [];
67
70
  let hasRealToolCall = false;
68
- let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null;
71
+ let hasMalformedToolCall = false;
72
+ let pending: { name: string; id: string; argsBuf: string; closed: boolean; events: AdapterEvent[] } | null = null;
73
+ const isBlank = (value: string): boolean => value.trim().length === 0;
69
74
  const flushPending = (): void => {
75
+ // A pending call that never saw tool_call_end is structurally malformed.
76
+ if (pending && !pending.closed) hasMalformedToolCall = true;
70
77
  if (pending && pending.name !== WEB_SEARCH_TOOL_NAME) {
71
78
  passthrough.push(...pending.events);
72
- hasRealToolCall = true;
79
+ if (pending.closed && !isBlank(pending.id) && !isBlank(pending.name)) hasRealToolCall = true;
73
80
  }
74
81
  pending = null;
75
82
  };
76
83
  for (const e of events) {
77
84
  if (e.type === "tool_call_start") {
78
85
  flushPending();
79
- pending = { name: e.name, id: e.id, argsBuf: "", events: [e] };
80
- } else if (e.type === "tool_call_delta" && pending) {
81
- pending.argsBuf += e.arguments;
82
- pending.events.push(e);
83
- } else if (e.type === "tool_call_end" && pending) {
84
- pending.events.push(e);
85
- if (pending.name === WEB_SEARCH_TOOL_NAME) {
86
- calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf) });
86
+ if (isBlank(e.id) || isBlank(e.name)) hasMalformedToolCall = true;
87
+ pending = { name: e.name, id: e.id, argsBuf: "", closed: false, events: [e] };
88
+ } else if (e.type === "tool_call_delta") {
89
+ // Orphan delta (no open call) is malformed.
90
+ if (!pending) hasMalformedToolCall = true;
91
+ else {
92
+ pending.argsBuf += e.arguments;
93
+ pending.events.push(e);
94
+ }
95
+ } else if (e.type === "tool_call_end") {
96
+ // Orphan end (no open call) is malformed.
97
+ if (!pending) {
98
+ hasMalformedToolCall = true;
87
99
  } else {
88
- passthrough.push(...pending.events);
89
- hasRealToolCall = true;
100
+ pending.events.push(e);
101
+ pending.closed = true;
102
+ if (pending.name === WEB_SEARCH_TOOL_NAME) {
103
+ calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf) });
104
+ } else {
105
+ passthrough.push(...pending.events);
106
+ if (!isBlank(pending.id) && !isBlank(pending.name)) hasRealToolCall = true;
107
+ }
108
+ pending = null;
90
109
  }
91
- pending = null;
92
110
  } else {
93
111
  passthrough.push(e);
94
112
  }
95
113
  }
96
114
  flushPending();
97
- return { calls, passthrough, hasRealToolCall };
115
+ return { calls, passthrough, hasRealToolCall, hasMalformedToolCall };
116
+ }
117
+
118
+ /**
119
+ * Visible final-answer text: a non-whitespace text_delta that is NOT commentary
120
+ * (#1001). Commentary streams early for progress and must not satisfy the
121
+ * forced-answer output check; thinking alone never counts either.
122
+ */
123
+ export function hasVisibleAssistantText(events: AdapterEvent[]): boolean {
124
+ return events.some(event =>
125
+ event.type === "text_delta"
126
+ && event.phase !== "commentary"
127
+ && event.text.trim().length > 0);
98
128
  }
99
129
 
100
130
  async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
@@ -206,6 +236,10 @@ class LoopError extends Error {
206
236
  }
207
237
  }
208
238
 
239
+ /**
240
+ * Dependencies for one web-search loop iteration: parsed request, active adapter,
241
+ * incoming metadata, and the configured search executor.
242
+ */
209
243
  export interface WebSearchLoopDeps {
210
244
  parsed: OcxParsedRequest;
211
245
  adapter: ProviderAdapter;
@@ -239,11 +273,15 @@ export interface WebSearchLoopDeps {
239
273
  onUsage?: (usage: OcxUsage | undefined) => void;
240
274
  /** Observe the exact adapter request selected for each routed-model iteration. */
241
275
  onRequestBuilt?: (request: AdapterRequest) => void;
276
+ /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */
277
+ onAttemptSend?: (recovery?: AttemptRecoveryKind) => void;
242
278
  /**
243
279
  * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
244
280
  * or null when the pool is exhausted (same semantics as the normal routed path).
245
281
  */
246
282
  on429?: (retryAfterHeader: string | null) => ProviderAdapter | null;
283
+ /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */
284
+ retryOn429Policy?: Required<RateLimitRetryPolicy> | null;
247
285
  }
248
286
 
249
287
  /**
@@ -260,6 +298,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
260
298
  // Mutable: 429 key-failover (deps.on429) can swap in a rebuilt adapter mid-loop.
261
299
  let adapter = deps.adapter;
262
300
 
301
+ // Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff
302
+ // heartbeat interval is derived from it so the watchdog is always fed during deliberate waits.
303
+ const stallTimeoutMs = typeof deps.stallTimeoutSec === "number" && Number.isFinite(deps.stallTimeoutSec) && deps.stallTimeoutSec > 0
304
+ ? Math.floor(deps.stallTimeoutSec * 1000)
305
+ : 300_000;
306
+
263
307
  const messages: OcxMessage[] = [...parsed.context.messages];
264
308
  const loopT0 = Date.now();
265
309
  const allTools = parsed.context.tools ?? [];
@@ -294,9 +338,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
294
338
  }
295
339
  type IterationSplit = ReturnType<typeof scanEventsForWebSearch>;
296
340
 
341
+ // Same-target 429 budget is per REQUEST, not per model iteration: later search rounds inherit
342
+ // what earlier rounds left of `attempts`, so a bounded multi-round turn can never exceed the
343
+ // configured replay count in total (a per-round reset would multiply it by maxSearches).
344
+ const rateLimitRetryPolicy = deps.retryOn429Policy ?? null;
345
+ let rateLimitRetries = 0;
346
+
297
347
  // Acquire one iteration's final response headers. The first call is drained eagerly so an initial
298
348
  // connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately
299
349
  // left unread until the downstream Responses SSE bridge exists.
350
+ /**
351
+ * Fetch one web-search iteration's final response headers, applying the response-header
352
+ * deadline and the same-target 429 retry policy (with awaited body release and deadline
353
+ * restart) before the `on429` key rotation.
354
+ */
300
355
  const prepareIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, IterationResponse> {
301
356
  // On the forced-answer pass the synthetic web_search tool is gone, so the model MUST answer
302
357
  // from the results already in `messages`. A weak model can still produce a thin answer that
@@ -313,30 +368,55 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
313
368
  // One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
314
369
  // clear() stops only its timer after final headers; the direct turn signal remains attached to
315
370
  // the returned response body through AbortSignal.any().
316
- const headerDeadline = clearableDeadline(connectTimeoutMs, signal);
371
+ let headerDeadline = clearableDeadline(connectTimeoutMs, signal);
317
372
  try {
318
- const fetchOnce = async (requestAdapter: ProviderAdapter): Promise<IterationResponse> => {
319
- const request = await requestAdapter.buildRequest(iterParsed, {
320
- headers: selectedForwardHeaders,
321
- abortSignal: headerDeadline.signal,
322
- translatorBudget,
323
- });
324
- try {
325
- deps.onRequestBuilt?.(request);
326
- } catch {
327
- // Diagnostics are best-effort and must never abort a web-search iteration.
373
+ /**
374
+ * Build and fetch one web-search iteration on the given adapter, under the iteration
375
+ * header deadline. The caller owns same-target 429 replays and key rotation around it.
376
+ * The outbound request is cached per adapter so a same-target replay reuses the EXACT
377
+ * URL, serialized body, and headers (builder runs once per target sequence).
378
+ */
379
+ let cachedRequest: AdapterRequest | undefined;
380
+ let cachedAdapter: ProviderAdapter | undefined;
381
+ /**
382
+ * Build and fetch one web-search iteration on the given adapter, under the iteration
383
+ * header deadline. The caller owns same-target 429 replays and key rotation around it.
384
+ */
385
+ const fetchOnce = async (requestAdapter: ProviderAdapter, recovery?: AttemptRecoveryKind): Promise<IterationResponse> => {
386
+ let request: AdapterRequest;
387
+ if (cachedRequest !== undefined && cachedAdapter === requestAdapter) {
388
+ request = cachedRequest;
389
+ } else {
390
+ request = await requestAdapter.buildRequest(iterParsed, {
391
+ headers: selectedForwardHeaders,
392
+ abortSignal: headerDeadline.signal,
393
+ translatorBudget,
394
+ });
395
+ try {
396
+ deps.onRequestBuilt?.(request);
397
+ } catch {
398
+ // Diagnostics are best-effort and must never abort a web-search iteration.
399
+ }
400
+ cachedRequest = request;
401
+ cachedAdapter = requestAdapter;
328
402
  }
329
403
  let response: Response;
330
404
  try {
331
- response = requestAdapter.fetchResponse
332
- ? await requestAdapter.fetchResponse(request, {
405
+ if (requestAdapter.fetchResponse) {
406
+ deps.onAttemptSend?.(recovery);
407
+ response = await requestAdapter.fetchResponse(request, {
333
408
  abortSignal: headerDeadline.signal,
334
409
  timeoutMs: connectTimeoutMs,
335
410
  returnRawErrors: true,
336
411
  stream: true,
337
- })
338
- : await fetchWithResetRetry(
339
- () => {
412
+ });
413
+ } else {
414
+ response = await fetchWithResetRetry(
415
+ (retryRecovery) => {
416
+ // Record every helper-driven send (the callback runs for the first attempt and
417
+ // each connection-reset replay); preserve the caller's recovery kind
418
+ // (rate-limit-429 / key-429) when the retry layer supplies none.
419
+ deps.onAttemptSend?.(retryRecovery ?? recovery);
340
420
  const h = new Headers(request.headers);
341
421
  if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
342
422
  return fetch(request.url, {
@@ -347,7 +427,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
347
427
  });
348
428
  },
349
429
  { abortSignal: headerDeadline.signal, label: "web-search-loop" },
350
- );
430
+ );
431
+ }
351
432
  } finally {
352
433
  request.releaseBodyObservation?.();
353
434
  }
@@ -355,6 +436,39 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
355
436
  };
356
437
 
357
438
  let prepared = await fetchOnce(adapter);
439
+ // Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key
440
+ // rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust.
441
+ while (
442
+ prepared.response.status === 429
443
+ && rateLimitRetryPolicy !== null
444
+ && rateLimitRetries < rateLimitRetryPolicy.attempts
445
+ ) {
446
+ rateLimitRetries += 1;
447
+ // Release unread body + heartbeat-fed wait via the shared same-target helper.
448
+ const retryAfterHeader = prepared.response.headers.get("retry-after");
449
+ // The old header deadline must not stay armed across the deliberate wait: clear it
450
+ // before sleeping so a stale expiry can never race the client-cancel path.
451
+ headerDeadline.clear();
452
+ try {
453
+ yield* prepareSameTarget429Wait({
454
+ body: prepared.response.body,
455
+ signal,
456
+ delayMs: rateLimitRetryDelayMs(rateLimitRetryPolicy, retryAfterHeader, Date.now()),
457
+ heartbeatIntervalMs: Math.min(10_000, Math.max(250, stallTimeoutMs / 2)),
458
+ });
459
+ } catch {
460
+ throw new LoopError(499, "client closed request during web-search");
461
+ }
462
+ // Client cancellation wins over any stale-deadline edge: re-check before telemetry/replay.
463
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
464
+ // The deliberate backoff must not consume the cumulative response-header deadline:
465
+ // start a fresh one so the replay gets a new connect budget (504 stays reserved for real
466
+ // upstream latency).
467
+ headerDeadline = clearableDeadline(connectTimeoutMs, signal);
468
+ // Stall-watchdog seam between bounded retry fetches.
469
+ yield { type: "heartbeat" };
470
+ prepared = await fetchOnce(adapter, "rate-limit-429");
471
+ }
358
472
  // 429 key-failover parity with the normal routed path: rotate pool keys until one responds
359
473
  // or the pool is exhausted (deps.on429 returns null — cooldown map guarantees termination).
360
474
  while (prepared.response.status === 429 && deps.on429) {
@@ -366,7 +480,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
366
480
  adapter = rotated;
367
481
  // Stall-watchdog seam between bounded retry fetches (audit 011 B3).
368
482
  yield { type: "heartbeat" };
369
- prepared = await fetchOnce(adapter);
483
+ prepared = await fetchOnce(adapter, "key-429");
370
484
  }
371
485
 
372
486
  // Final headers have arrived. Clear only the deadline timer before ANY body read.
@@ -607,6 +721,18 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
607
721
  // calls reach Codex. forceAnswer also finalizes.
608
722
  const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceAnswer;
609
723
  if (!shouldLoop) {
724
+ // #1001: a forced-answer pass that ends `done` must have produced
725
+ // usable output — never a malformed tool call, and never silence.
726
+ if (forceAnswer) {
727
+ // An unterminated call flushes AFTER the terminal event, so find
728
+ // the terminal rather than assuming it is last (#1001).
729
+ const terminalEvent = split.passthrough.find(event => event.type === "done");
730
+ if (terminalEvent?.type === "done"
731
+ && (split.hasMalformedToolCall
732
+ || (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) {
733
+ throw new LoopError(502, "forced-answer pass produced no usable assistant output");
734
+ }
735
+ }
610
736
  if (executedSearchCount > 0) {
611
737
  const failedCount = failedQueries.size;
612
738
  console.warn(
@@ -653,6 +779,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
653
779
  }, undefined,
654
780
  {
655
781
  translatorBudget,
782
+ replayCacheScope: parsed._clientThreadId ?? "global",
656
783
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
657
784
  hideThinkingSummary: parsed.options.hideThinkingSummary,
658
785
  ...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),