diff --git a/package-lock.json b/package-lock.json index 48e638d5e..813d59615 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4191,13 +4191,10 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" @@ -5487,7 +5484,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b18b16821..f984fdf53 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)). + ### Fixed - Fixed upstream request buffer limit failures to trigger automatic assistant retries. diff --git a/packages/ai/package.json b/packages/ai/package.json index 50a06caab..1b23d01d5 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -69,7 +69,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 90e6bbb2f..4c0d58e6c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -351,6 +351,12 @@ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); +// Public OpenAI documents additional_tools for applications that load tools +// outside the normal tool-search flow. Codex currently uses the input item for +// its Responses Lite GPT-5.6 models. +// https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input +const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS; +const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000; const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([ "gpt-5.4", @@ -755,8 +761,12 @@ function applyOpenAIToolSearchMetadata(model: Model): void { const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses"; const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses"; if (!(isOpenAIResponses || isOpenAICodex) || !OPENAI_TOOL_SEARCH_MODEL_IDS.has(model.id)) return; + const supportsAdditionalTools = + (isOpenAIResponses && OPENAI_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)) || + (isOpenAICodex && OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS.has(model.id)); model.compat = { ...(model.compat as OpenAIResponsesCompat | undefined), + ...(supportsAdditionalTools ? { supportsAdditionalTools: true } : {}), supportsToolSearch: true, }; } diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index e72ec06aa..863e2ef46 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -539,11 +539,17 @@ function buildRequestBody( ): RequestBody { const supportsStrictMode = model.compat?.supportsStrictMode ?? true; const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false; - const toolPlacement = splitDeferredTools(context, model.compat?.supportsToolSearch ?? false); + const deferredToolsMode = model.compat?.supportsAdditionalTools + ? "additional-tools" + : model.compat?.supportsToolSearch + ? "tool-search" + : undefined; + const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined); const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, { includeSystemPrompt: false, grammarToolInputProperties, deferredTools: toolPlacement.deferred, + deferredToolsMode, toolOptions: { strict: null, supportsStrictMode, diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 801c9cfcd..8ed844681 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -119,6 +119,7 @@ export interface ConvertResponsesMessagesOptions { includeSystemPrompt?: boolean; grammarToolInputProperties?: ReadonlyMap; deferredTools?: ReadonlyMap; + deferredToolsMode?: "additional-tools" | "tool-search"; toolOptions?: ConvertResponsesToolsOptions; } @@ -316,7 +317,13 @@ export function convertResponsesMessages( loadedToolNames.add(name); deferredTools.push(tool); } - if (deferredTools.length > 0) { + if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") { + messages.push({ + type: "additional_tools", + role: "developer", + tools: convertResponsesTools(deferredTools, options.toolOptions), + } satisfies ResponseInputItem); + } else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") { const names = deferredTools.map((tool) => tool.name); const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`; messages.push({ @@ -332,7 +339,7 @@ export function convertResponsesMessages( execution: "client", status: "completed", tools: convertResponsesTools(deferredTools, { - ...options?.toolOptions, + ...options.toolOptions, deferLoading: true, }), } satisfies ResponseToolSearchOutputItemParam); diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index b90de3b76..578398e8e 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -71,6 +71,7 @@ function getCompat(model: Model<"openai-responses">): Required; } +interface OpenAIAdditionalTools { + type: "additional_tools"; + role: "developer"; + tools: Array<{ type: string; name: string; defer_loading?: boolean }>; +} + interface OpenAIPayload { tools?: Array<{ name?: string; function?: { name: string } }>; - input?: Array; + input?: Array< + OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string } + >; } interface KimiTool { @@ -394,9 +402,57 @@ describe("deferred tools", () => { expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false); }); - it("loads an OpenAI Responses tool through client tool search", async () => { + it("loads an OpenAI Responses tool through additional_tools", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalTools = payload.input?.find( + (item): item is OpenAIAdditionalTools => item.type === "additional_tools", + ); + + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + expect(additionalTools).toMatchObject({ role: "developer" }); + expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]); + expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true); + expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false); + expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); + + it("preserves an additional_tools marker after the loaded tool is used", async () => { + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const lateCall: AssistantMessage = { + ...makeAssistantToolCall(), + content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }], + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + }; + context.messages.splice(3, 0, lateCall, { + ...makeToolResult(["late_tool"]), + toolCallId: "call_late|fc_late", + toolName: "late_tool", + }); + + const payload = await capturePayload(getModel("openai", "gpt-5.4"), context); + const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) => + item.type === "additional_tools" ? [index] : [], + ); + const lateCallIndex = (payload.input ?? []).findIndex( + (item) => item.type === "function_call" && item.name === "late_tool", + ); + + expect(additionalToolIndexes).toHaveLength(1); + expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex); + expect(openAIToolNames(payload)).toEqual(["base_tool"]); + }); + + it("falls back to client tool search when additional_tools is unsupported", async () => { + const model: Model<"openai-responses"> = { + ...getModel("openai", "gpt-5.4"), + provider: "openai-proxy", + compat: { supportsAdditionalTools: false, supportsToolSearch: true }, + }; + const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); + const payload = await capturePayload(model, context); const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call"); const searchOutput = payload.input?.find( (item): item is OpenAIToolSearchOutput => item.type === "tool_search_output", @@ -406,6 +462,7 @@ describe("deferred tools", () => { expect(searchCall).toMatchObject({ execution: "client", status: "completed" }); expect(searchOutput?.call_id).toBe(searchCall?.call_id); expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]); + expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false); }); it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)( @@ -432,23 +489,32 @@ describe("deferred tools", () => { expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); - it("uses tool search only for supported Codex models", async () => { + it("selects additional tools, tool search, or top-level tools for Codex models", async () => { const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]); - const supported = await capturePayload( + const additionalTools = await capturePayload( + getModel("openai-codex", "gpt-5.6-sol"), + context, + makeCodexToken(), + ); + const toolSearch = await capturePayload( getModel("openai-codex", "gpt-5.4"), context, makeCodexToken(), ); - const unsupported = await capturePayload( + const topLevel = await capturePayload( getModel("openai-codex", "gpt-5.3-codex-spark"), context, makeCodexToken(), ); - expect(openAIToolNames(supported)).toEqual(["base_tool"]); - expect(supported.input?.some((item) => item.type === "tool_search_output")).toBe(true); - expect(openAIToolNames(unsupported)).toEqual(["base_tool", "late_tool"]); - expect(unsupported.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]); + expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true); + expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false); + expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]); + expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true); + expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]); + expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false); + expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false); }); it("leaves providers without deferred loading unchanged", async () => { diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 32536f4a1..321dd5ec3 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -479,7 +479,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -1541,9 +1541,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1556,9 +1556,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index ea3af110d..bd313840e 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -506,7 +506,7 @@ "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", - "openai": "6.26.0", + "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, @@ -1531,9 +1531,9 @@ } }, "node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -1546,9 +1546,6 @@ "zod": { "optional": true } - }, - "bin": { - "openai": "bin/cli" } }, "node_modules/p-retry": { diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 7f679a494..7ea12cbeb 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -117,6 +117,7 @@ const OpenAIResponsesCompatSchema = Type.Object({ supportsLongCacheRetention: Type.Optional(Type.Boolean()), supportsStrictMode: Type.Optional(Type.Boolean()), supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), + supportsAdditionalTools: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), });