diff --git a/packages/ai/src/providers/openai-reasoning-effort.test.ts b/packages/ai/src/providers/openai-reasoning-effort.test.ts index 809b2957a49d..a842d305fa10 100644 --- a/packages/ai/src/providers/openai-reasoning-effort.test.ts +++ b/packages/ai/src/providers/openai-reasoning-effort.test.ts @@ -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", () => { diff --git a/packages/ai/src/providers/openai-reasoning-effort.ts b/packages/ai/src/providers/openai-reasoning-effort.ts index 87a3628e364f..377dfaa20cc1 100644 --- a/packages/ai/src/providers/openai-reasoning-effort.ts +++ b/packages/ai/src/providers/openai-reasoning-effort.ts @@ -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; }): 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)), + ); } diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index ac600059c360..e45282908a11 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -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( {