@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
3
  import { formatPassthroughUpstreamError } from "./passthrough-error";
4
+ import { describeUpstreamConnectFailure } from "./upstream-error";
4
5
  import {
5
6
  getConfigPath,
6
7
  multiAgentGuidanceEnabled,
@@ -34,11 +35,25 @@ import {
34
35
  forceRefreshOAuthAccessSnapshot,
35
36
  getOAuthCredentialApiBaseUrl,
36
37
  getOAuthCredentialProjectId,
38
+ getValidAccessTokenForAccount,
37
39
  getValidAccessTokenSnapshot,
38
40
  type OAuthAccessSnapshot,
39
41
  UnsupportedOAuthProviderError,
40
42
  } from "../../oauth";
43
+ import {
44
+ ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
45
+ anthropicSessionKeyFromParts,
46
+ bindAnthropicSessionAffinity,
47
+ formatAnthropicProviderForLog,
48
+ getAnthropicPoolAccessToken,
49
+ getAnthropicPoolRetryAfterSeconds,
50
+ isAnthropicAccountPoolEnabled,
51
+ promoteAnthropicActiveAccount,
52
+ resolveAnthropicAccountForSession,
53
+ rotateAnthropicAccountOn429,
54
+ } from "../../oauth/anthropic-routing";
41
55
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
56
+ import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
42
57
  import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
43
58
  import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
44
59
  import {
@@ -53,11 +68,13 @@ import {
53
68
  isCodexAuthContextUsable,
54
69
  resolveCodexAuthContext,
55
70
  codexProbeLeaseId,
71
+ codexProbeQuotaScope,
56
72
  releaseCodexAuthContextProbeLease,
57
73
  stripCodexRuntimeProviderFields,
58
74
  type CodexAuthContext,
59
75
  } from "../../codex/auth-context";
60
76
  import {
77
+ computeQuotaCooldown,
61
78
  formatCodexProviderForLog,
62
79
  previewCodexAccountForRequest,
63
80
  recordCodexUpstreamOutcome,
@@ -118,7 +135,16 @@ import {
118
135
  import { relaySseEagerBounded } from "../relay-eager";
119
136
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
120
137
  import { cancelBodyOnAbort } from "../../lib/abort";
121
- import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
138
+ import {
139
+ createResponsesItemIdPayloadRewrite,
140
+ hasResponsesItemIdRepair,
141
+ } from "../responses-item-id-repair";
142
+ import {
143
+ createImageGenCallRestoreRewrite,
144
+ imageGenToolCallAliases,
145
+ restoreImageGenCallsInJson,
146
+ } from "../responses-image-gen-repair";
147
+ import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
122
148
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
123
149
 
124
150
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
@@ -148,16 +174,9 @@ export function sidecarOutcomeRecorder(
148
174
 
149
175
 
150
176
 
151
- export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
177
+ import { isShadowSourceModel } from "../../lib/shadow-call";
152
178
 
153
- export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
154
- if (modelId.includes("/")) return false;
155
- const configuredStrings = Array.isArray(configured)
156
- ? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
157
- : [];
158
- const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
159
- return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
160
- }
179
+ export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
161
180
 
162
181
 
163
182
 
@@ -214,12 +233,174 @@ async function shouldRetryCodexPoolAccountModel400(
214
233
  }
215
234
  }
216
235
 
236
+ /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
237
+ function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
238
+ return response.status === 429 || response.status === 402;
239
+ }
240
+
241
+ interface CodexPoolAccountRetryArgs {
242
+ req: Request;
243
+ config: OcxConfig;
244
+ route: { providerName: string; modelId: string; provider: OcxProviderConfig };
245
+ parsed: OcxParsedRequest;
246
+ logCtx: RequestLogContext;
247
+ options: {
248
+ abortSignal?: AbortSignal;
249
+ onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
250
+ deferCodexResetDerivedCooldown?: boolean;
251
+ };
252
+ firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
253
+ firstResponse: Response;
254
+ outcomeStatus: number;
255
+ upstream: AbortController;
256
+ connectMs: number;
257
+ passthroughEstimate?: number;
258
+ stream: boolean;
259
+ }
260
+
261
+ type CodexPoolAccountRetryResult =
262
+ | {
263
+ kind: "retried";
264
+ authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
265
+ request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
266
+ upstreamResponse: Response;
267
+ selectedForwardHeaders: Headers;
268
+ }
269
+ | { kind: "no-alternate" }
270
+ | {
271
+ kind: "transport";
272
+ error: unknown;
273
+ authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
274
+ };
275
+
276
+ function codexQuotaOutcomeMeta(response: Response): {
277
+ retryAfter: string | null;
278
+ resetAt: string[];
279
+ } {
280
+ return {
281
+ retryAfter: response.headers.get("retry-after"),
282
+ resetAt: [
283
+ response.headers.get("x-codex-primary-reset-at"),
284
+ response.headers.get("x-codex-secondary-reset-at"),
285
+ response.headers.get("x-codex-tertiary-reset-at"),
286
+ ].filter((value): value is string => !!value),
287
+ };
288
+ }
289
+
290
+ /**
291
+ * A reset timestamp describes a quota window, not an explicit instruction to
292
+ * stop using the whole account. A combo may therefore try a later model in the
293
+ * same request, while Retry-After and headerless quota failures remain blocking.
294
+ */
295
+ function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
296
+ return enabled === true
297
+ && (response.status === 429 || response.status === 402)
298
+ && computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
299
+ }
300
+
301
+ /**
302
+ * One bounded alternate-account retry for Codex pool auth. Used for allow-listed
303
+ * model-400 and for pre-stream 429/402 quota failures (#584).
304
+ */
305
+ async function retryCodexPoolOnAlternateAccount(
306
+ args: CodexPoolAccountRetryArgs,
307
+ ): Promise<CodexPoolAccountRetryResult> {
308
+ const {
309
+ req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
310
+ outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
311
+ } = args;
312
+ let retryAuthCtx: CodexAuthContext | undefined;
313
+ try {
314
+ retryAuthCtx = await resolveCodexAuthContext(
315
+ req.headers,
316
+ config,
317
+ "pool",
318
+ { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
319
+ );
320
+ } catch (error) {
321
+ if (
322
+ !(error instanceof CodexPoolAuthenticationError)
323
+ && !(error instanceof CodexAuthContextError)
324
+ && !(error instanceof CodexAccountCooldownError)
325
+ ) throw error;
326
+ }
327
+ if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
328
+ return { kind: "no-alternate" };
329
+ }
330
+
331
+ const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
332
+ if (outcomeStatus === 429 || outcomeStatus === 402) {
333
+ const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
334
+ applyAccountQuotaFromUpstreamHeaders(firstAuthCtx.accountId, firstResponse.headers);
335
+ }
336
+ if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
337
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
338
+ ...quotaMeta,
339
+ threadId: req.headers.get("x-codex-parent-thread-id"),
340
+ modelId: route.modelId,
341
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
342
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
343
+ // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
344
+ ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
345
+ });
346
+ }
347
+
348
+ const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
349
+ const retryProvider = applyCodexAuthContextToProvider(
350
+ stripCodexRuntimeProviderFields(route.provider),
351
+ retryAuthCtx,
352
+ "pool",
353
+ );
354
+ const retryAdapter = resolveAdapter(
355
+ resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
356
+ config.cacheRetention,
357
+ );
358
+ const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
359
+ recordAdapterReasoning(logCtx, request);
360
+
361
+ await firstResponse.body?.cancel().catch(() => undefined);
362
+ options.onCodexAuthContextResolved?.(retryAuthCtx);
363
+ route.provider = retryProvider;
364
+ logCtx.provider = formatCodexProviderForLog(
365
+ route.providerName,
366
+ retryAuthCtx.accountId,
367
+ config,
368
+ );
369
+
370
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
371
+ try {
372
+ const upstreamResponse = await fetchWithHeaderTimeout(
373
+ request.url,
374
+ {
375
+ method: request.method,
376
+ headers: request.headers,
377
+ body: request.body,
378
+ },
379
+ upstream.signal,
380
+ connectMs,
381
+ stream,
382
+ providerFetch(route.provider),
383
+ );
384
+ return {
385
+ kind: "retried",
386
+ authCtx: retryAuthCtx,
387
+ request,
388
+ upstreamResponse,
389
+ selectedForwardHeaders: retryHeaders,
390
+ };
391
+ } catch (error) {
392
+ // Attribute the transport failure to the alternate account (already selected).
393
+ return { kind: "transport", error, authCtx: retryAuthCtx };
394
+ }
395
+ }
396
+
217
397
 
