@bitkyc08/opencodex 2.20.0 → 2.21.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.
@@ -14,8 +14,9 @@ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/
14
14
  import { existsSync } from "node:fs";
15
15
  import { pathToFileURL } from "node:url";
16
16
  import { createAdapterEventQueue } from "../adapters/run-turn-queue";
17
- import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
17
+ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
18
18
  import { namespacedToolName, toolChoiceToolPredicate } from "../types";
19
+ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
19
20
  import type { AttemptRecoveryKind } from "../usage/log";
20
21
  import { bridgeToResponsesSSE } from "../bridge";
21
22
  import { clearableDeadline, idleDeadline } from "../lib/abort";
@@ -93,6 +94,11 @@ interface ImageCall {
93
94
  id: string;
94
95
  name: string;
95
96
  args: string;
97
+ /**
98
+ * Provider-opaque metadata from the originating part (issue #1735). Stored PER CALL: a
99
+ * signature belongs to one specific part, so parallel calls must not share one value.
100
+ */
101
+ providerMetadata?: OcxProviderOpaqueToolCallMetadata;
96
102
  }
97
103
 
98
104
  /**
@@ -109,13 +115,13 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
109
115
  const calls: ImageCall[] = [];
110
116
  const passthrough: AdapterEvent[] = [];
111
117
  let hasRealToolCall = false;
112
- let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null;
118
+ let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
113
119
  const flushPending = (): void => {
114
120
  if (!pending) return;
115
121
  if (toolNames.has(pending.name)) {
116
122
  // Unterminated image call still carries buffered args — fulfill so malformed JSON
117
123
  // becomes a normal tool_result error instead of silently vanishing.
118
- calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf });
124
+ calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata });
119
125
  } else {
120
126
  passthrough.push(...pending.events);
121
127
  hasRealToolCall = true;
@@ -125,14 +131,14 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set<string>):
125
131
  for (const e of events) {
126
132
  if (e.type === "tool_call_start") {
127
133
  flushPending();
128
- pending = { name: e.name, id: e.id, argsBuf: "", events: [e] };
134
+ pending = { name: e.name, id: e.id, argsBuf: "", events: [e], providerMetadata: e.providerMetadata };
129
135
  } else if (e.type === "tool_call_delta" && pending) {
130
136
  pending.argsBuf += e.arguments;
131
137
  pending.events.push(e);
132
138
  } else if (e.type === "tool_call_end" && pending) {
133
139
  pending.events.push(e);
134
140
  if (toolNames.has(pending.name)) {
135
- calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf });
141
+ calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata });
136
142
  } else {
137
143
  passthrough.push(...pending.events);
138
144
  hasRealToolCall = true;
@@ -871,6 +877,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
871
877
  id: call.id,
872
878
  name: call.name,
873
879
  arguments: args,
880
+ // Clone per call: parallel media calls each keep their own signature.
881
+ ...(cloneProviderOpaqueToolCallMetadata(call.providerMetadata)
882
+ ? { providerMetadata: cloneProviderOpaqueToolCallMetadata(call.providerMetadata) }
883
+ : {}),
874
884
  })),
875
885
  ],
876
886
  timestamp: now,
@@ -1977,7 +1977,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1977
1977
  // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
1978
1978
  // devlog/_plan/260710_provider_hardening/002_research_cn.md.
1979
1979
  // 260814: glm-5.3 / glm-5.3[1m] added per docs.z.ai/devpack/latest-model, which lists them as
1980
- // Coding Plan ids on this same endpoint. Capabilities mirror 5.2 until Z.AI publishes tables.
1980
+ // Coding Plan ids on this same endpoint.
1981
+ // 260815: docs.z.ai/guides/llm/glm-5.3 now publishes the capability table (thinking, streaming,
1982
+ // function calling, caching, structured output) and a 128K output budget, recorded here as the
1983
+ // exact 131_072 every other source in this repo uses for that model. Coding Plan pricing stays
1984
+ // unpublished, so no cost entry is asserted.
1981
1985
  {
1982
1986
  id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key",
1983
1987
  dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.3",
@@ -1988,6 +1992,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1988
1992
  modelSuffixBracketStrip: true,
1989
1993
  noVisionModels: ZAI_GLM_5X_MODELS,
1990
1994
  modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS,
1995
+ modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])),
1996
+ modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])),
1991
1997
  modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])),
1992
1998
  preserveReasoningContentModels: ZAI_GLM_5X_MODELS,
1993
1999
  },
@@ -70,6 +70,9 @@ function rewriteForUpstream(
70
70
  || isPlainObject(value.format)
71
71
  || isPlainObject(value.parameters);
72
72
  if (!isDefinition) return { ...rest, type: "function" };
73
+ const inputDescription = value.name === "exec"
74
+ ? "JavaScript source for unified exec. Use await tools.exec_command(...) for shell commands and text(...) to return textual output; do not provide a bare shell command."
75
+ : "Raw input for this client-executed custom tool.";
73
76
  return {
74
77
  ...rest,
75
78
  type: "function",
@@ -78,7 +81,7 @@ function rewriteForUpstream(
78
81
  properties: {
79
82
  input: {
80
83
  type: "string",
81
- description: "Raw input for this client-executed custom tool.",
84
+ description: inputDescription,
82
85
  },
83
86
  },
84
87
  required: ["input"],
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../types";
13
13
  import { namespacedToolName } from "../types";
14
14
  import { responsesRequestSchema } from "./schema";
15
+ import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
15
16
  import { compactionItemToText } from "./compaction";
16
17
  import { previousResponseReplayPrefixLength } from "./state";
17
18
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
@@ -498,7 +499,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
498
499
  }
499
500
 
500
501
  if (effectiveType === "function_call") {
501
- const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string };
502
+ const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string; extra_content?: unknown };
502
503
  // Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of
503
504
  // throwing — a single poisoned history item would otherwise 400 every subsequent turn.
504
505
  let args: Record<string, unknown> = {};
@@ -519,6 +520,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
519
520
  type: "toolCall", id: call.call_id, name: call.name, arguments: args,
520
521
  ...(call.namespace ? { namespace: call.namespace } : {}),
521
522
  };
523
+ // Provider-opaque metadata (e.g. a Gemini thought signature) travels with the call so a
524
+ // history-replayed or previous_response_id turn rebuilds the same signed part instead of
525
+ // depending on the same-process replay cache (issue #1735).
526
+ const providerMetadata = providerMetadataFromResponsesFunctionCall(call);
527
+ if (providerMetadata) toolCall.providerMetadata = providerMetadata;
522
528
  assistantHolderWithReasoning().content.push(toolCall);
523
529
  continue;
524
530
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Provider-opaque tool-call metadata across the Responses boundary (issue #1735).
3
+ *
4
+ * Gemini issues a `thoughtSignature` on the exact part that carries a function call, and the
5
+ * next request is only valid if that signature comes back on the part rebuilt from that same
6
+ * call. Every synthetic loop in this proxy (web search, images, continuation replay) tears a
7
+ * tool call down into id/name/arguments and builds a fresh one, which silently dropped the
8
+ * signature and left only the same-process replay cache to paper over it. History replay and
9
+ * `previous_response_id` had no cache to fall back on.
10
+ *
11
+ * This module is the single seam where that metadata crosses into and out of the Responses
12
+ * wire, so a loop that rebuilds a call only has to carry one field instead of knowing about
13
+ * any provider. Values are treated as opaque: never parsed, merged, re-encoded, or synthesized.
14
+ */
15
+ import type { OcxProviderOpaqueToolCallMetadata } from "../types";
16
+
17
+ /** Wire shape: `extra_content.google.thought_signature` on a Responses function_call item. */
18
+ interface ResponsesExtraContent {
19
+ google?: { thought_signature?: unknown };
20
+ }
21
+
22
+ function isObj(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ /**
27
+ * Same ceiling the Antigravity replay cache already enforces on a stored signature. An opaque
28
+ * token this large is not a real signature, and accepting it would let a caller push unbounded
29
+ * state through history replay.
30
+ */
31
+ const MAX_SIGNATURE_BYTES = 64 * 1024;
32
+
33
+ function isCarryableSignature(value: unknown): value is string {
34
+ if (typeof value !== "string" || value.length === 0) return false;
35
+ // Cheap length pre-check: UTF-8 is at most 3 bytes per UTF-16 code unit for the BMP, so this
36
+ // skips the encode for the overwhelmingly common short case.
37
+ if (value.length <= MAX_SIGNATURE_BYTES / 3) return true;
38
+ return Buffer.byteLength(value, "utf8") <= MAX_SIGNATURE_BYTES;
39
+ }
40
+
41
+ /** Read provider metadata off an inbound Responses function_call item. */
42
+ export function providerMetadataFromResponsesFunctionCall(
43
+ item: { extra_content?: unknown } | undefined,
44
+ ): OcxProviderOpaqueToolCallMetadata | undefined {
45
+ const extra = item?.extra_content;
46
+ if (!isObj(extra)) return undefined;
47
+ const google = (extra as ResponsesExtraContent).google;
48
+ if (!isObj(google)) return undefined;
49
+ const signature = google.thought_signature;
50
+ if (!isCarryableSignature(signature)) return undefined;
51
+ return { google: { thoughtSignature: signature } };
52
+ }
53
+
54
+ /** Serialize provider metadata onto an outbound Responses function_call item. */
55
+ export function responsesExtraContentFromProviderMetadata(
56
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
57
+ ): { extra_content: { google: { thought_signature: string } } } | undefined {
58
+ const signature = metadata?.google?.thoughtSignature;
59
+ if (!isCarryableSignature(signature)) return undefined;
60
+ return { extra_content: { google: { thought_signature: signature } } };
61
+ }
62
+
63
+ /**
64
+ * Copy metadata for a rebuilt tool call. A signature belongs to one specific part, so a loop
65
+ * that fans one model response into several calls must copy per call and never share or merge.
66
+ */
67
+ export function cloneProviderOpaqueToolCallMetadata(
68
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
69
+ ): OcxProviderOpaqueToolCallMetadata | undefined {
70
+ const signature = metadata?.google?.thoughtSignature;
71
+ if (!isCarryableSignature(signature)) return undefined;
72
+ return { google: { thoughtSignature: signature } };
73
+ }
@@ -64,6 +64,12 @@ const functionCallItemSchema = z.object({
64
64
  name: z.string().min(1),
65
65
  namespace: z.string().optional(),
66
66
  arguments: z.string().optional(),
67
+ // Provider-opaque metadata that must survive the round trip verbatim (issue #1735). The shape
68
+ // is bounded on purpose: only the one nested key we round-trip is modeled, so an unexpected
69
+ // payload cannot ride through as arbitrary passthrough state.
70
+ extra_content: z.object({
71
+ google: z.object({ thought_signature: z.string().optional() }).optional(),
72
+ }).optional(),
67
73
  });
68
74
  const functionCallOutputItemSchema = z.object({
69
75
  type: z.literal("function_call_output"),
@@ -140,17 +140,53 @@ export function browserSecurityHeaders(): Record<string, string> {
140
140
  };
141
141
  }
142
142
 
143
+ /**
144
+ * Baseline data-plane request headers. ChatGPT-Account-Id is required for browser/Electron
145
+ * ChatGPT & Codex App voice preflights (direct forward auth matches the bearer to this account
146
+ * id). The OpenAI-Alpha .. X-OAI-Attestation block covers GPT-Live voice protocol headers
147
+ * relayed by the /v1/live call-create path.
148
+ */
149
+ const STATIC_ALLOWED_REQUEST_HEADERS =
150
+ "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation";
151
+
152
+ /**
153
+ * A fixed allow-list cannot enumerate vendor telemetry headers: the OpenAI and Anthropic
154
+ * browser SDKs send `X-Stainless-*` describing runtime and retry state, and the browser blocks
155
+ * the real request when the preflight omits even one of them (#1773).
156
+ *
157
+ * Echo what an already-allowed origin asked for, and fall back to the static list otherwise.
158
+ * The echo is deliberately gated on the origin check that ran first: this widens which headers
159
+ * an admitted caller may send, never which origins are admitted, and it grants nothing to an
160
+ * origin that would have been rejected anyway. Authentication is unchanged — the preflight
161
+ * itself carries no credential and produces no auth or account-pool side effect.
162
+ */
163
+ function allowedRequestHeaders(req?: Request): string {
164
+ const requested = req?.headers.get("Access-Control-Request-Headers")?.trim();
165
+ if (!requested) return STATIC_ALLOWED_REQUEST_HEADERS;
166
+ const seen = new Set(STATIC_ALLOWED_REQUEST_HEADERS.split(",").map(h => h.trim().toLowerCase()));
167
+ const extra: string[] = [];
168
+ for (const raw of requested.split(",")) {
169
+ const name = raw.trim();
170
+ // Header names are case-insensitive on the wire, so normalize before de-duplicating;
171
+ // echo the caller's spelling for the ones we add.
172
+ if (!name || seen.has(name.toLowerCase())) continue;
173
+ seen.add(name.toLowerCase());
174
+ extra.push(name);
175
+ }
176
+ return extra.length === 0 ? STATIC_ALLOWED_REQUEST_HEADERS : `${STATIC_ALLOWED_REQUEST_HEADERS}, ${extra.join(", ")}`;
177
+ }
178
+
143
179
  export function corsHeaders(req?: Request, config?: RequestPolicyView): Record<string, string> {
144
180
  const origin = req?.headers.get("Origin");
145
- const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
181
+ const originAllowed = Boolean(origin && req && config && isAllowedRequestOrigin(req, config));
182
+ const allowOrigin = originAllowed && origin ? origin : _corsOrigin;
146
183
  return {
147
184
  "Access-Control-Allow-Origin": allowOrigin,
148
185
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
149
- // ChatGPT-Account-Id is required for browser/Electron ChatGPT & Codex App voice preflights
150
- // (direct forward auth matches the bearer to this account id). The OpenAI-Alpha .. X-OAI-Attestation
151
- // block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
152
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
153
- "Vary": "Origin",
186
+ "Access-Control-Allow-Headers": allowedRequestHeaders(originAllowed ? req : undefined),
187
+ // A response that varies by the request's headers must say so, or a shared cache can
188
+ // replay one client's allow-list to a client that asked for different headers.
189
+ "Vary": "Origin, Access-Control-Request-Headers",
154
190
  ...browserSecurityHeaders(),
155
191
  };
156
192
  }
@@ -335,7 +335,7 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
335
335
  // without a token — keep it in sync with this proxy's /v1/models. Best-effort.
336
336
  try {
337
337
  const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
338
- await refreshGatewayModelCacheFromProxy(port);
338
+ await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config });
339
339
  } catch { /* best-effort */ }
