From 11e05e86a2333cfdc1472fb5502ac3dff6a78255 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 16:02:15 +0100 Subject: [PATCH] fix(agents): scope gpt-5.4-mini chat reasoning fallback (#76727) Fixes #76176. OpenAI live verification showed `gpt-5.4-mini` supports reasoning effort generally, but rejects `/v1/chat/completions` payloads that combine function tools with `reasoning_effort`. This keeps reasoning effort for tool-free Chat Completions and Responses, and omits it only for the rejected Chat Completions + function tools combination. Validation: - Live OpenAI API matrix on 2026-05-03 - pnpm test src/agents/openai-reasoning-effort.test.ts src/agents/openai-transport-stream.test.ts -- --reporter=verbose - GitHub PR CI green on ea3915308c3c12bb6e71cdb5f13471ec0eb5ba53 Thanks @ThisIsAdilah and @chinar-amrutkar. --- CHANGELOG.md | 1 + src/agents/openai-reasoning-effort.test.ts | 12 +++++ src/agents/openai-reasoning-effort.ts | 5 ++ src/agents/openai-transport-stream.test.ts | 62 ++++++++++++++++++++++ src/agents/openai-transport-stream.ts | 6 ++- 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef2c3a34fe..728c4db72c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored. +- Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar. - Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier. - Control UI/Skills: fix skill detail modal silently failing to open in all browsers by deferring `showModal()` until the dialog element is connected to the DOM; the Lit `ref` callback fired before connection causing a `DOMException: HTMLDialogElement.showModal: Dialog element is not connected` on every skill click. Thanks @nickmopen. - Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai. diff --git a/src/agents/openai-reasoning-effort.test.ts b/src/agents/openai-reasoning-effort.test.ts index ab5c0afa4d5..d4a2e7b5f05 100644 --- a/src/agents/openai-reasoning-effort.test.ts +++ b/src/agents/openai-reasoning-effort.test.ts @@ -13,6 +13,18 @@ describe("OpenAI reasoning effort support", () => { expect(resolveOpenAIReasoningEffortForModel({ model, effort: "xhigh" })).toBe("xhigh"); }); + it("preserves reasoning_effort metadata for gpt-5.4-mini in Chat Completions", () => { + const model = { provider: "openai", id: "gpt-5.4-mini", api: "openai-completions" }; + expect(resolveOpenAISupportedReasoningEfforts(model)).toContain("medium"); + expect(resolveOpenAIReasoningEffortForModel({ model, effort: "medium" })).toBe("medium"); + }); + + it("preserves reasoning_effort for gpt-5.4-mini in Responses", () => { + const model = { provider: "openai", id: "gpt-5.4-mini", api: "openai-responses" }; + expect(resolveOpenAISupportedReasoningEfforts(model)).toContain("medium"); + expect(resolveOpenAIReasoningEffortForModel({ model, effort: "medium" })).toBe("medium"); + }); + it("does not downgrade xhigh when Pi compat metadata declares it explicitly", () => { const model = { provider: "openai-codex", diff --git a/src/agents/openai-reasoning-effort.ts b/src/agents/openai-reasoning-effort.ts index 1bb2148ea79..ad77a5599f4 100644 --- a/src/agents/openai-reasoning-effort.ts +++ b/src/agents/openai-reasoning-effort.ts @@ -26,6 +26,11 @@ function normalizeModelId(id: string | null | undefined): string { return normalizeLowercaseStringOrEmpty(id ?? "").replace(/-\d{4}-\d{2}-\d{2}$/u, ""); } +export function isOpenAIGpt54MiniModel(model: OpenAIReasoningModel): boolean { + const id = normalizeModelId(typeof model.id === "string" ? model.id : undefined); + return /^gpt-5\.4-mini(?:-|$)/u.test(id); +} + export function normalizeOpenAIReasoningEffort(effort: string): string { return effort === "minimal" ? "minimal" : effort; } diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 73dc31415f9..d7393794127 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -2050,6 +2050,68 @@ describe("openai transport stream", () => { expect(params.reasoning_effort).toBe("high"); }); + it("omits reasoning_effort for gpt-5.4-mini Chat Completions tool payloads", () => { + const params = buildOpenAICompletionsParams( + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + } as never, + { + reasoning: "medium", + } as never, + ) as { reasoning_effort?: unknown; tools?: unknown }; + + expect(params.tools).toBeDefined(); + expect(params).not.toHaveProperty("reasoning_effort"); + }); + + it("keeps reasoning_effort for gpt-5.4-mini Chat Completions payloads without tools", () => { + const params = buildOpenAICompletionsParams( + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [], + } as never, + { + reasoning: "medium", + } as never, + ) as { reasoning_effort?: unknown; tools?: unknown }; + + expect(params.tools).toEqual([]); + expect(params.reasoning_effort).toBe("medium"); + }); + it("uses provider-native reasoning effort values declared by model compat", () => { const baseModel = { id: "qwen/qwen3-32b", diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index d1c9f543e88..2b16ca25706 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -28,6 +28,7 @@ import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; import { flattenCompletionMessagesToStringContent } from "./openai-completions-string-content.js"; import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js"; import { + isOpenAIGpt54MiniModel, normalizeOpenAIReasoningEffort, resolveOpenAIReasoningEffortForModel, type OpenAIApiReasoningEffort, @@ -1899,6 +1900,8 @@ export function buildOpenAICompletionsParams( fallbackMap: compat.reasoningEffortMap, }) : undefined; + const omitGpt54MiniToolReasoningEffort = + isOpenAIGpt54MiniModel(model) && Array.isArray(params.tools) && params.tools.length > 0; if ( compat.thinkingFormat === "openrouter" && model.reasoning && @@ -1910,7 +1913,8 @@ export function buildOpenAICompletionsParams( } else if ( resolvedCompletionsReasoningEffort && model.reasoning && - compat.supportsReasoningEffort + compat.supportsReasoningEffort && + !omitGpt54MiniToolReasoningEffort ) { params.reasoning_effort = resolvedCompletionsReasoningEffort; }