218
398
 
219
399
  export function codexForwardTerminalOutcomeRecorder(
220
400
  config: OcxConfig,
221
401
  authCtx: CodexAuthContext,
222
402
  provider: OcxProviderConfig,
403
+ modelId?: string,
223
404
  logCtx?: RequestLogContext,
224
405
  threadId?: string | null,
225
406
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
@@ -231,7 +412,9 @@ export function codexForwardTerminalOutcomeRecorder(
231
412
  // prior soft-avoid so a healthy account isn't stuck avoided.
232
413
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
233
414
  threadId,
415
+ modelId,
234
416
  probeLeaseId: codexProbeLeaseId(authCtx),
417
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
235
418
  });
236
419
  return;
237
420
  }
@@ -249,7 +432,9 @@ export function codexForwardTerminalOutcomeRecorder(
249
432
  : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
250
433
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
251
434
  threadId,
435
+ modelId,
252
436
  probeLeaseId: codexProbeLeaseId(authCtx),
437
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
253
438
  });
254
439
  };
255
440
  }
@@ -303,8 +488,15 @@ export interface HandleResponsesOptions {
303
488
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
304
489
  onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
305
490
  onNativePassthroughCancel?: () => void;
491
+ /**
492
+ * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
493
+ * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
494
+ */
495
+ promptCacheKeyIsSharedCohort?: boolean;
306
496
  /** Internal recursion guard; callers outside this module must not set it. */
307
497
  comboAttempt?: boolean;
498
+ /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
499
+ deferCodexResetDerivedCooldown?: boolean;
308
500
  /** 030-owned handoff when a child consumed the original failure under bounds. */
309
501
  onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
310
502
  }
