From e3b588e7d2e72fd29f7b28567300d3963df36566 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:27:30 -0500 Subject: [PATCH] refactor(ai): apply tool schema rules once per request (#51162) --- packages/ai/AGENTS.md | 1 + .../ai/src/protocols/anthropic-messages.ts | 13 +++------- packages/ai/src/protocols/bedrock-converse.ts | 16 ++++-------- packages/ai/src/protocols/gemini.ts | 13 +++++----- packages/ai/src/protocols/meta-responses.ts | 8 +----- packages/ai/src/protocols/mistral-chat.ts | 13 +++------- packages/ai/src/protocols/open-responses.ts | 12 +++------ packages/ai/src/protocols/openai-chat.ts | 20 +++----------- packages/ai/src/protocols/openai-responses.ts | 26 +++++-------------- .../ai/src/protocols/utils/tool-schema.ts | 24 +++++++++++------ packages/ai/src/route/client.ts | 9 +++++-- packages/ai/src/route/protocol.ts | 4 ++- packages/core/src/aisdk.ts | 2 ++ 13 files changed, 60 insertions(+), 101 deletions(-) diff --git a/packages/ai/AGENTS.md b/packages/ai/AGENTS.md index 56100667ead..80a77993ab1 100644 --- a/packages/ai/AGENTS.md +++ b/packages/ai/AGENTS.md @@ -275,6 +275,7 @@ Use this order for every protocol module: ### Rules - Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. +- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself. - Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. - Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing. - Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends. diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 7d71e9f1226..33805d8e283 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -18,7 +18,6 @@ import { type CacheHint, type FinishReasonDetails, type FinishReason, - type JsonSchema, type MediaPart, type ProviderMetadata, type ProviderOptions, @@ -31,7 +30,6 @@ import { classifyProviderFailure } from "../provider-error.js" import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js" import * as Cache from "./utils/cache.js" import { Lifecycle } from "./utils/lifecycle.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" const ADAPTER = "anthropic-messages" @@ -525,10 +523,10 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: s return typeof provider.redactedData === "string" ? provider.redactedData : undefined } -const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ name: tool.name, description: tool.description, - input_schema: inputSchema, + input_schema: tool.inputSchema, cache_control: cacheControl(breakpoints, tool.cache), }) @@ -1049,12 +1047,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) const flattened = ProviderShared.flattenToolRequest(updates.request) - const tools = - flattened.tools.length === 0 - ? undefined - : flattened.tools.map((tool) => - lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)), - ) + const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool)) // Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present. const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) const systemParts = request.system.filter((part) => part.text.length > 0) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 5d7815ca718..60325a0936d 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -9,7 +9,6 @@ import { type CacheHint, type FinishReason, type FinishReasonDetails, - type JsonSchema, type LLMRequest, type LanguageModel, type ProviderMetadata, @@ -26,7 +25,6 @@ import { BedrockCache } from "./utils/bedrock-cache.js" import { BedrockMedia } from "./utils/bedrock-media.js" import { Lifecycle } from "./utils/lifecycle.js" import { MistralToolID } from "./utils/mistral-tool-id.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" import { concatBytes } from "../utils/bytes.js" @@ -221,22 +219,18 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ +const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ toolSpec: { name: tool.name, ...(tool.description.trim().length > 0 ? { description: tool.description } : {}), - inputSchema: { json: inputSchema }, + inputSchema: { json: tool.inputSchema }, }, }) -const lowerTools = ( - model: LanguageModel, - breakpoints: BedrockCache.Breakpoints, - tools: ReadonlyArray, -): BedrockTool[] => { +const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { - result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))) + result.push(lowerToolSpec(tool)) const cachePoint = BedrockCache.block(breakpoints, tool.cache) if (cachePoint) result.push(cachePoint) } @@ -473,7 +467,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: const toolConfig = (() => { if (flattened.tools.length === 0) return undefined return { - tools: lowerTools(request.model, breakpoints, flattened.tools), + tools: lowerTools(breakpoints, flattened.tools), // Converse has no native "none". Keep definitions stable for prompt // caching and omit only the unsupported choice. toolChoice, diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 0cc74a702a6..1d8e502dbb6 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -11,7 +11,6 @@ import { Usage, type FinishReason, type LLMRequest, - type LanguageModel, type MediaPart, type ProviderMetadata, type ProviderOptions, @@ -24,7 +23,6 @@ import { Media } from "../media.js" import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { GeminiGenerateContent } from "./utils/gemini-generate-content.js" import { Lifecycle } from "./utils/lifecycle.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" const ADAPTER = "gemini" // Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost. @@ -270,12 +268,11 @@ interface ParserState { // ============================================================================= // Request Lowering // ============================================================================= -// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. Gemini's schema -// rules are this API's default, including for tuned endpoints whose IDs do not name Gemini. -const lowerTool = (tool: ToolDefinition, model: LanguageModel) => ({ +// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. +const lowerTool = (tool: ToolDefinition) => ({ name: tool.name, description: tool.description, - parametersJsonSchema: ToolSchemaProjection.modelCompatibility(tool.inputSchema, model, "gemini"), + parametersJsonSchema: tool.inputSchema, }) const lowerToolConfig = (toolChoice: NonNullable) => @@ -482,7 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque tools: hasTools ? [ { - functionDeclarations: flattened.tools.map((tool) => lowerTool(tool, request.model)), + functionDeclarations: flattened.tools.map(lowerTool), }, ] : undefined, @@ -818,6 +815,8 @@ export const protocol = Protocol.make({ schema: GeminiBody, from: fromRequest, }, + // Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini. + sanitizer: "gemini", stream: { event: Protocol.jsonEvent(GeminiEvent), initial: (request) => ({ diff --git a/packages/ai/src/protocols/meta-responses.ts b/packages/ai/src/protocols/meta-responses.ts index 3bcf9a95d67..79827fa3178 100644 --- a/packages/ai/src/protocols/meta-responses.ts +++ b/packages/ai/src/protocols/meta-responses.ts @@ -5,7 +5,6 @@ import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.j import { OpenResponses } from "./open-responses.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { detectMediaType } from "../utils/media-type.js" const ADAPTER = "meta-responses" @@ -103,12 +102,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L ? undefined : yield* Effect.forEach(projected.tools, (tool) => Effect.gen(function* () { - if (tool.native === undefined) - return yield* OpenResponses.lowerTool( - NAME, - tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model), - ) + if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool) return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta) }), ), diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts index 8f00e6b1f29..b1b1720f204 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -13,7 +13,6 @@ import { UnknownProviderError, Usage, type FinishReasonDetails, - type JsonSchema, type LLMRequest, type MediaPart, type ToolCallPart, @@ -23,7 +22,6 @@ import { classifyProviderFailure } from "../provider-error.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { Lifecycle } from "./utils/lifecycle.js" import { MistralToolID } from "./utils/mistral-tool-id.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" const ADAPTER = "mistral-chat" @@ -368,9 +366,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: return messages }) -const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({ +const lowerTool = (tool: ToolDefinition): MistralTool => ({ type: "function", - function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false }, + function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false }, }) export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) { @@ -396,12 +394,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque return { model: request.model.id, messages: yield* lowerMessages(flattened.request), - tools: - flattened.tools.length > 0 - ? flattened.tools.map((tool) => - lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)), - ) - : undefined, + tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined, tool_choice: toolChoice, stream: true as const, max_tokens: request.generation?.maxTokens, diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index f42f03022b8..2d56648067c 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -8,7 +8,6 @@ import { ProviderInternalError, Usage, type FinishReason, - type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -24,7 +23,6 @@ import { classifyProviderFailure } from "../provider-error.js" import { effortUpdate } from "../effort-updates.js" import { OpenResponsesOptions } from "./utils/open-responses-options.js" import { Lifecycle } from "./utils/lifecycle.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" const ADAPTER = "open-responses" @@ -443,18 +441,14 @@ interface ReasoningStreamItem { // ============================================================================= // Request Lowering // ============================================================================= -export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* ( - protocolName: string, - tool: ToolDefinition, - inputSchema: JsonSchema, -) { +export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) { if (tool.native !== undefined) return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`) return { type: "function" as const, name: tool.name, description: tool.description, - parameters: inputSchema, + parameters: tool.inputSchema, // The common tool definition does not currently express Responses strict-schema policy. strict: false, } @@ -827,7 +821,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd : yield* Effect.forEach(projected.tools, (tool) => tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) - : lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)), + : lowerTool(adapter.name, tool), ), tool_choice: allowedToolChoice(request) ?? diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 3d4b06a3c97..b72864abbcb 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -17,7 +17,6 @@ import { type FinishReason, type FinishReasonDetails, type CacheHint, - type JsonSchema, type LLMRequest, type MediaPart, type ReasoningPart, @@ -29,7 +28,6 @@ import { classifyProviderFailure } from "../provider-error.js" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { OpenAIOptions } from "./utils/openai-options.js" import { Lifecycle } from "./utils/lifecycle.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" const ADAPTER = "openai-chat" @@ -330,17 +328,12 @@ interface LoweringOptions { readonly toolCallID?: (id: string) => string } -const lowerTool = ( - tool: ToolDefinition, - inputSchema: JsonSchema, - options: LoweringOptions, - supportsStrictMode: boolean, -): OpenAIChatTool => ({ +const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({ type: "function", function: { name: tool.name, description: tool.description, - parameters: inputSchema, + parameters: tool.inputSchema, ...(supportsStrictMode ? { strict: false } : {}), }, cache_control: options.cacheControl?.(tool.cache), @@ -825,14 +818,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ? hasHistory ? [] : undefined - : flattened.tools.map((tool) => - lowerTool( - tool, - ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model), - options, - supportsStrictMode, - ), - ), + : flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)), tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, ...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}), diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 840cbaf8fe0..13f32d1e9d8 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -5,20 +5,12 @@ import { Auth } from "../route/auth.js" import { Endpoint } from "../route/endpoint.js" import { Protocol } from "../route/protocol.js" import { HttpTransport } from "../route/transport/index.js" -import { - LLMRequest, - mergeJsonRecords, - type JsonSchema, - type LanguageModel, - type ToolDefinition, - type ToolEntry, -} from "../schema/index.js" +import { LLMRequest, mergeJsonRecords, type ToolDefinition, type ToolEntry } from "../schema/index.js" import { resolveEffortUpdates } from "../effort-updates.js" import { OpenResponses } from "./open-responses.js" import { OpenResponsesOptions } from "./utils/open-responses-options.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js" -import { ToolSchemaProjection } from "./utils/tool-schema.js" import { OpenResponsesChannel } from "./open-responses-channel.js" import { ResponsesCompaction } from "./utils/responses-compaction.js" import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js" @@ -174,20 +166,19 @@ const nativeImageTool = (tool: ToolDefinition) => { return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined } -const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) { +const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) { const native = nativeImageToolInput(tool) if (native !== undefined) { if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid") } - return yield* OpenResponses.lowerTool(NAME, tool, inputSchema) + return yield* OpenResponses.lowerTool(NAME, tool) }) // Native namespaces hold only function tools, so deeper levels flatten into // the leaf names the same way non-native protocols flatten the whole tree. -const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) { - if (tool.type === "tool") - return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)) +const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) { + if (tool.type === "tool") return yield* lowerTool(tool) // OpenAI requires a namespace description; fall back to a generic one so a // missing description never blocks the request. return { @@ -195,7 +186,7 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (to name: tool.name, description: tool.description ?? `Tools in the ${tool.name} namespace.`, tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) => - OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)), + OpenResponses.lowerTool(NAME, leaf), ), } }) @@ -223,10 +214,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: ...(yield* OpenResponses.lowerConversation(updates.request, adapter)), ...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }), context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })), - tools: - request.tools.length === 0 - ? undefined - : yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)), + tools: request.tools.length === 0 ? undefined : yield* Effect.forEach(request.tools, lowerToolEntry), tool_choice: request.tools.length === 0 ? undefined diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index 100b082b5cb..013cd4d321a 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -1,4 +1,4 @@ -import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js" +import { ToolDefinition, type JsonSchema, type LanguageModel, type LLMRequest } from "../../schema/index.js" import { isRecord } from "../../utils/record.js" import { GeminiJsonSchema } from "./gemini-json-schema.js" @@ -70,13 +70,13 @@ const objectRoot = (schema: JsonSchema): JsonSchema => { // Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the // model name selects the family's rules so models reached through gateways and OpenAI-compatible // endpoints get the same handling. -const modelCompatibility = ( - schema: JsonSchema, - model: LanguageModel, - protocolDefault?: LanguageModelSanitizerCompatibility, -): JsonSchema => { +const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => { const root = objectRoot(schema) - switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) { + switch ( + model.compatibility?.sanitizer ?? + model.route.sanitizer ?? + MODEL_NAMES.find(([name]) => name.test(model.id))?.[1] + ) { case "gemini": return gemini(root) case "moonshot": @@ -87,10 +87,18 @@ const modelCompatibility = ( } } +// Applied once to every request before any protocol builds its body, including tools in namespaces. +const tools = (entries: LLMRequest["tools"], model: LanguageModel): LLMRequest["tools"] => + entries.map((tool) => + tool.type === "tool" + ? new ToolDefinition({ ...tool, inputSchema: modelCompatibility(tool.inputSchema, model) }) + : { ...tool, tools: tools(tool.tools, model) }, + ) + export const ToolSchemaProjection = { gemini, - modelCompatibility, moonshot, openAI, responses, + tools, } as const diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 64e96573b89..83e762160a1 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -11,7 +11,8 @@ import { applyEffortUpdates } from "../effort-updates.js" import { normalizeToolHistory } from "../tool-history.js" import { sanitizeSurrogates } from "../utils/sanitize.js" import * as ProviderShared from "../protocols/shared.js" -import type { ProtocolID, ProviderOptions } from "../schema/index.js" +import { ToolSchemaProjection } from "../protocols/utils/tool-schema.js" +import type { LanguageModelSanitizerCompatibility, ProtocolID, ProviderOptions } from "../schema/index.js" import { AIError, CompactionResponse, @@ -57,6 +58,7 @@ export interface Route< readonly defaults: RouteDefaults readonly body: RouteBody readonly supportsEffortUpdates?: (request: LLMRequest) => boolean + readonly sanitizer?: LanguageModelSanitizerCompatibility readonly with: { ( patch: RoutePatch & { readonly compact: Next }, @@ -388,6 +390,7 @@ function makeFromTransport( defaults: routeInput.defaults ?? {}, body: protocol.body, supportsEffortUpdates: protocol.supportsEffortUpdates, + sanitizer: protocol.sanitizer, with: (patch: RoutePatch) => { const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch return build({ @@ -559,7 +562,9 @@ const prepareRequest = (request: LLMRequest) => { tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) }, ) const resolved = applyCachePolicy( - applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })), + applyEffortUpdates( + LLMRequest.update(sanitized, { tools: ToolSchemaProjection.tools(dedupe(sanitized.tools), sanitized.model) }), + ), ) const headers = resolved.model.route.headers?.({ request: resolved }) return headers === undefined diff --git a/packages/ai/src/route/protocol.ts b/packages/ai/src/route/protocol.ts index 329c96923e6..89c8d536108 100644 --- a/packages/ai/src/route/protocol.ts +++ b/packages/ai/src/route/protocol.ts @@ -1,5 +1,5 @@ import { Schema, type Effect } from "effect" -import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js" +import type { AIError, LanguageModelSanitizerCompatibility, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js" /** * The semantic API contract of one model server family. @@ -43,6 +43,8 @@ export interface Protocol { readonly stream: ProtocolStream /** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */ readonly supportsEffortUpdates?: (request: LLMRequest) => boolean + /** Tool schema sanitizer for every model on this protocol unless the model's compatibility sets one; wrappers around another `body.from` must forward it. */ + readonly sanitizer?: LanguageModelSanitizerCompatibility } export interface ProtocolBody { diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 175781b9c66..cb27a55584c 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -366,6 +366,8 @@ function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) { provider: ProviderID.make(providerID), providerMetadataKey: optionKey, protocol: "ai-sdk", + // AI SDK providers convert tool schemas themselves, so model-family sanitizers stay off here. + sanitizer: "none", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, transport: {