340
340
 
341
341
  // Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
package/src/types.ts CHANGED
@@ -180,10 +180,27 @@ export interface OcxToolCall {
180
180
  arguments: Record<string, unknown>;
181
181
  customWireName?: string;
182
182
  thoughtSignature?: string;
183
+ /**
184
+ * Provider-issued opaque metadata that must survive the whole round trip unchanged
185
+ * (issue #1735). A signed Gemini part is only valid when its signature comes back on the
186
+ * SAME part it was issued for, so this travels with the individual tool call rather than
187
+ * being matched by name/arguments after the fact.
188
+ */
189
+ providerMetadata?: OcxProviderOpaqueToolCallMetadata;
183
190
  /** MCP namespace (e.g. "mcp__context7") when this call targets a namespaced tool. */
184
191
  namespace?: string;
185
192
  }
186
193
 
194
+ /**
195
+ * Opaque, provider-scoped tool-call metadata. Values are never parsed, merged, re-encoded, or
196
+ * synthesized — they are carried verbatim or not at all.
197
+ */
198
+ export interface OcxProviderOpaqueToolCallMetadata {
199
+ google?: {
200
+ thoughtSignature?: string;
201
+ };
202
+ }
203
+
187
204
  export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall;
188
205
 
189
206
  export interface OcxTool {
@@ -328,7 +345,7 @@ export type AdapterEvent =
328
345
  // Never rendered — it only rides the reasoning item's envelope so the next request can replay it.
329
346
  | { type: "kiro_redacted_reasoning"; data: string }
330
347
  | { type: "reasoning_raw_delta"; text: string }
331
- | { type: "tool_call_start"; id: string; name: string }
348
+ | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata }
332
349
  | { type: "tool_call_delta"; arguments: string }