@@ -478,7 +670,7 @@ async function resolveResponsesCodexAuth(
478
670
  if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
479
671
  let authCtx: CodexAuthContext;
480
672
  if (route.codexAccountMode) {
481
- authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
673
+ authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
482
674
  options.onCodexAuthContextResolved?.(authCtx);
483
675
  } else {
484
676
  authCtx = { kind: "main", accountId: null };
@@ -738,9 +930,17 @@ export async function handleComboResponses(
738
930
  const callbackGate = createChildPassthroughCallbackGate(options);
739
931
  let response: Response;
740
932
  try {
933
+ const currentTargetProvider = pick.target.provider;
934
+ const deferCodexResetDerivedCooldown = combo.strategy === "failover"
935
+ && combo.targets.slice(pick.targetIndex + 1).some(target =>
936
+ target.provider === currentTargetProvider
937
+ && payloadEligible(target)
938
+ && !isComboTargetInCooldown(comboId, target),
939
+ );
741
940
  response = await handleResponses(childRequest, config, childLog, {
742
941
  ...options,
743
942
  comboAttempt: true,
943
+ deferCodexResetDerivedCooldown,
744
944
  // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
745
945
  // Object.assign(logCtx, childLog) would overwrite the request-relative value).
746
946
  onFirstOutput: () => {
@@ -937,10 +1137,6 @@ export async function handleResponses(
937
1137
  }
938
1138
  if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
939
1139
 
940
- if (isThreadSpawnRequest(req.headers)) {
941
- await maybePrimeSubagentQuota(config);
942
- }
943
-
944
1140
  let route: RouteResult;
945
1141
  try {
946
1142
  route = routeModel(config, parsed.modelId);
@@ -951,6 +1147,17 @@ export async function handleResponses(
951
1147
  return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
952
1148
  }
953
1149
 
1150
+ const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
1151
+ && parsed._previousResponseInputExpanded !== true;
1152
+ // A canonical replay miss must not poll quota upstream before the final fail-closed decision.
1153
+ // Cached fallback state can still select a provider with native continuation support below.
1154
+ if (
1155
+ isThreadSpawnRequest(req.headers)
1156
+ && !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
1157
+ ) {
1158
+ await maybePrimeSubagentQuota(config);
1159
+ }
1160
+
954
1161
  let authCtx: CodexAuthContext = { kind: "main", accountId: null };
955
1162
  let selectedForwardHeaders = req.headers;
956
1163
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -999,6 +1206,20 @@ export async function handleResponses(
999
1206
  return unreadableEncryptedAgentTaskResponse();
1000
1207
  }
1001
1208
 
1209
+ // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
1210
+ // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
1211
+ // I/O instead of stripping the id and silently forwarding a context-free delta (#702).
1212
+ if (
1213
+ hasUnexpandedPreviousResponse
1214
+ && isCanonicalOpenAiForwardProvider(route.provider)
1215
+ ) {
1216
+ return formatErrorResponse(
1217
+ 400,
1218
+ "invalid_request_error",
1219
+ "OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
1220
+ );
1221
+ }
1222
+
1002
1223
  await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx });
1003
1224
 
1004
1225
  {
@@ -1023,16 +1244,54 @@ export async function handleResponses(
1023
1244
  const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
1024
1245
  && route.provider.authMode === "oauth";
1025
1246
  let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
1247
+ let anthropicPoolAccountId: string | null = null;
1248
+ let anthropicPoolFailovers = 0;
1249
+ const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
1250
+ ? anthropicSessionKeyFromParts({
1251
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
1252
+ threadIdHeader: req.headers.get("thread-id"),
1253
+ promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
1254
+ clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
1255
+ promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
1256
+ })
1257
+ : null;
1026
1258
  if (route.provider.authMode === "oauth") {
1027
1259
  try {
1028
- const resolved = await getValidAccessTokenSnapshot(route.providerName);
1029
- if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
1030
- route.provider = { ...route.provider, apiKey: resolved.accessToken };
1031
- // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
1032
- // CCA envelope; the server injects only the bare token, so pull project from the credential.
1033
- if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
1034
- const projectId = getOAuthCredentialProjectId(route.providerName);
1035
- if (projectId) route.provider = { ...route.provider, project: projectId };
1260
+ if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
1261
+ const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
1262
+ if (!selection.accountId) {
1263
+ if (selection.reason === "all-cooled") {
1264
+ const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
1265
+ return formatErrorResponse(
1266
+ 429,
1267
+ "rate_limit_error",
1268
+ "All Anthropic OAuth accounts are temporarily rate-limited",
1269
+ retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
1270
+ );
1271
+ }
1272
+ return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
1273
+ }
1274
+ const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
1275
+ anthropicPoolAccountId = selection.accountId;
1276
+ bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
1277
+ promoteAnthropicActiveAccount(selection.accountId);
1278
+ route.provider = { ...route.provider, apiKey: accessToken };
1279
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
1280
+ } else {
1281
+ const resolved = await getValidAccessTokenSnapshot(route.providerName);
1282
+ if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
1283
+ route.provider = { ...route.provider, apiKey: resolved.accessToken };
1284
+ if (route.providerName === "kiro") {
1285
+ // `{}` is intentional: this is an account-scoped request with no stored routing metadata.
1286
+ // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
1287
+ parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
1288
+ }
1289
+ // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
1290
+ // CCA envelope; the server injects only the bare token, so pull project from the credential.
1291
+ if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
1292
+ const projectId = getOAuthCredentialProjectId(route.providerName);
1293
+ if (projectId) route.provider = { ...route.provider, project: projectId };
1294
+ }
1036
1295
  }
1037
1296
  } catch (err) {
1038
1297
  if (err instanceof UnsupportedOAuthProviderError) {
@@ -1146,6 +1405,9 @@ export async function handleResponses(
1146
1405
  }
1147
1406
 
1148
1407
  if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
1408
+ const imageGenCallAliases = route.provider.authMode === "forward"
1409
+ ? new Map<string, { namespace: string; name: string }>()
1410
+ : imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap, parsed._rawBody);
1149
1411
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
1150
1412
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
1151
1413
  // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
@@ -1189,12 +1451,14 @@ export async function handleResponses(
1189
1451
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1190
1452
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1191
1453
  threadId: req.headers.get("x-codex-parent-thread-id"),
1454
+ modelId: route.modelId,
1192
1455
  probeLeaseId: codexProbeLeaseId(authCtx),
1456
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
1193
1457
  });
1194
1458
  }
1195
1459
  const msg = outcome === "timeout"
1196
1460
  ? `Provider connect timeout after ${connectMs}ms`
1197
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1461
+ : describeUpstreamConnectFailure(err, connectMs);
1198
1462
  return formatErrorResponse(502, "upstream_error", msg);
1199
1463
  };
1200
1464
  try {
@@ -1216,77 +1480,46 @@ export async function handleResponses(
1216
1480
  return transportFailureResponse(err);
1217
1481
  }
1218
1482
 
1219
- if (
1220
- usesCodexForwardPoolAuth(authCtx, route.provider)
1221
- && await shouldRetryCodexPoolAccountModel400(
1483
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1484
+ let poolRetryOutcome: number | undefined;
1485
+ if (await shouldRetryCodexPoolAccountModel400(
1222
1486
  upstreamResponse,
1223
1487
  route.modelId,
1224
1488
  options.abortSignal,
1225
- )
1226
- ) {
1227
- const firstAuthCtx = authCtx;
1228
- let retryAuthCtx: CodexAuthContext | undefined;
1229
- try {
1230
- retryAuthCtx = await resolveCodexAuthContext(
1231
- req.headers,
1232
- config,
1233
- "pool",
1234
- { excludeAccountId: firstAuthCtx.accountId },
1235
- );
1236
- } catch (error) {
1237
- if (
1238
- !(error instanceof CodexPoolAuthenticationError)
1239
- && !(error instanceof CodexAuthContextError)
1240
- && !(error instanceof CodexAccountCooldownError)
1241
- ) throw error;
1489
+ )) {
1490
+ poolRetryOutcome = 400;
1491
+ } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
1492
+ // Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
1493
+ poolRetryOutcome = upstreamResponse.status;
1242
1494
  }
1243
1495
 
1244
- if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
1245
- recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
1246
- threadId: req.headers.get("x-codex-parent-thread-id"),
1247
- probeLeaseId: codexProbeLeaseId(firstAuthCtx),
1248
- });
1249
-
1250
- const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
1251
- const retryProvider = applyCodexAuthContextToProvider(
1252
- stripCodexRuntimeProviderFields(route.provider),
1253
- retryAuthCtx,
1254
- "pool",
1255
- );
1256
- const retryAdapter = resolveAdapter(
1257
- resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
1258
- config.cacheRetention,
1259
- );
1260
- request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
1261
- recordAdapterReasoning(logCtx, request);
1262
-
1263
- await upstreamResponse.body?.cancel().catch(() => undefined);
1264
- authCtx = retryAuthCtx;
1265
- options.onCodexAuthContextResolved?.(retryAuthCtx);
1266
- selectedForwardHeaders = retryHeaders;
1267
- route.provider = retryProvider;
1268
- logCtx.provider = formatCodexProviderForLog(
1269
- route.providerName,
1270
- retryAuthCtx.accountId,
1496
+ if (poolRetryOutcome !== undefined) {
1497
+ const retry = await retryCodexPoolOnAlternateAccount({
1498
+ req,
1271
1499
  config,
1272
- );
1273
-
1274
- noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
1275
- try {
1276
- upstreamResponse = await fetchWithHeaderTimeout(
1277
- request.url,
1278
- {
1279
- method: request.method,
1280
- headers: request.headers,
1281
- body: request.body,
1282
- },
1283
- upstream.signal,
1284
- connectMs,
1285
- parsed.stream,
1286
- providerFetch(route.provider),
1287
- );
1288
- } catch (err) {
1289
- return transportFailureResponse(err);
1500
+ route,
1501
+ parsed,
1502
+ logCtx,
1503
+ options,
1504
+ firstAuthCtx: authCtx,
1505
+ firstResponse: upstreamResponse,
1506
+ outcomeStatus: poolRetryOutcome,
1507
+ upstream,
1508
+ connectMs,
1509
+ passthroughEstimate,
1510
+ stream: parsed.stream,
1511
+ });
1512
+ if (retry.kind === "transport") {
1513
+ authCtx = retry.authCtx;
1514
+ return transportFailureResponse(retry.error);
1515
+ }
1516
+ if (retry.kind === "retried") {
1517
+ authCtx = retry.authCtx;
1518
+ request = retry.request;
1519
+ upstreamResponse = retry.upstreamResponse;
1520
+ selectedForwardHeaders = retry.selectedForwardHeaders;
1521
+ // Keep subagent quota-failure health keyed to the account that actually served.
1522
+ subagentFallbackAccountId = retry.authCtx.accountId;
1290
1523
  }
1291
1524
  }
1292
1525
  }
@@ -1306,6 +1539,7 @@ export async function handleResponses(
1306
1539
  config,
1307
1540
  authCtx,
1308
1541
  route.provider,
1542
+ route.modelId,
1309
1543
  logCtx,
1310
1544
  req.headers.get("x-codex-parent-thread-id"),
1311
1545
  );
@@ -1314,7 +1548,7 @@ export async function handleResponses(
1314
1548
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1315
1549
  // primary was the 5h window; it now carries weekly data for GPT plans.
1316
1550
  // Prefer primary when present, fall back to secondary for compatibility.
1317
- const retryAfterRaw = upstreamResponse.headers.get("retry-after");
1551
+ const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
1318
1552
  const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
1319
1553
  applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
1320
1554
  if (terminalBodyWillRecord) {
@@ -1338,16 +1572,16 @@ export async function handleResponses(
1338
1572
  }
1339
1573
  options.onNativePassthroughTerminal?.(status);
1340
1574
  });
1341
- } else {
1575
+ } else if (!shouldDeferCodexResetDerivedCooldown(
1576
+ upstreamResponse,
1577
+ options.deferCodexResetDerivedCooldown,
1578
+ )) {
1342
1579
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
1343
- retryAfter: retryAfterRaw,
1344
- resetAt: [
1345
- upstreamResponse.headers.get("x-codex-primary-reset-at"),
1346
- upstreamResponse.headers.get("x-codex-secondary-reset-at"),
1347
- upstreamResponse.headers.get("x-codex-tertiary-reset-at"),
1348
- ].filter(Boolean),
1349
- threadId: req.headers.get("x-codex-parent-thread-id"),
1350
- probeLeaseId: codexProbeLeaseId(authCtx),
1580
+ ...quotaMeta,
1581
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1582
+ modelId: route.modelId,
1583
+ probeLeaseId: codexProbeLeaseId(authCtx),
1584
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
1351
1585
  });
1352
1586
  }
1353
1587
  }
@@ -1381,8 +1615,9 @@ export async function handleResponses(
1381
1615
  // known-bad runtime remains the tee path below.
1382
1616
  if (isEventStream && upstreamResponse.body) {
1383
1617
  const repairConfig = route.provider.responsesItemIdRepair;
1384
- const winNoRepair = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig);
1385
- const eagerDecision = winNoRepair ? decideEagerRelay(config.streamMode ?? "auto") : null;
1618
+ const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
1619
+ const winNoClientRewrite = process.platform === "win32" && !needsClientRewrite;
1620
+ const eagerDecision = winNoClientRewrite ? decideEagerRelay(config.streamMode ?? "auto") : null;
1386
1621
  if (eagerDecision?.useEagerRelay) {
1387
1622
  const turnAc = new AbortController();
1388
1623
  linkAbortSignal(upstream, turnAc.signal);
@@ -1433,6 +1668,8 @@ export async function handleResponses(
1433
1668
  onClientCancel: () => options.onNativePassthroughCancel?.(),
1434
1669
  onDone: () => unregisterTurn(turnAc),
1435
1670
  });
1671
+ // The eager branch is reachable only through winNoClientRewrite, so it must stay a pure
1672
+ // native relay without an image-gen or item-id JS pull wrapper on win32 (Bun#32111).
1436
1673
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
1437
1674
  return markNativePassthroughSseResponse(new Response(eagerBody, {
1438
1675
  status: upstreamResponse.status,
@@ -1492,12 +1729,19 @@ export async function handleResponses(
1492
1729
  // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
1493
1730
  // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
1494
1731
  // mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
1495
- const repairedBody = hasResponsesItemIdRepair(repairConfig)
1496
- ? relaySseWithResponsesItemIdRepair(nativeBody, repairConfig!)
1732
+ // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
1733
+ const payloadRewrites = [
1734
+ createImageGenCallRestoreRewrite(imageGenCallAliases),
1735
+ hasResponsesItemIdRepair(repairConfig)
1736
+ ? createResponsesItemIdPayloadRewrite(repairConfig!)
1737
+ : undefined,
1738
+ ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
1739
+ const rewrittenBody = payloadRewrites.length > 0
1740
+ ? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites))
1497
1741
  : nativeBody;
1498
- const clientBody = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig)
1742
+ const clientBody = process.platform === "win32" && !needsClientRewrite
1499
1743
  ? nativeBody
1500
- : relaySseWithFailedTail(repairedBody, upstream);
1744
+ : relaySseWithFailedTail(rewrittenBody, upstream);
1501
1745
  return markNativePassthroughSseResponse(new Response(clientBody, {
1502
1746
  status: upstreamResponse.status,
1503
1747
  headers,
@@ -1511,7 +1755,7 @@ export async function handleResponses(
1511
1755
  rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
1512
1756
  } catch { /* non-JSON despite content-type; recording is best-effort */ }
1513
1757
  }
1514
- return new Response(text, {
1758
+ return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), {
1515
1759
  status: upstreamResponse.status,
1516
1760
  statusText: upstreamResponse.statusText,
1517
1761
  headers,
@@ -1526,6 +1770,195 @@ export async function handleResponses(
1526
1770
  });
1527
1771
  }
1528
1772
 
1773
+ // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
1774
+ // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
1775
+ // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
1776
+ // completion instead of the synthetic compaction item Codex expects (#424).
1777
+ //
1778
+ // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
1779
+ // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
1780
+ // - non-runTurn: web-search wins over image when both eligible (documented priority)
1781
+ // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
1782
+ // can proceed for web-search-only turns
1783
+ const wsPlan = !routedCompaction
1784
+ ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
1785
+ : undefined;
1786
+ const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
1787
+ const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
1788
+ const canRunWebSearch = !!wsPlan && !adapter.runTurn;
1789
+ if ((imgPlan || vidPlan) && canRunWebSearch) {
1790
+ // Web search takes priority when both are active — the media bridge cannot run
1791
+ // alongside runWithWebSearch. Surface a runtime signal so the user knows their
1792
+ // configured video/image bridge was skipped for this turn, rather than silently
1793
+ // dropping a paid capability.
1794
+ if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
1795
+ if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
1796
+ }
1797
+ if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
1798
+ // The image bridge detects a hosted image_generation tool and requires streaming.
1799
+ // The video bridge activates from config and injects a tool — it also needs streaming
1800
+ // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
1801
+ // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
1802
+ if (!parsed.stream) {
1803
+ if (imgPlan) {
1804
+ return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
1805
+ }
1806
+ // Video-only: skip bridge for non-streaming requests
1807
+ } else {
1808
+ // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
1809
+ const priorTools = parsed.context.tools ?? [];
1810
+ const bridgeTools = [...priorTools.filter(t => {
1811
+ if (t.imageGeneration) return false;
1812
+ if (t.videoGeneration) return false;
1813
+ if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
1814
+ if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
1815
+ // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
1816
+ if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
1817
+ return true;
1818
+ })];
1819
+ const existingNames = new Set(bridgeTools.map(t => t.name));
1820
+ if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
1821
+ if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
1822
+ parsed.context.tools = bridgeTools;
1823
+ // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
1824
+ // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
1825
+ // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
1826
+ const tc = parsed.options.toolChoice;
1827
+ if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
1828
+ const mapped = tc.allowedTools.map(name =>
1829
+ name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
1830
+ ? IMAGE_GEN_TOOL_NAME
1831
+ : name,
1832
+ );
1833
+ parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
1834
+ } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
1835
+ && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
1836
+ parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
1837
+ }
1838
+ const imgResponse = await runWithImageBridge({
1839
+ parsed, adapter,
1840
+ ...(imgPlan ? { plan: imgPlan } : {}),
1841
+ ...(vidPlan ? { videoPlan: vidPlan } : {}),
1842
+ forwardHeaders: selectedForwardHeaders,
1843
+ onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
1844
+ abortSignal: options.abortSignal,
1845
+ maxRounds: imgPlan && vidPlan
1846
+ ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
1847
+ : imgPlan
1848
+ ? clampImageMaxRounds(config.images?.maxRounds)
1849
+ : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
1850
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1851
+ stallTimeoutSec: config.stallTimeoutSec,
1852
+ fetchImpl: providerFetch(route.provider),
1853
+ onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
1854
+ ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
1855
+ onUsage: usage => {
1856
+ // Cursor may assign _cursorConversationId inside the image loop's first runTurn;
1857
+ // backfill so Logs can filter/total that opening request (parity with the normal
1858
+ // runTurn branch).
1859
+ if (!logCtx.conversationId && parsed._cursorConversationId) {
1860
+ logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
1861
+ }
1862
+ logCtx.usageFromBridge = true;
1863
+ if (usage) {
1864
+ logCtx.usage = usage;
1865
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
1866
+ }
1867
+ },
1868
+ on429: retryAfter => {
1869
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1870
+ retryAfter,
1871
+ now: Date.now(),
1872
+ attemptedKey: route.provider.apiKey,
1873
+ promptCacheKey: parsed.options.promptCacheKey,
1874
+ });
1875
+ if (!rotated) return null;
1876
+ route.provider = rotated;
1877
+ return resolveAdapter(
1878
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1879
+ config.cacheRetention,
1880
+ );
1881
+ },
1882
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1883
+ ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
1884
+ onCompletedResponse: (response, providerState) =>
1885
+ rememberResponseState(
1886
+ parsed._rawBody,
1887
+ response,
1888
+ continuationStateForResponse(providerState),
1889
+ adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
1890
+ ),
1891
+ });
1892
+ if (imgResponse.body) {
1893
+ const imgTurnAc = new AbortController();
1894
+ return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc), {
1895
+ status: imgResponse.status,
1896
+ headers: imgResponse.headers,
1897
+ });
1898
+ }
1899
+ return imgResponse;
1900
+ } // end else (streaming bridge)
1901
+ }
1902
+
1903
+ // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
1904
+ // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
1905
+ // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
1906
+ // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
1907
+ // through web-search instead of being swallowed. runTurn adapters never enter this branch.
1908
+ if (canRunWebSearch && wsPlan) {
1909
+ parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
1910
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
1911
+ const wsResponse = await runWithWebSearch({
1912
+ parsed, adapter,
1913
+ backend: wsPlan.backend,
1914
+ forwardProvider: wsPlan.forwardSidecar?.provider,
1915
+ anthropicSidecar: wsPlan.anthropicSidecar,
1916
+ hostedTool: wsPlan.hostedTool,
1917
+ selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
1918
+ settings: wsPlan.settings,
1919
+ maxSearches: wsPlan.maxSearches,
1920
+ forceEmptyResponseId: true,
1921
+ abortSignal: options.abortSignal,
1922
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1923
+ onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
1924
+ onUsage: usage => {
1925
+ logCtx.usageFromBridge = true;
1926
+ if (usage) {
1927
+ logCtx.usage = usage;
1928
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
1929
+ }
1930
+ },
1931
+ recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
1932
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1933
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
1934
+ stallTimeoutSec: wsPlan.stallTimeoutSec,
1935
+ on429: retryAfter => {
1936
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1937
+ retryAfter,
1938
+ now: Date.now(),
1939
+ attemptedKey: route.provider.apiKey,
1940
+ promptCacheKey: parsed.options.promptCacheKey,
1941
+ });
1942
+ if (!rotated) return null;
1943
+ route.provider = rotated;
1944
+ return resolveAdapter(
1945
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1946
+ config.cacheRetention,
1947
+ );
1948
+ },
1949
+ });
1950
+ // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
1951
+ // in-flight web-search turns instead of skipping them during graceful shutdown.
1952
+ if (wsResponse.body) {
1953
+ const wsTurnAc = new AbortController();
1954
+ return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
1955
+ status: wsResponse.status,
1956
+ headers: wsResponse.headers,
1957
+ });
1958
+ }
1959
+ return wsResponse;
1960
+ }
1961
+
1529
1962
  if (adapter.runTurn) {
1530
1963
  const runTurnAbort = new AbortController();
1531
1964
  linkAbortSignal(runTurnAbort, options.abortSignal);
@@ -1646,64 +2079,6 @@ export async function handleResponses(
1646
2079
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
1647
2080
  }
1648
2081
 
1649
- // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
1650
- // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
1651
- // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
1652
- const wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar);
1653
- if (wsPlan) {
1654
- parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
1655
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
1656
- const wsResponse = await runWithWebSearch({
1657
- parsed, adapter,
1658
- backend: wsPlan.backend,
1659
- forwardProvider: wsPlan.forwardSidecar?.provider,
1660
- anthropicSidecar: wsPlan.anthropicSidecar,
1661
- hostedTool: wsPlan.hostedTool,
1662
- selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
1663
- settings: wsPlan.settings,
1664
- maxSearches: wsPlan.maxSearches,
1665
- forceEmptyResponseId: true,
1666
- abortSignal: options.abortSignal,
1667
- ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1668
- onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
1669
- onUsage: usage => {
1670
- logCtx.usageFromBridge = true;
1671
- if (usage) {
1672
- logCtx.usage = usage;
1673
- if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
1674
- }
1675
- },
1676
- recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
1677
- connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1678
- routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
1679
- stallTimeoutSec: wsPlan.stallTimeoutSec,
1680
- on429: retryAfter => {
1681
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
1682
- retryAfter,
1683
- now: Date.now(),
1684
- attemptedKey: route.provider.apiKey,
1685
- promptCacheKey: parsed.options.promptCacheKey,
1686
- });
1687
- if (!rotated) return null;
1688
- route.provider = rotated;
1689
- return resolveAdapter(
1690
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1691
- config.cacheRetention,
1692
- );
1693
- },
1694
- });
1695
- // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
1696
- // in-flight web-search turns instead of skipping them during graceful shutdown.
1697
- if (wsResponse.body) {
1698
- const wsTurnAc = new AbortController();
1699
- return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
1700
- status: wsResponse.status,
1701
- headers: wsResponse.headers,
1702
- });
1703
- }
1704
- return wsResponse;
1705
- }
1706
-
1707
2082
  const upstream = new AbortController();
