mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(ai): keep declared tool names across protocols
This commit is contained in:
@@ -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.`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user