333
350
  | { type: "tool_call_end" }
334
351
  /** Internal boundary between a guarded first pass and its one-shot continuation. */
@@ -1,6 +1,7 @@
1
1
  import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
2
- import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
2
+ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
3
3
  import { namespacedToolName, toolChoiceToolPredicate } from "../types";
4
+ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
4
5
  import type { AttemptRecoveryKind } from "../usage/log";
5
6
  import { bridgeToResponsesSSE } from "../bridge";
6
7
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
@@ -32,6 +33,11 @@ interface WebSearchCall {
32
33
  // empty array means the model called the tool with neither `query` nor `queries` (handled as an
33
34
  // empty-query placeholder).
34
35
  queries: string[];
36
+ /**
37
+ * Provider-opaque metadata from the originating part (issue #1735). Stored PER CALL so a
38
+ * signature can never migrate to a different call when the model batches several.
39
+ */
40
+ providerMetadata?: OcxProviderOpaqueToolCallMetadata;
35
41
  }
36
42
 
37
43
  /**
@@ -69,7 +75,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): {
69
75
  const passthrough: AdapterEvent[] = [];
70
76
  let hasRealToolCall = false;
71
77
  let hasMalformedToolCall = false;
72
- let pending: { name: string; id: string; argsBuf: string; closed: boolean; events: AdapterEvent[] } | null = null;
78
+ let pending: { name: string; id: string; argsBuf: string; closed: boolean; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
73
79
  const isBlank = (value: string): boolean => value.trim().length === 0;
74
80
  const flushPending = (): void => {
75
81
  // A pending call that never saw tool_call_end is structurally malformed.
@@ -84,7 +90,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): {
84
90
  if (e.type === "tool_call_start") {
85
91
  flushPending();
86
92
  if (isBlank(e.id) || isBlank(e.name)) hasMalformedToolCall = true;
87
- pending = { name: e.name, id: e.id, argsBuf: "", closed: false, events: [e] };
93
+ pending = { name: e.name, id: e.id, argsBuf: "", closed: false, events: [e], providerMetadata: e.providerMetadata };
88
94
  } else if (e.type === "tool_call_delta") {
89
95
  // Orphan delta (no open call) is malformed.
90
96
  if (!pending) hasMalformedToolCall = true;
@@ -100,7 +106,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): {
100
106
  pending.events.push(e);
101
107
  pending.closed = true;
102
108
  if (pending.name === WEB_SEARCH_TOOL_NAME) {
103
- calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf) });
109
+ calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf), providerMetadata: pending.providerMetadata });
104
110
  } else {
105
111
  passthrough.push(...pending.events);
106
112
  if (!isBlank(pending.id) && !isBlank(pending.name)) hasRealToolCall = true;
@@ -678,7 +684,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
678
684
  // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
679
685
  // unsigned raw reasoning has to ride along for providers that require it back (#688).
680
686
  ...precedingThinking,
681
- { type: "toolCall" as const, id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs },
687
+ {
688
+ type: "toolCall" as const,
689
+ id: call.id,
690
+ name: WEB_SEARCH_TOOL_NAME,
691
+ arguments: callArgs,
692
+ // Re-attach the signature to the rebuilt call so a sidecar turn keeps Gemini
693
+ // reasoning continuity instead of relying on the same-process replay cache.
694
+ ...(cloneProviderOpaqueToolCallMetadata(call.providerMetadata)
695
+ ? { providerMetadata: cloneProviderOpaqueToolCallMetadata(call.providerMetadata) }
696
+ : {}),
697
+ },
682
698
  ],
683
699
  timestamp: now,
684
700
  });