refactor(ai): keep declared tool names across protocols

This commit is contained in:
Aiden Cline
2026-09-29 21:21:28 -05:00
parent 74dbc509d7
commit 5310957aff
18 changed files with 325 additions and 130 deletions
@@ -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.`,
@@ -463,7 +463,7 @@ const applyThinkingBindingDefault = (request: LLMRequest, thinking: Readonly<Rec
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const tools = ProviderShared.toolDefinitions(request.tools)
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
@@ -485,16 +485,16 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const toolConfig = (() => {
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.`,
+4 -4
View File
@@ -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,
+3 -3
View File
@@ -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,
+28 -29
View File
@@ -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)
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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),
+8 -9
View File
@@ -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 } } : {}),
@@ -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<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
+4 -29
View File
@@ -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<ToolEntry>, path: ReadonlyArray<string> = []) => {
const flat = tools.flatMap((tool): ReadonlyArray<ToolDefinition> => {
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<ToolEntry>) =>
tools.filter((tool): tool is ToolDefinition => tool.type === "tool")
export const matchToolChoice = <Auto, None, Required, Tool>(
route: string,
+1 -1
View File
@@ -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"
+20 -6
View File
@@ -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<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly sanitizer?: LanguageModelSanitizerCompatibility
readonly namespaces: NamespaceStyle
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -391,6 +393,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
sanitizer: protocol.sanitizer,
namespaces: protocol.namespaces ?? "flat",
with: (patch: RoutePatch<Body, Prepared>) => {
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<Service, never, RequestExecutor.Service> = Layer
): Effect.Effect<CompactionCheckpointResponse, AIError>
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
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)
})
}
+3
View File
@@ -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<Body, Frame, Event, State> {
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<Body> {
+114
View File
@@ -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<ToolEntry>, path: ReadonlyArray<string>): Effect.Effect<Leaf[], AIError> =>
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<ToolEntry>) =>
Effect.forEach(
tools,
(tool): Effect.Effect<ToolEntry, AIError> =>
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<Message>, 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"
+34 -12
View File
@@ -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<string, AnyTool>
/**
* 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<ToolDefinitionClass> =>
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<ToolEntry> =>
nest(
Object.entries(tools).map(([key, item]) => ({
path: key.split("."),
definition: item._definition,
})),
)
const nest = (
entries: ReadonlyArray<{ readonly path: ReadonlyArray<string>; readonly definition: ToolDefinitionClass }>,
): ReadonlyArray<ToolEntry> =>
[...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
@@ -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" },
])
}),
@@ -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,
)
+3 -3
View File
@@ -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),