@oh-my-pi/pi-ai 18.12.2 → 18.12.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.3] - 2026-10-19
6
+
7
+ ### Added
8
+
9
+ - Added `validateToolArguments(tool, call, { keepUnknownRootKeys: true })` to keep unknown root keys that a closed schema would otherwise drop during argument repair ([#16317](https://github.com/can1357/oh-my-pi/issues/16317))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Claude streams that a gateway cut off before `message_delta` failing the turn instead of being retried ([#16341](https://github.com/can1357/oh-my-pi/pull/16341) by [@lz-lunzi](https://github.com/lz-lunzi))
14
+ - Fixed Gemini pairing parallel same-name tool results with the wrong call when history stores them out of call order (sessions from before 18.4.2, SDK-built contexts) ([#16259](https://github.com/can1357/oh-my-pi/pull/16259) by [@will-bogusz](https://github.com/will-bogusz))
15
+ - Fixed Factory Droid Gemini dropping calls to tools with names over 64 characters (most MCP tools) and their results from later requests, so the model called the tool again; such names now shorten to 64 characters ([#16283](https://github.com/can1357/oh-my-pi/pull/16283) by [@will-bogusz](https://github.com/will-bogusz))
16
+ - Fixed Factory Droid Gemini requests failing with 400 "For schema with items, schema type should be ARRAY" when a tool parameter accepts a string or a list, as the default `task` tool's `model` field does ([#16249](https://github.com/can1357/oh-my-pi/pull/16249) by [@will-bogusz](https://github.com/will-bogusz))
17
+ - Fixed `PI_REQ_DEBUG=1` request dumps writing your API key, bearer token and response cookies to disk in plain text: credential headers now read `[redacted]`, as in rejected-request dumps ([#16293](https://github.com/can1357/oh-my-pi/pull/16293) by [@will-bogusz](https://github.com/will-bogusz))
18
+ - Fixed finalized signed reasoning disappearing from displayed thinking when no deltas streamed ([#16227](https://github.com/can1357/oh-my-pi/issues/16227))
19
+
5
20
  ## [18.12.2] - 2026-10-18
6
21
 
7
22
  ### Added
@@ -2332,26 +2347,4 @@
2332
2347
  - Fixed OpenAI Responses native history replay leaking image generation provider-only fields into the next request, which made OpenAI-compatible proxies reject `pi` tool-calling sessions with `Unknown parameter: input[1].action`. ([#3201](https://github.com/can1357/oh-my-pi/issues/3201))
2333
2348
  - Fixed a stream thought-leakage issue for `gemini-3.5-flash` where the model's internal reasoning JSON could leak into the visible text stream. The stream parser now uses a brace-balanced counting algorithm to accurately slice and discard the leading thought JSON block, with a robust fallback for unescaped double quotes, dynamic tool-name derivation, and preservation of subsequent text deltas without triggering empty-response retries.
2334
2349
 
2335
- ## [16.1.10] - 2026-06-21
2336
-
2337
- ### Changed
2338
-
2339
- - Improved JSON robustness by replacing external dependency with a custom, high-performance parser
2340
- - Strengthened streaming JSON parsing to prevent non-finite numbers from surfacing as `undefined/NaN`
2341
- - Configured JSON parser to reject JS-specific `NaN` and `Infinity` values for tool arguments
2342
- - Replaced the JSON repair/parse helpers (`parseJsonWithRepair`, `parseStreamingJson`) with a single from-scratch tolerant parser (`RelaxedJson`) that accepts single-quoted strings, unquoted object keys, trailing/stray commas, `//` and `/* */` comments, Python `True`/`False`/`None`, raw control characters, invalid escapes, and unescaped apostrophes (`'it's'`). Final parsing still throws on truncated/garbage input (so a malformed tool call is skipped rather than executed with half-formed args) and rejects JS-only `NaN`/`Infinity`; streaming parsing stays non-throwing and rolls back incomplete trailing tokens instead of surfacing `undefined`/`NaN`. The Cursor provider's ad-hoc regex + JSON5 tool-argument parser now routes through the shared parser.
2343
-
2344
- ### Fixed
2345
-
2346
- - Fixed tool call ID normalization for Anthropic-compatible models
2347
- - Fixed Anthropic Messages replay sanitizing malformed tool-call IDs, including aborted native tool calls with empty IDs, so retries no longer send invalid `tool_use.id` / `tool_result.tool_use_id` pairs.
2348
- - Fixed the Codex Responses WebSocket transport attributing a prior turn's output to the current one on a reused connection: a trailing/duplicate frame from a cleanly-completed previous response that slipped past the queue drain could be consumed as this request's terminal (ending the turn with empty output) or as a stale tool call. Frames are now keyed by `response.id` — a frame carrying the previous response's id is dropped, and one carrying a third id (or a regressed `sequence_number`) fails closed so the turn retries instead of mixing two responses' streams. Idless frames (deltas, the rate-limit/metadata preamble, `response.created`-less streams) still pass through, matching upstream codex-rs.
2349
- - Fixed `transformMessages` pulling an earlier, orphaned tool result onto a later tool call that reused the same id (left behind when compaction folded the originating `tool_use` into a summary). The pending-call flush now pairs each call with a result positioned _after_ its assistant turn, so a reused id surfaces its own output rather than a prior turn's.
2350
- - Fixed DashScope 429 rate-limit messages that mention authorization being classified as credential failures, preventing valid API keys from being invalidated after throttling. ([#3172](https://github.com/can1357/oh-my-pi/issues/3172))
2351
- - Fixed OpenCode Go `401 Insufficient balance` quota errors being treated as unknown failures instead of usage-limit errors, restoring credential rotation and fallback chains. ([#3169](https://github.com/can1357/oh-my-pi/issues/3169))
2352
-
2353
- ### Removed
2354
-
2355
- - Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
2356
-
2357
- Older entries are archived in [packages/ai/CHANGELOG.md@bb602d67c9b6](https://github.com/can1357/oh-my-pi/blob/bb602d67c9b685535bbf8ef1a1cc9de3c944ede4/packages/ai/CHANGELOG.md).
2350
+ Older entries are archived in [packages/ai/CHANGELOG.md@5eb78e90fa4e](https://github.com/can1357/oh-my-pi/blob/5eb78e90fa4ec4318b9520302f1977fa58c48745/packages/ai/CHANGELOG.md).
@@ -76,6 +76,23 @@ export declare function isThinkingPart(part: Pick<Part, "thought" | "thoughtSign
76
76
  */
77
77
  export declare function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined;
78
78
  export declare const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
79
+ /**
80
+ * Gemini pairs id-less function responses with calls by position. The agent loop stores
81
+ * parallel results in call order, but histories can still arrive out of call order (sessions
82
+ * saved by older versions, contexts built through the SDK), so the converters guard the wire:
83
+ * record each call as it is emitted and each response with its call, then {@link apply} sorts
84
+ * every turn's matched responses back into call order within the slots they already occupy.
85
+ */
86
+ export declare class FunctionCallOrder {
87
+ #private;
88
+ call(id: string): void;
89
+ /** The call ordinal of `id`, if that call was recorded. */
90
+ ordinal(id: string): number | undefined;
91
+ response(part: Part, callId: string): void;
92
+ apply(contents: readonly {
93
+ parts?: Part[];
94
+ }[]): void;
95
+ }
79
96
  /**
80
97
  * Convert internal messages to Gemini Content[] format.
81
98
  */
@@ -85,3 +85,5 @@ export declare function rewriteCopilotError(errorMessage: string, error: unknown
85
85
  * credential rotation (sibling keys fail identically).
86
86
  */
87
87
  export declare function rewriteClinePassError(errorMessage: string, provider: string): string;
88
+ /** Copy of `headers` with every credential value replaced by `[redacted]`; shared by every persisted dump. */
89
+ export declare function redactHeaders<T>(headers: Record<string, T> | undefined): Record<string, T | string> | undefined;
@@ -19,10 +19,20 @@ import type { Tool, ToolCall } from "../types.js";
19
19
  * @throws Error if tool is not found or validation fails
20
20
  */
21
21
  export declare function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["arguments"];
22
+ /** Options for {@link validateToolArguments}. */
23
+ export interface ValidateToolArgumentsOptions {
24
+ /**
25
+ * Keep unknown root keys on the validated value instead of letting the
26
+ * unrecognized-key repair delete them from a closed schema. Set for tools
27
+ * that own their refusal (`lenientArgValidation`), so execute() can reject
28
+ * fields the model sent rather than run without them.
29
+ */
30
+ keepUnknownRootKeys?: boolean;
31
+ }
22
32
  /**
23
33
  * Validates tool call arguments against an ArkType or plain JSON Schema schema.
24
34
  * Applies conservative LLM-quirk normalization before declaring failure.
25
35
  *
26
36
  * @throws Error with a formatted message when validation cannot be reconciled.
27
37
  */
28
- export declare function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"];
38
+ export declare function validateToolArguments(tool: Tool, toolCall: ToolCall, options?: ValidateToolArgumentsOptions): ToolCall["arguments"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.12.2",
3
+ "version": "18.12.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.12.2",
159
- "@oh-my-pi/pi-catalog": "18.12.2",
160
- "@oh-my-pi/pi-natives": "18.12.2",
161
- "@oh-my-pi/pi-utils": "18.12.2",
162
- "@oh-my-pi/pi-wire": "18.12.2"
158
+ "@oh-my-pi/omptype": "18.12.3",
159
+ "@oh-my-pi/pi-catalog": "18.12.3",
160
+ "@oh-my-pi/pi-natives": "18.12.3",
161
+ "@oh-my-pi/pi-utils": "18.12.3",
162
+ "@oh-my-pi/pi-wire": "18.12.3"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -167,7 +167,7 @@ function matchesPayloadRejectionText(text: string): boolean {
167
167
 
168
168
  const TIMEOUT_PATTERN = /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i;
169
169
  const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
170
- const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|stop)/i;
170
+ const TRANSIENT_ENVELOPE_TRUNCATION_PATTERN = /before message_(?:start|delta|stop)/i;
171
171
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
172
172
  /** Python h2/httpx diagnostics forwarded through provider or proxy error events. */
173
173
  export const PYTHON_HTTP2_STREAM_RESET_PATTERN = /<StreamReset stream_id:\d+, error_code:(?:2|7), remote_reset:True>/;
@@ -1103,7 +1103,7 @@ export function isTransientStreamParseError(error: unknown): boolean {
1103
1103
  * non-terminal status treat it as transient (#11805).
1104
1104
  */
1105
1105
  const STREAM_DROP_PATTERN =
1106
- /stream disconnected before completion|(?:stream|websocket) closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
1106
+ /stream disconnected before completion|(?:stream|websocket) closed before response\.completed|stream was interrupted|stream ended before (?:terminal (?:chunk|completion event)|message_(?:start|delta|stop))|socket disconnected before secure tls connection/i;
1107
1107
 
1108
1108
  /**
1109
1109
  * Transport stream-drop diagnostic (see {@link STREAM_DROP_PATTERN}). Unlike
@@ -15,6 +15,7 @@ import { notifyProviderResponse } from "../../utils/provider-response";
15
15
  import { dereferenceJsonSchema, normalizeSchemaForFactoryDroid, toolWireSchema } from "../../utils/schema";
16
16
  import {
17
17
  extractGoogleErrorMessage,
18
+ FunctionCallOrder,
18
19
  googleStreamChunkError,
19
20
  GoogleTextBlocks,
20
21
  mapGoogleUsage,
@@ -41,14 +42,15 @@ function geminiThinkingLevel(effort: string | undefined, supportsMedium: boolean
41
42
  }
42
43
 
43
44
  /**
44
- * The CLI sanitizes tool names to `[a-zA-Z0-9_-]`; names longer than 64 chars
45
- * are truncated and suffixed with `_` + an 8-char sha256. Applied to
46
- * declarations and replayed functionCall/functionResponse names.
45
+ * The CLI sanitizes tool names to `[a-zA-Z0-9_-]`; a name longer than 64
46
+ * chars keeps its first 55 and ends `_` + an 8-char sha256, 64 in all, which
47
+ * stays inside the route's tool-name bound. Applied to declarations and
48
+ * replayed functionCall/functionResponse names.
47
49
  */
48
50
  function sanitizeFactoryDroidToolName(name: string): string {
49
51
  const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, "_");
50
52
  if (sanitized.length <= 64) return sanitized;
51
- return `${sanitized.slice(0, 64)}_${Bun.SHA256.hash(sanitized, "hex").slice(0, 8)}`;
53
+ return `${sanitized.slice(0, 55)}_${Bun.SHA256.hash(sanitized, "hex").slice(0, 8)}`;
52
54
  }
53
55
 
54
56
  /**
@@ -167,7 +169,9 @@ function adoptFactoryReplay(message: Message, model: Model<"factory-droid-agent"
167
169
  * text carries the signature Gemini put on it.
168
170
  * - Tool calls replay as `functionCall` parts carrying their
169
171
  * `thoughtSignature`; consecutive tool results group into ONE user content,
170
- * because the proxy 400s when a call turn's response part count mismatches.
172
+ * because the proxy 400s when a call turn's response part count mismatches,
173
+ * and sort back into call order, because Gemini pairs id-less responses by
174
+ * position.
171
175
  * - Tool names are sanitized to the CLI's `[a-zA-Z0-9_-]` shape on
172
176
  * declarations and on replayed call/response names.
173
177
  * - After the latest user turn containing a non-response part, function calls
@@ -182,6 +186,7 @@ function toGeminiContents(
182
186
  systemInstruction?: { parts: Part[] };
183
187
  } {
184
188
  const contents: Array<{ role: "user" | "model"; parts: Part[] }> = [];
189
+ const callOrder = new FunctionCallOrder();
185
190
  const messages = transformMessages(
186
191
  context.messages.map(message => adoptFactoryReplay(message, model)),
187
192
  model,
@@ -209,6 +214,7 @@ function toGeminiContents(
209
214
  } else if (block.type === "thinking" && block.thinkingSignature) {
210
215
  parts.push({ text: block.thinking, thoughtSignature: block.thinkingSignature });
211
216
  } else if (block.type === "toolCall") {
217
+ callOrder.call(block.id);
212
218
  parts.push({
213
219
  functionCall: { name: block.name, args: block.arguments },
214
220
  ...(block.thoughtSignature ? { thoughtSignature: block.thoughtSignature } : {}),
@@ -239,6 +245,7 @@ function toGeminiContents(
239
245
  ...(binaryParts.length > 0 ? { parts: binaryParts } : {}),
240
246
  },
241
247
  };
248
+ callOrder.response(part, message.toolCallId);
242
249
  const last = contents[contents.length - 1];
243
250
  if (last && last.role === "user" && last.parts.every(p => p.functionResponse)) {
244
251
  last.parts.push(part);
@@ -246,6 +253,7 @@ function toGeminiContents(
246
253
  contents.push({ role: "user", parts: [part] });
247
254
  }
248
255
  }
256
+ callOrder.apply(contents);
249
257
  // Sentinel injection: scope to model turns at/after the latest user turn
250
258
  // with a non-response part (the validator only checks the current tail).
251
259
  let lastUserText = 0;
@@ -155,24 +155,73 @@ function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: str
155
155
  return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
156
156
  }
157
157
 
158
+ /** Sort the items whose key is known into key order within the slots they occupy; others stay put. */
159
+ function sortKnownInPlace<T>(items: T[], keyOf: (item: T) => number | undefined): void {
160
+ const known: Array<{ position: number; key: number; item: T }> = [];
161
+ items.forEach((item, position) => {
162
+ const key = keyOf(item);
163
+ if (key !== undefined) known.push({ position, key, item });
164
+ });
165
+ if (known.length < 2) return;
166
+ const inOrder = [...known].sort((a, b) => a.key - b.key);
167
+ known.forEach(({ position }, index) => {
168
+ items[position] = inOrder[index].item;
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Gemini pairs id-less function responses with calls by position. The agent loop stores
174
+ * parallel results in call order, but histories can still arrive out of call order (sessions
175
+ * saved by older versions, contexts built through the SDK), so the converters guard the wire:
176
+ * record each call as it is emitted and each response with its call, then {@link apply} sorts
177
+ * every turn's matched responses back into call order within the slots they already occupy.
178
+ */
179
+ export class FunctionCallOrder {
180
+ #ordinals = new Map<string, number>();
181
+ #responses = new Map<Part, number>();
182
+ #next = 0;
183
+
184
+ call(id: string): void {
185
+ this.#ordinals.set(id, this.#next++);
186
+ }
187
+
188
+ /** The call ordinal of `id`, if that call was recorded. */
189
+ ordinal(id: string): number | undefined {
190
+ return this.#ordinals.get(id);
191
+ }
192
+
193
+ response(part: Part, callId: string): void {
194
+ const ordinal = this.#ordinals.get(callId);
195
+ if (ordinal !== undefined) this.#responses.set(part, ordinal);
196
+ }
197
+
198
+ apply(contents: readonly { parts?: Part[] }[]): void {
199
+ for (const content of contents) {
200
+ if (content.parts) sortKnownInPlace(content.parts, part => this.#responses.get(part));
201
+ }
202
+ }
203
+ }
204
+
158
205
  /**
159
206
  * Convert internal messages to Gemini Content[] format.
160
207
  */
161
208
  export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
162
209
  const contents: Content[] = [];
163
210
  const emittedToolCallNames = new Map<string, string>();
211
+ const callOrder = new FunctionCallOrder();
164
212
 
165
213
  const transformedMessages = transformMessages(context.messages, model, id => normalizeToolCallId(id));
166
214
 
167
215
  // Gemini < 3 image tool results go in a separate user turn, but parallel tool results must
168
216
  // stay a single contiguous functionResponse turn ("number of function response parts is not
169
217
  // equal to number of function call parts"). Buffer image turns and flush them only after the
170
- // merged functionResponse turn is complete.
171
- let pendingToolImageParts: Part[] = [];
218
+ // merged functionResponse turn is complete, each result's images in its call's order.
219
+ let pendingToolImages: Array<{ ordinal: number | undefined; parts: Part[] }> = [];
172
220
  const flushPendingToolImages = () => {
173
- if (pendingToolImageParts.length === 0) return;
174
- contents.push({ role: "user", parts: pendingToolImageParts });
175
- pendingToolImageParts = [];
221
+ if (pendingToolImages.length === 0) return;
222
+ sortKnownInPlace(pendingToolImages, group => group.ordinal);
223
+ contents.push({ role: "user", parts: pendingToolImages.flatMap(group => group.parts) });
224
+ pendingToolImages = [];
176
225
  };
177
226
 
178
227
  for (const msg of transformedMessages) {
@@ -246,6 +295,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
246
295
  }
247
296
  } else if (block.type === "toolCall") {
248
297
  emittedToolCallNames.set(block.id, block.name);
298
+ callOrder.call(block.id);
249
299
  // Gemini 3 requires a thought signature on function calls it makes. The
250
300
  // public API requires the bypass sentinel on every unsigned call. Cloud
251
301
  // Code Assist requires it only when the first call itself is unsigned;
@@ -314,6 +364,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
314
364
  ...(includeId ? { id: msg.toolCallId } : {}),
315
365
  },
316
366
  };
367
+ callOrder.response(functionResponsePart, msg.toolCallId);
317
368
 
318
369
  // Cloud Code Assist API requires all function responses to be in a single user turn.
319
370
  // Check if the last content is already a user turn with function responses and merge.
@@ -329,11 +380,15 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
329
380
 
330
381
  // For Gemini < 3, buffer images for a separate user message after the functionResponse turn
331
382
  if (hasImages && !modelSupportsMultimodalFunctionResponse) {
332
- pendingToolImageParts.push({ text: "Tool result image:" }, ...imageParts);
383
+ pendingToolImages.push({
384
+ ordinal: callOrder.ordinal(msg.toolCallId),
385
+ parts: [{ text: "Tool result image:" }, ...imageParts],
386
+ });
333
387
  }
334
388
  }
335
389
  }
336
390
  flushPendingToolImages();
391
+ callOrder.apply(contents);
337
392
 
338
393
  return contents;
339
394
  }
@@ -268,12 +268,13 @@ function redactUrlQuery(url: string | undefined): string | undefined {
268
268
  }
269
269
  }
270
270
 
271
- function redactHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
271
+ /** Copy of `headers` with every credential value replaced by `[redacted]`; shared by every persisted dump. */
272
+ export function redactHeaders<T>(headers: Record<string, T> | undefined): Record<string, T | string> | undefined {
272
273
  if (!headers) {
273
274
  return undefined;
274
275
  }
275
276
 
276
- const redacted: Record<string, string> = {};
277
+ const redacted: Record<string, T | string> = {};
277
278
  for (const [key, value] of Object.entries(headers)) {
278
279
  if (SENSITIVE_HEADER_PATTERN.test(key)) {
279
280
  redacted[key] = "[redacted]";
@@ -256,9 +256,10 @@ class LeakedThinkingProjector {
256
256
  thinkingEnd(srcIndex: number, source: ThinkingContent | undefined): void {
257
257
  const index = this.#thinkingBlocks.get(srcIndex);
258
258
  if (index === undefined) {
259
- if (source?.thinkingSignature) this.#projectSignedThinking(srcIndex, "", source);
259
+ if (source?.thinkingSignature) this.#projectSignedThinking(srcIndex, source);
260
260
  return;
261
261
  }
262
+ this.#appendThinkingTail(index, source);
262
263
  adoptThinkingState(this.#partial.content[index] as ThinkingContent, source);
263
264
  if (!this.#pendingThinkingEnds.delete(index)) return;
264
265
  if (this.#thinking?.index === index) this.#thinking = undefined;
@@ -271,20 +272,31 @@ class LeakedThinkingProjector {
271
272
  * semantics as {@link toolStart}) so block order survives for replay —
272
273
  * the signature item must precede the function call it signs.
273
274
  */
274
- #projectSignedThinking(srcIndex: number, thinking: string, source: ThinkingContent): void {
275
+ #projectSignedThinking(srcIndex: number, source: ThinkingContent): void {
275
276
  this.#flushHealer();
276
277
  this.#closeText();
277
278
  this.#closeThinking();
278
- const block: ThinkingContent = { type: "thinking", thinking };
279
+ const block: ThinkingContent = { type: "thinking", thinking: "" };
279
280
  adoptThinkingState(block, source);
280
281
  this.#partial.content.push(block);
281
282
  const index = this.#partial.content.length - 1;
282
283
  this.#anchor(index, srcIndex);
283
284
  this.#thinkingBlocks.set(srcIndex, index);
284
285
  this.#out.push({ type: "thinking_start", contentIndex: index, partial: this.#partial });
286
+ this.#appendThinkingTail(index, source);
285
287
  this.#emitThinkingEnd(index);
286
288
  }
287
289
 
290
+ /** Emit only finalized native thinking that did not arrive as deltas. */
291
+ #appendThinkingTail(index: number, source: ThinkingContent | undefined): void {
292
+ const block = this.#partial.content[index] as ThinkingContent;
293
+ if (!source || source.thinking.length <= block.thinking.length || !source.thinking.startsWith(block.thinking))
294
+ return;
295
+ const delta = source.thinking.slice(block.thinking.length);
296
+ block.thinking += delta;
297
+ this.#out.push({ type: "thinking_delta", contentIndex: index, delta, partial: this.#partial });
298
+ }
299
+
288
300
  /** Forward a completed native image after releasing held text. */
289
301
  image(srcIndex: number, content: ImageContent): void {
290
302
  this.#flushHealer();
@@ -380,7 +392,7 @@ class LeakedThinkingProjector {
380
392
  const block = message.content[srcIndex];
381
393
  if (block?.type !== "thinking" || !block.thinkingSignature) continue;
382
394
  if (this.#thinkingBlocks.has(srcIndex)) continue;
383
- this.#projectSignedThinking(srcIndex, block.thinking, block);
395
+ this.#projectSignedThinking(srcIndex, block);
384
396
  }
385
397
  for (let srcIndex = 0; srcIndex < message.content.length; srcIndex++) {
386
398
  const block = message.content[srcIndex];
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import * as fs from "node:fs/promises";
3
+ import { redactHeaders } from "./http-inspector";
3
4
  import { decodeRequestBodyBytes } from "./request-body";
4
5
 
5
6
  const REQUEST_DEBUG_ENV = "PI_REQ_DEBUG";
@@ -62,7 +63,7 @@ export async function createRequestDebugSession(payload: RequestDebugPayload): P
62
63
  method: payload.method,
63
64
  url: payload.url,
64
65
  };
65
- const headers = headersToRecord(payload.headers);
66
+ const headers = redactHeaders(headersToRecord(payload.headers));
66
67
  if (headers) requestDump.headers = headers;
67
68
  if (payload.body !== undefined) requestDump.body = payload.body;
68
69
  if (payload.bodyText !== undefined) requestDump.bodyText = payload.bodyText;
@@ -303,7 +304,7 @@ function looksLikeJson(text: string): boolean {
303
304
 
304
305
  function formatResponseHeaderBlock(statusLine: string, headers?: RequestDebugHeaders): string {
305
306
  const lines = [statusLine];
306
- const record = headersToRecord(headers);
307
+ const record = redactHeaders(headersToRecord(headers));
307
308
  if (record) {
308
309
  for (const name in record) {
309
310
  const value = record[name];
@@ -93,11 +93,18 @@ The droid CLI's gemini wire (`factory-droid/google-generate`) uses an
93
93
  ALLOWLIST copier instead of the shared denylist normalizer — it preserves
94
94
  `type`/`title`/`description`/`required`/`format`/`minimum`/`maximum`/
95
95
  `minLength`/`maxLength`/`pattern`/`minItems`/`maxItems`/`default`/`example`,
96
- converts `const` to a stringified single-entry `enum`, merges `allOf`
97
- (branch `required` lists union), collapses `anyOf`/`oneOf` into the parent
98
- (parent `properties`/`required` kept, branch properties merged in, a branch
99
- field required only when every non-null branch requires it, a `null` branch
100
- becomes `nullable: true`), and silently drops
96
+ converts `const` to a stringified single-entry `enum`, merges `allOf` first
97
+ (branch `required` lists union), then handles `anyOf`/`oneOf` by branch types:
98
+ typed non-null branches of different types stay an `anyOf` (a `null` branch
99
+ becomes `nullable: true`; branch `required` names missing from the branch's
100
+ own `properties` are dropped) when the node has only `title`/`description`/
101
+ `default`/`example` beside it, since the proxy 400s on other fields alongside
102
+ `anyOf` and on a `string` carrying `items`. Otherwise the union collapses into
103
+ the parent (parent `properties`/`required` kept, branch properties merged in, a
104
+ branch field required only when every non-null branch requires it, a `null`
105
+ branch becomes `nullable: true`); for different-type branches only those of
106
+ the node's own type merge (none when no branch has it), or those of the first
107
+ branch's type when the node declares none. It silently drops
101
108
  everything else. It never adds `propertyOrdering`, never edits descriptions,
102
109
  and does not spill stripped keywords. Use `normalizeSchemaForFactoryDroid`
103
110
  only in `packages/ai/src/providers/factory-droid/gemini.ts`; the shared
@@ -45,6 +45,16 @@ function dedupe(values: unknown[]): unknown[] {
45
45
  return values.filter((entry, index, array) => array.indexOf(entry) === index);
46
46
  }
47
47
 
48
+ /** Bound keywords whose conjunction keeps the stricter value. */
49
+ const STRICTER_BOUND: Record<string, (a: number, b: number) => number> = {
50
+ minimum: Math.max,
51
+ minLength: Math.max,
52
+ minItems: Math.max,
53
+ maximum: Math.min,
54
+ maxLength: Math.min,
55
+ maxItems: Math.min,
56
+ };
57
+
48
58
  function mergeFactoryDroidSchemas(left: JsonObject, right: JsonObject, requiredMerge: RequiredMerge): JsonObject {
49
59
  const merged: JsonObject = { ...left };
50
60
  for (const [key, value] of Object.entries(right)) {
@@ -62,8 +72,17 @@ function mergeFactoryDroidSchemas(left: JsonObject, right: JsonObject, requiredM
62
72
  merged.properties = properties;
63
73
  } else if (key === "required") {
64
74
  // Combined below: intersection must also see a missing right-hand `required`.
75
+ } else if (requiredMerge === "union" && key in STRICTER_BOUND && typeof value === "number") {
76
+ // Conjunctions keep the tighter of two bounds.
77
+ const current = merged[key];
78
+ merged[key] = typeof current === "number" ? STRICTER_BOUND[key](current, value) : value;
65
79
  } else if (key === "enum" && Array.isArray(left.enum) && Array.isArray(value)) {
66
80
  merged.enum = dedupe([...(left.enum as unknown[]), ...(value as unknown[])]);
81
+ } else if (requiredMerge === "union" && key === "anyOf" && Array.isArray(merged.anyOf) && Array.isArray(value)) {
82
+ // Two retained unions in one conjunction: keep every pairing of compatible
83
+ // branches instead of dropping the right-hand constraint.
84
+ const pairs = intersectUnionBranches(merged.anyOf as JsonObject[], value as JsonObject[]);
85
+ if (pairs.length > 0) merged.anyOf = pairs;
67
86
  } else if (!(key in merged)) {
68
87
  merged[key] = value;
69
88
  }
@@ -83,6 +102,88 @@ function mergeFactoryDroidSchemas(left: JsonObject, right: JsonObject, requiredM
83
102
  return merged;
84
103
  }
85
104
 
105
+ /** Branch-wise conjunction of two unions: each same-typed (or untyped) pair merged, enums intersected. */
106
+ function intersectUnionBranches(left: JsonObject[], right: JsonObject[]): JsonObject[] {
107
+ const pairs: JsonObject[] = [];
108
+ for (const a of left) {
109
+ for (const b of right) {
110
+ if (a.type !== undefined && b.type !== undefined && a.type !== b.type) continue;
111
+ const pair = mergeFactoryDroidSchemas(a, b, "union");
112
+ if (Array.isArray(a.enum) && Array.isArray(b.enum)) {
113
+ // The merge unions enums; a conjunction admits only values both allow.
114
+ const allowed = (a.enum as unknown[]).filter(value => (b.enum as unknown[]).includes(value));
115
+ if (allowed.length === 0) continue;
116
+ pair.enum = allowed;
117
+ }
118
+ pairs.push(pair);
119
+ }
120
+ }
121
+ return pairs;
122
+ }
123
+
124
+ /** Fields the proxy accepts beside `anyOf`; anything else there is a 400. */
125
+ const ANY_OF_SIBLING_KEYS: Record<string, true> = {
126
+ title: true,
127
+ description: true,
128
+ default: true,
129
+ example: true,
130
+ };
131
+
132
+ /** The proxy 400s on `required` names a schema's own `properties` lacks. */
133
+ function withoutUndefinedRequired(branch: JsonObject): JsonObject {
134
+ if (!Array.isArray(branch.required)) return branch;
135
+ const properties = isJsonObject(branch.properties) ? branch.properties : {};
136
+ const required = (branch.required as unknown[]).filter(
137
+ name => typeof name === "string" && Object.hasOwn(properties, name),
138
+ );
139
+ const { required: _dropped, ...rest } = branch;
140
+ return required.length > 0 ? { ...rest, required } : rest;
141
+ }
142
+
143
+ function settleUnion(out: JsonObject, branches: JsonObject[], nullable: boolean): void {
144
+ let nonNull = branches;
145
+ const mixedTypes =
146
+ nonNull.every(branch => typeof branch.type === "string") && new Set(nonNull.map(branch => branch.type)).size > 1;
147
+ if (mixedTypes && Object.keys(out).every(key => key in ANY_OF_SIBLING_KEYS)) {
148
+ out.anyOf = nonNull.map(withoutUndefinedRequired);
149
+ if (nullable) out.nullable = true;
150
+ return;
151
+ }
152
+ if (mixedTypes) {
153
+ // `anyOf` must be the only structural field, and branches of
154
+ // different types never merge into one Schema: keep only the
155
+ // branches of the node's own type (none if no branch has it),
156
+ // else of the first branch's type.
157
+ const ownType = Array.isArray(out.type)
158
+ ? (out.type as unknown[]).find(t => typeof t === "string" && t !== "null")
159
+ : (out.type ?? (isJsonObject(out.properties) ? "object" : undefined));
160
+ const kept = ownType ?? nonNull[0].type;
161
+ nonNull = nonNull.filter(branch => branch.type === kept);
162
+ } else if (typeof out.type === "string") {
163
+ // An untyped branch keeps the union from counting as mixed, but a typed branch of another type
164
+ // still must not lend its fields (`items` on a string) to this node.
165
+ const compatible = nonNull.filter(branch => branch.type === undefined || branch.type === out.type);
166
+ if (compatible.length > 0) nonNull = compatible;
167
+ }
168
+ let collapsed: JsonObject | undefined;
169
+ for (const branch of nonNull) {
170
+ collapsed = collapsed ? mergeFactoryDroidSchemas(collapsed, branch, "intersection") : { ...branch };
171
+ }
172
+ if (collapsed) {
173
+ collapsed.nullable = nullable;
174
+ Object.assign(out, mergeFactoryDroidSchemas(out, collapsed, "union"));
175
+ // A pattern cannot be conjoined into one regex the proxy accepts; the union's own,
176
+ // narrower one wins over one an `allOf` merged in first.
177
+ if (typeof collapsed.pattern === "string") out.pattern = collapsed.pattern;
178
+ // Narrowing can drop the branch that defined a required property.
179
+ if (mixedTypes && Array.isArray(out.required)) {
180
+ const kept = withoutUndefinedRequired(out);
181
+ if (kept.required) out.required = kept.required;
182
+ else delete out.required;
183
+ }
184
+ }
185
+ }
186
+
86
187
  function copyFactoryDroidSchema(node: unknown): JsonObject | undefined {
87
188
  if (!isJsonObject(node)) return undefined;
88
189
 
@@ -106,32 +207,28 @@ function copyFactoryDroidSchema(node: unknown): JsonObject | undefined {
106
207
  if (copied !== undefined) out.items = copied;
107
208
  }
108
209
 
210
+ // allOf: merge every branch into this node's own copy first, so the union
211
+ // handling below sees every field that will sit beside it.
212
+ if (Array.isArray(node.allOf)) {
213
+ for (const branch of node.allOf) {
214
+ const copied = copyFactoryDroidSchema(branch);
215
+ if (copied) Object.assign(out, mergeFactoryDroidSchemas(out, copied, "union"));
216
+ }
217
+ }
218
+
109
219
  // anyOf/oneOf unions: merge the non-null branches (a field stays required
110
220
  // only when every branch requires it), marking the result nullable when a
111
221
  // `type: "null"` branch is present, then fold that into this node so its
112
- // own properties/required survive.
222
+ // own properties/required survive. Typed branches of different types cannot
223
+ // merge into one Schema (`string` gaining `items` is a 400), so they stay an
224
+ // `anyOf` when the node has nothing else the proxy forbids beside it.
113
225
  const unionKey = Array.isArray(node.anyOf) ? "anyOf" : Array.isArray(node.oneOf) ? "oneOf" : undefined;
114
226
  if (unionKey) {
115
227
  const branches = (node[unionKey] as unknown[])
116
228
  .map(copyFactoryDroidSchema)
117
229
  .filter((branch): branch is JsonObject => branch !== undefined);
118
230
  const nonNull = branches.filter(branch => !typeIncludesNull(branch));
119
- let collapsed: JsonObject | undefined;
120
- for (const branch of nonNull) {
121
- collapsed = collapsed ? mergeFactoryDroidSchemas(collapsed, branch, "intersection") : { ...branch };
122
- }
123
- if (collapsed) {
124
- collapsed.nullable = nonNull.length < branches.length;
125
- Object.assign(out, mergeFactoryDroidSchemas(out, collapsed, "union"));
126
- }
127
- }
128
-
129
- // allOf: merge every branch into this node's own copy.
130
- if (Array.isArray(node.allOf)) {
131
- for (const branch of node.allOf) {
132
- const copied = copyFactoryDroidSchema(branch);
133
- if (copied) Object.assign(out, mergeFactoryDroidSchemas(out, copied, "union"));
134
- }
231
+ settleUnion(out, nonNull, nonNull.length < branches.length);
135
232
  }
136
233
 
137
234
  // The Schema proto takes a single string `type`: collapse draft-2020-12
@@ -153,8 +250,37 @@ function copyFactoryDroidSchema(node: unknown): JsonObject | undefined {
153
250
  return out;
154
251
  }
155
252
 
253
+ /**
254
+ * A kept `anyOf` can gain structural siblings when an enclosing `allOf`
255
+ * merges into it (at any depth); settle those against the merged node.
256
+ */
257
+ function settleMergedUnions(node: JsonObject): void {
258
+ if (isJsonObject(node.properties)) {
259
+ for (const schema of Object.values(node.properties)) {
260
+ if (isJsonObject(schema)) settleMergedUnions(schema);
261
+ }
262
+ }
263
+ if (isJsonObject(node.items)) settleMergedUnions(node.items);
264
+ if (!Array.isArray(node.anyOf)) return;
265
+ for (const branch of node.anyOf) {
266
+ if (isJsonObject(branch)) settleMergedUnions(branch);
267
+ }
268
+ // A union left with one branch (an allOf intersection) is that branch.
269
+ const single = node.anyOf.length === 1;
270
+ const { anyOf, nullable, ...rest } = node;
271
+ if (!single && Object.keys(rest).every(key => key in ANY_OF_SIBLING_KEYS)) return;
272
+ delete node.anyOf;
273
+ delete node.nullable;
274
+ settleUnion(node, anyOf as JsonObject[], nullable === true);
275
+ if (!("type" in node) && isJsonObject(node.properties)) node.type = "object";
276
+ // The collapse merges branches, which can leave new unions beside fields.
277
+ settleMergedUnions(node);
278
+ }
279
+
156
280
  /** Project a dereferenced JSON Schema onto Factory Gemini's allowed fields. */
157
281
  export function normalizeSchemaForFactoryDroid(value: unknown): unknown {
158
282
  if (!isJsonObject(value)) return value;
159
- return copyFactoryDroidSchema(value) ?? {};
283
+ const out = copyFactoryDroidSchema(value) ?? {};
284
+ settleMergedUnions(out);
285
+ return out;
160
286
  }
@@ -1647,6 +1647,7 @@ interface FlatIssue {
1647
1647
  * - **unrecognized**: when a closed object received an extra key
1648
1648
  * (`additionalProperties: false`), drop that key so re-validation succeeds.
1649
1649
  * This effectively coerces object schemas to loose semantics recursively.
1650
+ * Dropped root keys are recorded in `droppedRootKeys` when one is supplied.
1650
1651
  *
1651
1652
  * The function is safe and conservative:
1652
1653
  * - Only processes "type" and "unrecognized" issues
@@ -1654,7 +1655,11 @@ interface FlatIssue {
1654
1655
  * - Only wraps singleton array values for non-union type expectations
1655
1656
  * - Clones the args object before mutation (copy-on-write)
1656
1657
  */
1657
- function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unknown; changed: boolean } {
1658
+ function coerceArgsFromIssues(
1659
+ args: unknown,
1660
+ issues: FlatIssue[],
1661
+ droppedRootKeys?: Map<string, unknown>,
1662
+ ): { value: unknown; changed: boolean } {
1658
1663
  if (issues.length === 0) return { value: args, changed: false };
1659
1664
 
1660
1665
  let changed = false;
@@ -1669,6 +1674,11 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn
1669
1674
  // Container-to-string conversion remains enabled for string branches.
1670
1675
  if (issue.keyword === "unrecognized") {
1671
1676
  if (issue.unionBranch) continue;
1677
+ if (droppedRootKeys) {
1678
+ const segments = decodeJsonPointer(issue.instancePath);
1679
+ const dropped = segments.length === 1 ? getValueAtPointer(nextArgs, issue.instancePath) : undefined;
1680
+ if (dropped !== undefined) droppedRootKeys.set(segments[0]!, dropped);
1681
+ }
1672
1682
  const previous = nextArgs;
1673
1683
  nextArgs = deleteValueAtPointer(nextArgs, issue.instancePath);
1674
1684
  if (nextArgs !== previous) changed = true;
@@ -1755,6 +1765,19 @@ function preserveUnknownRootFields(input: unknown, parsed: unknown): unknown {
1755
1765
  return { ...input, ...parsed };
1756
1766
  }
1757
1767
 
1768
+ /** Put root keys the unrecognized-key repair deleted back onto a validated value, never overwriting a key it kept. */
1769
+ function restoreDroppedRootKeys(value: unknown, dropped: ReadonlyMap<string, unknown> | undefined): unknown {
1770
+ if (!dropped || dropped.size === 0 || !isPlainRecord(value)) return value;
1771
+ const out: Record<string, unknown> = { ...value };
1772
+ for (const [key, entry] of dropped) {
1773
+ // Own-property check and define: `constructor`/`__proto__` must restore as plain fields.
1774
+ if (!Object.hasOwn(out, key)) {
1775
+ Object.defineProperty(out, key, { value: entry, enumerable: true, writable: true, configurable: true });
1776
+ }
1777
+ }
1778
+ return out;
1779
+ }
1780
+
1758
1781
  function flattenJsonSchemaIssues(
1759
1782
  issues: ReadonlyArray<JsonSchemaValidationIssue>,
1760
1783
  speculativeUnion = false,
@@ -1999,13 +2022,28 @@ function truncateArgsForError(value: unknown): unknown {
1999
2022
  return value;
2000
2023
  }
2001
2024
 
2025
+ /** Options for {@link validateToolArguments}. */
2026
+ export interface ValidateToolArgumentsOptions {
2027
+ /**
2028
+ * Keep unknown root keys on the validated value instead of letting the
2029
+ * unrecognized-key repair delete them from a closed schema. Set for tools
2030
+ * that own their refusal (`lenientArgValidation`), so execute() can reject
2031
+ * fields the model sent rather than run without them.
2032
+ */
2033
+ keepUnknownRootKeys?: boolean;
2034
+ }
2035
+
2002
2036
  /**
2003
2037
  * Validates tool call arguments against an ArkType or plain JSON Schema schema.
2004
2038
  * Applies conservative LLM-quirk normalization before declaring failure.
2005
2039
  *
2006
2040
  * @throws Error with a formatted message when validation cannot be reconciled.
2007
2041
  */
2008
- export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] {
2042
+ export function validateToolArguments(
2043
+ tool: Tool,
2044
+ toolCall: ToolCall,
2045
+ options: ValidateToolArgumentsOptions = {},
2046
+ ): ToolCall["arguments"] {
2009
2047
  const originalArgs = toolCall.arguments;
2010
2048
  if (originalArgs && typeof originalArgs === "object" && "__parseError" in originalArgs) {
2011
2049
  const parseError = originalArgs.__parseError;
@@ -2095,11 +2133,12 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
2095
2133
  let result = validateContext(ctx, normalizedArgs);
2096
2134
  if (result.success) return result.value as ToolCall["arguments"];
2097
2135
 
2098
- const coercionOutcome = runCoercionPasses(ctx, normalizedArgs, result);
2136
+ const droppedRootKeys = options.keepUnknownRootKeys ? new Map<string, unknown>() : undefined;
2137
+ const coercionOutcome = runCoercionPasses(ctx, normalizedArgs, result, true, false, droppedRootKeys);
2099
2138
  normalizedArgs = coercionOutcome.args;
2100
2139
  changed ||= coercionOutcome.changed;
2101
2140
  result = coercionOutcome.result;
2102
- if (result.success) return result.value as ToolCall["arguments"];
2141
+ if (result.success) return restoreDroppedRootKeys(result.value, droppedRootKeys) as ToolCall["arguments"];
2103
2142
 
2104
2143
  // Last resort: some providers parse in-band tool-call syntax server-side,
2105
2144
  // and a mistyped/missing `</arg_value>` closer inlines the remaining pairs
@@ -2111,11 +2150,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
2111
2150
  changed = true;
2112
2151
  result = validateContext(ctx, normalizedArgs);
2113
2152
  if (!result.success) {
2114
- const healedOutcome = runCoercionPasses(ctx, normalizedArgs, result);
2153
+ const healedOutcome = runCoercionPasses(ctx, normalizedArgs, result, true, false, droppedRootKeys);
2115
2154
  normalizedArgs = healedOutcome.args;
2116
2155
  result = healedOutcome.result;
2117
2156
  }
2118
- if (result.success) return result.value as ToolCall["arguments"];
2157
+ if (result.success) return restoreDroppedRootKeys(result.value, droppedRootKeys) as ToolCall["arguments"];
2119
2158
  }
2120
2159
 
2121
2160
  // Format validation errors nicely. The header phrase is asserted by
@@ -2151,6 +2190,7 @@ function runCoercionPasses(
2151
2190
  initial: ContextValidationResult,
2152
2191
  isRoot = true,
2153
2192
  insideContent = false,
2193
+ droppedRootKeys?: Map<string, unknown>,
2154
2194
  ): { args: unknown; result: ContextValidationResult; changed: boolean } {
2155
2195
  const { json } = ctx;
2156
2196
  const root = ctx.kind === "json" ? (ctx.root ?? json) : json;
@@ -2159,7 +2199,7 @@ function runCoercionPasses(
2159
2199
  let changed = false;
2160
2200
  for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) {
2161
2201
  if (result.success) break;
2162
- const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues);
2202
+ const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues, droppedRootKeys);
2163
2203
  let passChanged = coercion.changed;
2164
2204
  normalizedArgs = coercion.value;
2165
2205