mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-17 02:30:43 +00:00
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 ea3915308c
Thanks @ThisIsAdilah and @chinar-amrutkar.
135 lines
4.6 KiB
TypeScript
135 lines
4.6 KiB
TypeScript
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
|
|
export type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
|
|
export type OpenAIApiReasoningEffort = OpenAIReasoningEffort | (string & {});
|
|
|
|
type OpenAIReasoningModel = {
|
|
provider?: unknown;
|
|
id?: unknown;
|
|
api?: unknown;
|
|
baseUrl?: unknown;
|
|
compat?: unknown;
|
|
};
|
|
|
|
const GPT_5_REASONING_EFFORTS = ["minimal", "low", "medium", "high"] as const;
|
|
const GPT_51_REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
|
|
const GPT_52_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh"] as const;
|
|
const GPT_CODEX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
|
|
const GPT_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"] as const;
|
|
const GPT_5_PRO_REASONING_EFFORTS = ["high"] as const;
|
|
const GPT_51_CODEX_MAX_REASONING_EFFORTS = ["none", "medium", "high", "xhigh"] as const;
|
|
const GPT_51_CODEX_MINI_REASONING_EFFORTS = ["medium"] as const;
|
|
const GENERIC_REASONING_EFFORTS = ["low", "medium", "high"] as const;
|
|
|
|
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;
|
|
}
|
|
|
|
function readCompatReasoningEfforts(compat: unknown): OpenAIApiReasoningEffort[] | undefined {
|
|
if (!compat || typeof compat !== "object") {
|
|
return undefined;
|
|
}
|
|
const raw = (compat as { supportedReasoningEfforts?: unknown }).supportedReasoningEfforts;
|
|
if (!Array.isArray(raw)) {
|
|
return undefined;
|
|
}
|
|
const supported = [
|
|
...new Set(
|
|
raw
|
|
.filter((value): value is string => typeof value === "string")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean),
|
|
),
|
|
];
|
|
return supported.length > 0 ? supported : undefined;
|
|
}
|
|
|
|
function isDisabledReasoningEffort(effort: string): boolean {
|
|
return effort === "none" || effort === "off";
|
|
}
|
|
|
|
export function resolveOpenAISupportedReasoningEfforts(
|
|
model: OpenAIReasoningModel,
|
|
): readonly OpenAIApiReasoningEffort[] {
|
|
const compatEfforts = readCompatReasoningEfforts(model.compat);
|
|
if (compatEfforts) {
|
|
return compatEfforts;
|
|
}
|
|
|
|
const provider = normalizeLowercaseStringOrEmpty(
|
|
typeof model.provider === "string" ? model.provider : "",
|
|
);
|
|
const id = normalizeModelId(typeof model.id === "string" ? model.id : undefined);
|
|
if (id === "gpt-5.1-codex-mini") {
|
|
return GPT_51_CODEX_MINI_REASONING_EFFORTS;
|
|
}
|
|
if (id === "gpt-5.1-codex-max") {
|
|
return GPT_51_CODEX_MAX_REASONING_EFFORTS;
|
|
}
|
|
if (/^gpt-5(?:\.\d+)?-codex(?:-|$)/u.test(id) || provider === "openai-codex") {
|
|
return GPT_CODEX_REASONING_EFFORTS;
|
|
}
|
|
if (id === "gpt-5-pro") {
|
|
return GPT_5_PRO_REASONING_EFFORTS;
|
|
}
|
|
if (/^gpt-5\.[2-9](?:\.\d+)?-pro(?:-|$)/u.test(id)) {
|
|
return GPT_PRO_REASONING_EFFORTS;
|
|
}
|
|
if (/^gpt-5\.[2-9](?:\.\d+)?(?:-|$)/u.test(id)) {
|
|
return GPT_52_REASONING_EFFORTS;
|
|
}
|
|
if (/^gpt-5\.1(?:-|$)/u.test(id)) {
|
|
return GPT_51_REASONING_EFFORTS;
|
|
}
|
|
if (/^gpt-5(?:-|$)/u.test(id)) {
|
|
return GPT_5_REASONING_EFFORTS;
|
|
}
|
|
return GENERIC_REASONING_EFFORTS;
|
|
}
|
|
|
|
export function supportsOpenAIReasoningEffort(
|
|
model: OpenAIReasoningModel,
|
|
effort: string,
|
|
): boolean {
|
|
return resolveOpenAISupportedReasoningEfforts(model).includes(
|
|
normalizeOpenAIReasoningEffort(effort) as OpenAIApiReasoningEffort,
|
|
);
|
|
}
|
|
|
|
export function resolveOpenAIReasoningEffortForModel(params: {
|
|
model: OpenAIReasoningModel;
|
|
effort: string;
|
|
fallbackMap?: Record<string, string>;
|
|
}): OpenAIApiReasoningEffort | undefined {
|
|
const requested = normalizeOpenAIReasoningEffort(params.effort);
|
|
const mapped = params.fallbackMap?.[requested] ?? requested;
|
|
const normalized = normalizeOpenAIReasoningEffort(mapped);
|
|
const supported = resolveOpenAISupportedReasoningEfforts(params.model);
|
|
if (supported.includes(normalized as OpenAIApiReasoningEffort)) {
|
|
return normalized as OpenAIApiReasoningEffort;
|
|
}
|
|
if (isDisabledReasoningEffort(requested) || isDisabledReasoningEffort(normalized)) {
|
|
return undefined;
|
|
}
|
|
if (requested === "minimal" && supported.includes("low")) {
|
|
return "low";
|
|
}
|
|
if ((requested === "minimal" || requested === "low") && supported.includes("medium")) {
|
|
return "medium";
|
|
}
|
|
if (requested === "xhigh" && supported.includes("high")) {
|
|
return "high";
|
|
}
|
|
return supported.find((effort) => effort !== "none");
|
|
}
|