@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -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/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -34,11 +34,25 @@ import {
34
34
  forceRefreshOAuthAccessSnapshot,
35
35
  getOAuthCredentialApiBaseUrl,
36
36
  getOAuthCredentialProjectId,
37
+ getValidAccessTokenForAccount,
37
38
  getValidAccessTokenSnapshot,
38
39
  type OAuthAccessSnapshot,
39
40
  UnsupportedOAuthProviderError,
40
41
  } from "../../oauth";
42
+ import {
43
+ ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
44
+ anthropicSessionKeyFromParts,
45
+ bindAnthropicSessionAffinity,
46
+ formatAnthropicProviderForLog,
47
+ getAnthropicPoolAccessToken,
48
+ getAnthropicPoolRetryAfterSeconds,
49
+ isAnthropicAccountPoolEnabled,
50
+ promoteAnthropicActiveAccount,
51
+ resolveAnthropicAccountForSession,
52
+ rotateAnthropicAccountOn429,
53
+ } from "../../oauth/anthropic-routing";
41
54
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
55
+ import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
42
56
  import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
43
57
  import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
44
58
  import {
@@ -53,11 +67,13 @@ import {
53
67
  isCodexAuthContextUsable,
54
68
  resolveCodexAuthContext,
55
69
  codexProbeLeaseId,
70
+ codexProbeQuotaScope,
56
71
  releaseCodexAuthContextProbeLease,
57
72
  stripCodexRuntimeProviderFields,
58
73
  type CodexAuthContext,
59
74
  } from "../../codex/auth-context";
60
75
  import {
76
+ computeQuotaCooldown,
61
77
  formatCodexProviderForLog,
62
78
  previewCodexAccountForRequest,
63
79
  recordCodexUpstreamOutcome,
@@ -118,7 +134,16 @@ import {
118
134
  import { relaySseEagerBounded } from "../relay-eager";
119
135
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
120
136
  import { cancelBodyOnAbort } from "../../lib/abort";
121
- import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
137
+ import {
138
+ createResponsesItemIdPayloadRewrite,
139
+ hasResponsesItemIdRepair,
140
+ } from "../responses-item-id-repair";
141
+ import {
142
+ createImageGenCallRestoreRewrite,
143
+ imageGenToolCallAliases,
144
+ restoreImageGenCallsInJson,
145
+ } from "../responses-image-gen-repair";
146
+ import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
122
147
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
123
148
 
124
149
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
@@ -214,12 +239,174 @@ async function shouldRetryCodexPoolAccountModel400(
214
239
  }
215
240
  }
216
241
 
242
+ /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
243
+ function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
244
+ return response.status === 429 || response.status === 402;
245
+ }
246
+
247
+ interface CodexPoolAccountRetryArgs {
248
+ req: Request;
249
+ config: OcxConfig;
250
+ route: { providerName: string; modelId: string; provider: OcxProviderConfig };
251
+ parsed: OcxParsedRequest;
252
+ logCtx: RequestLogContext;
253
+ options: {
254
+ abortSignal?: AbortSignal;
255
+ onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
256
+ deferCodexResetDerivedCooldown?: boolean;
257
+ };
258
+ firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
259
+ firstResponse: Response;
260
+ outcomeStatus: number;
261
+ upstream: AbortController;
262
+ connectMs: number;
263
+ passthroughEstimate?: number;
264
+ stream: boolean;
265
+ }
266
+
267
+ type CodexPoolAccountRetryResult =
268
+ | {
269
+ kind: "retried";
270
+ authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
271
+ request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
272
+ upstreamResponse: Response;
273
+ selectedForwardHeaders: Headers;
274
+ }
275
+ | { kind: "no-alternate" }
276
+ | {
277
+ kind: "transport";
278
+ error: unknown;
279
+ authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
280
+ };
281
+
282
+ function codexQuotaOutcomeMeta(response: Response): {
283
+ retryAfter: string | null;
284
+ resetAt: string[];
285
+ } {
286
+ return {
287
+ retryAfter: response.headers.get("retry-after"),
288
+ resetAt: [
289
+ response.headers.get("x-codex-primary-reset-at"),
290
+ response.headers.get("x-codex-secondary-reset-at"),
291
+ response.headers.get("x-codex-tertiary-reset-at"),
292
+ ].filter((value): value is string => !!value),
293
+ };
294
+ }
295
+
296
+ /**
297
+ * A reset timestamp describes a quota window, not an explicit instruction to
298
+ * stop using the whole account. A combo may therefore try a later model in the
299
+ * same request, while Retry-After and headerless quota failures remain blocking.
300
+ */
301
+ function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
302
+ return enabled === true
303
+ && (response.status === 429 || response.status === 402)
304
+ && computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
305
+ }
306
+
307
+ /**
308
+ * One bounded alternate-account retry for Codex pool auth. Used for allow-listed
309
+ * model-400 and for pre-stream 429/402 quota failures (#584).
310
+ */
311
+ async function retryCodexPoolOnAlternateAccount(
312
+ args: CodexPoolAccountRetryArgs,
313
+ ): Promise<CodexPoolAccountRetryResult> {
314
+ const {
315
+ req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
316
+ outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
317
+ } = args;
318
+ let retryAuthCtx: CodexAuthContext | undefined;
319
+ try {
320
+ retryAuthCtx = await resolveCodexAuthContext(
321
+ req.headers,
322
+ config,
323
+ "pool",
324
+ { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
325
+ );
326
+ } catch (error) {
327
+ if (
328
+ !(error instanceof CodexPoolAuthenticationError)
329
+ && !(error instanceof CodexAuthContextError)
330
+ && !(error instanceof CodexAccountCooldownError)
331
+ ) throw error;
332
+ }
333
+ if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
334
+ return { kind: "no-alternate" };
335
+ }
336
+
337
+ const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
338
+ if (outcomeStatus === 429 || outcomeStatus === 402) {
339
+ const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
340
+ applyAccountQuotaFromUpstreamHeaders(firstAuthCtx.accountId, firstResponse.headers);
341
+ }
342
+ if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
343
+ recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
344
+ ...quotaMeta,
345
+ threadId: req.headers.get("x-codex-parent-thread-id"),
346
+ modelId: route.modelId,
347
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
348
+ probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
349
+ // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
350
+ ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
351
+ });
352
+ }
353
+
354
+ const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
355
+ const retryProvider = applyCodexAuthContextToProvider(
356
+ stripCodexRuntimeProviderFields(route.provider),
357
+ retryAuthCtx,
358
+ "pool",
359
+ );
360
+ const retryAdapter = resolveAdapter(
361
+ resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
362
+ config.cacheRetention,
363
+ );
364
+ const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
365
+ recordAdapterReasoning(logCtx, request);
366
+
367
+ await firstResponse.body?.cancel().catch(() => undefined);
368
+ options.onCodexAuthContextResolved?.(retryAuthCtx);
369
+ route.provider = retryProvider;
370
+ logCtx.provider = formatCodexProviderForLog(
371
+ route.providerName,
372
+ retryAuthCtx.accountId,
373
+ config,
374
+ );
375
+
376
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
377
+ try {
378
+ const upstreamResponse = await fetchWithHeaderTimeout(
379
+ request.url,
380
+ {
381
+ method: request.method,
382
+ headers: request.headers,
383
+ body: request.body,
384
+ },
385
+ upstream.signal,
386
+ connectMs,
387
+ stream,
388
+ providerFetch(route.provider),
389
+ );
390
+ return {
391
+ kind: "retried",
392
+ authCtx: retryAuthCtx,
393
+ request,
394
+ upstreamResponse,
395
+ selectedForwardHeaders: retryHeaders,
396
+ };
397
+ } catch (error) {
398
+ // Attribute the transport failure to the alternate account (already selected).
399
+ return { kind: "transport", error, authCtx: retryAuthCtx };
400
+ }
401
+ }
402
+
217
403
 
