mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 04:11:34 +00:00
fix(ai): match reasoning efforts case-insensitively without lowering provider values (#102993)
* fix(ai): match reasoning effort case-insensitively * fix(ai): preserve mapped reasoning effort casing * fix(ai): preserve unmapped reasoning effort casing * fix(ai): match reasoning effort map keys by case * fix(ai): harden reasoning effort normalization Co-authored-by: Ted Li <tl2493@columbia.edu> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveOpenAIReasoningEffortForModel,
|
||||
resolveOpenAISupportedReasoningEfforts,
|
||||
supportsOpenAIReasoningEffort,
|
||||
} from "./openai-reasoning-effort.js";
|
||||
|
||||
describe("OpenAI reasoning effort support", () => {
|
||||
@@ -36,6 +37,14 @@ describe("OpenAI reasoning effort support", () => {
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "medium" })).toBe("medium");
|
||||
});
|
||||
|
||||
it("matches canonical reasoning efforts case-insensitively", () => {
|
||||
const model = { provider: "openai", id: "gpt-5.6-sol" };
|
||||
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "HIGH" })).toBe("high");
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: " XHIGH " })).toBe("xhigh");
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "MAX" })).toBe("max");
|
||||
});
|
||||
|
||||
it("does not downgrade xhigh when model compat metadata declares it explicitly", () => {
|
||||
const model = {
|
||||
provider: "openai",
|
||||
@@ -81,13 +90,107 @@ describe("OpenAI reasoning effort support", () => {
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("omits unsupported disabled reasoning instead of falling back to enabled effort", () => {
|
||||
it("preserves provider-native compat values mapped from canonical efforts", () => {
|
||||
const model = {
|
||||
provider: "example",
|
||||
id: "custom-reasoning",
|
||||
compat: {
|
||||
supportedReasoningEfforts: ["ProviderDefault"],
|
||||
reasoningEffortMap: {
|
||||
high: "ProviderDefault",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveOpenAIReasoningEffortForModel({
|
||||
model: { provider: "groq", id: "openai/gpt-oss-120b" },
|
||||
effort: "off",
|
||||
model,
|
||||
effort: "HIGH",
|
||||
fallbackMap: model.compat.reasoningEffortMap,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
).toBe("ProviderDefault");
|
||||
});
|
||||
|
||||
it("matches canonical fallback map keys case-insensitively", () => {
|
||||
const model = {
|
||||
provider: "example",
|
||||
id: "custom-reasoning",
|
||||
compat: {
|
||||
supportedReasoningEfforts: ["ProviderLow", "ProviderHigh"],
|
||||
reasoningEffortMap: {
|
||||
HIGH: "ProviderHigh",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveOpenAIReasoningEffortForModel({
|
||||
model,
|
||||
effort: "HIGH",
|
||||
fallbackMap: model.compat.reasoningEffortMap,
|
||||
}),
|
||||
).toBe("ProviderHigh");
|
||||
});
|
||||
|
||||
it("preserves canonical-looking provider-native compat values mapped from canonical efforts", () => {
|
||||
const model = {
|
||||
provider: "example",
|
||||
id: "custom-reasoning",
|
||||
compat: {
|
||||
supportedReasoningEfforts: ["LOW", "MEDIUM", "HIGH"],
|
||||
reasoningEffortMap: {
|
||||
high: "HIGH",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveOpenAISupportedReasoningEfforts(model)).toEqual(["LOW", "MEDIUM", "HIGH"]);
|
||||
expect(
|
||||
resolveOpenAIReasoningEffortForModel({
|
||||
model,
|
||||
effort: "HIGH",
|
||||
fallbackMap: model.compat.reasoningEffortMap,
|
||||
}),
|
||||
).toBe("HIGH");
|
||||
});
|
||||
|
||||
it("requires an explicit map for canonical-looking provider casing", () => {
|
||||
const model = {
|
||||
provider: "example",
|
||||
id: "custom-reasoning",
|
||||
compat: {
|
||||
supportedReasoningEfforts: ["NONE", "HIGH"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "none" })).toBeUndefined();
|
||||
expect(
|
||||
resolveOpenAIReasoningEffortForModel({
|
||||
model,
|
||||
effort: "none",
|
||||
fallbackMap: { none: "NONE" },
|
||||
}),
|
||||
).toBe("NONE");
|
||||
});
|
||||
|
||||
it("does not fold provider-native compat values", () => {
|
||||
const model = {
|
||||
provider: "example",
|
||||
id: "custom-reasoning",
|
||||
compat: {
|
||||
supportedReasoningEfforts: ["ProviderDefault"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(supportsOpenAIReasoningEffort(model, "ProviderDefault")).toBe(true);
|
||||
expect(supportsOpenAIReasoningEffort(model, "providerdefault")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits unsupported disabled reasoning instead of falling back to enabled effort", () => {
|
||||
const model = { provider: "groq", id: "openai/gpt-oss-120b" };
|
||||
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "off" })).toBeUndefined();
|
||||
expect(resolveOpenAIReasoningEffortForModel({ model, effort: "OFF" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors compat metadata that disables reasoning effort payloads", () => {
|
||||
|
||||
@@ -39,6 +39,16 @@ 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;
|
||||
const CANONICAL_REASONING_EFFORTS = new Set([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"off",
|
||||
]);
|
||||
|
||||
function normalizeModelId(id: string | null | undefined): string {
|
||||
return normalizeLowercaseStringOrEmpty(id ?? "").replace(/-\d{4}-\d{2}-\d{2}$/u, "");
|
||||
@@ -59,7 +69,10 @@ export function isOpenAIGpt55Model(model: OpenAIReasoningModel): boolean {
|
||||
|
||||
/** Normalize user-facing reasoning effort names to API effort names. */
|
||||
export function normalizeOpenAIReasoningEffort(effort: string): string {
|
||||
return effort === "minimal" ? "minimal" : effort;
|
||||
const trimmed = effort.trim();
|
||||
const folded = trimmed.toLowerCase();
|
||||
// Only fold canonical names; provider-native values can be case-sensitive.
|
||||
return CANONICAL_REASONING_EFFORTS.has(folded) ? folded : trimmed;
|
||||
}
|
||||
|
||||
function readCompatReasoningEfforts(compat: unknown): OpenAIApiReasoningEffort[] | undefined {
|
||||
@@ -140,8 +153,16 @@ export function resolveOpenAIReasoningEffortForModel(params: {
|
||||
fallbackMap?: Record<string, string>;
|
||||
}): OpenAIApiReasoningEffort | undefined {
|
||||
const requested = normalizeOpenAIReasoningEffort(params.effort);
|
||||
const mapped = params.fallbackMap?.[requested] ?? requested;
|
||||
const normalized = normalizeOpenAIReasoningEffort(mapped);
|
||||
// Config preserves map-key casing, so only canonical keys get a folded lookup.
|
||||
const mapped =
|
||||
params.fallbackMap?.[requested] ??
|
||||
(params.fallbackMap && CANONICAL_REASONING_EFFORTS.has(requested)
|
||||
? Object.entries(params.fallbackMap).find(
|
||||
([effort]) => normalizeOpenAIReasoningEffort(effort) === requested,
|
||||
)?.[1]
|
||||
: undefined);
|
||||
// Fallback maps emit provider-native payload labels; keep their case for exact compat lists.
|
||||
const normalized = mapped === undefined ? requested : mapped.trim();
|
||||
const supported = resolveOpenAISupportedReasoningEfforts(params.model);
|
||||
if (supported.includes(normalized as OpenAIApiReasoningEffort)) {
|
||||
return normalized as OpenAIApiReasoningEffort;
|
||||
@@ -161,5 +182,7 @@ export function resolveOpenAIReasoningEffortForModel(params: {
|
||||
if (requested === "max" && supported.includes("xhigh")) {
|
||||
return "xhigh";
|
||||
}
|
||||
return supported.find((effort) => effort !== "none");
|
||||
return supported.find(
|
||||
(effort) => !isDisabledReasoningEffort(normalizeOpenAIReasoningEffort(effort)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5791,6 +5791,39 @@ describe("openai transport stream", () => {
|
||||
expect(params.reasoning).toEqual({ effort: "high", summary: "auto" });
|
||||
});
|
||||
|
||||
it("normalizes canonical reasoning casing in Responses and Chat Completions payloads", () => {
|
||||
const context = {
|
||||
systemPrompt: "system",
|
||||
messages: [],
|
||||
tools: [],
|
||||
} as never;
|
||||
const baseModel = {
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"] as Model["input"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
|
||||
const responses = buildOpenAIResponsesParams(
|
||||
{ ...baseModel, api: "openai-responses" } satisfies Model<"openai-responses">,
|
||||
context,
|
||||
{ reasoningEffort: " XHIGH " } as never,
|
||||
) as { reasoning?: unknown };
|
||||
const completions = buildOpenAICompletionsParams(
|
||||
{ ...baseModel, api: "openai-completions" } satisfies Model<"openai-completions">,
|
||||
context,
|
||||
{ reasoningEffort: " XHIGH " } as never,
|
||||
) as { reasoning_effort?: unknown };
|
||||
|
||||
expect(responses.reasoning).toEqual({ effort: "xhigh", summary: "auto" });
|
||||
expect(completions.reasoning_effort).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("uses disabled OpenAI Responses reasoning when the model supports none", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user