From eacaa130abaee03132ae6502f37a603e2cd067f1 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Mon, 13 Jul 2026 04:48:33 -0300 Subject: [PATCH] ai: OpenAI and Codex forced tool calls (#6588) * feat(ai): support forced OpenAI Codex tool calls * feat(ai): support OpenAI Responses tool choice --- packages/ai/CHANGELOG.md | 2 + packages/ai/src/api/openai-codex-responses.ts | 5 +- packages/ai/src/api/openai-responses.ts | 5 ++ packages/ai/test/openai-codex-stream.test.ts | 56 +++++++++++++++++++ .../openai-responses-copilot-provider.test.ts | 48 ++++++++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 19f9cebb..b13ff430 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -6,6 +6,8 @@ - Added cache-friendly dynamic tool loading. `ToolResultMessage.addedToolNames` marks where tools from `Context.tools` became available; Anthropic and OpenAI Responses use native deferred loading so late tools stay out of the cached prefix, while other providers continue using `Context.tools` normally ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)). - Added native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)). +- Added `toolChoice` support to OpenAI Codex Responses, including `"required"` to force a tool call. +- Added `toolChoice` support to OpenAI Responses, including required and named tool selection. ### Fixed diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 3d1b27f7..fc57ec33 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -85,6 +85,7 @@ export interface OpenAICodexResponsesOptions extends StreamOptions { reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; textVerbosity?: "low" | "medium" | "high"; + toolChoice?: "auto" | "none" | "required"; } type CodexResponseStatus = "completed" | "incomplete" | "failed" | "cancelled" | "queued" | "in_progress"; @@ -97,7 +98,7 @@ interface RequestBody { previous_response_id?: string; input?: ResponseInput; tools?: OpenAITool[]; - tool_choice?: "auto"; + tool_choice?: OpenAICodexResponsesOptions["toolChoice"]; parallel_tool_calls?: boolean; temperature?: number; reasoning?: { effort?: string; summary?: string }; @@ -497,7 +498,7 @@ function buildRequestBody( text: { verbosity: options?.textVerbosity || "low" }, include: ["reasoning.encrypted_content"], prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), - tool_choice: "auto", + tool_choice: options?.toolChoice ?? "auto", parallel_tool_calls: true, }; diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 00d7a43b..287cc4df 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -83,6 +83,7 @@ export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; } /** @@ -254,6 +255,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.tools = convertResponsesTools(toolPlacement.immediate); } + if (options?.toolChoice !== undefined) { + params.tool_choice = options.toolChoice; + } + if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { const effort = options?.reasoningEffort diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 016acc82..fc47572a 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { zstdDecompressSync } from "node:zlib"; +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenAICodexWebSocketSessions, @@ -700,6 +701,61 @@ describe("openai-codex streaming", () => { expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" }); }); + it("forwards required tool choice", async () => { + const token = mockToken(); + const encoder = new TextEncoder(); + const sse = buildSSEPayload({ status: "completed" }); + let requestedToolChoice: unknown; + + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL, init?: RequestInit) => { + requestedToolChoice = decodeCodexRequestBody(init?.body)?.tool_choice; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + + await streamOpenAICodexResponses( + model, + { + messages: [ + { role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() }, + ], + tools: [ + { + name: "ping", + description: "Ping", + parameters: Type.Object({ value: Type.String() }), + }, + ], + }, + { apiKey: token, transport: "sse", toolChoice: "required" }, + ).result(); + + expect(requestedToolChoice).toBe("required"); + }); + it.each(["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"])("clamps %s minimal reasoning effort to low", async (modelId) => { const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); process.env.PI_CODING_AGENT_DIR = tempDir; diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index 39bc7ca6..1f1f945f 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -1,3 +1,4 @@ +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getModel } from "../src/compat.ts"; @@ -91,6 +92,53 @@ describe("openai-responses provider defaults", () => { }); }); + it("forwards required tool choice", async () => { + let capturedPayload: unknown; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + getModel("openai", "gpt-5.4"), + { + messages: [ + { + role: "user", + content: "Do not call ping. Respond with text instead.", + timestamp: Date.now(), + }, + ], + tools: [ + { + name: "ping", + description: "Ping", + parameters: Type.Object({ value: Type.String() }), + }, + ], + }, + { + apiKey: "test-key", + toolChoice: "required", + onPayload: (payload) => { + capturedPayload = payload; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload).toMatchObject({ + tool_choice: "required", + tools: [expect.objectContaining({ name: "ping" })], + }); + }); + it.each([ "gpt-5.1", "gpt-5.2",