ai: OpenAI and Codex forced tool calls (#6588)

* feat(ai): support forced OpenAI Codex tool calls

* feat(ai): support OpenAI Responses tool choice
This commit is contained in:
Alexey Zaytsev
2026-07-13 04:48:33 -03:00
committed by GitHub
parent 16a3d420ff
commit eacaa130ab
5 changed files with 114 additions and 2 deletions
+2
View File
@@ -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 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 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 ### Fixed
@@ -85,6 +85,7 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null;
serviceTier?: ResponseCreateParamsStreaming["service_tier"]; serviceTier?: ResponseCreateParamsStreaming["service_tier"];
textVerbosity?: "low" | "medium" | "high"; textVerbosity?: "low" | "medium" | "high";
toolChoice?: "auto" | "none" | "required";
} }
type CodexResponseStatus = "completed" | "incomplete" | "failed" | "cancelled" | "queued" | "in_progress"; type CodexResponseStatus = "completed" | "incomplete" | "failed" | "cancelled" | "queued" | "in_progress";
@@ -97,7 +98,7 @@ interface RequestBody {
previous_response_id?: string; previous_response_id?: string;
input?: ResponseInput; input?: ResponseInput;
tools?: OpenAITool[]; tools?: OpenAITool[];
tool_choice?: "auto"; tool_choice?: OpenAICodexResponsesOptions["toolChoice"];
parallel_tool_calls?: boolean; parallel_tool_calls?: boolean;
temperature?: number; temperature?: number;
reasoning?: { effort?: string; summary?: string }; reasoning?: { effort?: string; summary?: string };
@@ -497,7 +498,7 @@ function buildRequestBody(
text: { verbosity: options?.textVerbosity || "low" }, text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId), prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
tool_choice: "auto", tool_choice: options?.toolChoice ?? "auto",
parallel_tool_calls: true, parallel_tool_calls: true,
}; };
+5
View File
@@ -83,6 +83,7 @@ export interface OpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
reasoningSummary?: "auto" | "detailed" | "concise" | null; reasoningSummary?: "auto" | "detailed" | "concise" | null;
serviceTier?: ResponseCreateParamsStreaming["service_tier"]; 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); params.tools = convertResponsesTools(toolPlacement.immediate);
} }
if (options?.toolChoice !== undefined) {
params.tool_choice = options.toolChoice;
}
if (model.reasoning) { if (model.reasoning) {
if (options?.reasoningEffort || options?.reasoningSummary) { if (options?.reasoningEffort || options?.reasoningSummary) {
const effort = options?.reasoningEffort const effort = options?.reasoningEffort
@@ -2,6 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { zstdDecompressSync } from "node:zlib"; import { zstdDecompressSync } from "node:zlib";
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
closeOpenAICodexWebSocketSessions, closeOpenAICodexWebSocketSessions,
@@ -700,6 +701,61 @@ describe("openai-codex streaming", () => {
expect(requestedReasoning).toEqual({ effort: "xhigh", summary: "auto" }); 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<Uint8Array>({
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) => { 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-")); const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
process.env.PI_CODING_AGENT_DIR = tempDir; process.env.PI_CODING_AGENT_DIR = tempDir;
@@ -1,3 +1,4 @@
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { getModel } from "../src/compat.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([ it.each([
"gpt-5.1", "gpt-5.1",
"gpt-5.2", "gpt-5.2",