diff --git a/docs/provider-endpoint-constraints.md b/docs/provider-endpoint-constraints.md index b3781f654..957476df5 100644 --- a/docs/provider-endpoint-constraints.md +++ b/docs/provider-endpoint-constraints.md @@ -257,8 +257,9 @@ session/provider path. ### Responses and Codex custom tools Responses and Codex both support freeform custom grammar tools for `apply_patch`. -Both disable request-level parallel tool calls when any custom grammar tool is -present. Responses additionally: +Custom grammar tools do not force request-level `parallel_tool_calls`; Codex +`responsesLite` separately disables request-level parallel tool calls whenever +tools are present. Responses additionally: - sanitizes schemas differently - quarantines invalid enum/const schema contradictions diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 83a4e51ae..681989fe4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed OpenAI Responses and Codex Responses custom grammar tool requests to leave `parallel_tool_calls` unset instead of forcing serial tool calls; Codex `responsesLite` still disables parallel tool calls when tools are present. + ### Fixed - Fixed Bedrock `/btw` and other no-tool ephemeral turns failing after prior tool calls by sending the required sentinel `toolConfig` whenever replayed history contains `toolUse`/`toolResult` blocks. ([#3124](https://github.com/can1357/oh-my-pi/issues/3124)) diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 37eafbefc..0164e4903 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -714,7 +714,8 @@ async function buildCodexRequestContext( }; } -async function buildTransformedCodexRequestBody( +/** @internal Exported for tests. */ +export async function buildTransformedCodexRequestBody( model: Model<"openai-codex-responses">, context: Context, options: OpenAICodexResponsesOptions | undefined, @@ -744,16 +745,6 @@ async function buildTransformedCodexRequestBody( params.tool_choice = toolChoice; } } - // When a custom-tool is active, force serial tool-calling. OpenAI's - // `parallel_tool_calls` is request-scoped — disabling it here affects - // every tool in the turn, not just the custom one. That's coarser - // than spec §1's "supports_parallel_tool_calls = false" (which - // strictly targets `apply_patch`), but the platform API offers no - // per-tool flag. - const emittedTools = params.tools as CodexToolPayload[]; - if (emittedTools.some(t => t.type === "custom")) { - params.parallel_tool_calls = false; - } } const systemPrompts = normalizeSystemPrompts(context.systemPrompt); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index c9673926a..12315959b 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -862,15 +862,6 @@ export function buildParams( params.tool_choice = toolChoice; } } - // The apply_patch spec §1 marks only `apply_patch` itself as - // `supports_parallel_tool_calls = false`. OpenAI's Responses API - // exposes `parallel_tool_calls` as a request-scoped flag, not a - // per-tool one, so when a custom grammar tool is in the list we - // disable parallelism for the whole turn. Slightly coarser than - // the spec requires — but the platform API offers no finer knob. - if (params.tools.some(t => (t as { type?: string }).type === "custom")) { - params.parallel_tool_calls = false; - } } const reasoningPolicy = resolveOpenAICompatPolicy(model, { diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index 7e539ff9c..10b8bb7ec 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { + buildTransformedCodexRequestBody, convertOpenAICodexResponsesTools as convertCodexTools, normalizeCodexToolChoice, } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { + buildParams, convertTools, mapOpenAIResponsesToolChoiceForTools, supportsFreeformApplyPatch, @@ -74,6 +76,13 @@ const plainTool: Tool = { parameters: type({ path: "string" }), }; +function hasCustomTool(tools: unknown): boolean { + return ( + Array.isArray(tools) && + tools.some(tool => typeof tool === "object" && tool !== null && (tool as { type?: unknown }).type === "custom") + ); +} + const unionBranches = [ { type: "object", @@ -221,6 +230,31 @@ describe("tool choice mapping: freeform emission", () => { }); }); +describe("request params: freeform custom tools", () => { + test("openai responses leaves parallel tool calls unset", () => { + const { params } = buildParams( + makeModel({ applyPatchToolType: "freeform" }), + { messages: [{ role: "user", content: "edit", timestamp: 0 }], tools: [editTool] }, + undefined, + undefined, + ); + + expect(hasCustomTool(params.tools)).toBe(true); + expect(params.parallel_tool_calls).toBeUndefined(); + }); + + test("codex responses leaves parallel tool calls unset for custom tools", async () => { + const params = await buildTransformedCodexRequestBody( + makeCodexModel({ applyPatchToolType: "freeform" }), + { messages: [{ role: "user", content: "edit", timestamp: 0 }], tools: [editTool] }, + undefined, + ); + + expect(hasCustomTool(params.tools)).toBe(true); + expect(params.parallel_tool_calls).toBeUndefined(); + }); +}); + describe("custom_tool_call stream receive", () => { async function* makeStream(events: unknown[]): AsyncIterable { for (const e of events) yield e as ResponseStreamEvent;