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:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user