1708
2083
  const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
1709
2084
  const connectMs = config.connectTimeoutMs ?? 200_000;
@@ -1741,9 +2116,7 @@ export async function handleResponses(
1741
2116
  cleanupUpstreamAbort();
1742
2117
  upstream.abort();
1743
2118
  if (options.abortSignal?.aborted) return clientCancelledResponse();
1744
- const msg = err instanceof Error && err.name === "TimeoutError"
1745
- ? `Provider connect timeout after ${connectMs}ms`
1746
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2119
+ const msg = describeUpstreamConnectFailure(err, connectMs);
1747
2120
  return formatErrorResponse(502, "upstream_error", msg);
1748
2121
  }
1749
2122
 
@@ -1783,9 +2156,7 @@ export async function handleResponses(
1783
2156
  if (options.abortSignal?.aborted) {
1784
2157
  return { failed: clientCancelledResponse() };
1785
2158
  }
1786
- const msg = err instanceof Error && err.name === "TimeoutError"
1787
- ? `Provider connect timeout after ${connectMs}ms`
1788
- : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
2159
+ const msg = describeUpstreamConnectFailure(err, connectMs);
1789
2160
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
1790
2161
  }
1791
2162
  };
@@ -1806,6 +2177,9 @@ export async function handleResponses(
1806
2177
  return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
1807
2178
  }
