@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 +16 -23
- package/dist/types/providers/google-shared.d.ts +17 -0
- package/dist/types/utils/http-inspector.d.ts +2 -0
- package/dist/types/utils/validation.d.ts +11 -1
- package/package.json +6 -6
- package/src/error/flags.ts +2 -2
- package/src/providers/factory-droid/gemini.ts +13 -5
- package/src/providers/google-shared.ts +61 -6
- package/src/utils/http-inspector.ts +3 -2
- package/src/utils/leaked-thinking-stream.ts +16 -4
- package/src/utils/request-debug.ts +3 -2
- package/src/utils/schema/CONSTRAINTS.md +12 -5
- package/src/utils/schema/allowlist.ts +144 -18
- package/src/utils/validation.ts +47 -7
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
|
-
|
|
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.
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.12.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.12.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.12.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.12.
|
|
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"
|
package/src/error/flags.ts
CHANGED
|
@@ -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_-]`;
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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,
|
|
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
|
|
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 (
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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),
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
+
const out = copyFactoryDroidSchema(value) ?? {};
|
|
284
|
+
settleMergedUnions(out);
|
|
285
|
+
return out;
|
|
160
286
|
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|