diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 3ec5575f89..7db349a7f7 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -141,6 +141,11 @@ export const Tool = Schema.Struct({ export const ToolChoice = Schema.Union([ Schema.Literals(["auto", "none", "required"]), Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), + Schema.Struct({ + type: Schema.tag("allowed_tools"), + mode: Schema.Literals(["auto", "none", "required"]), + tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })), + }), ]) // Fields shared between the HTTP body and the WebSocket `response.create` @@ -170,6 +175,8 @@ export const coreFields = { }), ), max_output_tokens: Schema.optional(Schema.Number), + max_tool_calls: Schema.optional(Schema.Int), + parallel_tool_calls: Schema.optional(Schema.Boolean), temperature: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number), } @@ -578,10 +585,22 @@ const lowerOptions = (request: LLMRequest) => { : {}), ...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}), ...(options.serviceTier ? { service_tier: options.serviceTier } : {}), + ...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}), + ...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}), ...(options.truncation ? { truncation: options.truncation } : {}), } } +const allowedToolChoice = (request: LLMRequest) => { + const allowed = OpenResponsesOptions.resolve(request).allowedTools + if (!allowed) return undefined + return { + type: "allowed_tools" as const, + mode: allowed.mode, + tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })), + } +} + export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* ( request: LLMRequest, extension: Extension, @@ -601,7 +620,9 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ), ), - tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined, + tool_choice: + allowedToolChoice(request) ?? + (request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined), stream: true as const, max_output_tokens: generation?.maxTokens, temperature: generation?.temperature, diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 5069874cb3..0adf5707ea 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -121,7 +121,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: : yield* Effect.forEach(request.tools, (tool) => lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), ), - tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined, + tool_choice: + body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined), } satisfies OpenAIResponsesBody }) diff --git a/packages/ai/src/protocols/utils/open-responses-options.ts b/packages/ai/src/protocols/utils/open-responses-options.ts index ff148fc1fd..8f504a8030 100644 --- a/packages/ai/src/protocols/utils/open-responses-options.ts +++ b/packages/ai/src/protocols/utils/open-responses-options.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Option, Schema } from "effect" import { TextVerbosity, type LLMRequest } from "../../schema/index.js" export const ResponseIncludables = [ @@ -11,7 +11,7 @@ export const ResponseIncludables = [ "reasoning.encrypted_content", "message.output_text.logprobs", ] as const -export type ResponseIncludable = (typeof ResponseIncludables)[number] +export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {}) export const ServiceTiers = ["auto", "default", "flex", "priority"] as const export type ServiceTier = (typeof ServiceTiers)[number] @@ -19,52 +19,54 @@ export type ServiceTier = (typeof ServiceTiers)[number] export const Truncations = ["auto", "disabled"] as const export type Truncation = (typeof Truncations)[number] -const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) -const INCLUDABLES = new Set(ResponseIncludables) -const SERVICE_TIERS = new Set(ServiceTiers) -const TRUNCATIONS = new Set(Truncations) - -const isTextVerbosity = (value: unknown): value is Schema.Schema.Type => - typeof value === "string" && TEXT_VERBOSITY.has(value) - -const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value) -const isTruncation = (value: unknown): value is Truncation => typeof value === "string" && TRUNCATIONS.has(value) - export const ReasoningEffort = Schema.String export const TextVerbositySchema = TextVerbosity -export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables) +export const ResponseIncludableSchema = Schema.declare( + (value): value is ResponseIncludable => typeof value === "string", + { title: "ResponseIncludable" }, +) export const ServiceTierSchema = Schema.Literals(ServiceTiers) export const TruncationSchema = Schema.Literals(Truncations) -export interface Resolved { - readonly instructions?: string - readonly store?: boolean - readonly reasoningEffort?: string - readonly reasoningSummary?: "auto" | "concise" | "detailed" - readonly include?: ReadonlyArray - readonly textVerbosity?: Schema.Schema.Type - readonly serviceTier?: ServiceTier - readonly truncation?: Truncation +export const AllowedTools = Schema.Struct({ + toolNames: Schema.Array(Schema.String), + mode: Schema.optional(Schema.Literals(["auto", "none", "required"])), +}) +export type AllowedTools = typeof AllowedTools.Type + +export const Options = Schema.Struct({ + instructions: Schema.optional(Schema.String), + store: Schema.optional(Schema.Boolean), + reasoningEffort: Schema.optional(ReasoningEffort), + reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])), + include: Schema.optional(Schema.Array(ResponseIncludableSchema)), + textVerbosity: Schema.optional(TextVerbositySchema), + serviceTier: Schema.optional(ServiceTierSchema), + truncation: Schema.optional(TruncationSchema), + allowedTools: Schema.optional(AllowedTools), + maxToolCalls: Schema.optional(Schema.Int), + parallelToolCalls: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type + +export type Resolved = Omit & { + readonly allowedTools?: AllowedTools & { readonly mode: NonNullable } } +const decodeOptions = Schema.decodeUnknownOption(Options) + export const resolve = (request: LLMRequest): Resolved => { - const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"] - const include = Array.isArray(input?.include) - ? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry)) - : [] - const reasoningSummary = input?.reasoningSummary + const input = Option.getOrUndefined( + decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]), + ) + if (!input) return {} return { - instructions: typeof input?.instructions === "string" ? input.instructions : undefined, - store: typeof input?.store === "boolean" ? input.store : undefined, - reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined, - reasoningSummary: - reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed" - ? reasoningSummary + ...input, + include: input.include?.length ? input.include : undefined, + allowedTools: + input.allowedTools && input.allowedTools.toolNames.length > 0 + ? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" } : undefined, - include: include.length > 0 ? include : undefined, - textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined, - serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined, - truncation: isTruncation(input?.truncation) ? input.truncation : undefined, } } diff --git a/packages/ai/src/providers/open-responses-options.ts b/packages/ai/src/providers/open-responses-options.ts index 1a844e2732..71bc1e2d01 100644 --- a/packages/ai/src/providers/open-responses-options.ts +++ b/packages/ai/src/providers/open-responses-options.ts @@ -1,17 +1,7 @@ -import type { ResponseIncludable, ServiceTier, Truncation } from "../protocols/utils/open-responses-options.js" -import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js" +import type { Options } from "../protocols/utils/open-responses-options.js" +import type { ProviderOptions } from "../schema/index.js" -export interface OpenResponsesOptionsInput { - readonly [key: string]: unknown - readonly instructions?: string - readonly store?: boolean - readonly reasoningEffort?: ReasoningEffort - readonly reasoningSummary?: "auto" | "concise" | "detailed" - readonly include?: ReadonlyArray - readonly textVerbosity?: TextVerbosity - readonly serviceTier?: ServiceTier - readonly truncation?: Truncation -} +export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown } export type OpenResponsesProviderOptionsInput = ProviderOptions & { readonly openresponses?: OpenResponsesOptionsInput diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 0ca953ee22..005d07aac6 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, Message } from "../../src/index.js" +import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js" import { configure } from "../../src/providers/openai-compatible-responses.js" import { OpenAI } from "../../src/providers.js" import { OpenResponses } from "../../src/protocols/open-responses.js" @@ -123,14 +123,36 @@ describe("Open Responses-compatible route", () => { const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1", - providerOptions: { openresponses: { reasoningEffort: "low", store: true, truncation: "auto" } }, + providerOptions: { + openresponses: { + reasoningEffort: "low", + store: true, + truncation: "auto", + allowedTools: { toolNames: ["lookup"] }, + maxToolCalls: 2, + parallelToolCalls: false, + }, + }, }).model("example-model") - const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." })) + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Think.", + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], + }), + ) expect(prepared.body).toMatchObject({ reasoning: { effort: "low" }, store: true, truncation: "auto", + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "lookup" }], + }, + max_tool_calls: 2, + parallel_tool_calls: false, }) }), ) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index dd97982baa..da07239d71 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -246,11 +246,7 @@ describe("OpenAI Responses route", () => { const prepared = yield* compileRequest( LLM.request({ model, - messages: [ - Message.user("Before."), - Message.system("Operator update."), - Message.assistant("After."), - ], + messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")], }), ) @@ -1278,12 +1274,20 @@ describe("OpenAI Responses route", () => { model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), prompt: "think", promptCacheKey: "session_123", + tools: [ + ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }), + ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }), + ], + toolChoice: "none", providerOptions: { openai: { reasoningEffort: "high", reasoningSummary: "auto", include: ["reasoning.encrypted_content"], truncation: "disabled", + allowedTools: { toolNames: ["read", "grep"], mode: "required" }, + maxToolCalls: 4, + parallelToolCalls: false, }, }, }), @@ -1295,6 +1299,16 @@ describe("OpenAI Responses route", () => { expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) expect(prepared.body.text).toEqual({ verbosity: "low" }) expect(prepared.body.truncation).toBe("disabled") + expect(prepared.body.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "read" }, + { type: "function", name: "grep" }, + ], + }) + expect(prepared.body.max_tool_calls).toBe(4) + expect(prepared.body.parallel_tool_calls).toBe(false) }), ) @@ -1320,20 +1334,17 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("filters unknown includable values out of the include array", () => + it.effect("passes forward-compatible includable values through", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", - // The user passed one invalid entry alongside a valid one. Keep the - // valid one so the request still succeeds rather than failing on a - // typo from upstream config. providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } }, }), ) - expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"]) }), ) @@ -1347,13 +1358,13 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("treats an all-invalid include as no include at all", () => + it.effect("passes an unknown includable value through", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }), ) - expect(prepared.body.include).toBeUndefined() + expect(prepared.body.include).toEqual(["bogus.thing"]) }), )