diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 2dc1cecd6a7..98a17b4370c 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1031,8 +1031,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // messages. Tools live highest in the cache hierarchy, so when callers // 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)) + const definitions = ProviderShared.toolDefinitions(request.tools) + const tools = definitions.length === 0 ? undefined : definitions.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) @@ -1044,7 +1044,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques text: part.text, cache_control: cacheControl(breakpoints, part.cache), })) - const messages = yield* lowerMessages(flattened.request, breakpoints) + const messages = yield* lowerMessages(updates.request, breakpoints) if (breakpoints.dropped > 0) { yield* Effect.logWarning( `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 52a411e966e..9a153a7121c 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -463,7 +463,7 @@ const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly { - if (flattened.tools.length === 0) return undefined + if (tools.length === 0) return undefined return { - tools: lowerTools(breakpoints, flattened.tools), + tools: lowerTools(breakpoints, tools), // Converse has no native "none". Keep definitions stable for prompt // caching and omit only the unsupported choice. toolChoice, } })() const system = lowerSystem(breakpoints, request.system) - const messages = yield* lowerMessages(flattened.request, breakpoints) + const messages = yield* lowerMessages(request, breakpoints) if (breakpoints.dropped > 0) { yield* Effect.logWarning( `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 8c02f11cc9f..dd8555c86be 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -438,8 +438,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { - const flattened = ProviderShared.flattenToolRequest(request) - const hasTools = flattened.tools.length > 0 + const tools = ProviderShared.toolDefinitions(request.tools) + const hasTools = tools.length > 0 const generation = request.generation const options = yield* decodeOptions(request.providerOptions ?? {}) const generationConfig = { @@ -471,7 +471,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque return { cachedContent: options.cachedContent, - contents: yield* lowerMessages(flattened.request), + contents: yield* lowerMessages(request), safetySettings: options.safetySettings, serviceTier: options.serviceTier, systemInstruction: @@ -479,7 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque tools: hasTools ? [ { - functionDeclarations: flattened.tools.map(lowerTool), + functionDeclarations: tools.map(lowerTool), }, ] : undefined, diff --git a/packages/ai/src/protocols/meta-messages.ts b/packages/ai/src/protocols/meta-messages.ts index 0b9f6bd3243..663380c6bb1 100644 --- a/packages/ai/src/protocols/meta-messages.ts +++ b/packages/ai/src/protocols/meta-messages.ts @@ -26,8 +26,8 @@ const Body = Schema.Struct({ }) const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LLMRequest) { - const projected = ProviderShared.flattenToolRequest(request) - const body = yield* AnthropicMessages.protocol.body.from(projected.request) + const tools = ProviderShared.toolDefinitions(request.tools) + const body = yield* AnthropicMessages.protocol.body.from(request) return { ...body, tools: @@ -35,7 +35,7 @@ const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LL ? undefined : yield* Effect.forEach(body.tools, (tool, index) => Effect.gen(function* () { - const native = projected.tools[index]?.native + const native = tools[index]?.native if (native === undefined) return tool const search = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MetaResponses.WebSearch))( native.meta, diff --git a/packages/ai/src/protocols/meta-responses.ts b/packages/ai/src/protocols/meta-responses.ts index 79827fa3178..b11dfdb9a80 100644 --- a/packages/ai/src/protocols/meta-responses.ts +++ b/packages/ai/src/protocols/meta-responses.ts @@ -67,40 +67,39 @@ const adapter = { const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: LLMRequest) { const key = request.model.route.providerMetadataKey ?? String(request.model.provider) - const projected = ProviderShared.flattenToolRequest( - LLMRequest.update(request, { - messages: request.messages.map((message) => - Message.make({ - ...message, - content: message.content.map((part) => { - if ( - part.type !== "tool-result" || - !part.providerExecuted || - part.name !== "image_generation" || - part.result.type !== "content" || - part.providerMetadata?.[key]?.itemId !== part.id - ) - return part - // Meta's signed image ID carries edit state; replay the handle, not the image bytes as a user message. - return ToolResultPart.make({ - ...part, - result: { - type: "json", - value: { type: "image_generation_call", id: part.id, status: "completed", result: null }, - }, - }) - }), + const tools = ProviderShared.toolDefinitions(request.tools) + const projected = LLMRequest.update(request, { + messages: request.messages.map((message) => + Message.make({ + ...message, + content: message.content.map((part) => { + if ( + part.type !== "tool-result" || + !part.providerExecuted || + part.name !== "image_generation" || + part.result.type !== "content" || + part.providerMetadata?.[key]?.itemId !== part.id + ) + return part + // Meta's signed image ID carries edit state; replay the handle, not the image bytes as a user message. + return ToolResultPart.make({ + ...part, + result: { + type: "json", + value: { type: "image_generation_call", id: part.id, status: "completed", result: null }, + }, + }) }), - ), - }), - ) + }), + ), + }) return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({ - ...(yield* OpenResponses.lowerConversation(projected.request, adapter)), + ...(yield* OpenResponses.lowerConversation(projected, adapter)), ...OpenResponses.lowerGeneration(request), tools: - projected.tools.length === 0 + tools.length === 0 ? undefined - : yield* Effect.forEach(projected.tools, (tool) => + : yield* Effect.forEach(tools, (tool) => Effect.gen(function* () { 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 99f20487341..0e226c3f5f2 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -390,11 +390,11 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque tool: (name) => ({ type: "function" as const, function: { name } }), }) : undefined - const flattened = ProviderShared.flattenToolRequest(request) + const tools = ProviderShared.toolDefinitions(request.tools) return { model: request.model.id, - messages: yield* lowerMessages(flattened.request), - tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined, + messages: yield* lowerMessages(request), + tools: tools.length > 0 ? 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 129cc1441d0..4fff347c5b8 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -816,11 +816,11 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd request: LLMRequest, adapter: ProviderAdapter, ) { - const projected = ProviderShared.flattenToolRequest(request) + const tools = ProviderShared.toolDefinitions(request.tools) return { - ...(yield* lowerConversation(projected.request, adapter)), + ...(yield* lowerConversation(request, adapter)), ...lowerGeneration(request), - tools: projected.tools.length === 0 ? undefined : yield* lowerTools(projected.tools, adapter), + tools: tools.length === 0 ? undefined : yield* lowerTools(tools, adapter), tool_choice: allowedToolChoice(request) ?? (request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined), diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 8f1794b1e34..ea08b228784 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -814,7 +814,6 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( `OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`, ) const generation = request.generation - const flattened = ProviderShared.flattenToolRequest(request) const provider = String(request.model.provider) const baseURL = request.model.route.endpoint.baseURL const detectedMaxTokensField = detectMaxTokensField(provider, baseURL) @@ -827,16 +826,16 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( const zaiToolStream = request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id) const hasHistory = hasToolHistory(request.messages) - const hasActiveTools = flattened.tools.length > 0 + const tools = ProviderShared.toolDefinitions(request.tools) + const hasActiveTools = tools.length > 0 return { model: request.model.id, - messages: yield* lowerMessages(flattened.request, options), - tools: - flattened.tools.length === 0 - ? hasHistory - ? [] - : undefined - : flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)), + messages: yield* lowerMessages(request, options), + tools: !hasActiveTools + ? hasHistory + ? [] + : undefined + : 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 e36496954b9..e352f0f1d94 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -172,8 +172,7 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe 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. +// The route has already joined namespace levels below the first into leaf names. 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 @@ -182,7 +181,7 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (to type: "namespace" as const, name: tool.name, description: tool.description ?? `Tools in the ${tool.name} namespace.`, - tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) => + tools: yield* Effect.forEach(ProviderShared.toolDefinitions(tool.tools), (leaf) => OpenResponses.lowerTool(NAME, leaf), ), } @@ -308,6 +307,7 @@ export const protocol = Protocol.make({ terminal: OpenResponses.terminal, }, supportsEffortUpdates, + namespaces: "native", }) const endpoint = Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }) diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index bd8d2538d6a..a0a1bd8bb06 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -288,36 +288,11 @@ export const unsupportedOperation = (input: { }) /** - * Lower namespaces to flat definitions for protocols without a native - * namespace construct. Leaf names join their namespace path with `_` because - * `.` is not broadly accepted in provider tool names. + * Tool definitions of a route-lowered request. The route flattens namespaces + * before a flat protocol's `body.from`, so no namespace reaches this point. */ -export const flattenTools = (tools: ReadonlyArray, path: ReadonlyArray = []) => { - const flat = tools.flatMap((tool): ReadonlyArray => { - if (tool.type === "namespace") return flattenTools(tool.tools, [...path, tool.name]) - if (path.length === 0) return [tool] - return [new ToolDefinition({ ...tool, name: [...path, tool.name].join("_") })] - }) - return [...new Map(flat.map((tool) => [tool.name, tool])).values()] -} - -export const flattenToolRequest = (request: LLMRequest) => { - const messages = request.messages.map((message) => { - const content = message.content.map((part) => { - if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part - return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined } - }) - return content.every((part, index) => part === message.content[index]) - ? message - : new Message({ ...message, content }) - }) - return { - tools: flattenTools(request.tools), - request: messages.every((message, index) => message === request.messages[index]) - ? request - : LLMRequest.update(request, { messages }), - } -} +export const toolDefinitions = (tools: ReadonlyArray) => + tools.filter((tool): tool is ToolDefinition => tool.type === "tool") export const matchToolChoice = ( route: string, diff --git a/packages/ai/src/protocols/xai-responses.ts b/packages/ai/src/protocols/xai-responses.ts index bb19ebd843a..da564cc318e 100644 --- a/packages/ai/src/protocols/xai-responses.ts +++ b/packages/ai/src/protocols/xai-responses.ts @@ -95,7 +95,7 @@ export const protocol = Protocol.make({ }) export const compact = ResponsesCompaction.make(adapter, (request) => - OpenResponses.lowerTools(ProviderShared.flattenTools(request.tools), adapter), + OpenResponses.lowerTools(ProviderShared.toolDefinitions(request.tools), adapter), ) export * as XAIResponses from "./xai-responses.js" diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 83e762160a1..bbaf46515da 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -10,6 +10,7 @@ import { applyCachePolicy } from "../cache-policy.js" import { applyEffortUpdates } from "../effort-updates.js" import { normalizeToolHistory } from "../tool-history.js" import { sanitizeSurrogates } from "../utils/sanitize.js" +import { ToolNames, type NamespaceStyle } from "../tool-names.js" import * as ProviderShared from "../protocols/shared.js" import { ToolSchemaProjection } from "../protocols/utils/tool-schema.js" import type { LanguageModelSanitizerCompatibility, ProtocolID, ProviderOptions } from "../schema/index.js" @@ -59,6 +60,7 @@ export interface Route< readonly body: RouteBody readonly supportsEffortUpdates?: (request: LLMRequest) => boolean readonly sanitizer?: LanguageModelSanitizerCompatibility + readonly namespaces: NamespaceStyle readonly with: { ( patch: RoutePatch & { readonly compact: Next }, @@ -391,6 +393,7 @@ function makeFromTransport( body: protocol.body, supportsEffortUpdates: protocol.supportsEffortUpdates, sanitizer: protocol.sanitizer, + namespaces: protocol.namespaces ?? "flat", with: (patch: RoutePatch) => { const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch return build({ @@ -572,8 +575,12 @@ const prepareRequest = (request: LLMRequest) => { : LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) }) } +// Protocols receive wire tool names; callers keep declared names in both directions. +const lowerRequest = (request: LLMRequest) => ToolNames.lower(prepareRequest(request), request.model.route.namespaces) + const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) { - const resolved = prepareRequest(request) + const lowered = yield* lowerRequest(request) + const resolved = lowered.request const route = resolved.model.route const body = yield* route.body @@ -586,6 +593,7 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options route, body, prepared, + raise: lowered.raise, } }) @@ -606,7 +614,9 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o Stream.unwrap( Effect.gen(function* () { const compiled = yield* compile(request, options) - return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options) + return compiled.route + .streamPrepared(compiled.prepared, compiled.request, runtime, options) + .pipe(Stream.map(compiled.raise)) }), ) @@ -672,10 +682,14 @@ export const layer: Layer.Layer = Layer ): Effect.Effect function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) { return Effect.suspend((): Effect.Effect => { - if (options?.mechanism === "trigger" && canCompact(request, options)) - return request.model.route.compact.trigger(prepareRequest(request), executor, options) - if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request)) - return request.model.route.compact.endpoint(prepareRequest(request), executor, options) + if (options?.mechanism === "trigger" && canCompact(request, options)) { + const trigger = request.model.route.compact.trigger + return lowerRequest(request).pipe(Effect.flatMap((lowered) => trigger(lowered.request, executor, options))) + } + if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request)) { + const endpoint = request.model.route.compact.endpoint + return lowerRequest(request).pipe(Effect.flatMap((lowered) => endpoint(lowered.request, executor, options))) + } return unsupportedCompaction(request, options?.mechanism) }) } diff --git a/packages/ai/src/route/protocol.ts b/packages/ai/src/route/protocol.ts index 89c8d536108..23911098f9e 100644 --- a/packages/ai/src/route/protocol.ts +++ b/packages/ai/src/route/protocol.ts @@ -1,5 +1,6 @@ import { Schema, type Effect } from "effect" import type { AIError, LanguageModelSanitizerCompatibility, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js" +import type { NamespaceStyle } from "../tool-names.js" /** * The semantic API contract of one model server family. @@ -45,6 +46,8 @@ export interface Protocol { 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 + /** How `body.from` receives tool namespaces; defaults to `"flat"`. Wrappers around another `body.from` must forward it. */ + readonly namespaces?: NamespaceStyle } export interface ProtocolBody { diff --git a/packages/ai/src/tool-names.ts b/packages/ai/src/tool-names.ts new file mode 100644 index 00000000000..f2f79ccb179 --- /dev/null +++ b/packages/ai/src/tool-names.ts @@ -0,0 +1,114 @@ +import { Effect } from "effect" +import { + AIError, + LLMRequest, + Message, + ToolDefinition, + type LLMEvent, + type ToolEntry, + type ToolNamespace, +} from "./schema/index.js" +import { ProviderShared } from "./protocols/shared.js" + +/** + * How a protocol represents tool namespaces on the wire. + * + * Callers always name a namespaced tool by its declared path, for example + * `{ namespace: "crm.orders", name: "list" }`. Flat protocols see + * `crm_orders_list`; native protocols keep the outer namespace and see + * `{ namespace: "crm", name: "orders_list" }`. + */ +export type NamespaceStyle = "flat" | "native" + +interface ToolName { + readonly namespace?: string + readonly name: string +} + +interface Leaf { + readonly namespace?: string + readonly tool: ToolDefinition +} + +/** + * Lower declared tool names in a request to the protocol's wire names, and + * return the inverse for the protocol's tool events. + */ +export const lower = Effect.fn("ToolNames.lower")(function* (request: LLMRequest, style: NamespaceStyle) { + const leaves = yield* collect(request.tools, []) + const names = leaves.map((leaf) => { + const declared = { namespace: leaf.namespace, name: leaf.tool.name } + return { declared, wire: key(wire(declared, style)) } + }) + const collision = names.flatMap((entry, index) => + names + .slice(0, index) + .filter((other) => other.wire === entry.wire) + .map((other) => [other, entry] as const), + )[0] + if (collision !== undefined) + return yield* ProviderShared.invalidRequest( + `Tools "${key(collision[0].declared)}" and "${key(collision[1].declared)}" both use the provider tool name "${collision[1].wire}"`, + ) + const declared = new Map(names.map((entry) => [entry.wire, entry.declared])) + return { + request: LLMRequest.update(request, { + tools: style === "flat" ? leaves.map((leaf) => rename(leaf, style)) : yield* nativeTools(request.tools), + messages: lowerMessages(request.messages, style), + }), + raise: (event: LLMEvent): LLMEvent => { + if (!("name" in event)) return event + const name = declared.get(key(event)) + return name === undefined ? event : { ...event, namespace: name.namespace, name: name.name } + }, + } +}) + +const collect = (tools: ReadonlyArray, path: ReadonlyArray): Effect.Effect => + Effect.forEach(tools, (tool) => { + if (tool.type === "tool") + return Effect.succeed([{ namespace: path.length === 0 ? undefined : path.join("."), tool }]) + if (tool.name.includes(".")) + return ProviderShared.invalidRequest(`Tool namespace "${tool.name}" must not contain "."`) + return collect(tool.tools, [...path, tool.name]) + }).pipe(Effect.map((groups) => groups.flat())) + +// Native namespaces are one level deep, so deeper levels join into the leaf name. +const nativeTools = (tools: ReadonlyArray) => + Effect.forEach( + tools, + (tool): Effect.Effect => + tool.type === "tool" + ? Effect.succeed(tool) + : collect(tool.tools, [tool.name]).pipe( + Effect.map((leaves): ToolNamespace => ({ ...tool, tools: leaves.map((leaf) => rename(leaf, "native")) })), + ), + ) + +const lowerMessages = (messages: ReadonlyArray, style: NamespaceStyle) => + messages.map((message) => { + const content = message.content.map((part) => { + if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part + const name = wire(part, style) + return { ...part, namespace: name.namespace, name: name.name } + }) + return content.every((part, index) => part === message.content[index]) + ? message + : new Message({ ...message, content }) + }) + +const rename = (leaf: Leaf, style: NamespaceStyle) => + leaf.namespace === undefined + ? leaf.tool + : new ToolDefinition({ ...leaf.tool, name: wire({ namespace: leaf.namespace, name: leaf.tool.name }, style).name }) + +const wire = (tool: ToolName, style: NamespaceStyle): ToolName => { + if (tool.namespace === undefined) return { name: tool.name } + const path = tool.namespace.split(".") + if (style === "flat") return { name: [...path, tool.name].join("_") } + return { namespace: path[0], name: [...path.slice(1), tool.name].join("_") } +} + +const key = (tool: ToolName) => (tool.namespace === undefined ? tool.name : `${tool.namespace}.${tool.name}`) + +export * as ToolNames from "./tool-names.js" diff --git a/packages/ai/src/tool.ts b/packages/ai/src/tool.ts index c04b666887c..f54b16af21b 100644 --- a/packages/ai/src/tool.ts +++ b/packages/ai/src/tool.ts @@ -3,6 +3,7 @@ import { Tool } from "@opencode/schema/tool" import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, + ToolEntry, ToolOutput as ToolOutputType, } from "./schema/index.js" import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js" @@ -207,29 +208,50 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { } /** - * A record of named tools. The record key becomes the tool name on the wire. + * A record of named tools. A dotted key such as `crm.lookup` declares `lookup` + * inside the `crm` namespace, matching the `namespace.name` identity that + * `ToolRuntime.dispatch` resolves. */ export type Tools = Record /** - * Convert a tools record into the `ToolDefinition[]` shape that - * `LLMRequest.tools` expects. + * Convert a tools record into the tool entries that `LLMRequest.tools` + * expects, nesting dotted keys into namespaces. * * Tool names come from the record keys, so the per-tool cached * `_definition` is rebuilt with the correct name here. The JSON Schema body * is reused. */ -export const toDefinitions = (tools: Tools): ReadonlyArray => - Object.entries(tools).map( - ([name, item]) => - new ToolDefinition({ - name, - description: item._definition.description, - inputSchema: item._definition.inputSchema, - outputSchema: item._definition.outputSchema, - }), +export const toDefinitions = (tools: Tools): ReadonlyArray => + nest( + Object.entries(tools).map(([key, item]) => ({ + path: key.split("."), + definition: item._definition, + })), ) +const nest = ( + entries: ReadonlyArray<{ readonly path: ReadonlyArray; readonly definition: ToolDefinitionClass }>, +): ReadonlyArray => + [...new Set(entries.map((entry) => (entry.path.length === 1 ? entry : entry.path[0])))].map((group) => { + if (typeof group !== "string") + return new ToolDefinition({ + name: group.path[0], + description: group.definition.description, + inputSchema: group.definition.inputSchema, + outputSchema: group.definition.outputSchema, + }) + return { + type: "namespace" as const, + name: group, + tools: nest( + entries + .filter((entry) => entry.path.length > 1 && entry.path[0] === group) + .map((entry) => ({ ...entry, path: entry.path.slice(1) })), + ), + } + }) + const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { const document = Schema.toJsonSchemaDocument(schema) if (Object.keys(document.definitions).length === 0) return document.schema diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 81860398d6d..baf0f1e8e33 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -213,26 +213,83 @@ describe("Open Responses-compatible route", () => { strict: false, }, ]) + + const collision = yield* compileRequest( + LLM.request({ + model, + tools: [ + ToolDefinition.make({ name: "acme_users", description: "Top-level users", inputSchema: {} }), + { + type: "namespace", + name: "acme", + tools: [ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} })], + }, + ], + }), + ).pipe(Effect.flip) + expect(collision.message).toContain( + 'Tools "acme_users" and "acme.users" both use the provider tool name "acme_users"', + ) }), ) - it.effect("flattens tool namespaces in history", () => + it.effect("keeps declared tool names through flattened streaming and history replay", () => Effect.gen(function* () { const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( "example-model", ) + const tools = [ + { + type: "namespace" as const, + name: "crm", + tools: [ + { + type: "namespace" as const, + name: "orders", + tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })], + }, + ], + }, + ] + const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "crm_orders_list", arguments: "" } + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "List orders.", tools })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 0, item }, + { type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type.startsWith("tool-"))).toEqual([ + expect.objectContaining({ type: "tool-input-start", name: "list", namespace: "crm.orders" }), + expect.objectContaining({ type: "tool-input-end", name: "list", namespace: "crm.orders" }), + expect.objectContaining({ type: "tool-call", name: "list", namespace: "crm.orders", input: {} }), + ]) + const prepared = yield* compileRequest( LLM.request({ model, + tools, messages: [ - Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }), - Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: "done", resultType: "text" }), + response.message, + Message.tool({ id: "call_1", name: "list", namespace: "crm.orders", result: "done", resultType: "text" }), ], }), ) expect(prepared.body.input).toEqual([ - { type: "function_call", call_id: "call_1", name: "crm_lookup", namespace: undefined, arguments: "{}" }, + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "crm_orders_list", + namespace: undefined, + arguments: "{}", + }, { type: "function_call_output", call_id: "call_1", output: "done" }, ]) }), diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 280271a31e8..e19f898e293 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2432,17 +2432,28 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("preserves tool namespaces through streaming and history replay", () => + it.effect("keeps declared tool names through native streaming and history replay", () => Effect.gen(function* () { + const tools = [ + ToolNamespace.make({ + name: "crm", + tools: [ + ToolNamespace.make({ + name: "orders", + tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })], + }), + ], + }), + ] const item = { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "crm", - name: "lookup", + name: "orders_list", arguments: "", } - const response = yield* LLMClient.generate(request).pipe( + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "List orders.", tools })).pipe( Effect.provide( fixedResponse( sseEvents( @@ -2466,21 +2477,22 @@ describe("OpenAI Responses route", () => { const toolEvents = response.events.filter((event) => event.type.startsWith("tool-")) expect(toolEvents).toEqual([ - expect.objectContaining({ type: "tool-input-start", name: "lookup", namespace: "crm" }), - expect.objectContaining({ type: "tool-input-delta", name: "lookup", namespace: "crm" }), - expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }), - expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), + expect.objectContaining({ type: "tool-input-start", name: "list", namespace: "crm.orders" }), + expect.objectContaining({ type: "tool-input-delta", name: "list", namespace: "crm.orders" }), + expect.objectContaining({ type: "tool-input-end", name: "list", namespace: "crm.orders" }), + expect.objectContaining({ type: "tool-call", name: "list", namespace: "crm.orders", input: { id: "123" } }), ]) expect(response.message.content).toEqual([ - expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), + expect.objectContaining({ type: "tool-call", name: "list", namespace: "crm.orders", input: { id: "123" } }), ]) const prepared = yield* compileRequest( LLM.request({ model, + tools, messages: [ response.message, - Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: { customer: "Ada" } }), + Message.tool({ id: "call_1", name: "list", namespace: "crm.orders", result: { customer: "Ada" } }), ], }), ) @@ -2490,7 +2502,7 @@ describe("OpenAI Responses route", () => { id: "fc_1", call_id: "call_1", namespace: "crm", - name: "lookup", + name: "orders_list", arguments: '{"id":"123"}', }, { type: "function_call_output", call_id: "call_1", output: '{"customer":"Ada"}' }, @@ -4998,11 +5010,11 @@ describe("OpenAI Responses route", () => { it.effect("retains the token-sharing HTTP 429 body for retry hooks", () => Effect.gen(function* () { - const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } }) + const body = JSON.stringify({ + error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" }, + }) const error = yield* LLMClient.generate(request).pipe( - Effect.provide( - fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }), - ), + Effect.provide(fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } })), Effect.flip, ) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index cb27a55584c..36339b7b96e 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -368,6 +368,7 @@ function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) { protocol: "ai-sdk", // AI SDK providers convert tool schemas themselves, so model-family sanitizers stay off here. sanitizer: "none", + namespaces: "flat", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, transport: { @@ -477,9 +478,8 @@ function callOptions( modelID: ID, optionKey: string, ): LanguageModelV3CallOptions { - const flattened = ProviderShared.flattenToolRequest(request) return { - prompt: prompt(flattened.request), + prompt: prompt(request), maxOutputTokens: request.generation?.maxTokens, temperature: request.generation?.temperature, stopSequences: request.generation?.stop === undefined ? undefined : [...request.generation.stop], @@ -488,7 +488,7 @@ function callOptions( presencePenalty: request.generation?.presencePenalty, frequencyPenalty: request.generation?.frequencyPenalty, seed: request.generation?.seed, - tools: flattened.tools.map(tool), + tools: ProviderShared.toolDefinitions(request.tools).map(tool), toolChoice: toolChoice(request.toolChoice), headers: request.http?.headers, providerOptions: requestProviderOptions(request.providerOptions, packageName, modelID, optionKey),