@bitkyc08/opencodex 2.87.0 → 2.88.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 (68) hide show
  1. package/gui/dist/assets/{App-ZfYbLHkB.js → App-CHC89EXQ.js} +3 -3
  2. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-B5bC7AWL.js} +1 -1
  3. package/gui/dist/assets/index-B5bsvxor.js +94 -0
  4. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-D8y2wTsB.js} +1 -1
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  8. package/src/adapters/anthropic-replay.ts +135 -0
  9. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  10. package/src/adapters/anthropic.ts +76 -45
  11. package/src/adapters/google-progress-history.ts +53 -0
  12. package/src/adapters/google.ts +17 -1
  13. package/src/bridge/internal.ts +6 -2
  14. package/src/bridge/response-json.ts +2 -2
  15. package/src/bridge/sse.ts +7 -6
  16. package/src/claude/inbound-content-options.ts +66 -0
  17. package/src/claude/outbound.ts +13 -5
  18. package/src/cli/cross-home-owner.ts +16 -2
  19. package/src/cli/loopback-family.ts +31 -0
  20. package/src/codex/catalog/routed-gather.ts +3 -1
  21. package/src/codex/websocket-activity.ts +161 -0
  22. package/src/codex/websocket-registry.ts +25 -1
  23. package/src/config/opencode-zen-upgrade.ts +9 -8
  24. package/src/generated/compatibility-version.json +88 -52
  25. package/src/images/loop.ts +72 -21
  26. package/src/lib/request-failure-model.ts +2 -0
  27. package/src/oauth/generic-account-failover.ts +7 -4
  28. package/src/protocols/encoders/adapter-events.ts +5 -5
  29. package/src/protocols/encoders/messages.ts +2 -2
  30. package/src/providers/opencode-zen-rate-limit.ts +7 -2
  31. package/src/responses/reasoning-replay-cache.ts +50 -4
  32. package/src/routing/analytics.ts +7 -15
  33. package/src/server/claude-messages.ts +2 -1
  34. package/src/server/index/live-sideband.ts +3 -2
  35. package/src/server/index/serve-options.ts +11 -5
  36. package/src/server/index/websocket-handler.ts +18 -4
  37. package/src/server/index.ts +7 -7
  38. package/src/server/lifecycle.ts +13 -1
  39. package/src/server/messages-native.ts +16 -3
  40. package/src/server/responses/adapter-continuation.ts +34 -18
  41. package/src/server/responses/adapter-delivery.ts +53 -6
  42. package/src/server/responses/adapter-dispatch.ts +27 -1
  43. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  44. package/src/server/responses/combo-stream-preflight.ts +3 -2
  45. package/src/server/responses/compaction-recovery.ts +2 -0
  46. package/src/server/responses/core-codex-account.ts +2 -2
  47. package/src/server/responses/core-combo.ts +3 -2
  48. package/src/server/responses/core-lifetime.ts +3 -2
  49. package/src/server/responses/core-options.ts +2 -0
  50. package/src/server/responses/core-replay.ts +5 -1
  51. package/src/server/responses/passthrough-execution.ts +3 -2
  52. package/src/server/responses/request-prepare.ts +1 -0
  53. package/src/server/responses/response-effects.ts +1 -0
  54. package/src/server/responses/response-tool-ownership.ts +21 -0
  55. package/src/server/responses/sidecar-execution.ts +43 -7
  56. package/src/server/responses/sidecar-send-budget.ts +2 -2
  57. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  58. package/src/server/ws-bridge.ts +2 -0
  59. package/src/types/opencode-zen.ts +9 -3
  60. package/src/types/request.ts +10 -2
  61. package/src/types/wire.ts +4 -2
  62. package/src/types.ts +1 -0
  63. package/src/usage/summary.ts +9 -3
  64. package/src/usage/telemetry-contract.ts +1 -0
  65. package/src/web-search/anthropic-history.ts +59 -0
  66. package/src/web-search/loop.ts +181 -68
  67. package/src/web-search/progress-stream.ts +11 -1
  68. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -1,3 +1,5 @@
