@oh-my-pi/pi-ai 15.7.2 → 15.7.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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - Fixed Anthropic stream idle-timeout retries after the provider stream has already begun.
8
+
9
+ ## [15.7.3] - 2026-05-31
10
+
11
+ ### Changed
12
+
13
+ - Throttled per-delta streaming JSON re-parsing of OpenAI Responses/Codex tool-call arguments (bounding mid-stream parse cost from O(N²) to O(N)). Finalization via `response.output_item.done` now writes the authoritative full arguments back to the persisted assistant-message block, so tool calls finalized without a trailing `response.function_call_arguments.done` no longer retain stale/empty (`{}`) arguments. ([#1507](https://github.com/can1357/oh-my-pi/pull/1507))
14
+
5
15
  ## [15.6.0] - 2026-05-30
6
16
 
7
17
  ### Fixed
@@ -130,6 +140,15 @@
130
140
 
131
141
  - Fixed Synthetic model discovery to treat the provider `/models` response as authoritative so deprecated bundled IDs are pruned from the runtime cache, and changed Synthetic login validation to avoid probing a specific model ([#1417](https://github.com/can1357/oh-my-pi/issues/1417)).
132
142
 
143
+ ### Added
144
+
145
+ - Added `parseStreamingJsonThrottled` to `@oh-my-pi/pi-ai/utils/json-parse` — a per-delta wrapper around `parseStreamingJson` that skips re-parses until the buffer has grown by `minGrowthBytes` (default 256). Wired into the streaming hot path of every provider's tool-call argument accumulator (`anthropic`, `amazon-bedrock`, `openai-completions`, `openai-codex-responses`, `openai-responses-shared`) so per-delta cost is O(N) in total buffer length instead of O(N²). Each provider's `toolcall_end` still runs a final unthrottled parse, so the published `block.arguments` is unchanged.
146
+ - Added named-tool routing support to Google providers: `GoogleSharedStreamOptions.toolChoice` and `GoogleGeminiCliOptions.toolChoice` now accept `{ mode: "ANY"; allowedFunctionNames: [string, ...string[]] }` in addition to the string forms. `mapGoogleToolChoice` converts `ToolChoice` objects of shape `{ type: "tool" | "function", name }` to the wire form. Mirrors the equivalent Anthropic mapper.
147
+
148
+ ### Changed
149
+
150
+ - Changed `mapGoogleToolChoice` to be exported from `@oh-my-pi/pi-ai/stream` so callers can build the wire-shape allow-list directly without re-deriving it.
151
+
133
152
  ## [15.5.0] - 2026-05-26
134
153
  ### Added
135
154
 
@@ -7,7 +7,15 @@ import { type GoogleThinkingLevel } from "./google-shared";
7
7
  */
8
8
  export type { GoogleThinkingLevel };
9
9
  export interface GoogleGeminiCliOptions extends StreamOptions {
10
- toolChoice?: "auto" | "none" | "any";
10
+ /**
11
+ * Tool selection mode. String forms map directly to Gemini
12
+ * `FunctionCallingConfigMode`. The object form forces a single named tool —
13
+ * `mode: "ANY"` is wire-required when `allowedFunctionNames` is set.
14
+ */
15
+ toolChoice?: "auto" | "none" | "any" | {
16
+ mode: "ANY";
17
+ allowedFunctionNames: [string, ...string[]];
18
+ };
11
19
  /**
12
20
  * Thinking/reasoning configuration.
13
21
  * - Gemini 2.x models: use `budgetTokens` to set the thinking budget
@@ -61,6 +69,7 @@ interface CloudCodeAssistRequest {
61
69
  toolConfig?: {
62
70
  functionCallingConfig: {
63
71
  mode: FunctionCallingConfigMode;
72
+ allowedFunctionNames?: string[];
64
73
  };
65
74
  };
66
75
  };
@@ -19,7 +19,15 @@ export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LO
19
19
  * `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
20
20
  */
21
21
  export interface GoogleSharedStreamOptions extends StreamOptions {
22
- toolChoice?: "auto" | "none" | "any";
22
+ /**
23
+ * Tool selection mode. String forms map directly to Gemini
24
+ * `FunctionCallingConfigMode`. The object form forces a single named tool
25
+ * — `mode: "ANY"` is wire-required when `allowedFunctionNames` is set.
26
+ */
27
+ toolChoice?: "auto" | "none" | "any" | {
28
+ mode: "ANY";
29
+ allowedFunctionNames: [string, ...string[]];
30
+ };
23
31
  thinking?: {
24
32
  enabled: boolean;
25
33
  budgetTokens?: number;
@@ -1,5 +1,8 @@
1
1
  import type { Effort } from "./model-thinking";
2
2
  import type { AnthropicOptions } from "./providers/anthropic";
3
+ import type { GoogleOptions } from "./providers/google";
4
+ import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli";
5
+ import type { GoogleVertexOptions } from "./providers/google-vertex";
3
6
  import type { Api, AssistantMessage, Context, Model, OptionsForApi, SimpleStreamOptions, ToolChoice } from "./types";
4
7
  import { AssistantMessageEventStream } from "./utils/event-stream";
5
8
  /**
@@ -22,3 +25,4 @@ export declare function completeSimple<TApi extends Api>(model: Model<TApi>, con
22
25
  export declare const OUTPUT_FALLBACK_BUFFER = 4000;
23
26
  export declare const ANTHROPIC_THINKING: Record<Effort, number>;
24
27
  export declare function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["toolChoice"];
28
+ export declare function mapGoogleToolChoice(choice?: ToolChoice): GoogleOptions["toolChoice"] | GoogleGeminiCliOptions["toolChoice"] | GoogleVertexOptions["toolChoice"];
@@ -8,3 +8,30 @@ export declare function parseJsonWithRepair<T>(json: string): T;
8
8
  * @returns Parsed object or empty object if parsing fails
9
9
  */
10
10
  export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
11
+ /**
12
+ * Default minimum byte growth before `parseStreamingJsonThrottled` will
13
+ * re-parse a streaming tool-call argument buffer. Bounds the mid-stream
14
+ * partial-parse cost from quadratic to linear in N.
15
+ */
16
+ export declare const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
17
+ /**
18
+ * Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
19
+ *
20
+ * Tool calls arrive as a long sequence of small deltas — calling
21
+ * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
22
+ * each time, giving O(N²) work in the total buffer length. Throttling skips
23
+ * the re-parse until at least `minGrowthBytes` of new content has arrived
24
+ * since the last successful parse, bounding mid-stream cost to O(N).
25
+ *
26
+ * Each provider tracks the last parsed length on its tool-call block, so the
27
+ * final `toolcall_end` parse (which providers already perform unconditionally)
28
+ * is the authoritative full parse — the throttle only delays mid-stream UI
29
+ * updates by at most `minGrowthBytes` of accumulated partial content.
30
+ *
31
+ * @returns the parsed object plus the new `parsedLen` to persist; or `null`
32
+ * when the buffer has not grown enough to warrant a re-parse.
33
+ */
34
+ export declare function parseStreamingJsonThrottled<T = Record<string, unknown>>(partialJson: string | undefined, lastParsedLen: number, minGrowthBytes?: number): {
35
+ value: T;
36
+ parsedLen: number;
37
+ } | null;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.7.2",
4
+ "version": "15.7.3",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -32,7 +32,7 @@
32
32
  "check": "biome check . && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
34
34
  "lint": "biome lint .",
35
- "test": "bun test",
35
+ "test": "bun test --parallel",
36
36
  "fix": "biome check --write --unsafe .",
37
37
  "fmt": "biome format --write .",
38
38
  "generate-models": "bun scripts/generate-models.ts"
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.99.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@oh-my-pi/pi-utils": "15.7.2",
43
+ "@oh-my-pi/pi-utils": "15.7.3",
44
44
  "openai": "^6.39.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -30,7 +30,7 @@ import type {
30
30
  import { normalizeToolCallId, resolveCacheRetention } from "../utils";
31
31
  import { AssistantMessageEventStream } from "../utils/event-stream";
32
32
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
33
- import { parseStreamingJson } from "../utils/json-parse";
33
+ import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
34
34
  import { toolWireSchema } from "../utils/schema/wire";
35
35
  import { resolveAwsCredentials } from "./aws-credentials";
36
36
  import { decodeEventStream } from "./aws-eventstream";
@@ -72,7 +72,11 @@ function resolveBearerToken(options: BedrockOptions): string | undefined {
72
72
  return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
73
73
  }
74
74
 
75
- type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
75
+ type Block = (TextContent | ThinkingContent | ToolCall) & {
76
+ index?: number;
77
+ partialJson?: string;
78
+ lastParseLen?: number;
79
+ };
76
80
 
77
81
  // ---------- Bedrock wire-format types ----------
78
82
  // Mirrors only what we actually consume from `ConverseStreamRequest` /
@@ -454,7 +458,11 @@ function handleContentBlockDelta(
454
458
  }
455
459
  } else if (delta?.toolUse && block?.type === "toolCall") {
456
460
  block.partialJson = (block.partialJson || "") + (delta.toolUse.input || "");
457
- block.arguments = parseStreamingJson(block.partialJson);
461
+ const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
462
+ if (throttled) {
463
+ block.arguments = throttled.value;
464
+ block.lastParseLen = throttled.parsedLen;
465
+ }
458
466
  stream.push({ type: "toolcall_delta", contentIndex: index, delta: delta.toolUse.input || "", partial: output });
459
467
  } else if (delta?.reasoningContent) {
460
468
  let thinkingBlock = block;
@@ -518,6 +526,7 @@ function handleContentBlockStop(
518
526
  case "toolCall":
519
527
  block.arguments = parseStreamingJson(block.partialJson);
520
528
  delete (block as Block).partialJson;
529
+ delete (block as Block).lastParseLen;
521
530
  stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
522
531
  break;
523
532
  }
@@ -65,7 +65,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
65
65
  import { isFoundryEnabled } from "../utils/foundry";
66
66
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
67
67
  import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
68
- import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
68
+ import { parseJsonWithRepair, parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
69
69
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
70
70
  import { notifyProviderResponse } from "../utils/provider-response";
71
71
  import { isCopilotTransientModelError } from "../utils/retry";
@@ -1204,7 +1204,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1204
1204
  | ThinkingContent
1205
1205
  | RedactedThinkingContent
1206
1206
  | TextContent
1207
- | (ToolCall & { partialJson: string })
1207
+ | (ToolCall & { partialJson: string; lastParseLen?: number })
1208
1208
  ) & { index: number };
1209
1209
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
1210
1210
  const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
@@ -1378,7 +1378,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1378
1378
  const block = blocks[index];
1379
1379
  if (block && block.type === "toolCall") {
1380
1380
  block.partialJson += event.delta.partial_json;
1381
- block.arguments = parseStreamingJson(block.partialJson);
1381
+ const throttled = parseStreamingJsonThrottled(block.partialJson, block.lastParseLen ?? 0);
1382
+ if (throttled) {
1383
+ block.arguments = throttled.value;
1384
+ block.lastParseLen = throttled.parsedLen;
1385
+ }
1382
1386
  stream.push({
1383
1387
  type: "toolcall_delta",
1384
1388
  contentIndex: index,
@@ -1416,6 +1420,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1416
1420
  } else if (block.type === "toolCall") {
1417
1421
  block.arguments = parseStreamingJson(block.partialJson);
1418
1422
  delete (block as { partialJson?: string }).partialJson;
1423
+ delete (block as { lastParseLen?: number }).lastParseLen;
1419
1424
  stream.push({
1420
1425
  type: "toolcall_end",
1421
1426
  contentIndex: index,
@@ -1536,9 +1541,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1536
1541
  }
1537
1542
  const isTransientEnvelopeFailure =
1538
1543
  isTransientStreamParseError(streamFailure) || isTransientStreamEnvelopeError(streamFailure);
1544
+ const isLocalIdleTimeout =
1545
+ streamFailure === idleTimeoutAbortError ||
1546
+ (streamFailure instanceof Error && streamFailure.message === idleTimeoutAbortError.message);
1539
1547
  const canRetryTransientEnvelopeFailure = isTransientEnvelopeFailure && !streamedReplayUnsafeContent;
1540
1548
  const canRetryProviderFailure =
1541
- firstTokenTime === undefined && isProviderRetryableError(streamFailure, model.provider);
1549
+ !isLocalIdleTimeout &&
1550
+ firstTokenTime === undefined &&
1551
+ !streamedReplayUnsafeContent &&
1552
+ isProviderRetryableError(streamFailure, model.provider);
1542
1553
  if (
1543
1554
  activeAbortTracker.wasCallerAbort() ||
1544
1555
  providerRetryAttempt >= PROVIDER_MAX_RETRIES ||
@@ -1574,6 +1585,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1574
1585
  for (const block of output.content) {
1575
1586
  delete (block as { index?: number }).index;
1576
1587
  delete (block as { partialJson?: string }).partialJson;
1588
+ delete (block as { lastParseLen?: number }).lastParseLen;
1577
1589
  }
1578
1590
  const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason();
1579
1591
  output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error";
@@ -47,7 +47,12 @@ import {
47
47
  export type { GoogleThinkingLevel };
48
48
 
49
49
  export interface GoogleGeminiCliOptions extends StreamOptions {
50
- toolChoice?: "auto" | "none" | "any";
50
+ /**
51
+ * Tool selection mode. String forms map directly to Gemini
52
+ * `FunctionCallingConfigMode`. The object form forces a single named tool —
53
+ * `mode: "ANY"` is wire-required when `allowedFunctionNames` is set.
54
+ */
55
+ toolChoice?: "auto" | "none" | "any" | { mode: "ANY"; allowedFunctionNames: [string, ...string[]] };
51
56
  /**
52
57
  * Thinking/reasoning configuration.
53
58
  * - Gemini 2.x models: use `budgetTokens` to set the thinking budget
@@ -212,6 +217,7 @@ interface CloudCodeAssistRequest {
212
217
  toolConfig?: {
213
218
  functionCallingConfig: {
214
219
  mode: FunctionCallingConfigMode;
220
+ allowedFunctionNames?: string[];
215
221
  };
216
222
  };
217
223
  };
@@ -745,11 +751,19 @@ export function buildRequest(
745
751
  const convertedTools = convertTools(context.tools, model);
746
752
  request.tools = isAntigravity ? normalizeAntigravityTools(convertedTools) : convertedTools;
747
753
  if (options.toolChoice) {
748
- request.toolConfig = {
749
- functionCallingConfig: {
750
- mode: mapToolChoice(options.toolChoice),
751
- },
752
- };
754
+ const choice = options.toolChoice;
755
+ if (typeof choice === "string") {
756
+ request.toolConfig = {
757
+ functionCallingConfig: { mode: mapToolChoice(choice) },
758
+ };
759
+ } else {
760
+ request.toolConfig = {
761
+ functionCallingConfig: {
762
+ mode: "ANY",
763
+ allowedFunctionNames: [...choice.allowedFunctionNames],
764
+ },
765
+ };
766
+ }
753
767
  }
754
768
  }
755
769
 
@@ -59,7 +59,12 @@ export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LO
59
59
  * `google-gemini-cli` uses a different transport and request shape — do not extend this for it.
60
60
  */
61
61
  export interface GoogleSharedStreamOptions extends StreamOptions {
62
- toolChoice?: "auto" | "none" | "any";
62
+ /**
63
+ * Tool selection mode. String forms map directly to Gemini
64
+ * `FunctionCallingConfigMode`. The object form forces a single named tool
65
+ * — `mode: "ANY"` is wire-required when `allowedFunctionNames` is set.
66
+ */
67
+ toolChoice?: "auto" | "none" | "any" | { mode: "ANY"; allowedFunctionNames: [string, ...string[]] };
63
68
  thinking?: {
64
69
  enabled: boolean;
65
70
  budgetTokens?: number;
@@ -690,11 +695,21 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
690
695
  };
691
696
 
692
697
  if (context.tools && context.tools.length > 0 && options.toolChoice) {
693
- config.toolConfig = {
694
- functionCallingConfig: {
695
- mode: mapToolChoice(options.toolChoice),
696
- },
697
- };
698
+ const choice = options.toolChoice;
699
+ if (typeof choice === "string") {
700
+ config.toolConfig = {
701
+ functionCallingConfig: { mode: mapToolChoice(choice) },
702
+ };
703
+ } else {
704
+ // Named-tool routing — `mode: "ANY"` plus an explicit allow-list. The
705
+ // caller is responsible for ensuring the names exist in `context.tools`.
706
+ config.toolConfig = {
707
+ functionCallingConfig: {
708
+ mode: "ANY",
709
+ allowedFunctionNames: [...choice.allowedFunctionNames],
710
+ },
711
+ };
712
+ }
698
713
  } else {
699
714
  config.toolConfig = undefined;
700
715
  }
@@ -53,7 +53,7 @@ import {
53
53
  getStreamFirstEventTimeoutMs,
54
54
  iterateWithIdleTimeout,
55
55
  } from "../utils/idle-iterator";
56
- import { parseStreamingJson } from "../utils/json-parse";
56
+ import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
57
57
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
58
58
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
59
59
  import { notifyRawSseEvent } from "../utils/sse-debug";
@@ -170,7 +170,7 @@ function createCodexWebSocketTimeoutMessage(reason: string, details: CodexWebSoc
170
170
 
171
171
  type CodexTransport = "sse" | "websocket";
172
172
  type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
173
- type CodexOutputBlock = ThinkingContent | TextContent | (ToolCall & { partialJson: string });
173
+ type CodexOutputBlock = ThinkingContent | TextContent | (ToolCall & { partialJson: string; lastParseLen?: number });
174
174
 
175
175
  export interface OpenAICodexWebSocketDebugStats {
176
176
  fullContextRequests: number;
@@ -1216,7 +1216,11 @@ function handleToolCallArgumentsDelta(
1216
1216
  if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
1217
1217
  const delta = (rawEvent as { delta?: string }).delta || "";
1218
1218
  currentBlock.partialJson += delta;
1219
- currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
1219
+ const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
1220
+ if (throttled) {
1221
+ currentBlock.arguments = throttled.value;
1222
+ currentBlock.lastParseLen = throttled.parsedLen;
1223
+ }
1220
1224
  stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
1221
1225
  }
1222
1226
 
@@ -1230,6 +1234,8 @@ function handleToolCallArgumentsDone(
1230
1234
  if (typeof args === "string") {
1231
1235
  currentBlock.partialJson = args;
1232
1236
  currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
1237
+ delete (currentBlock as { partialJson?: string }).partialJson;
1238
+ delete (currentBlock as { lastParseLen?: number }).lastParseLen;
1233
1239
  }
1234
1240
  }
1235
1241
 
@@ -1308,6 +1314,13 @@ function handleOutputItemDone(
1308
1314
  name: item.name,
1309
1315
  arguments: parseStreamingJson(item.arguments || "{}"),
1310
1316
  };
1317
+ if (runtime.currentBlock?.type === "toolCall") {
1318
+ // Persist the authoritative final args on the stored block; the throttled
1319
+ // delta parser may have left currentBlock.arguments stale (often `{}`).
1320
+ runtime.currentBlock.arguments = toolCall.arguments;
1321
+ delete (runtime.currentBlock as { partialJson?: string }).partialJson;
1322
+ delete (runtime.currentBlock as { lastParseLen?: number }).lastParseLen;
1323
+ }
1311
1324
  runtime.canSafelyReplayWebsocketOverSse = false;
1312
1325
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
1313
1326
  return;
@@ -50,7 +50,7 @@ import {
50
50
  getStreamFirstEventTimeoutMs,
51
51
  iterateWithIdleTimeout,
52
52
  } from "../utils/idle-iterator";
53
- import { parseStreamingJson } from "../utils/json-parse";
53
+ import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
54
54
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
55
55
  import { getKimiCommonHeaders } from "../utils/oauth/kimi";
56
56
  import { notifyProviderResponse } from "../utils/provider-response";
@@ -539,7 +539,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
539
539
  // so users don't see raw `<|...|>` tokens.
540
540
  const stripDeepseekChatTemplateTokens =
541
541
  /deepseek/i.test(model.id) && (model.provider === "nvidia" || model.provider === "deepseek");
542
- type ToolCallStreamBlock = ToolCall & { partialArgs?: string; streamIndex?: number };
542
+ type ToolCallStreamBlock = ToolCall & { partialArgs?: string; streamIndex?: number; lastParseLen?: number };
543
543
  type OpenAIStreamBlock = TextContent | ThinkingContent | ToolCallStreamBlock;
544
544
  const pendingToolCallBlocks: ToolCallStreamBlock[] = [];
545
545
  const toolCallBlockByIndex = new Map<number, ToolCallStreamBlock>();
@@ -554,6 +554,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
554
554
  if (contentIndex < 0) return;
555
555
  block.arguments = parseStreamingJson(block.partialArgs);
556
556
  delete block.partialArgs;
557
+ delete block.lastParseLen;
557
558
  if (block.streamIndex !== undefined) {
558
559
  toolCallBlockByIndex.delete(block.streamIndex);
559
560
  delete block.streamIndex;
@@ -848,7 +849,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
848
849
  if (toolCall.function?.arguments) {
849
850
  delta = toolCall.function.arguments;
850
851
  block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
851
- block.arguments = parseStreamingJson(block.partialArgs);
852
+ const throttled = parseStreamingJsonThrottled(block.partialArgs, block.lastParseLen ?? 0);
853
+ if (throttled) {
854
+ block.arguments = throttled.value;
855
+ block.lastParseLen = throttled.parsedLen;
856
+ }
852
857
  }
853
858
  stream.push({
854
859
  type: "toolcall_delta",
@@ -30,7 +30,7 @@ import {
30
30
  } from "../types";
31
31
  import { normalizeResponsesToolCallId } from "../utils";
32
32
  import type { AssistantMessageEventStream } from "../utils/event-stream";
33
- import { parseStreamingJson } from "../utils/json-parse";
33
+ import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
34
34
  import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
35
35
  export const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES: ReadonlySet<string> = new Set([
36
36
  "response.created",
@@ -401,7 +401,11 @@ export async function processResponsesStream<TApi extends Api>(
401
401
  | ResponseFunctionToolCall
402
402
  | ResponseCustomToolCall
403
403
  | null = null;
404
- let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null;
404
+ let currentBlock:
405
+ | ThinkingContent
406
+ | TextContent
407
+ | (ToolCall & { partialJson: string; lastParseLen?: number })
408
+ | null = null;
405
409
  const blocks = output.content;
406
410
  const blockIndex = () => blocks.length - 1;
407
411
  let sawFirstToken = false;
@@ -540,7 +544,11 @@ export async function processResponsesStream<TApi extends Api>(
540
544
  } else if (event.type === "response.function_call_arguments.delta") {
541
545
  if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
542
546
  currentBlock.partialJson += event.delta;
543
- currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
547
+ const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
548
+ if (throttled) {
549
+ currentBlock.arguments = throttled.value;
550
+ currentBlock.lastParseLen = throttled.parsedLen;
551
+ }
544
552
  stream.push({
545
553
  type: "toolcall_delta",
546
554
  contentIndex: blockIndex(),
@@ -552,6 +560,8 @@ export async function processResponsesStream<TApi extends Api>(
552
560
  if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
553
561
  currentBlock.partialJson = event.arguments;
554
562
  currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
563
+ delete (currentBlock as { partialJson?: string }).partialJson;
564
+ delete (currentBlock as { lastParseLen?: number }).lastParseLen;
555
565
  }
556
566
  } else if (event.type === "response.custom_tool_call_input.delta") {
557
567
  if (currentItem?.type === "custom_tool_call" && currentBlock?.type === "toolCall") {
@@ -617,6 +627,15 @@ export async function processResponsesStream<TApi extends Api>(
617
627
  name: item.name,
618
628
  arguments: args,
619
629
  };
630
+ if (currentBlock?.type === "toolCall") {
631
+ // Persist the authoritative final args on the stored block. The
632
+ // throttled delta parser may have skipped the last partial parse,
633
+ // leaving currentBlock.arguments stale (often `{}`); the emitted
634
+ // toolCall and the persisted block must agree.
635
+ currentBlock.arguments = args;
636
+ delete (currentBlock as { partialJson?: string }).partialJson;
637
+ delete (currentBlock as { lastParseLen?: number }).lastParseLen;
638
+ }
620
639
  currentBlock = null;
621
640
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
622
641
  } else if (item.type === "custom_tool_call") {
package/src/stream.ts CHANGED
@@ -650,7 +650,7 @@ export function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["t
650
650
  return undefined;
651
651
  }
652
652
 
653
- function mapGoogleToolChoice(
653
+ export function mapGoogleToolChoice(
654
654
  choice?: ToolChoice,
655
655
  ): GoogleOptions["toolChoice"] | GoogleGeminiCliOptions["toolChoice"] | GoogleVertexOptions["toolChoice"] {
656
656
  if (!choice) return undefined;
@@ -659,7 +659,16 @@ function mapGoogleToolChoice(
659
659
  if (choice === "auto" || choice === "none" || choice === "any") return choice;
660
660
  return undefined;
661
661
  }
662
- return "any";
662
+ // Named-tool routing on Google: emit an `ANY`-mode allow-list of one entry,
663
+ // mirroring the Anthropic mapper that returns `{type: "tool", name}`.
664
+ if (choice.type === "tool") {
665
+ return choice.name ? { mode: "ANY", allowedFunctionNames: [choice.name] } : undefined;
666
+ }
667
+ if (choice.type === "function") {
668
+ const name = "function" in choice ? choice.function?.name : choice.name;
669
+ return name ? { mode: "ANY", allowedFunctionNames: [name] } : undefined;
670
+ }
671
+ return undefined;
663
672
  }
664
673
 
665
674
  function mapOpenAiToolChoice(choice?: ToolChoice): OpenAICompletionsOptions["toolChoice"] {
@@ -146,3 +146,37 @@ export function parseStreamingJson<T = Record<string, unknown>>(partialJson: str
146
146
  }
147
147
  }
148
148
  }
149
+
150
+ /**
151
+ * Default minimum byte growth before `parseStreamingJsonThrottled` will
152
+ * re-parse a streaming tool-call argument buffer. Bounds the mid-stream
153
+ * partial-parse cost from quadratic to linear in N.
154
+ */
155
+ export const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
156
+
157
+ /**
158
+ * Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
159
+ *
160
+ * Tool calls arrive as a long sequence of small deltas — calling
161
+ * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
162
+ * each time, giving O(N²) work in the total buffer length. Throttling skips
163
+ * the re-parse until at least `minGrowthBytes` of new content has arrived
164
+ * since the last successful parse, bounding mid-stream cost to O(N).
165
+ *
166
+ * Each provider tracks the last parsed length on its tool-call block, so the
167
+ * final `toolcall_end` parse (which providers already perform unconditionally)
168
+ * is the authoritative full parse — the throttle only delays mid-stream UI
169
+ * updates by at most `minGrowthBytes` of accumulated partial content.
170
+ *
171
+ * @returns the parsed object plus the new `parsedLen` to persist; or `null`
172
+ * when the buffer has not grown enough to warrant a re-parse.
173
+ */
174
+ export function parseStreamingJsonThrottled<T = Record<string, unknown>>(
175
+ partialJson: string | undefined,
176
+ lastParsedLen: number,
177
+ minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH,
178
+ ): { value: T; parsedLen: number } | null {
179
+ const len = partialJson?.length ?? 0;
180
+ if (len === 0 || (lastParsedLen > 0 && len - lastParsedLen < minGrowthBytes)) return null;
181
+ return { value: parseStreamingJson<T>(partialJson), parsedLen: len };
182
+ }