mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
fix(openai): strip reasoning_effort when GPT-5.x tools present (port from 9router#2540)
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 ... Please use /v1/responses instead"). The existing forceResponsesUpstream guard only reroutes openai-compatible-* connections carrying MCP/tool_search tool shapes; the plain openai provider had no equivalent guard, so gpt-5.x models used with a coding client (function tools + any explicit reasoning effort) still hit the upstream 400. Add stripGpt5ReasoningWhenTools() (gpt5SamplingGuard.ts), wired into chatCore.ts alongside the existing sampling guard, to drop reasoning_effort/reasoning when function tools are present and reasoning is active, letting the request succeed on /v1/chat/completions.
Reported-by: Tech Solution (@techsolutionmta) (https://github.com/decolua/9router/issues/2540)
This commit is contained in:
1
changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md
Normal file
1
changelog.d/fixes/2540-gpt5-tools-reasoning-effort.md
Normal file
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -79,3 +79,64 @@ export function stripGpt5SamplingWhenReasoning<T extends Record<string, unknown>
|
||||
);
|
||||
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 <model> 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<T extends Record<string, unknown>>(
|
||||
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;
|
||||
}
|
||||
|
||||
103
tests/unit/gpt5-tools-reasoning-guard.test.ts
Normal file
103
tests/unit/gpt5-tools-reasoning-guard.test.ts
Normal file
@@ -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
|
||||
* <model> 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user