1
+ import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery";
2
+ import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo";
1
3
  /**
2
4
  * Media bridge agentic loop — supports both image and video generation sidecars.
3
5
  *
@@ -23,7 +25,7 @@ import { bridgeToResponsesSSE } from "../bridge";
23
25
  import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal";
24
26
  import { clearableDeadline, idleDeadline } from "../lib/abort";
25
27
  import { readBoundedResponseBody } from "../lib/bounded-body";
26
- import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
28
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
27
29
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
28
30
  import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing";
29
31
  import {
@@ -33,7 +35,7 @@ import {
33
35
  TRANSLATOR_MAX_TURN_BYTES,
34
36
  TranslatorBudgetExceededError,
35
37
  } from "../lib/translator-budget";
36
- import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream";
38
+ import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "../web-search/progress-stream";
37
39
  import { fulfillImageCall } from "./fulfill";
38
40
  import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video";
39
41
  import { submitVideoJob } from "./xai-video-client";
@@ -270,17 +272,18 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
270
272
  return parts;
271
273
  }
272
274
 
273
- function jsonError(status: number, message: string): Response {
274
- return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
275
+ function jsonError(status: number, message: string, event?: Extract<AdapterEvent, { type: "error" }>): Response {
276
+ return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null,
277
+ ...(event?.retryable === false ? { retryable: false } : {}) } }), {
275
278
  status,
276
- headers: { "Content-Type": "application/json" },
279
+ headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) },
277
280
  });
278
281
  }
279
282
 
280
283
  /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
281
284
  * non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
282
285
  class LoopError extends Error {
283
- constructor(readonly status: number, message: string) {
286
+ constructor(readonly status: number, message: string, readonly event?: Extract<AdapterEvent, { type: "error" }>) {
284
287
  super(message);
285
288
  this.name = "LoopError";
286
289
  }
@@ -291,6 +294,7 @@ class LoopError extends Error {
291
294
  * metadata, and the optional image/video bridge plans.
292
295
  */
293
296
  export interface ImageBridgeDeps {
297
+ thinkingRecovery?: ReturnType<typeof createAnthropicThinkingRecovery>;
294
298
  parsed: OcxParsedRequest;
295
299
  clientToolCatalogRestriction?: ClientToolCatalogRestriction;
296
300
  adapter: ProviderAdapter;
@@ -321,7 +325,7 @@ export interface ImageBridgeDeps {
321
325
  /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */
322
326
  fetchImpl?: typeof globalThis.fetch;
323
327
  /** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */
324
- fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch;
328
+ fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch;
325
329
  /** Reserve the routed provider's next request-start slot before each adapter dispatch. */
326
330
  waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
327
331
  /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
@@ -464,6 +468,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
464
468
  interface IterationResponse {
465
469
  response: Response;
466
470
  responseAdapter: ProviderAdapter;
471
+ parsed?: OcxParsedRequest;
472
+ retryThinking?: () => Promise<IterationResponse>;
467
473
  /** runTurn events are already bounded; consume them without replaying or charging twice. */
468
474
  collectedEvents?: AdapterEvent[];
469
475
  }
@@ -634,7 +640,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
634
640
  cachedRequest = request;
635
641
  cachedAdapter = requestAdapter;
636
642
  }
637
- const requestFetch = deps.fetchForRequest?.(request, iterParsed, requestAdapter) ?? fetchImpl;
643
+ const requestFetch = deps.fetchForRequest?.(request, iterParsed, requestAdapter, recovery) ?? fetchImpl;
638
644
  let response: Response;
639
645
  try {
640
646
  if (requestAdapter.fetchResponse) {
@@ -685,7 +691,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
685
691
  } finally {
686
692
  request.releaseBodyObservation?.();
687
693
  }
688
- return { response, responseAdapter: requestAdapter };
694
+ bindAnthropicRejectionResponse(request, response);
695
+ return { response, responseAdapter: requestAdapter, parsed: iterParsed };
689
696
  };
690
697
 
691
698
  let prepared = await fetchOnce(adapter);
@@ -739,9 +746,18 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
739
746
  prepared = await fetchOnce(adapter, rotated.recoveryKind);
740
747
  }
741
748
 
749
+ if (await deps.thinkingRecovery?.prepareHttp(prepared.response, prepared.responseAdapter.name, iterParsed)) {
750
+ try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* closed */ }
751
+ cachedRequest = undefined;
752
+ prepared = await fetchOnce(adapter, "opaque-blob-rejection");
753
+ }
742
754
  // Final headers have arrived. Clear only the deadline timer before ANY body read.
