feat(ai): support Responses tool controls (#43329)

This commit is contained in:
Aiden Cline
2026-08-18 23:33:04 -05:00
committed by GitHub
parent d5bf8799c0
commit daf3f9ed08
6 changed files with 115 additions and 68 deletions
+22 -1
View File
@@ -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,
@@ -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
})
@@ -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<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(ResponseIncludables)
const SERVICE_TIERS = new Set<string>(ServiceTiers)
const TRUNCATIONS = new Set<string>(Truncations)
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
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<ResponseIncludable>(
(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<ResponseIncludable>
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
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<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
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,
}
}
@@ -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<ResponseIncludable>
readonly textVerbosity?: TextVerbosity
readonly serviceTier?: ServiceTier
readonly truncation?: Truncation
}
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
readonly openresponses?: OpenResponsesOptionsInput
@@ -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,
})
}),
)
@@ -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"])
}),
)