1808
2179
  sentOAuthSnapshot = refreshed;
2180
+ if (route.providerName === "kiro") {
2181
+ parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
2182
+ }
1809
2183
  const refreshedProvider = resolveProviderTransport(
1810
2184
  route.providerName,
1811
2185
  { ...route.provider, apiKey: refreshed.accessToken },
@@ -1827,7 +2201,7 @@ export async function handleResponses(
1827
2201
  // SAME request once per remaining key. OAuth/forward providers and single-key pools
1828
2202
  // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
1829
2203
  while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
1830
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
2204
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1831
2205
  retryAfter: upstreamResponse.headers.get("retry-after"),
1832
2206
  now: Date.now(),
1833
2207
  attemptedKey: route.provider.apiKey,
@@ -1846,6 +2220,42 @@ export async function handleResponses(
1846
2220
  if ("failed" in result) return result.failed;
1847
2221
  upstreamResponse = result;
1848
2222
  }
2223
+
2224
+ // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
2225
+ // with another eligible OAuth account (bounded per request). Disabled by default.
2226
+ while (
2227
+ upstreamResponse.status === 429
2228
+ && anthropicPoolAccountId
2229
+ && isAnthropicAccountPoolEnabled(config)
2230
+ && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
2231
+ ) {
2232
+ const nextAccountId = rotateAnthropicAccountOn429(
2233
+ config,
2234
+ anthropicPoolAccountId,
2235
+ upstreamResponse.headers.get("retry-after"),
2236
+ anthropicSessionKey,
2237
+ );
2238
+ if (!nextAccountId) break;
2239
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
2240
+ try {
2241
+ const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
2242
+ anthropicPoolAccountId = nextAccountId;
2243
+ anthropicPoolFailovers += 1;
2244
+ route.provider = { ...route.provider, apiKey: accessToken };
2245
+ promoteAnthropicActiveAccount(nextAccountId);
2246
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
2247
+ activeAdapter = resolveAdapter(
2248
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
2249
+ config.cacheRetention,
2250
+ );
2251
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
2252
+ const result = await rebuildAndRefetch("anthropic-oauth-429");
2253
+ if ("failed" in result) return result.failed;
2254
+ upstreamResponse = result;
2255
+ } catch {
2256
+ break;
2257
+ }
2258
+ }
1849
2259
  // Anthropic 413 request_too_large: rebuild once with every image one tier lower
1850
2260
  // (spiral guard: single attempt). The biased response re-enters the 429 check above.
1851
2261
  if (shouldAttemptImageTierRetry({
@@ -1954,7 +2364,7 @@ export async function handleResponses(
1954
2364
  }
1955
2365
 
1956
2366
  if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
1957
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
2367
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1958
2368
  retryAfter: response.headers.get("retry-after"),
1959
2369
  now: Date.now(),
1960
2370
  attemptedKey: route.provider.apiKey,
@@ -1970,6 +2380,38 @@ export async function handleResponses(
1970
2380
  continue;
1971
2381
  }
1972
2382
  }
2383
+ if (
2384
+ response.status === 429
2385
+ && anthropicPoolAccountId
2386
+ && isAnthropicAccountPoolEnabled(config)
2387
+ && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
2388
+ ) {
2389
+ const nextAccountId = rotateAnthropicAccountOn429(
2390
+ config,
2391
+ anthropicPoolAccountId,
2392
+ response.headers.get("retry-after"),
2393
+ anthropicSessionKey,
2394
+ );
2395
+ if (nextAccountId) {
2396
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
2397
+ try {
2398
+ const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
2399
+ anthropicPoolAccountId = nextAccountId;
2400
+ anthropicPoolFailovers += 1;
2401
+ route.provider = { ...route.provider, apiKey: accessToken };
2402
+ promoteAnthropicActiveAccount(nextAccountId);
2403
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
2404
+ activeAdapter = resolveAdapter(
2405
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
2406
+ config.cacheRetention,
2407
+ );
2408
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
2409
+ continue;
2410
+ } catch {
2411
+ // fall through to emit continuation error below
2412
+ }
2413
+ }
2414
+ }
1973
2415
  if (shouldAttemptImageTierRetry({
1974
2416
  status: response.status,
1975
2417
  adapterName: activeAdapter.name,