743
755
  headerDeadline.clear();
744
756
  if (!prepared.response.ok) {
757
+ if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") {
758
+ const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal);
759
+ throw new LoopError(event.status ?? prepared.response.status, event.message, event);
760
+ }
745
761
  let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
746
762
  try {
747
763
  body = await readBoundedResponseBody(prepared.response, { signal });
@@ -762,9 +778,21 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
762
778
  const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
763
779
  throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
764
780
  }
765
- return prepared;
781
+ return { ...prepared, retryThinking: async () => {
782
+ cachedRequest = undefined;
783
+ headerDeadline = clearableDeadline(connectTimeoutMs, signal);
784
+ try { return await fetchOnce(adapter, "opaque-blob-rejection"); }
785
+ catch (error) {
786
+ if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
787
+ if (headerDeadline.didExpire()) throw new LoopError(504,
788
+ `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`);
789
+ throw error;
790
+ } finally { headerDeadline.clear(); deps.onIterationEnd?.(); }
791
+ } };
766
792
  } catch (error) {
767
793
  if (isTranslatorBudgetExceededError(error)) throw error;
794
+ if (error instanceof SendBudgetExhaustedError) throw new LoopError(429, error.message,
795
+ { type: "error", status: 429, errorType: "rate_limit_error", code: error.code, message: error.message, retryable: false });
768
796
  if (headerDeadline.didExpire()) {
769
797
  throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`);
770
798
  }
@@ -791,14 +819,31 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
791
819
  const iterationBudget = prepared.collectedEvents ? undefined : createIterationEventBudget();
792
820
  try {
793
821
  if (iterationBudget) {
794
- const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
795
- for await (const event of parseStreamWithProgress(prepared.response, parse, {
796
- signal,
797
- inactivityTimeoutMs: stallTimeoutMs,
798
- translatorBudget,
799
- // A pass that fails was still billed; the error terminal below reports it with the hidden passes.
800
- onErrorUsage: usage => { hiddenUsage = addUsage(hiddenUsage, usage); },
801
- })) {
822
+ const parse = async function* (current: IterationResponse): AsyncGenerator<AdapterEvent> {
823
+ if (!current.response.ok && deps.thinkingRecovery) {
824
+ yield await deps.thinkingRecovery.projectHttpFailure(current.response, signal);
825
+ return;
826
+ }
827
+ try {
828
+ yield* parseStreamWithProgress(current.response, current.responseAdapter.parseStream.bind(current.responseAdapter), {
829
+ signal, inactivityTimeoutMs: stallTimeoutMs, translatorBudget,
830
+ preserveAdapterError: deps.thinkingRecovery !== undefined,
831
+ onErrorUsage: usage => { hiddenUsage = addUsage(hiddenUsage, usage); },
832
+ });
833
+ } catch (error) {
834
+ if (!(error instanceof WebSearchAdapterEventError)) throw error;
835
+ if (!error.replaySafe) yield { type: "heartbeat", replayUnsafe: true };
836
+ yield isNonReplayableResponse(current.response)
837
+ ? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false }
838
+ : error.event;
839
+ }
840
+ };
841
+ const source = deps.thinkingRecovery && prepared.retryThinking
842
+ ? deps.thinkingRecovery.wrap(parse(prepared), async function* () {
843
+ yield* parse(await prepared.retryThinking!());
844
+ }, () => prepared.responseAdapter.name, !isNonReplayableResponse(prepared.response), prepared.response, prepared.parsed)
845
+ : parse(prepared);
846
+ for await (const event of source) {
802
847
  if (event.type === "heartbeat") yield event;
803
848
  else {
804
849
  iterationBudget.retain(event);
@@ -812,6 +857,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
812
857
  throw error;
813
858
  }
814
859
  if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
860
+ if (error instanceof LoopError) throw error;
861
+ if (error instanceof SendBudgetExhaustedError) throw new LoopError(429, error.message,
862
+ { type: "error", status: 429, errorType: "rate_limit_error", code: error.code, message: error.message, retryable: false });
815
863
  if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
816
864
  if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
817
865
  throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
@@ -829,8 +877,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
829
877
  if (terminal.code === "translation_buffer_limit") {
830
878
  throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
831
879
  }
832
- throw new LoopError(502, terminal.message);
880
+ throw new LoopError(terminal.status ?? 502, terminal.message, terminal);
833
881
  }
882
+ deps.thinkingRecovery?.observeIteration(events);
834
883
  return scanEventsForImageCall(events, mediaToolNames);
835
884
  };
836
885
 
@@ -845,7 +894,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
845
894
  firstPrepared = await prepareIterationDrained(maxRounds <= 0);
846
895
  } catch (e) {
847
896
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
848
- if (e instanceof LoopError) return jsonError(e.status, e.message);
897
+ if (e instanceof LoopError) return jsonError(e.status, e.message, e.event);
898
+ if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
899
+ { type: "error", status: 429, errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false });
849
900
  throw e;
850
901
  }
851
902
  }
@@ -1101,7 +1152,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
1101
1152
  yield {
1102
1153
  type: "error",
1103
1154
  message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)),
1104
- ...(e instanceof LoopError ? { status: e.status } : {}),
1155
+ ...(e instanceof LoopError ? { status: e.status, ...(e.event ?? {}) } : {}),
1105
1156
  ...(usage ? { usage } : {}),
1106
1157
  };
1107
1158
  }
@@ -223,6 +223,8 @@ const RECOVERY_KIND_CAUSE = {
223
223
  "rate-limit-429": "rate-limit",
224
224
  "anthropic-oauth-429": "rate-limit",
225
225
  "oauth-account-429": "rate-limit",
226
+ // A 402 is the account's quota, not a transient limit; only another account can serve it.
227
+ "oauth-account-402": "quota-exhausted",
226
228
  "image-413": "payload-too-large",
227
229
  // The gateway rejects a body it accepts seconds later and the replay is byte-identical, so
228
230
  // nothing about the payload was wrong; the origin declined to take it at that moment.
@@ -483,7 +483,8 @@ export function noteGenericPoolSelection(
483
483
  * Cool the account that actually 429'd and name the next eligible one, or null.
484
484
  *
485
485
  * Returns the id only; the caller mints the credential so a failed refresh does not leave the
486
- * cooldown applied to an account we then could not use.
486
+ * cooldown applied to an account we then could not use. `commit: false` still cools the failed
487
+ * account but only peeks the round-robin ring, for callers that will not dispatch the answer.
487
488
  */
488
489
  export function rotateGenericOAuthAccountOn429(
489
490
  config: OcxConfig,
@@ -492,9 +493,10 @@ export function rotateGenericOAuthAccountOn429(
492
493
  retryAfterHeader: string | null | undefined,
493
494
  now = Date.now(),
494
495
  requestedModelId?: string | null,
496
+ commit = true,
495
497
  ): string | null {
496
498
  return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate",
497
- retryAfterHeader, now, requestedModelId);
499
+ retryAfterHeader, now, requestedModelId, undefined, commit);
498
500
  }
499
501
 
500
502
  /** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */
@@ -507,6 +509,7 @@ export function rotateGenericOAuthAccountOnRefusal(
507
509
  now = Date.now(),
508
510
  requestedModelId?: string | null,
509
511
  monthlyCooldownMs?: number,
512
+ commit = true,
510
513
  ): string | null {
511
514
  if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null;
512
515
  if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
@@ -566,8 +569,8 @@ export function rotateGenericOAuthAccountOnRefusal(
566
569
  }
567
570
  if (strategy === "round-robin") {
568
571
  // PICK here, not peek: the failure already happened and this answer is the one being used,
569
- // so the ring genuinely advances.
570
- return pickRoundRobinAccount(
572
+ // so the ring genuinely advances. A caller that only asks whether a sibling exists peeks.
573
+ return (commit ? pickRoundRobinAccount : peekRoundRobinAccount)(
571
574
  genericPoolKey(providerName),
572
575
  candidates,
573
576
  genericStickyLimit(config, providerName),
@@ -28,7 +28,7 @@
28
28
  * completed-response effects belong to the caller, which folds the same events with
29
29
  * `buildResponseJSON` at the terminal through `beforeTerminal`.
30
30
  */
31
- import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types";
31
+ import type { AdapterEvent, AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types";
32
32
  import { normalizeDeclaredToolName } from "../../types";
33
33
  import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers";
34
34
  import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors";
@@ -61,7 +61,7 @@ export interface ClientWebSearch {
61
61
  status: "completed" | "failed";
62
62
  queries: string[];
63
63
  sources: { url: string; title?: string }[];
64
- fetch?: { url: string; result: unknown };
64
+ anthropic?: AnthropicServerBlocks;
65
65
  }
66
66
 
67
67
  /**
@@ -412,9 +412,9 @@ export function encodeAdapterEventStream(
412
412
  budget.closeCall(call.callId);
413
413
  currentToolCall = null;
414
414
  };
415
- const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: ClientWebSearch["fetch"]) => {
415
+ const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => {
416
416
  if (!currentWebSearch) return;
417
- writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(fetch ? { fetch } : {}) });
417
+ writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(anthropic ? { anthropic } : {}) });
418
418
  currentWebSearch = null;
419
419
  };
420
420
  const appendToolArgs = (call: OpenToolCall, fragment: string) => {
@@ -689,7 +689,7 @@ export function encodeAdapterEventStream(
689
689
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
690
690
  currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id };
691
691
  }
692
- closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.fetch);
692
+ closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.anthropic);
693
693
  break;
694
694
  }
695
695
  case "done": {
@@ -345,9 +345,9 @@ function createAnthropicMessageWriter(
345
345
  type: "web_search_call",
346
346
  id: search.itemId,
347
347
  status: search.status,
348
- action: webSearchAction(search.queries, search.fetch),
348
+ action: webSearchAction(search.queries, search.anthropic),
349
349
  ...(search.sources.length > 0 ? { sources: search.sources } : {}),
350
- ...(search.fetch ? { fetch_result: search.fetch.result } : {}),
350
+ ...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}),
351
351
  });
352
352
  const toolIndex = blockIndex++;
353
353
  emit("content_block_start", {
@@ -70,6 +70,9 @@ export function enrichOpenCodeZenRateLimitMessage(
70
70
  message: string,
71
71
  opts: {
72
72
  status: number;
73
+ /** Parsed upstream error type, when available. */
74
+ upstreamErrorType?: string | null;
75
+ upstreamErrorCode?: string | null;
73
76
  providerName?: string;
74
77
  baseUrl?: string;
75
78
  adapter?: string;
@@ -84,7 +87,9 @@ export function enrichOpenCodeZenRateLimitMessage(
84
87
  ): string {
85
88
  if (opts.status !== 429) return message;
86
89
  if (!isOpenCodeZenRateLimitProvider(opts)) return message;
87
- if (!/rate\s*limit/i.test(message)) return message;
90
+ const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " ");
91
+ if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message;
92
+ if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message;
88
93
  if (message.includes(ENRICHMENT_MARKER)) return message;
89
94
 
90
95
  const upstreamRetry = validateClientRetryAfterHeader(
@@ -165,7 +170,7 @@ export function enrichOpenCodeZenFreeTierMessage(
165
170
  }
166
171
 
167
172
  /**
168
- * Single entry point for Zen upstream-error guidance on the Responses wire: short-window
173
+ * Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window
169
174
  * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
170
175
  * outside its own case, so the composition is safe for every other upstream failure.
171
176
  */
@@ -46,6 +46,7 @@ export function nativeReasoningTagMatches(expected: string | undefined, presente
46
46
  const MAX_TOTAL_BYTES = 256 * 1024;
47
47
  const TTL_MS = 60 * 60 * 1000;
48
48
  const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
49
+ const MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS = 256;
49
50
  const replayIdentityKey = randomBytes(32);
50
51
  const CREDENTIAL_HEADER_NAMES = new Set([
51
52
  "authorization",
@@ -78,8 +79,14 @@ interface ServingIdentityEntry {
78
79
  interface OpaqueBlobRejectionEntry {
79
80
  bytes: number;
80
81
  at: number;
82
+ /** Undefined retains the legacy blanket rejection; only payload digests are stored here. */
83
+ fingerprints?: ReadonlySet<string>;
81
84
  }
82
85
 
86
+ export type ReasoningReplayOpaqueBlobRejection =
87
+ | { mode: "blanket" }
88
+ | { mode: "selective"; fingerprints: ReadonlySet<string> };
89
+
83
90
  const entries = new Map<string, CacheEntry>();
84
91
  const servingIdentities = new Map<string, ServingIdentityEntry>();
85
92
  const opaqueBlobRejections = new Map<string, OpaqueBlobRejectionEntry>();
@@ -310,18 +317,57 @@ export function reasoningReplayOpaqueBlobRejectionMemoized(
310
317
  return opaqueBlobRejections.has(key);
311
318
  }
312
319
 
320
+ /** Undefined means no live memo; a selective result owns a defensive copy of its digests. */
321
+ export function getReasoningReplayOpaqueBlobRejection(
322
+ scope: OcxReasoningReplayScopeRef | undefined,
323
+ ): ReasoningReplayOpaqueBlobRejection | undefined {
324
+ const key = opaqueBlobRejectionKeyFor(scope);
325
+ if (!key) return undefined;
326
+ sweepExpiredOpaqueBlobRejections(now());
327
+ const entry = opaqueBlobRejections.get(key);
328
+ return entry === undefined ? undefined : entry.fingerprints === undefined ? { mode: "blanket" }
329
+ : { mode: "selective", fingerprints: new Set(entry.fingerprints) };
330
+ }
331
+
332
+ /** Incomplete, invalid or unbounded enumerations cannot safely narrow the legacy rejection. */
333
+ function boundedRejectionFingerprints(fingerprints: ReadonlySet<string> | undefined): Set<string> | undefined {
334
+ if (fingerprints === undefined) return undefined;
335
+ const copy = new Set<string>();
336
+ try {
337
+ // The intrinsic validates a real Set without trusting an overridden iterator or size getter.
338
+ for (const value of Set.prototype.values.call(fingerprints) as IterableIterator<unknown>) {
339
+ if (typeof value !== "string" || value.length !== 64 || !/^[a-f0-9]{64}$/.test(value)
340
+ || copy.size >= MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) return undefined;
341
+ copy.add(value);
342
+ }
343
+ } catch { return undefined; }
344
+ return copy.size > 0 ? copy : undefined;
345
+ }
346
+
313
347
  /** Record only after a blobless retry succeeded for this durable serving identity. */
314
348
  export function rememberReasoningReplayOpaqueBlobRejection(
315
349
  scope: OcxReasoningReplayScopeRef | undefined,
350
+ fingerprints?: ReadonlySet<string>,
316
351
  ): void {
317
352
  const key = opaqueBlobRejectionKeyFor(scope);
318
353
  if (!key) return;
319
- const bytes = Buffer.byteLength(key, "utf8");
320
- if (bytes > MAX_TOTAL_BYTES) return;
354
+ const keyBytes = Buffer.byteLength(key, "utf8");
355
+ if (keyBytes > MAX_TOTAL_BYTES) return;
321
356
  const at = now();
322
357
  sweepExpiredOpaqueBlobRejections(at);
323
- if (opaqueBlobRejections.has(key)) deleteOpaqueBlobRejection(key);
324
- opaqueBlobRejections.set(key, { bytes, at });
358
+ const previous = opaqueBlobRejections.get(key);
359
+ let merged = previous && previous.fingerprints === undefined ? undefined : boundedRejectionFingerprints(fingerprints);
360
+ if (merged && previous?.fingerprints) {
361
+ for (const fingerprint of previous.fingerprints) {
362
+ merged.add(fingerprint);
363
+ if (merged.size > MAX_OPAQUE_BLOB_REJECTION_FINGERPRINTS) { merged = undefined; break; }
364
+ }
365
+ }
366
+ // A near-limit key can still retain its existing blanket memo if the digest set will not fit.
367
+ if (merged && keyBytes + merged.size * 64 > MAX_TOTAL_BYTES) merged = undefined;
368
+ const bytes = keyBytes + (merged?.size ?? 0) * 64;
369
+ if (previous) deleteOpaqueBlobRejection(key);
370
+ opaqueBlobRejections.set(key, { bytes, at, ...(merged ? { fingerprints: merged } : {}) });
325
371
  opaqueBlobRejectionTotalBytes += bytes;
326
372
  while (
327
373
  (opaqueBlobRejectionTotalBytes > MAX_TOTAL_BYTES || opaqueBlobRejections.size > MAX_ENTRIES)
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { PersistedUsageEntry } from "../usage/log";
15
- import { estimateRequestCost, serviceTierContext } from "../usage/cost";
15
+ import { computeEntryCost } from "../usage/summary";
16
16
  import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
17
17
 
18
18
  export const ANALYTICS_MAX_ROWS = 50_000;
@@ -122,6 +122,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([
122
122
  "oauth-account-403",
123
123
  "anthropic-oauth-429",
124
124
  "oauth-account-429",
125
+ "oauth-account-402",
125
126
  ]);
126
127
 
127
128
  function percentile(sorted: number[], p: number): number | undefined {
@@ -158,19 +159,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number):
158
159
  });
159
160
  }
160
161
 
161
- function successCostUsd(
162
- row: Pick<ScannedRow, "provider" | "model">,
163
- entry: PersistedUsageEntry,
164
- ): number | null {
165
- if (!entry.usage) return null;
166
- const estimate = estimateRequestCost({
167
- provider: row.provider,
168
- model: row.model,
169
- usage: entry.usage,
170
- usageStatus: entry.usageStatus,
171
- serviceTier: serviceTierContext(entry),
172
- });
173
- return estimate ? estimate.cost.total : null;
162
+ /** Same attempt-aware pricing as /api/usage, so a refused retry before a measured send is priced. */
163
+ function successCostUsd(entry: PersistedUsageEntry): number | null {
164
+ const { isPriced, costTotal } = computeEntryCost(entry);
165
+ return isPriced ? costTotal : null;
174
166
  }
175
167
 
176
168
  export async function computeRoutingAnalytics(
@@ -245,7 +237,7 @@ export async function computeRoutingAnalytics(
245
237
  if (kind === "success") {
246
238
  const entry = parseEntry(row.rowJson);
247
239
  if (entry) {
248
- rowCostUsd = successCostUsd(row, entry);
240
+ rowCostUsd = successCostUsd(entry);
249
241
  if (rowCostUsd !== null) {
250
242
  costTotalUsd += rowCostUsd;
251
243
  costCount += 1;
@@ -33,7 +33,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl
33
33
  import { recordDesktopRequest } from "../claude/desktop-health";
34
34
  import { stripOneMillionMarker } from "../claude/context-windows";
35
35
  import { captureClaudeInbound } from "../claude/inbound-debug";
36
- import { declaredAnthropicServerTools } from "../claude/inbound-content-options";
36
+ import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options";
37
37
  import { claudeCodeForIngress } from "../claude/intercept/model-bindings";
38
38
  import { classifyInterceptClient } from "../claude/intercept/client-class";
39
39
  import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility";
@@ -1133,6 +1133,7 @@ async function handleClaudeMessagesWithBudget(
1133
1133
  nativeReasoningMint,
1134
1134
  // Out of body like the replay above: the core hands these to an Anthropic destination only.
1135
1135
  anthropicServerTools: declaredAnthropicServerTools(anthropicBody),
1136
+ anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody),
1136
1137
  claudeGoAffinity: { sessionLane: claudeGoSessionLane },
1137
1138
  claudeNativeSessionId,
1138
1139
  stripClaudeMainAuthForNoncanonicalForward: true,
@@ -40,8 +40,9 @@ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
40
40
  *
41
41
  * What must never happen is neither: an immortal socket plus a destination that silently accepts
42
42
  * the orphaned delta. `tests/responses/ws-endpoint.test.ts` holds exactly that pair together.
43
- * Raising the timeout off 0 is still worth doing for its own reasons (a dead peer holds a socket
44
- * forever today), and Bun caps the value at 255 seconds, well inside the bound below.
43
+ * Responses admission separately reclaims its least recently used idle connection under
44
+ * capacity pressure. That is not a time limit, so expired-reference validation remains required.
45
+ * Keeping this shared timeout at 0 also preserves quiet live/voice sessions.
45
46
  */
46
47
  export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
47
48
  /** Ceiling a finite websocket idle timeout must stay under, in seconds. See above. */
@@ -1618,11 +1618,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1618
1618
  // Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
1619
1619
  // the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
1620
1620
  const sessionReq = withCallerSessionIdentity(req, admission);
1621
- return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors(
1622
- await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1623
- req,
1624
- policy,
1625
- ), { requestId, start, logCtx });
1621
+ // Only expose an id when the handler or a logged refusal owns its history row.
1622
+ let ownsRequestLog = false;
1623
+ const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => {
1624
+ ownsRequestLog = true;
1625
+ return withCors(
1626
+ await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
1627
+ req,
1628
+ policy,
1629
+ );
1630
+ }, { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } });
1631
+ return ownsRequestLog ? withRequestLogId(response, requestId) : response;
1626
1632
  }
1627
1633
 
1628
1634
  if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
@@ -89,6 +89,7 @@ import {
89
89
  import type { ServeOptionsContext } from "./serve-options";
90
90
  import type { RequestMetricsRecorder } from "../request-metrics";
91
91
  import { resolveInboundBodyLimitBytes } from "../request-decompress";
92
+ import { responseToolOwnership } from "../responses/response-tool-ownership";
92
93
 
93
94
  /**
94
95
  * The WebSocket half of the Bun.serve options, split out of serve-options.ts to keep that file
@@ -184,6 +185,8 @@ export function createWebsocketHandler(
184
185
  }
185
186
  return;
186
187
  }
188
+ if (ws.data.responsesActivity?.closing) return;
189
+ ws.data.responsesActivity?.touch();
187
190
  const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
188
191
  if (rawBytes > MAX_WS_FRAME_BYTES) {
189
192
  sendJsonFrame(ws, buildWsErrorFrame(413, {
@@ -223,6 +226,7 @@ export function createWebsocketHandler(
223
226
  if (frame.type === "response.inject") {
224
227
  if (!ws.data.nativeControl?.inject) throw new NativeSteeringError("injection_not_supported", "Native injection is disabled or unavailable on this route.");
225
228
  ws.data.nativeControl.inject(frame);
229
+ ws.data.responsesActivity?.acceptResults(frame.input);
226
230
  return;
227
231
  }
228
232
  if (frame.type === "response.steer") {
@@ -230,7 +234,10 @@ export function createWebsocketHandler(
230
234
  ws.data.nativeControl.steer(frame);
231
235
  return;
232
236
  }
233
- if (ws.data.nativeControl?.continue(frame)) return;
237
+ if (ws.data.nativeControl?.continue(frame)) {
238
+ ws.data.responsesActivity?.acceptResults(frame.input);
239
+ return;
240
+ }
234
241
  } catch (error) {
235
242
  sendJsonFrame(ws, buildWsErrorFrame(400, {
236
243
  type: "invalid_request_error",
@@ -294,6 +301,7 @@ export function createWebsocketHandler(
294
301
  }
295
302
 
296
303
  // Only a genuinely admitted turn may receive steering or continuations.
304
+ const finishActivity = ws.data.responsesActivity?.beginTurn(frame.input);
297
305
  ws.data.nativeControl = nativeControl;
298
306
  const payload: Record<string, unknown> = { ...frame };
299
307
  delete payload.type;
@@ -346,11 +354,16 @@ export function createWebsocketHandler(
346
354
  terminalRecorder = recorder;
347
355
  },
348
356
  });
357
+ const observeLog = nativeControl?.relayActive
358
+ ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
359
+ : (payload: string) => inspectResponseLogSsePayload(logCtx, payload);
360
+ const providerExecuted = responseToolOwnership(response);
349
361
  await sendResponseToWebSocket(ws, response, isCurrent, {
350
362
  untilEof: nativeControl?.relayActive === true,
351
- onSsePayload: nativeControl?.relayActive
352
- ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start))
353
- : payload => inspectResponseLogSsePayload(logCtx, payload),
363
+ onSsePayload: payload => {
364
+ ws.data.responsesActivity?.observePayload(payload, providerExecuted);
365
+ observeLog(payload);
366
+ },
354
367
  onTerminal: status => {
355
368
  terminalRecorder?.(status, logCtx.terminalHttpStatus);
356
369
  finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
@@ -392,6 +405,7 @@ export function createWebsocketHandler(
392
405
  /* socket already gone or send dropped */
393
406
  }
394
407
  } finally {
408
+ finishActivity?.();
395
409
  turnAdmissionLease.release();
396
410
  if (ws.data.nativeControl === nativeControl) ws.data.nativeControl = undefined;
397
411
  if (!logged && turnAbort.signal.aborted) finalizeLog(499);