218
404
 
219
405
  export function codexForwardTerminalOutcomeRecorder(
220
406
  config: OcxConfig,
221
407
  authCtx: CodexAuthContext,
222
408
  provider: OcxProviderConfig,
409
+ modelId?: string,
223
410
  logCtx?: RequestLogContext,
224
411
  threadId?: string | null,
225
412
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
@@ -231,7 +418,9 @@ export function codexForwardTerminalOutcomeRecorder(
231
418
  // prior soft-avoid so a healthy account isn't stuck avoided.
232
419
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
233
420
  threadId,
421
+ modelId,
234
422
  probeLeaseId: codexProbeLeaseId(authCtx),
423
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
235
424
  });
236
425
  return;
237
426
  }
@@ -249,7 +438,9 @@ export function codexForwardTerminalOutcomeRecorder(
249
438
  : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
250
439
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
251
440
  threadId,
441
+ modelId,
252
442
  probeLeaseId: codexProbeLeaseId(authCtx),
443
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
253
444
  });
254
445
  };
255
446
  }
@@ -303,8 +494,15 @@ export interface HandleResponsesOptions {
303
494
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
304
495
  onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
305
496
  onNativePassthroughCancel?: () => void;
497
+ /**
498
+ * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
499
+ * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
500
+ */
501
+ promptCacheKeyIsSharedCohort?: boolean;
306
502
  /** Internal recursion guard; callers outside this module must not set it. */
307
503
  comboAttempt?: boolean;
504
+ /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
505
+ deferCodexResetDerivedCooldown?: boolean;
308
506
  /** 030-owned handoff when a child consumed the original failure under bounds. */
309
507
  onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
310
508
  }
