From b664f9369a84c16ba95472eee3fc08081cc85346 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 22 Aug 2026 20:53:19 +0000 Subject: [PATCH 1/2] fix(reasoning): apply model defaults when settings unset --- src/api/providers/__tests__/nanogpt.spec.ts | 42 ++++++++++++++++++ src/api/providers/nanogpt.ts | 27 ++++++++---- .../components/settings/ThinkingBudget.tsx | 14 ++---- .../__tests__/ThinkingBudget.spec.tsx | 43 +++++++++++++++++++ 4 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 0ddb4f5d5d..ab253ea8b1 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -171,6 +171,40 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_completion_tokens") }) + it("uses the model's advertised reasoning effort when settings are unset", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + }) + + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" }) + }) + + it("uses the first supported effort when the model cannot disable reasoning", async () => { + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + + it("omits reasoning effort when reasoning is explicitly disabled", async () => { + await collectStream( + new NanoGptHandler({ + nanoGptModelId: "model:thinking", + enableReasoningEffort: false, + reasoningEffort: "high", + }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + it("keeps Muse Spark tool-result history contiguous across turns", async () => { const modelId = "meta/muse-spark-1.2-contributor" vi.mocked(getModels).mockResolvedValue({ @@ -337,6 +371,14 @@ describe("NanoGptHandler", () => { }) describe("completePrompt", () => { + it("uses the same default reasoning effort as streaming requests", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + + await new NanoGptHandler({ nanoGptModelId: "model:thinking" }).completePrompt("prompt") + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + it("requests cache-capable routing without changing the completion model ID", async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) const handler = new NanoGptHandler({ diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 7b0c7930d3..7589ebd129 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -7,6 +7,7 @@ import { nanoGptDefaultModelId, nanoGptDefaultModelInfo, providerIdentifiers, + type ModelInfo, type NanoGptRoutingPreference, } from "@roo-code/types" @@ -32,15 +33,25 @@ const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contrib const OPENAI_REASONING_EFFORTS = ["low", "medium", "high"] as const type OpenAiReasoningEffort = (typeof OPENAI_REASONING_EFFORTS)[number] -function getReasoningEffort(options: ApiHandlerOptions, supported: unknown): OpenAiReasoningEffort | undefined { - const effort = options.reasoningEffort - const selectedEffort = OPENAI_REASONING_EFFORTS.find((candidate) => candidate === effort) - if (!selectedEffort) { +function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): OpenAiReasoningEffort | undefined { + if (options.enableReasoningEffort === false || options.reasoningEffort === "disable") { return undefined } - if (supported === true || (Array.isArray(supported) && supported.includes(selectedEffort))) { - return selectedEffort + const supported = info.supportsReasoningEffort + const candidates = [options.reasoningEffort, info.reasoningEffort] + if (Array.isArray(supported) && !supported.includes("disable")) { + candidates.push(OPENAI_REASONING_EFFORTS.find((effort) => supported.includes(effort))) + } + + for (const effort of candidates) { + const selectedEffort = OPENAI_REASONING_EFFORTS.find((candidate) => candidate === effort) + if ( + selectedEffort && + (supported === true || (Array.isArray(supported) && supported.includes(selectedEffort))) + ) { + return selectedEffort + } } return undefined @@ -114,7 +125,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa body.temperature = this.options.modelTemperature } - const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + const reasoningEffort = getReasoningEffort(this.options, info) if (reasoningEffort) { body.reasoning_effort = reasoningEffort } @@ -165,7 +176,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa body.temperature = this.options.modelTemperature } - const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + const reasoningEffort = getReasoningEffort(this.options, info) if (reasoningEffort) { body.reasoning_effort = reasoningEffort } diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index d8ee0cd448..47883171aa 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -102,9 +102,8 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod // Default reasoning effort - use model's default if available // GPT-5 models have "medium" as their default in the model configuration const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined - const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort - ? modelDefaultReasoningEffort || "medium" - : "disable" + const defaultReasoningEffort: ReasoningEffortOption = + modelDefaultReasoningEffort ?? (modelInfo?.requiredReasoningEffort ? "medium" : "disable") // Current reasoning effort from settings, or fall back to default. // Clamp to availableOptions so the Select trigger always renders a valid option. const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined @@ -120,19 +119,12 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod useEffect(() => { if ( isReasoningEffortSupported && - modelInfo?.requiredReasoningEffort && storedReasoningEffort !== currentReasoningEffort && currentReasoningEffort !== "disable" ) { setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false) } - }, [ - isReasoningEffortSupported, - storedReasoningEffort, - currentReasoningEffort, - modelInfo?.requiredReasoningEffort, - setApiConfigurationField, - ]) + }, [isReasoningEffortSupported, storedReasoningEffort, currentReasoningEffort, setApiConfigurationField]) // Sync enableReasoningEffort based on selection // "disable" turns off reasoning; "none" is a valid level (reasoning enabled) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 3c97ee20e9..b68c5a06e1 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -273,11 +273,13 @@ describe("ThinkingBudget", () => { }) it("should fall back to first available option when stored value is not in the explicit array", () => { + const setApiConfigurationField = vi.fn() // Covers the clamp branch: defaultReasoningEffort="disable" but array omits "disable" render( { // The select value should be "low" (first item), not "disable" expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it("should use and persist an optional model's advertised reasoning default", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "high") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it("should preserve an explicit disable selection for optional reasoning", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "disable") + expect(setApiConfigurationField).not.toHaveBeenCalled() }) it("should normalize an invalid disabled value to the default for required reasoning", () => { From 84769af299df3f2a082c95dd42cc8e22e06defd2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 22 Aug 2026 22:04:38 +0000 Subject: [PATCH 2/2] test(reasoning): cover default state matrix --- .../__tests__/ThinkingBudget.spec.tsx | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index b68c5a06e1..ee4ee63f01 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -4,7 +4,7 @@ import React from "react" import { render, screen, fireEvent } from "@/utils/test-utils" -import type { ModelInfo } from "@roo-code/types" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" import { ThinkingBudget } from "../ThinkingBudget" @@ -352,6 +352,81 @@ describe("ThinkingBudget", () => { expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "max", false) }) + it("should use the first supported effort when required reasoning has no advertised default", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it.each<{ + name: string + apiConfiguration: ProviderSettings + modelInfo: ModelInfo + expected: string + expectedWrite?: string + }>([ + { + name: "keeps a supported stored effort over the model default", + apiConfiguration: { reasoningEffort: "low", enableReasoningEffort: true }, + modelInfo: { + ...reasoningEffortModelInfo, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + expected: "low", + expectedWrite: undefined, + }, + { + name: "normalizes an unsupported stored effort to the model default", + apiConfiguration: { reasoningEffort: "max", enableReasoningEffort: true }, + modelInfo: { + ...reasoningEffortModelInfo, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + expected: "high", + expectedWrite: "high", + }, + { + name: "defaults optional boolean reasoning support to disabled", + apiConfiguration: {}, + modelInfo: reasoningEffortModelInfo, + expected: "disable", + expectedWrite: undefined, + }, + ])("$name", ({ apiConfiguration, modelInfo, expected, expectedWrite }) => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", expected) + if (expectedWrite) { + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", expectedWrite, false) + } else { + expect(setApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything(), false) + } + }) + it("should fall back to rawReasoningEffort when availableOptions is empty", () => { // Covers the ?? rawReasoningEffort branch when availableOptions[0] is undefined render(