diff --git a/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md b/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md new file mode 100644 index 0000000000..bdd460cfcb --- /dev/null +++ b/changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md @@ -0,0 +1 @@ +- **fix(openai):** strip `reasoning_effort`/`reasoning` for GPT-5.x models on the raw `openai` Chat Completions surface when the request carries function `tools` — upstream rejects that combination with HTTP 400 ("Function tools with reasoning_effort are not supported ... Please use /v1/responses instead"), and the dashboard has no `reasoning_effort:"none"` override to work around it client-side — thanks @techsolutionmta diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e134a744f8..ec016439c7 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -110,7 +110,10 @@ import { normalizeMimoThinking } from "../services/mimoThinking.ts"; import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; -import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts"; +import { + stripGpt5SamplingWhenReasoning, + stripGpt5ReasoningWhenTools, +} from "../services/gpt5SamplingGuard.ts"; import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; @@ -2090,6 +2093,19 @@ export async function handleChatCore({ log ); + // GPT-5.x reasoning models (raw openai Chat Completions) also reject function `tools` + // combined with an active `reasoning_effort`: HTTP 400 "Function tools with + // reasoning_effort are not supported ... Please use /v1/responses instead." Unlike the + // openai-compatible-* MCP/tool_search shape (forceResponsesUpstream.ts), the plain + // `openai` provider always stays on /chat/completions, so strip the reasoning fields + // here instead of rerouting. Port of 9router#2540. + translatedBody = stripGpt5ReasoningWhenTools( + translatedBody, + provider, + finalModelToUpstream, + log + ); + // Rename max_tokens to max_completion_tokens if not supported (#1961) if (!supportsMaxTokens({ provider, model })) { if (translatedBody.max_tokens !== undefined) { diff --git a/open-sse/services/gpt5SamplingGuard.ts b/open-sse/services/gpt5SamplingGuard.ts index b5b35863fc..e96703ae2d 100644 --- a/open-sse/services/gpt5SamplingGuard.ts +++ b/open-sse/services/gpt5SamplingGuard.ts @@ -79,3 +79,64 @@ export function stripGpt5SamplingWhenReasoning ); return next as T; } + +const REASONING_FIELDS = ["reasoning_effort", "reasoning"] as const; + +/** + * True when the request carries a non-empty `tools` array holding at least one + * function-shaped tool entry (`{type:"function", ...}` or a bare `{name, ...}` + * without a `type`, the OpenAI Chat Completions convention). + */ +function hasFunctionTools(record: JsonRecord): boolean { + if (!Array.isArray(record.tools) || record.tools.length === 0) return false; + return record.tools.some((toolValue) => { + const tool = asRecord(toolValue); + if (!tool) return false; + const toolType = typeof tool.type === "string" ? tool.type : ""; + return toolType === "" || toolType === "function"; + }); +} + +/** + * Raw api.openai.com Chat Completions rejects GPT-5.x reasoning models that + * carry BOTH function `tools` and an active `reasoning_effort` with HTTP 400: + * "Function tools with reasoning_effort are not supported for in + * /v1/chat/completions. Please use /v1/responses instead." OmniRoute's + * `shouldForceResponsesUpstream` guard only re-routes `openai-compatible-*` + * connections carrying MCP/tool_search tool shapes to `/responses` — the + * plain `openai` provider always stays on `/chat/completions`, so this + * combination still reaches the upstream 400 today. Strip the reasoning + * fields instead so the request succeeds on `/chat/completions` (the + * dashboard offers no `reasoning_effort:"none"` override, so this cannot be + * worked around client-side). Port of 9router#2540. + */ +export function stripGpt5ReasoningWhenTools>( + body: T, + provider: string | null | undefined, + model: string | null | undefined, + log?: { warn?: (tag: string, message: string) => void } | null +): T { + if (provider !== "openai") return body; + if (typeof model !== "string" || !/^gpt-5/i.test(model)) return body; + + const record = asRecord(body); + if (!record) return body; + if (!hasFunctionTools(record)) return body; + if (!hasActiveReasoning(record, model)) return body; + + const stripped: string[] = []; + for (const field of REASONING_FIELDS) { + if (Object.hasOwn(record, field)) stripped.push(field); + } + if (stripped.length === 0) return body; + + const next: JsonRecord = { ...record }; + for (const field of stripped) delete next[field]; + + log?.warn?.( + "PARAMS", + `Stripped ${stripped.join(", ")} for ${model} (function tools + reasoning_effort ` + + `are rejected on /v1/chat/completions; use /v1/responses instead)` + ); + return next as T; +} diff --git a/tests/unit/gpt5-tools-reasoning-guard.test.ts b/tests/unit/gpt5-tools-reasoning-guard.test.ts new file mode 100644 index 0000000000..410b2d734c --- /dev/null +++ b/tests/unit/gpt5-tools-reasoning-guard.test.ts @@ -0,0 +1,103 @@ +/** + * GPT-5 tools+reasoning guard — `stripGpt5ReasoningWhenTools`. + * + * On the raw `openai` Chat Completions surface, GPT-5.x reasoning models reject a + * request that carries BOTH function tools and an active `reasoning_effort` with + * HTTP 400: "Function tools with reasoning_effort are not supported for + * in /v1/chat/completions. Please use /v1/responses instead." + * (port of 9router#2540). OmniRoute's `forceResponsesUpstream` guard only fires + * for `openai-compatible-*` connections carrying MCP/tool_search tool shapes — + * the plain `openai` provider has no equivalent guard, so this scenario still + * reaches the upstream 400 today. Strip `reasoning_effort`/`reasoning` when + * function tools are present so the request succeeds on /v1/chat/completions. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { stripGpt5ReasoningWhenTools } from "../../open-sse/services/gpt5SamplingGuard.ts"; + +test("strips reasoning_effort for openai gpt-5.x when function tools are present", () => { + const body = { + model: "gpt-5.6-sol", + reasoning_effort: "high", + tools: [{ type: "function", function: { name: "read_file" } }], + messages: [], + }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result.reasoning_effort, undefined); +}); + +test("strips nested reasoning.effort for openai gpt-5.x when function tools are present", () => { + const body = { + model: "gpt-5.6-sol", + reasoning: { effort: "medium" }, + tools: [{ type: "function", function: { name: "read_file" } }], + }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result.reasoning, undefined); +}); + +test("keeps reasoning_effort=none untouched (already non-reasoning mode)", () => { + const body = { + model: "gpt-5.6-sol", + reasoning_effort: "none", + tools: [{ type: "function", function: { name: "read_file" } }], + }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result.reasoning_effort, "none"); +}); + +test("keeps reasoning_effort when there are no tools", () => { + const body = { model: "gpt-5.6-sol", reasoning_effort: "high", messages: [] }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result.reasoning_effort, "high"); +}); + +test("keeps reasoning_effort when tools array is empty", () => { + const body = { model: "gpt-5.6-sol", reasoning_effort: "high", tools: [] }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result.reasoning_effort, "high"); +}); + +test("non-openai provider is untouched", () => { + const body = { + model: "gpt-5.6-sol", + reasoning_effort: "high", + tools: [{ type: "function", function: { name: "x" } }], + }; + const result = stripGpt5ReasoningWhenTools(body, "codex", "gpt-5.6-sol"); + assert.equal(result.reasoning_effort, "high"); +}); + +test("non-gpt-5 openai model is untouched", () => { + const body = { + model: "gpt-4o", + reasoning_effort: "high", + tools: [{ type: "function", function: { name: "x" } }], + }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-4o"); + assert.equal(result.reasoning_effort, "high"); +}); + +test("returns the same reference when nothing to strip", () => { + const body = { model: "gpt-5.6-sol", tools: [{ type: "function" }], messages: [] }; + const result = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-sol"); + assert.equal(result, body); +}); + +test("logs the stripped fields when a logger is provided", () => { + const calls: Array<[string, string]> = []; + const log = { warn: (tag: string, message: string) => calls.push([tag, message]) }; + stripGpt5ReasoningWhenTools( + { + model: "gpt-5.6-sol", + reasoning_effort: "high", + tools: [{ type: "function", function: { name: "x" } }], + }, + "openai", + "gpt-5.6-sol", + log + ); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], "PARAMS"); + assert.match(calls[0][1], /reasoning_effort/); +});