@@ -478,7 +676,7 @@ async function resolveResponsesCodexAuth(
478
676
  if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
479
677
  let authCtx: CodexAuthContext;
480
678
  if (route.codexAccountMode) {
481
- authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
679
+ authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
482
680
  options.onCodexAuthContextResolved?.(authCtx);
483
681
  } else {
484
682
  authCtx = { kind: "main", accountId: null };
@@ -738,9 +936,17 @@ export async function handleComboResponses(
738
936
  const callbackGate = createChildPassthroughCallbackGate(options);
739
937
  let response: Response;
740
938
  try {
939
+ const currentTargetProvider = pick.target.provider;
940
+ const deferCodexResetDerivedCooldown = combo.strategy === "failover"
941
+ && combo.targets.slice(pick.targetIndex + 1).some(target =>
942
+ target.provider === currentTargetProvider
943
+ && payloadEligible(target)
944
+ && !isComboTargetInCooldown(comboId, target),
945
+ );
741
946
  response = await handleResponses(childRequest, config, childLog, {
742
947
  ...options,
743
948
  comboAttempt: true,
949
+ deferCodexResetDerivedCooldown,
744
950
  // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
745
951
  // Object.assign(logCtx, childLog) would overwrite the request-relative value).
746
952
  onFirstOutput: () => {
@@ -1023,16 +1229,54 @@ export async function handleResponses(
1023
1229
  const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
1024
1230
  && route.provider.authMode === "oauth";
1025
1231
  let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
1232
+ let anthropicPoolAccountId: string | null = null;
1233
+ let anthropicPoolFailovers = 0;
1234
+ const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
1235
+ ? anthropicSessionKeyFromParts({
1236
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
1237
+ threadIdHeader: req.headers.get("thread-id"),
1238
+ promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
1239
+ clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
1240
+ promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
1241
+ })
1242
+ : null;
1026
1243
  if (route.provider.authMode === "oauth") {
1027
1244
  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 };
1245
+ if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
1246
+ const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
1247
+ if (!selection.accountId) {
1248
+ if (selection.reason === "all-cooled") {
1249
+ const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
1250
+ return formatErrorResponse(
1251
+ 429,
1252
+ "rate_limit_error",
1253
+ "All Anthropic OAuth accounts are temporarily rate-limited",
1254
+ retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
1255
+ );
1256
+ }
1257
+ return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
1258
+ }
1259
+ const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
1260
+ anthropicPoolAccountId = selection.accountId;
1261
+ bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
1262
+ promoteAnthropicActiveAccount(selection.accountId);
1263
+ route.provider = { ...route.provider, apiKey: accessToken };
1264
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
1265
+ } else {
1266
+ const resolved = await getValidAccessTokenSnapshot(route.providerName);
1267
+ if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
1268
+ route.provider = { ...route.provider, apiKey: resolved.accessToken };
1269
+ if (route.providerName === "kiro") {
1270
+ // `{}` is intentional: this is an account-scoped request with no stored routing metadata.
1271
+ // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
1272
+ parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
1273
+ }
1274
+ // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
1275
+ // CCA envelope; the server injects only the bare token, so pull project from the credential.
1276
+ if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
1277
+ const projectId = getOAuthCredentialProjectId(route.providerName);
1278
+ if (projectId) route.provider = { ...route.provider, project: projectId };
1279
+ }
1036
1280
  }
1037
1281
  } catch (err) {
1038
1282
  if (err instanceof UnsupportedOAuthProviderError) {
@@ -1146,6 +1390,9 @@ export async function handleResponses(
1146
1390
  }
1147
1391
 
1148
1392
  if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
1393
+ const imageGenCallAliases = route.provider.authMode === "forward"
1394
+ ? new Map<string, { namespace: string; name: string }>()
1395
+ : imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap, parsed._rawBody);
1149
1396
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
1150
1397
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
1151
1398
  // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
@@ -1189,7 +1436,9 @@ export async function handleResponses(
1189
1436
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1190
1437
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1191
1438
  threadId: req.headers.get("x-codex-parent-thread-id"),
1439
+ modelId: route.modelId,
1192
1440
  probeLeaseId: codexProbeLeaseId(authCtx),
1441
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
1193
1442
  });
1194
1443
  }
1195
1444
  const msg = outcome === "timeout"
@@ -1216,77 +1465,46 @@ export async function handleResponses(
1216
1465
  return transportFailureResponse(err);
1217
1466
  }
1218
1467
 
1219
- if (
1220
- usesCodexForwardPoolAuth(authCtx, route.provider)
1221
- && await shouldRetryCodexPoolAccountModel400(
1468
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1469
+ let poolRetryOutcome: number | undefined;
1470
+ if (await shouldRetryCodexPoolAccountModel400(
1222
1471
  upstreamResponse,
1223
1472
  route.modelId,
1224
1473
  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;
1474
+ )) {
1475
+ poolRetryOutcome = 400;
1476
+ } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
1477
+ // Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
1478
+ poolRetryOutcome = upstreamResponse.status;
1242
1479
  }
1243
1480
 
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,
1481
+ if (poolRetryOutcome !== undefined) {
1482
+ const retry = await retryCodexPoolOnAlternateAccount({
1483
+ req,
1271
1484
  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);
1485
+ route,
1486
+ parsed,
1487
+ logCtx,
1488
+ options,
1489
+ firstAuthCtx: authCtx,
1490
+ firstResponse: upstreamResponse,
1491
+ outcomeStatus: poolRetryOutcome,
1492
+ upstream,
1493
+ connectMs,
1494
+ passthroughEstimate,
1495
+ stream: parsed.stream,
1496
+ });
1497
+ if (retry.kind === "transport") {
1498
+ authCtx = retry.authCtx;
1499
+ return transportFailureResponse(retry.error);
1500
+ }
1501
+ if (retry.kind === "retried") {
1502
+ authCtx = retry.authCtx;
1503
+ request = retry.request;
1504
+ upstreamResponse = retry.upstreamResponse;
1505
+ selectedForwardHeaders = retry.selectedForwardHeaders;
1506
+ // Keep subagent quota-failure health keyed to the account that actually served.
1507
+ subagentFallbackAccountId = retry.authCtx.accountId;
1290
1508
  }
1291
1509
  }
1292
1510
  }
@@ -1306,6 +1524,7 @@ export async function handleResponses(
1306
1524
  config,
1307
1525
  authCtx,
1308
1526
  route.provider,
1527
+ route.modelId,
1309
1528
  logCtx,
1310
1529
  req.headers.get("x-codex-parent-thread-id"),
1311
1530
  );
@@ -1314,7 +1533,7 @@ export async function handleResponses(
1314
1533
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1315
1534
  // primary was the 5h window; it now carries weekly data for GPT plans.
1316
1535
  // Prefer primary when present, fall back to secondary for compatibility.
1317
- const retryAfterRaw = upstreamResponse.headers.get("retry-after");
1536
+ const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
1318
1537
  const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
1319
1538
  applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
1320
1539
  if (terminalBodyWillRecord) {
@@ -1338,16 +1557,16 @@ export async function handleResponses(
1338
1557
  }
1339
1558
  options.onNativePassthroughTerminal?.(status);
1340
1559
  });
1341
- } else {
1560
+ } else if (!shouldDeferCodexResetDerivedCooldown(
1561
+ upstreamResponse,
1562
+ options.deferCodexResetDerivedCooldown,
1563
+ )) {
1342
1564
  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),
1565
+ ...quotaMeta,
1566
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1567
+ modelId: route.modelId,
1568
+ probeLeaseId: codexProbeLeaseId(authCtx),
1569
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
1351
1570
  });
