@oh-my-pi/pi-ai 15.0.1 → 15.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +20 -17
  3. package/package.json +4 -7
  4. package/src/index.ts +2 -2
  5. package/src/providers/amazon-bedrock.ts +7 -1
  6. package/src/providers/anthropic.ts +12 -4
  7. package/src/providers/azure-openai-responses.ts +6 -2
  8. package/src/providers/cursor.ts +2 -1
  9. package/src/providers/gitlab-duo.ts +10 -4
  10. package/src/providers/google-gemini-cli.ts +2 -1
  11. package/src/providers/google-shared.ts +3 -3
  12. package/src/providers/google-vertex.ts +28 -10
  13. package/src/providers/google.ts +12 -4
  14. package/src/providers/mock.ts +469 -0
  15. package/src/providers/ollama.ts +4 -3
  16. package/src/providers/openai-anthropic-shim.ts +2 -0
  17. package/src/providers/openai-codex-responses.ts +6 -5
  18. package/src/providers/openai-completions-compat.ts +19 -9
  19. package/src/providers/openai-completions.ts +149 -16
  20. package/src/providers/openai-responses.ts +21 -22
  21. package/src/providers/register-builtins.ts +41 -8
  22. package/src/types.ts +36 -2
  23. package/src/utils/discovery/antigravity.ts +1 -1
  24. package/src/utils/discovery/codex.ts +1 -1
  25. package/src/utils/discovery/cursor.ts +1 -1
  26. package/src/utils/discovery/gemini.ts +1 -1
  27. package/src/utils/discovery/openai-compatible.ts +1 -1
  28. package/src/utils/h2-fetch.ts +15 -2
  29. package/src/utils/idle-iterator.ts +6 -1
  30. package/src/utils/schema/compatibility.ts +10 -27
  31. package/src/utils/schema/fields.ts +10 -2
  32. package/src/utils/schema/index.ts +3 -0
  33. package/src/utils/schema/json-schema-validator.ts +564 -0
  34. package/src/utils/schema/meta-validator.ts +171 -0
  35. package/src/utils/schema/normalize-cca.ts +5 -27
  36. package/src/utils/schema/strict-mode.ts +22 -10
  37. package/src/utils/schema/wire.ts +114 -0
  38. package/src/utils/tool-call-healing.ts +271 -0
  39. package/src/utils/validation.ts +344 -117
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.0] - 2026-05-15
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed TypeBox root exports (`Type`, `Static`, and `TSchema`) from the package entrypoint, so callers importing those symbols from `@oh-my-pi/pi-ai` must migrate to `zod` or `@oh-my-pi/pi-ai/types`
10
+
11
+ ### Added
12
+
13
+ - Added support for defining tool schemas with Zod (`z.object`, `z.string`, etc.) by allowing `Tool.parameters` to be either Zod schemas or legacy JSON Schema objects and converting them to provider wire format automatically
14
+ - Added package-level schema helpers in the `zod/v4` style by exporting `z` and `ZodType` from the root entrypoint
15
+ - Added a `mock` API provider via `createMockModel` to build `Model<"mock">` instances for fully in-memory, deterministic assistant streams in tests
16
+ - Added `streamMock` and `registerMockApi` so mock responses can be consumed through `stream()` and the global custom API registry without an external model backend
17
+ - Added async/sync response scripting with optional context-based handlers, and new `push()`/`reset()` controls to drive multi-turn mock interactions and inspect per-call invocation state
18
+ - Added support in mock responses for simulating tool calls, usage metadata, custom stop reasons, delayed emissions, and terminal error/aborted outcomes
19
+
20
+ ### Changed
21
+
22
+ - Changed Azure OpenAI Responses tool schema conversion to sanitize tool parameter schemas and rewrite `oneOf` branches as `anyOf` so tool calls remain compatible with Azure's schema expectations
23
+ - Changed `Static<S>` to extract a schema object’s `static` type when present, improving inferred tool argument types for non-Zod parameter definitions
24
+ - Changed `Static` typing behavior so it now infers argument types from Zod schemas and defaults to `unknown` for non-Zod JSON Schema parameter definitions
25
+ - Restored the default steady-state stream idle timeout to 120s (regressed in 15.0.0). 30s was too aggressive for reasoning models, slow proxies, and tool-call planning gaps, surfacing as repeated `Provider stream stalled while waiting for the next event` errors. Existing `PI_STREAM_IDLE_TIMEOUT_MS` / `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` overrides are unchanged.
26
+
27
+ ### Fixed
28
+
29
+ - Preserved top-level unknown fields in validated tool-call arguments so extra root properties are retained after schema coercion
30
+ - Fixed coercion for Zod `record` fields by parsing JSON-stringified record arguments into objects
31
+ - Validated legacy draft-07 JSON Schema tool parameters directly instead of converting through Zod, improving support for features like `$ref`, `definitions`, `nullable`, and `uniqueItems`
32
+ - Fixed Cloud Code Assist schema preparation to strip unsupported `propertyNames` and fall back to a minimal tool schema when schema meta-validation detects malformed keywords
33
+ - Fixed OpenAI Completions streaming to avoid treating non-output chunks (including role-only preambles) as progress events so idle-timeout watchdog behavior no longer hangs on no-op streamed chunks
34
+ - Fixed Cloud Code Assist schema compatibility checks by replacing strict AJV meta-schema validation with structural JSON Schema validation to avoid rejecting structurally valid tool schemas
35
+ - Fixed lazy built-in provider streams (`anthropic-messages`, `bedrock-converse-stream`, `cursor-agent`, `google-*`, `ollama-chat`, `openai-*`) prematurely aborting slow first-token responses with `Provider stream stalled while waiting for the next event`. The lazy-stream watchdog wrapper was treating the synthetic `start` event (yielded immediately by every provider before the model emits any tokens) as the first real item, which caused the watchdog to drop from `firstItemTimeoutMs` (100s) to `idleTimeoutMs` (30s) before the upstream model had produced anything. The shared `iterateWithIdleTimeout` now keeps `awaitingFirstItem` true until a real progress item arrives, and the lazy-stream wrapper marks `start` as a non-progress keepalive ([#1073](https://github.com/can1357/oh-my-pi/pull/1073) regression).
36
+ - Heal leaked Kimi K2 chat-template tool-call tokens (`<|tool_calls_section_begin|>` … `<|tool_call_argument_begin|>` … `<|tool_calls_section_end|>`) that some hosts (native `kimi-code` API, OpenRouter, Fireworks, etc.) emit into `delta.content` instead of structured `tool_calls`. The OpenAI-completions stream consumer now strips the markers from visible text, reconstructs the embedded calls as proper `toolCall` content blocks (stream-aware, token-boundary-safe), and promotes `finish_reason: stop` to `toolUse` when calls were healed.
37
+ - Fixed OpenAI-completions Kimi K2 healed-call promotion clobbering non-stop terminal finish reasons (`error`, `length`, `aborted`); promotion now only fires when the prior stop reason is the natural-completion `stop`
38
+ - Fixed OpenAI-completions duplicate Kimi tool calls when a single chunk delivers both leaked markers and a structured `delta.tool_calls`; the healer now strips visible markers but discards its synthesized calls so structured payloads remain the single source of truth
39
+ - Fixed Kimi tool-call healer synthesizing a bogus empty call when assistant text mentions a literal `<|tool_call_end|>` (or `<|tool_call_begin|>` / `<|tool_call_argument_begin|>`) outside an active `<|tool_calls_section_begin|>…<|tool_calls_section_end|>` section; the tokens now survive as text
40
+ - Fixed OpenAI-completions ignoring per-request `StreamOptions.streamFirstEventTimeoutMs` when configuring the underlying OpenAI SDK HTTP timeout, causing slow-before-headers providers to be aborted at the env default before the wrapping watchdog armed
41
+ - Fixed JSON Schema validator silently accepting values that violate `propertyNames`, `patternProperties`, `dependentRequired`, `dependencies`, `if`/`then`/`else`, `contains`, and `prefixItems`; the in-tree validator now enforces these keywords instead of falling through. `unevaluatedProperties`/`unevaluatedItems` remain permissive but log a one-time warning so tool authors are not surprised.
42
+ - Fixed recursive `$ref` schemas being treated as universally valid: the validator previously short-circuited on the second occurrence of any ref it had already seen, so nested values violating the referenced sub-schema passed. Cycle detection now keys on (ref, value-identity) pairs with a depth cap for primitive values, so genuine sub-tree violations are still caught.
43
+ - Fixed JSON Schema meta-validator accepting malformed `if`/`then`/`else` and `dependencies` keywords; each conditional sub-schema is now structurally validated and draft-07 `dependencies` accepts either a schema or a string array of dependent keys.
44
+ - Fixed Zod-emitted wire schemas dropping null-valued unknown root fields before `preserveUnknownRootFields` could snapshot them, so callers like `task.simple` no longer lose a `schema: null` argument and downstream rejection paths fire as intended.
45
+ - Fixed mock provider partial `Usage` to recompute `totalTokens` (and `cost.total` when cost components are supplied) when omitted, instead of reporting 0
46
+ - Fixed mock provider auto-generated tool-call IDs to use a per-instance counter (now reset by `reset()`), so test order no longer affects IDs across `createMockModel()` instances
47
+
48
+ ## [15.0.2] - 2026-05-15
49
+ ### Fixed
50
+
51
+ - Fixed `StreamOptions.fetch` typing to accept fetch-compatible override functions that do not expose `preconnect`, allowing custom fetch implementations to be used without type errors across runtimes
52
+ - Fixed Moonshot Kimi K2.6 forced tool calls to send `thinking: { type: "disabled" }`, avoiding `tool_choice 'specified' is incompatible with thinking enabled` 400s while preserving the requested named tool ([#1077](https://github.com/can1357/oh-my-pi/issues/1077)).
53
+
5
54
  ## [15.0.1] - 2026-05-14
6
55
  ### Breaking Changes
7
56
 
@@ -22,6 +71,11 @@
22
71
 
23
72
  - Fixed OAuth credentials being silently disabled when two omp processes (or any two `AuthStorage` instances sharing a `agent.db`) race on token refresh. Anthropic rotates refresh tokens on every use, so the loser's `invalid_grant` response previously soft-deleted the row that the winner just rotated, forcing the user to `/login` again. `#tryOAuthCredential` now re-reads the row from disk before declaring a definitive failure: if the persisted `refresh` differs from the snapshot it tried, the peer-rotated credential is reloaded and the request retries against the fresh token instead of disabling the live row.
24
73
  - Closed a remaining race window in OAuth refresh-failure handling: between re-reading the credential row to check for peer rotation and the subsequent soft-delete, another process could still complete a refresh and rotate the row, leaving us to disable the freshly-rotated credential by `id`. The disable now runs as a single CAS update conditioned on the row's `data` still matching the snapshot we tried to refresh, and on `disabled_cause IS NULL`. If the CAS reports 0 rows changed (peer rotation, or row already disabled by a concurrent failure on the same snapshot), we reload from disk and retry instead of mutating the wrong row or emitting a spurious `credential_disabled` event.
74
+ ### Changed
75
+ - Lowered the default steady-state stream idle timeout from 120s to 30s while preserving the existing environment overrides.
76
+
77
+ ### Fixed
78
+ - Lazy built-in provider streams now enforce the shared idle watchdog and abort stalled provider requests, so session auto-retry can continue after transient network drops instead of remaining stuck. Caller aborts still terminate as aborted.
25
79
 
26
80
  ## [14.9.3] - 2026-05-10
27
81
 
package/README.md CHANGED
@@ -89,18 +89,21 @@ npm install @oh-my-pi/pi-ai
89
89
  ## Quick Start
90
90
 
91
91
  ```typescript
92
- import { Type, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai";
92
+ import { z, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai";
93
93
 
94
94
  // Fully typed with auto-complete support for both providers and models
95
95
  const model = getModel("openai", "gpt-4o-mini");
96
96
 
97
- // Define tools with TypeBox schemas for type safety and validation
97
+ // Define tools with Zod schemas for type safety and validation
98
98
  const tools: Tool[] = [
99
99
  {
100
100
  name: "get_time",
101
101
  description: "Get the current time",
102
- parameters: Type.Object({
103
- timezone: Type.Optional(Type.String({ description: "Optional timezone (e.g., America/New_York)" })),
102
+ parameters: z.object({
103
+ timezone: z
104
+ .string()
105
+ .optional()
106
+ .describe("Optional timezone (e.g., America/New_York)"),
104
107
  }),
105
108
  },
106
109
  ];
@@ -213,34 +216,34 @@ for (const block of response.content) {
213
216
 
214
217
  ## Tools
215
218
 
216
- Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using AJV. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
219
+ Tools enable LLMs to interact with external systems. This library uses **Zod** schemas for type-safe tool definitions with automatic validation. Schemas are converted to JSON Schema for providers as needed.
217
220
 
218
221
  ### Defining Tools
219
222
 
220
223
  ```typescript
221
- import { Type, Tool, StringEnum } from "@oh-my-pi/pi-ai";
224
+ import { z, Tool, StringEnum } from "@oh-my-pi/pi-ai";
222
225
 
223
- // Define tool parameters with TypeBox
226
+ // Define tool parameters with Zod
224
227
  const weatherTool: Tool = {
225
228
  name: "get_weather",
226
229
  description: "Get current weather for a location",
227
- parameters: Type.Object({
228
- location: Type.String({ description: "City name or coordinates" }),
230
+ parameters: z.object({
231
+ location: z.string().describe("City name or coordinates"),
229
232
  units: StringEnum(["celsius", "fahrenheit"], { default: "celsius" }),
230
233
  }),
231
234
  };
232
235
 
233
- // Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
234
- // Type.Enum generates anyOf/const patterns that Google doesn't support
236
+ // Note: For Google API compatibility, use the StringEnum helper instead of z.enum alone
237
+ // when you need wire-compatible { type: "string", enum: [...] } shapes.
235
238
 
236
239
  const bookMeetingTool: Tool = {
237
240
  name: "book_meeting",
238
241
  description: "Schedule a meeting",
239
- parameters: Type.Object({
240
- title: Type.String({ minLength: 1 }),
241
- startTime: Type.String({ format: "date-time" }),
242
- endTime: Type.String({ format: "date-time" }),
243
- attendees: Type.Array(Type.String({ format: "email" }), { minItems: 1 }),
242
+ parameters: z.object({
243
+ title: z.string().min(1),
244
+ startTime: z.string().describe("ISO 8601 date-time"),
245
+ endTime: z.string().describe("ISO 8601 date-time"),
246
+ attendees: z.array(z.email()).min(1),
244
247
  }),
245
248
  };
246
249
  ```
@@ -340,7 +343,7 @@ for await (const event of s) {
340
343
 
341
344
  ### Validating Tool Arguments
342
345
 
343
- When using `agentLoop`, tool arguments are automatically validated against your TypeBox schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry.
346
+ When using `agentLoop`, tool arguments are automatically validated against your Zod parameter schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry.
344
347
 
345
348
  When implementing your own tool execution loop with `stream()` or `complete()`, use `validateToolCall` to validate arguments before passing them to your tools:
346
349
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.0.1",
4
+ "version": "15.1.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
- "homepage": "https://github.com/can1357/oh-my-pi",
6
+ "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -46,12 +46,9 @@
46
46
  "@aws-sdk/credential-provider-node": "^3.972.39",
47
47
  "@bufbuild/protobuf": "^2.12.0",
48
48
  "@google/genai": "^1.52.0",
49
- "@oh-my-pi/pi-natives": "15.0.1",
50
- "@oh-my-pi/pi-utils": "15.0.1",
51
- "@sinclair/typebox": "^0.34.49",
49
+ "@oh-my-pi/pi-natives": "15.1.0",
50
+ "@oh-my-pi/pi-utils": "15.1.0",
52
51
  "@smithy/node-http-handler": "^4.6.1",
53
- "ajv": "^8.20.0",
54
- "ajv-formats": "^3.0.1",
55
52
  "openai": "^6.36.0",
56
53
  "partial-json": "^0.1.7",
57
54
  "proxy-agent": "^8.0.1",
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
- export type { Static, TSchema } from "@sinclair/typebox";
2
- export { Type } from "@sinclair/typebox";
1
+ export { type ZodType, z } from "zod/v4";
3
2
  export * from "./api-registry";
4
3
  export * from "./auth-storage";
5
4
  export * from "./model-cache";
@@ -17,6 +16,7 @@ export type * from "./providers/google-gemini-cli";
17
16
  export * from "./providers/google-gemini-headers";
18
17
  export type * from "./providers/google-vertex";
19
18
  export * from "./providers/kimi";
19
+ export * from "./providers/mock";
20
20
  export * from "./providers/ollama";
21
21
  export * from "./providers/openai-codex-responses";
22
22
  export * from "./providers/openai-completions";
@@ -46,6 +46,7 @@ import { normalizeToolCallId, resolveCacheRetention } from "../utils";
46
46
  import { AssistantMessageEventStream } from "../utils/event-stream";
47
47
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
48
48
  import { parseStreamingJson } from "../utils/json-parse";
49
+ import { toolWireSchema } from "../utils/schema/wire";
49
50
  import { transformMessages } from "./transform-messages";
50
51
 
51
52
  export interface BedrockOptions extends StreamOptions {
@@ -668,7 +669,12 @@ function convertToolConfig(
668
669
  toolSpec: {
669
670
  name: tool.name,
670
671
  description: tool.description || "",
671
- inputSchema: { json: tool.parameters },
672
+ // Wire schema is structurally a JSON Schema document; the Bedrock SDK
673
+ // types it as the recursive `DocumentType` from `@smithy/types`, which
674
+ // `Record<string, unknown>` does not directly satisfy at the type
675
+ // level. Cast through `unknown` so the actual JSON value passes the
676
+ // type checker without changing runtime behavior.
677
+ inputSchema: { json: toolWireSchema(tool) as unknown as Record<string, never> },
672
678
  },
673
679
  }));
674
680
 
@@ -25,6 +25,7 @@ import type {
25
25
  AssistantMessage,
26
26
  CacheRetention,
27
27
  Context,
28
+ FetchImpl,
28
29
  ImageContent,
29
30
  Message,
30
31
  Model,
@@ -57,7 +58,7 @@ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
57
58
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
58
59
  import { notifyProviderResponse } from "../utils/provider-response";
59
60
  import { isCopilotTransientModelError } from "../utils/retry";
60
- import { COMBINATOR_KEYS, NO_STRICT } from "../utils/schema";
61
+ import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
61
62
  import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
62
63
  import {
63
64
  buildCopilotDynamicHeaders,
@@ -541,6 +542,7 @@ export type AnthropicClientOptionsArgs = {
541
542
  isOAuth?: boolean;
542
543
  hasTools?: boolean;
543
544
  onSseEvent?: AnthropicOptions["onSseEvent"];
545
+ fetch?: FetchImpl;
544
546
  };
545
547
 
546
548
  export type AnthropicClientOptionsResult = {
@@ -965,6 +967,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
965
967
  isOAuth: options?.isOAuth,
966
968
  hasTools: !!context.tools?.length,
967
969
  onSseEvent: options?.onSseEvent,
970
+ fetch: options?.fetch,
968
971
  });
969
972
  client = created.client;
970
973
  isOAuthToken = created.isOAuthToken;
@@ -1405,7 +1408,12 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
1405
1408
  const baseUrl = resolveAnthropicBaseUrl(model, apiKey);
1406
1409
  const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
1407
1410
  const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
1408
- const debugFetch = onSseEvent ? wrapFetchForSseDebug(fetch, event => onSseEvent(event, model)) : undefined;
1411
+ const baseFetch = args.fetch ?? fetch;
1412
+ const debugFetch = onSseEvent
1413
+ ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model))
1414
+ : args.fetch
1415
+ ? baseFetch
1416
+ : undefined;
1409
1417
  if (model.provider === "github-copilot") {
1410
1418
  const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
1411
1419
  const betaFeatures = [...extraBetas];
@@ -2064,7 +2072,7 @@ export function convertAnthropicMessages(
2064
2072
  return params;
2065
2073
  }
2066
2074
 
2067
- const ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS = new Set(["maxItems", "patternProperties"]);
2075
+ const ANTHROPIC_UNSUPPORTED_TOOL_SCHEMA_FIELDS = new Set(["maxItems", "patternProperties", "propertyNames"]);
2068
2076
  const ANTHROPIC_STRICT_TOOL_ALLOWLIST = new Set(["bash", "python", "edit", "find"]);
2069
2077
  const MAX_ANTHROPIC_STRICT_TOOLS = 20;
2070
2078
  const MAX_ANTHROPIC_STRICT_OPTIONAL_PARAMETERS = 24;
@@ -2306,7 +2314,7 @@ function normalizeAnthropicStrictSchema(
2306
2314
  }
2307
2315
 
2308
2316
  function buildAnthropicBaseToolInputSchema(tool: Tool): Record<string, unknown> {
2309
- const jsonSchema = tool.parameters as Record<string, unknown>;
2317
+ const jsonSchema = toolWireSchema(tool);
2310
2318
  return normalizeAnthropicToolSchema({
2311
2319
  ...jsonSchema,
2312
2320
  type: "object",
@@ -26,6 +26,7 @@ import {
26
26
  getStreamFirstEventTimeoutMs,
27
27
  iterateWithIdleTimeout,
28
28
  } from "../utils/idle-iterator";
29
+ import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
29
30
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
30
31
  import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
31
32
  import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
@@ -241,6 +242,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
241
242
 
242
243
  const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
243
244
 
245
+ const baseFetch = options?.fetch ?? fetch;
244
246
  return new AzureOpenAI({
245
247
  apiKey,
246
248
  apiVersion,
@@ -248,7 +250,9 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
248
250
  maxRetries: 5,
249
251
  defaultHeaders: headers,
250
252
  baseURL: baseUrl,
251
- fetch: options?.onSseEvent ? wrapFetchForSseDebug(fetch, event => options.onSseEvent?.(event, model)) : fetch,
253
+ fetch: options?.onSseEvent
254
+ ? wrapFetchForSseDebug(baseFetch, event => options.onSseEvent?.(event, model))
255
+ : baseFetch,
252
256
  });
253
257
  }
254
258
 
@@ -327,7 +331,7 @@ function convertTools(tools: Tool[]): OpenAITool[] {
327
331
  type: "function",
328
332
  name: tool.name,
329
333
  description: tool.description || "",
330
- parameters: tool.parameters as Record<string, unknown>,
334
+ parameters: sanitizeSchemaForOpenAIResponses(toolWireSchema(tool)),
331
335
  strict: false,
332
336
  }));
333
337
  }
@@ -30,6 +30,7 @@ import { normalizeSystemPrompts } from "../utils";
30
30
  import { AssistantMessageEventStream } from "../utils/event-stream";
31
31
  import { parseStreamingJson } from "../utils/json-parse";
32
32
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
33
+ import { toolWireSchema } from "../utils/schema/wire";
33
34
  import type { McpToolDefinition } from "./cursor/gen/agent_pb";
34
35
  import {
35
36
  AgentClientMessageSchema,
@@ -2067,7 +2068,7 @@ function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[]
2067
2068
  }
2068
2069
 
2069
2070
  return advertisedTools.map(tool => {
2070
- const jsonSchema = tool.parameters as Record<string, unknown> | undefined;
2071
+ const jsonSchema = toolWireSchema(tool);
2071
2072
  const schemaValue: JsonValue =
2072
2073
  jsonSchema && typeof jsonSchema === "object"
2073
2074
  ? (jsonSchema as JsonValue)
@@ -1,5 +1,5 @@
1
1
  import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream";
2
- import type { Api, Context, Model, SimpleStreamOptions } from "../types";
2
+ import type { Api, Context, FetchImpl, Model, SimpleStreamOptions } from "../types";
3
3
  import { AssistantMessageEventStream } from "../utils/event-stream";
4
4
  import type { OpenAICompletionsOptions } from "./openai-completions";
5
5
  import type { OpenAIResponsesOptions } from "./openai-responses";
@@ -172,13 +172,16 @@ interface DirectAccessToken {
172
172
 
173
173
  const directAccessCache = new Map<string, DirectAccessToken>();
174
174
 
175
- async function getDirectAccessToken(gitlabAccessToken: string): Promise<DirectAccessToken> {
175
+ async function getDirectAccessToken(
176
+ gitlabAccessToken: string,
177
+ fetchImpl: FetchImpl = fetch,
178
+ ): Promise<DirectAccessToken> {
176
179
  const cached = directAccessCache.get(gitlabAccessToken);
177
180
  if (cached && cached.expiresAt > Date.now()) {
178
181
  return cached;
179
182
  }
180
183
 
181
- const response = await fetch(`${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`, {
184
+ const response = await fetchImpl(`${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`, {
182
185
  method: "POST",
183
186
  headers: {
184
187
  Authorization: `Bearer ${gitlabAccessToken}`,
@@ -240,7 +243,7 @@ export function streamGitLabDuo(
240
243
  throw new Error(`Unsupported GitLab Duo model: ${model.id}`);
241
244
  }
242
245
 
243
- const directAccess = await getDirectAccessToken(options.apiKey);
246
+ const directAccess = await getDirectAccessToken(options.apiKey, options.fetch);
244
247
  const headers = {
245
248
  ...directAccess.headers,
246
249
  ...options.headers,
@@ -278,6 +281,7 @@ export function streamGitLabDuo(
278
281
  onPayload: options.onPayload,
279
282
  onResponse: options.onResponse,
280
283
  onSseEvent: options.onSseEvent,
284
+ fetch: options.fetch,
281
285
  thinkingEnabled: Boolean(reasoningEffort) && model.reasoning,
282
286
  thinkingBudgetTokens: reasoningEffort
283
287
  ? (options.thinkingBudgets?.[reasoningEffort] ?? ANTHROPIC_THINKING[reasoningEffort])
@@ -314,6 +318,7 @@ export function streamGitLabDuo(
314
318
  onPayload: options.onPayload,
315
319
  onResponse: options.onResponse,
316
320
  onSseEvent: options.onSseEvent,
321
+ fetch: options.fetch,
317
322
  reasoning: reasoningEffort,
318
323
  toolChoice: options.toolChoice,
319
324
  } satisfies OpenAIResponsesOptions,
@@ -345,6 +350,7 @@ export function streamGitLabDuo(
345
350
  onPayload: options.onPayload,
346
351
  onResponse: options.onResponse,
347
352
  onSseEvent: options.onSseEvent,
353
+ fetch: options.fetch,
348
354
  reasoning: reasoningEffort,
349
355
  toolChoice: options.toolChoice,
350
356
  } satisfies OpenAICompletionsOptions,
@@ -362,6 +362,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
362
362
  maxAttempts: MAX_RETRIES + 1,
363
363
  defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
364
364
  maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
365
+ fetch: options?.fetch,
365
366
  },
366
367
  );
367
368
  if (!response.ok) {
@@ -545,7 +546,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
545
546
  throw new Error("Missing request URL");
546
547
  }
547
548
 
548
- currentResponse = await fetch(requestUrl, {
549
+ currentResponse = await (options?.fetch ?? fetch)(requestUrl, {
549
550
  method: "POST",
550
551
  headers: requestHeaders,
551
552
  body: requestBodyJson,
@@ -30,7 +30,7 @@ import type {
30
30
  import { normalizeSystemPrompts } from "../utils";
31
31
  import { AssistantMessageEventStream } from "../utils/event-stream";
32
32
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
33
- import { prepareSchemaForCCA, sanitizeSchemaForGoogle } from "../utils/schema";
33
+ import { prepareSchemaForCCA, sanitizeSchemaForGoogle, toolWireSchema } from "../utils/schema";
34
34
  import { transformMessages } from "./transform-messages";
35
35
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
36
36
 
@@ -340,8 +340,8 @@ export function convertTools(
340
340
  name: tool.name,
341
341
  description: tool.description || "",
342
342
  ...(useParameters
343
- ? { parameters: prepareSchemaForCCA(tool.parameters) }
344
- : { parametersJsonSchema: tool.parameters }),
343
+ ? { parameters: prepareSchemaForCCA(toolWireSchema(tool)) }
344
+ : { parametersJsonSchema: toolWireSchema(tool) }),
345
345
  })),
346
346
  },
347
347
  ];
@@ -1,6 +1,6 @@
1
1
  import { GoogleGenAI } from "@google/genai";
2
2
  import { $env } from "@oh-my-pi/pi-utils";
3
- import type { Context, Model, StreamFunction } from "../types";
3
+ import type { Context, FetchImpl, Model, StreamFunction } from "../types";
4
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
5
5
  import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
6
6
 
@@ -25,7 +25,9 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
25
25
  const apiKey = resolveApiKey(options);
26
26
  const project = apiKey ? undefined : resolveProject(options);
27
27
  const location = apiKey ? undefined : resolveLocation(options);
28
- const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
28
+ const client = apiKey
29
+ ? createClientWithApiKey(model, apiKey, options?.fetch)
30
+ : createClient(model, project!, location!, options?.fetch);
29
31
  const params = buildGoogleGenerateContentParams(model, context, options ?? {});
30
32
  const url = apiKey
31
33
  ? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
@@ -34,29 +36,45 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
34
36
  },
35
37
  });
36
38
 
37
- function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
38
- if (!model.headers) {
39
- return undefined;
39
+ function buildHttpOptions(
40
+ model: Model<"google-vertex">,
41
+ fetchOverride: FetchImpl | undefined,
42
+ ): { headers?: Record<string, string>; fetch?: FetchImpl } | undefined {
43
+ const options: { headers?: Record<string, string>; fetch?: FetchImpl } = {};
44
+ if (model.headers) {
45
+ options.headers = { ...model.headers };
46
+ }
47
+ if (fetchOverride) {
48
+ options.fetch = fetchOverride;
40
49
  }
41
- return { headers: { ...model.headers } };
50
+ return Object.keys(options).length > 0 ? options : undefined;
42
51
  }
43
52
 
44
- function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI {
53
+ function createClient(
54
+ model: Model<"google-vertex">,
55
+ project: string,
56
+ location: string,
57
+ fetchOverride: FetchImpl | undefined,
58
+ ): GoogleGenAI {
45
59
  return new GoogleGenAI({
46
60
  vertexai: true,
47
61
  project,
48
62
  location,
49
63
  apiVersion: API_VERSION,
50
- httpOptions: buildHttpOptions(model),
64
+ httpOptions: buildHttpOptions(model, fetchOverride),
51
65
  });
52
66
  }
53
67
 
54
- function createClientWithApiKey(model: Model<"google-vertex">, apiKey: string): GoogleGenAI {
68
+ function createClientWithApiKey(
69
+ model: Model<"google-vertex">,
70
+ apiKey: string,
71
+ fetchOverride: FetchImpl | undefined,
72
+ ): GoogleGenAI {
55
73
  return new GoogleGenAI({
56
74
  vertexai: true,
57
75
  apiKey,
58
76
  apiVersion: API_VERSION,
59
- httpOptions: buildHttpOptions(model),
77
+ httpOptions: buildHttpOptions(model, fetchOverride),
60
78
  });
61
79
  }
62
80
 
@@ -1,6 +1,6 @@
1
1
  import { GoogleGenAI } from "@google/genai";
2
2
  import { getEnvApiKey } from "../stream";
3
- import type { Context, Model, StreamFunction } from "../types";
3
+ import type { Context, FetchImpl, Model, StreamFunction } from "../types";
4
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
5
5
  import { buildGoogleGenerateContentParams, type GoogleSharedStreamOptions, streamGoogleGenAI } from "./google-shared";
6
6
 
@@ -17,15 +17,20 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
17
17
  api: "google-generative-ai",
18
18
  prepare: () => {
19
19
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
20
- const client = createClient(model, apiKey);
20
+ const client = createClient(model, apiKey, options?.fetch);
21
21
  const params = buildGoogleGenerateContentParams(model, context, options ?? {});
22
22
  const url = model.baseUrl ? `${model.baseUrl}/models/${model.id}:streamGenerateContent` : undefined;
23
23
  return { client, params, url };
24
24
  },
25
25
  });
26
26
 
27
- function createClient(model: Model<"google-generative-ai">, apiKey?: string): GoogleGenAI {
28
- const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
27
+ function createClient(model: Model<"google-generative-ai">, apiKey?: string, fetchOverride?: FetchImpl): GoogleGenAI {
28
+ const httpOptions: {
29
+ baseUrl?: string;
30
+ apiVersion?: string;
31
+ headers?: Record<string, string>;
32
+ fetch?: FetchImpl;
33
+ } = {};
29
34
  if (model.baseUrl) {
30
35
  httpOptions.baseUrl = model.baseUrl;
31
36
  httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
@@ -33,6 +38,9 @@ function createClient(model: Model<"google-generative-ai">, apiKey?: string): Go
33
38
  if (model.headers) {
34
39
  httpOptions.headers = model.headers;
35
40
  }
41
+ if (fetchOverride) {
42
+ httpOptions.fetch = fetchOverride;
43
+ }
36
44
 
37
45
  return new GoogleGenAI({
38
46
  apiKey,