From 2fe21224ac34f19eba08a2c661dc6972e24d2095 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 6 May 2026 20:40:47 -0400 Subject: [PATCH] refactor(llm): share tool choice lowering --- .../llm/src/protocols/anthropic-messages.ts | 16 +++++++-------- .../llm/src/protocols/bedrock-converse.ts | 16 +++++++-------- packages/llm/src/protocols/gemini.ts | 18 +++++++---------- packages/llm/src/protocols/openai-chat.ts | 14 ++++++------- .../llm/src/protocols/openai-responses.ts | 14 ++++++------- packages/llm/src/protocols/shared.ts | 20 ++++++++++++++++++- 6 files changed, 54 insertions(+), 44 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 023265617e..dc6250f318 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -209,15 +209,13 @@ const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ input_schema: tool.inputSchema, }) -const lowerToolChoice = Effect.fn("AnthropicMessages.lowerToolChoice")(function* ( - toolChoice: NonNullable, -) { - if (toolChoice.type === "none") return undefined - if (toolChoice.type === "required") return { type: "any" as const } - if (toolChoice.type !== "tool") return { type: "auto" as const } - if (!toolChoice.name) return yield* invalid("Anthropic Messages tool choice requires a tool name") - return { type: "tool" as const, name: toolChoice.name } -}) +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { + auto: () => ({ type: "auto" as const }), + none: () => undefined, + required: () => ({ type: "any" as const }), + tool: (name) => ({ type: "tool" as const, name }), + }) const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ type: "tool_use", diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index bdb5544985..8aadbb3fe9 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -232,15 +232,13 @@ const textWithCache = (text: string, cache: CacheHint | undefined): Array, -) { - if (toolChoice.type === "none") return undefined - if (toolChoice.type === "required") return { any: {} } as const - if (toolChoice.type !== "tool") return { auto: {} } as const - if (!toolChoice.name) return yield* invalid("Bedrock Converse tool choice requires a tool name") - return { tool: { name: toolChoice.name } } as const -}) +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Bedrock Converse", toolChoice, { + auto: () => ({ auto: {} }) as const, + none: () => undefined, + required: () => ({ any: {} }) as const, + tool: (name) => ({ tool: { name } }) as const, + }) const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ toolUse: { diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index a310f4f132..e9d4957471 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -170,17 +170,13 @@ const lowerTool = (tool: ToolDefinition) => ({ parameters: GeminiToolSchema.convert(tool.inputSchema), }) -const lowerToolConfig = Effect.fn("Gemini.lowerToolConfig")(function* ( - toolChoice: NonNullable, -) { - if (toolChoice.type === "required") return { functionCallingConfig: { mode: "ANY" as const } } - if (toolChoice.type === "none") return { functionCallingConfig: { mode: "NONE" as const } } - if (toolChoice.type !== "tool") return { functionCallingConfig: { mode: "AUTO" as const } } - if (!toolChoice.name) return yield* invalid("Gemini tool choice requires a tool name") - return { - functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [toolChoice.name] }, - } -}) +const lowerToolConfig = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Gemini", toolChoice, { + auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }), + none: () => ({ functionCallingConfig: { mode: "NONE" as const } }), + required: () => ({ functionCallingConfig: { mode: "ANY" as const } }), + tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }), + }) const lowerUserPart = (part: TextPart | MediaPart) => part.type === "text" diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index c2a8e202dc..f38eb86661 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -166,13 +166,13 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ }, }) -const lowerToolChoice = Effect.fn("OpenAIChat.lowerToolChoice")(function* ( - toolChoice: NonNullable, -) { - if (toolChoice.type !== "tool") return toolChoice.type - if (!toolChoice.name) return yield* invalid("OpenAI Chat tool choice requires a tool name") - return { type: "function" as const, function: { name: toolChoice.name } } -}) +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Chat", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, function: { name } }), + }) const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ id: part.id, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index a75a77e57b..dd1e575c2c 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -158,13 +158,13 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ parameters: tool.inputSchema, }) -const lowerToolChoice = Effect.fn("OpenAIResponses.lowerToolChoice")(function* ( - toolChoice: NonNullable, -) { - if (toolChoice.type !== "tool") return toolChoice.type - if (!toolChoice.name) return yield* invalid("OpenAI Responses tool choice requires a tool name") - return { type: "function" as const, name: toolChoice.name } -}) +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, name }), + }) const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ type: "function_call", diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 26dd113005..59019558a6 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer" import { Cause, Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" -import { InvalidRequestError, ProviderChunkError, type MediaPart, type ToolResultPart } from "../schema" +import { InvalidRequestError, ProviderChunkError, type LLMRequest, type MediaPart, type ToolResultPart } from "../schema" export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) @@ -168,6 +168,24 @@ export const sseFraming = ( */ export const invalidRequest = (message: string) => new InvalidRequestError({ message }) +export const matchToolChoice = ( + adapter: string, + toolChoice: NonNullable, + cases: { + readonly auto: () => Auto + readonly none: () => None + readonly required: () => Required + readonly tool: (name: string) => Tool + }, +) => + Effect.gen(function* () { + if (toolChoice.type === "auto") return cases.auto() + if (toolChoice.type === "none") return cases.none() + if (toolChoice.type === "required") return cases.required() + if (!toolChoice.name) return yield* invalidRequest(`${adapter} tool choice requires a tool name`) + return cases.tool(toolChoice.name) + }) + /** * Build a `validate` step from a Schema decoder. Replaces the per-adapter * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>