1352
1571
  }
1353
1572
  }
@@ -1381,8 +1600,9 @@ export async function handleResponses(
1381
1600
  // known-bad runtime remains the tee path below.
1382
1601
  if (isEventStream && upstreamResponse.body) {
1383
1602
  const repairConfig = route.provider.responsesItemIdRepair;
1384
- const winNoRepair = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig);
1385
- const eagerDecision = winNoRepair ? decideEagerRelay(config.streamMode ?? "auto") : null;
1603
+ const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
1604
+ const winNoClientRewrite = process.platform === "win32" && !needsClientRewrite;
1605
+ const eagerDecision = winNoClientRewrite ? decideEagerRelay(config.streamMode ?? "auto") : null;
1386
1606
  if (eagerDecision?.useEagerRelay) {
1387
1607
  const turnAc = new AbortController();
1388
1608
  linkAbortSignal(upstream, turnAc.signal);
@@ -1433,6 +1653,8 @@ export async function handleResponses(
1433
1653
  onClientCancel: () => options.onNativePassthroughCancel?.(),
1434
1654
  onDone: () => unregisterTurn(turnAc),
1435
1655
  });
1656
+ // The eager branch is reachable only through winNoClientRewrite, so it must stay a pure
1657
+ // native relay without an image-gen or item-id JS pull wrapper on win32 (Bun#32111).
1436
1658
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
1437
1659
  return markNativePassthroughSseResponse(new Response(eagerBody, {
1438
1660
  status: upstreamResponse.status,
@@ -1492,12 +1714,19 @@ export async function handleResponses(
1492
1714
  // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
1493
1715
  // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
1494
1716
  // 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!)
1717
+ // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
1718
+ const payloadRewrites = [
1719
+ createImageGenCallRestoreRewrite(imageGenCallAliases),
1720
+ hasResponsesItemIdRepair(repairConfig)
1721
+ ? createResponsesItemIdPayloadRewrite(repairConfig!)
1722
+ : undefined,
1723
+ ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
1724
+ const rewrittenBody = payloadRewrites.length > 0
1725
+ ? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites))
1497
1726
  : nativeBody;
1498
- const clientBody = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig)
1727
+ const clientBody = process.platform === "win32" && !needsClientRewrite
1499
1728
  ? nativeBody
1500
- : relaySseWithFailedTail(repairedBody, upstream);
1729
+ : relaySseWithFailedTail(rewrittenBody, upstream);
1501
1730
  return markNativePassthroughSseResponse(new Response(clientBody, {
1502
1731
  status: upstreamResponse.status,
1503
1732
  headers,
@@ -1511,7 +1740,7 @@ export async function handleResponses(
1511
1740
  rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
1512
1741
  } catch { /* non-JSON despite content-type; recording is best-effort */ }
1513
1742
  }
1514
- return new Response(text, {
1743
+ return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), {
1515
1744
  status: upstreamResponse.status,
1516
1745
  statusText: upstreamResponse.statusText,
1517
1746
  headers,
@@ -1526,6 +1755,195 @@ export async function handleResponses(
1526
1755
  });
1527
1756
  }
1528
1757
 
1758
+ // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
1759
+ // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
1760
+ // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
1761
+ // completion instead of the synthetic compaction item Codex expects (#424).
1762
+ //
1763
+ // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
1764
+ // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
1765
+ // - non-runTurn: web-search wins over image when both eligible (documented priority)
1766
+ // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
1767
+ // can proceed for web-search-only turns
1768
+ const wsPlan = !routedCompaction
1769
+ ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
1770
+ : undefined;
1771
+ const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
1772
+ const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
1773
+ const canRunWebSearch = !!wsPlan && !adapter.runTurn;
1774
+ if ((imgPlan || vidPlan) && canRunWebSearch) {
1775
+ // Web search takes priority when both are active — the media bridge cannot run
1776
+ // alongside runWithWebSearch. Surface a runtime signal so the user knows their
1777
+ // configured video/image bridge was skipped for this turn, rather than silently
1778
+ // dropping a paid capability.
1779
+ if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
1780
+ if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
1781
+ }
1782
+ if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
1783
+ // The image bridge detects a hosted image_generation tool and requires streaming.
1784
+ // The video bridge activates from config and injects a tool — it also needs streaming
1785
+ // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
1786
+ // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
1787
+ if (!parsed.stream) {
1788
+ if (imgPlan) {
1789
+ return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
1790
+ }
1791
+ // Video-only: skip bridge for non-streaming requests
1792
+ } else {
1793
+ // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
1794
+ const priorTools = parsed.context.tools ?? [];
1795
+ const bridgeTools = [...priorTools.filter(t => {
1796
+ if (t.imageGeneration) return false;
1797
+ if (t.videoGeneration) return false;
1798
+ if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
1799
+ if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
1800
+ // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
1801
+ if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
1802
+ return true;
1803
+ })];
1804
+ const existingNames = new Set(bridgeTools.map(t => t.name));
1805
+ if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
1806
+ if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
1807
+ parsed.context.tools = bridgeTools;
1808
+ // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
1809
+ // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
1810
+ // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
1811
+ const tc = parsed.options.toolChoice;
1812
+ if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
1813
+ const mapped = tc.allowedTools.map(name =>
1814
+ name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
1815
+ ? IMAGE_GEN_TOOL_NAME
1816
+ : name,
1817
+ );
1818
+ parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
1819
+ } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
1820
+ && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
1821
+ parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
1822
+ }
1823
+ const imgResponse = await runWithImageBridge({
1824
+ parsed, adapter,
1825
+ ...(imgPlan ? { plan: imgPlan } : {}),
1826
+ ...(vidPlan ? { videoPlan: vidPlan } : {}),
1827
+ forwardHeaders: selectedForwardHeaders,
1828
+ onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
1829
+ abortSignal: options.abortSignal,
1830
+ maxRounds: imgPlan && vidPlan
1831
+ ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
1832
+ : imgPlan
1833
+ ? clampImageMaxRounds(config.images?.maxRounds)
1834
+ : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
1835
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1836
+ stallTimeoutSec: config.stallTimeoutSec,
1837
+ fetchImpl: providerFetch(route.provider),
1838
+ onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
1839
+ ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
1840
+ onUsage: usage => {
1841
+ // Cursor may assign _cursorConversationId inside the image loop's first runTurn;
1842
+ // backfill so Logs can filter/total that opening request (parity with the normal
1843
+ // runTurn branch).
1844
+ if (!logCtx.conversationId && parsed._cursorConversationId) {
1845
+ logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
1846
+ }
1847
+ logCtx.usageFromBridge = true;
1848
+ if (usage) {
1849
+ logCtx.usage = usage;
1850
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
1851
+ }
1852
+ },
1853
+ on429: retryAfter => {
1854
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1855
+ retryAfter,
1856
+ now: Date.now(),
1857
+ attemptedKey: route.provider.apiKey,
1858
+ promptCacheKey: parsed.options.promptCacheKey,
1859
+ });
1860
+ if (!rotated) return null;
1861
+ route.provider = rotated;
1862
+ return resolveAdapter(
1863
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1864
+ config.cacheRetention,
1865
+ );
1866
+ },
1867
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1868
+ ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
1869
+ onCompletedResponse: (response, providerState) =>
1870
+ rememberResponseState(
1871
+ parsed._rawBody,
1872
+ response,
1873
+ continuationStateForResponse(providerState),
1874
+ adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
1875
+ ),
1876
+ });
1877
+ if (imgResponse.body) {
1878
+ const imgTurnAc = new AbortController();
1879
+ return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc), {
1880
+ status: imgResponse.status,
1881
+ headers: imgResponse.headers,
1882
+ });
1883
+ }
1884
+ return imgResponse;
1885
+ } // end else (streaming bridge)
1886
+ }
1887
+
1888
+ // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
1889
+ // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
1890
+ // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
1891
+ // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
1892
+ // through web-search instead of being swallowed. runTurn adapters never enter this branch.
1893
+ if (canRunWebSearch && wsPlan) {
1894
+ parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
1895
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
1896
+ const wsResponse = await runWithWebSearch({
1897
+ parsed, adapter,
1898
+ backend: wsPlan.backend,
1899
+ forwardProvider: wsPlan.forwardSidecar?.provider,
1900
+ anthropicSidecar: wsPlan.anthropicSidecar,
1901
+ hostedTool: wsPlan.hostedTool,
1902
+ selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
1903
+ settings: wsPlan.settings,
1904
+ maxSearches: wsPlan.maxSearches,
1905
+ forceEmptyResponseId: true,
1906
+ abortSignal: options.abortSignal,
1907
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1908
+ onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
1909
+ onUsage: usage => {
1910
+ logCtx.usageFromBridge = true;
1911
+ if (usage) {
1912
+ logCtx.usage = usage;
1913
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
1914
+ }
1915
+ },
1916
+ recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
1917
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1918
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
1919
+ stallTimeoutSec: wsPlan.stallTimeoutSec,
1920
+ on429: retryAfter => {
1921
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1922
+ retryAfter,
1923
+ now: Date.now(),
1924
+ attemptedKey: route.provider.apiKey,
1925
+ promptCacheKey: parsed.options.promptCacheKey,
1926
+ });
1927
+ if (!rotated) return null;
1928
+ route.provider = rotated;
1929
+ return resolveAdapter(
1930
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1931
+ config.cacheRetention,
1932
+ );
1933
+ },
1934
+ });
1935
+ // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
1936
+ // in-flight web-search turns instead of skipping them during graceful shutdown.
1937
+ if (wsResponse.body) {
1938
+ const wsTurnAc = new AbortController();
1939
+ return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
1940
+ status: wsResponse.status,
1941
+ headers: wsResponse.headers,
1942
+ });
1943
+ }
1944
+ return wsResponse;
1945
+ }
1946
+
1529
1947
  if (adapter.runTurn) {
1530
1948
  const runTurnAbort = new AbortController();
1531
1949
  linkAbortSignal(runTurnAbort, options.abortSignal);
@@ -1646,64 +2064,6 @@ export async function handleResponses(
1646
2064
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
1647
2065
  }
1648
2066
 
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
2067
  const upstream = new AbortController();
1708
2068
  const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
1709
2069
  const connectMs = config.connectTimeoutMs ?? 200_000;
@@ -1806,6 +2166,9 @@ export async function handleResponses(
1806
2166
  return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
1807
2167
  }
1808
2168
  sentOAuthSnapshot = refreshed;
2169
+ if (route.providerName === "kiro") {
2170
+ parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
2171
+ }
1809
2172
  const refreshedProvider = resolveProviderTransport(
1810
2173
  route.providerName,
1811
2174
  { ...route.provider, apiKey: refreshed.accessToken },
@@ -1827,7 +2190,7 @@ export async function handleResponses(
1827
2190
  // SAME request once per remaining key. OAuth/forward providers and single-key pools
1828
2191
  // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
1829
2192
  while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
1830
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
2193
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1831
2194
  retryAfter: upstreamResponse.headers.get("retry-after"),
1832
2195
  now: Date.now(),
1833
2196
  attemptedKey: route.provider.apiKey,
@@ -1846,6 +2209,42 @@ export async function handleResponses(
1846
2209
  if ("failed" in result) return result.failed;
1847
2210
  upstreamResponse = result;
1848
2211
  }
2212
+
2213
+ // Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
2214
+ // with another eligible OAuth account (bounded per request). Disabled by default.
2215
+ while (
2216
+ upstreamResponse.status === 429
2217
+ && anthropicPoolAccountId
2218
+ && isAnthropicAccountPoolEnabled(config)
2219
+ && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
2220
+ ) {
2221
+ const nextAccountId = rotateAnthropicAccountOn429(
2222
+ config,
2223
+ anthropicPoolAccountId,
2224
+ upstreamResponse.headers.get("retry-after"),
2225
+ anthropicSessionKey,
2226
+ );
2227
+ if (!nextAccountId) break;
2228
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
2229
+ try {
2230
+ const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
2231
+ anthropicPoolAccountId = nextAccountId;
2232
+ anthropicPoolFailovers += 1;
2233
+ route.provider = { ...route.provider, apiKey: accessToken };
2234
+ promoteAnthropicActiveAccount(nextAccountId);
2235
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
2236
+ activeAdapter = resolveAdapter(
2237
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
2238
+ config.cacheRetention,
2239
+ );
2240
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
2241
+ const result = await rebuildAndRefetch("anthropic-oauth-429");
2242
+ if ("failed" in result) return result.failed;
2243
+ upstreamResponse = result;
2244
+ } catch {
2245
+ break;
2246
+ }
2247
+ }
1849
2248
  // Anthropic 413 request_too_large: rebuild once with every image one tier lower
1850
2249
  // (spiral guard: single attempt). The biased response re-enters the 429 check above.
1851
2250
  if (shouldAttemptImageTierRetry({
@@ -1954,7 +2353,7 @@ export async function handleResponses(
1954
2353
  }
1955
2354
 
1956
2355
  if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
1957
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
2356
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
1958
2357
  retryAfter: response.headers.get("retry-after"),
1959
2358
  now: Date.now(),
1960
2359
  attemptedKey: route.provider.apiKey,
@@ -1970,6 +2369,38 @@ export async function handleResponses(
1970
2369
  continue;
1971
2370
  }
1972
2371
  }
2372
+ if (
2373
+ response.status === 429
2374
+ && anthropicPoolAccountId
2375
+ && isAnthropicAccountPoolEnabled(config)
2376
+ && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
2377
+ ) {
2378
+ const nextAccountId = rotateAnthropicAccountOn429(
2379
+ config,
2380
+ anthropicPoolAccountId,
2381
+ response.headers.get("retry-after"),
2382
+ anthropicSessionKey,
2383
+ );
2384
+ if (nextAccountId) {
2385
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
2386
+ try {
2387
+ const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
2388
+ anthropicPoolAccountId = nextAccountId;
2389
+ anthropicPoolFailovers += 1;
2390
+ route.provider = { ...route.provider, apiKey: accessToken };
2391
+ promoteAnthropicActiveAccount(nextAccountId);
2392
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
2393
+ activeAdapter = resolveAdapter(
2394
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
2395
+ config.cacheRetention,
2396
+ );
2397
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
2398
+ continue;
2399
+ } catch {
2400
+ // fall through to emit continuation error below
2401
+ }
2402
+ }
2403
+ }
1973
2404
  if (shouldAttemptImageTierRetry({
1974
2405
  status: response.status,
1975
2406
  adapterName: activeAdapter.name,