From 92e34d47c6684983f76af348fdc7e2c0a84ebf5d Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Fri, 11 Sep 2026 23:24:12 +0900 Subject: [PATCH 1/2] fix(openai): honor compatible reasoning effort across requests Use the custom model effort selected in settings instead of stale top-level values, and propagate it consistently across streaming, non-streaming, O-series, and single-completion requests while respecting explicit disable. Keep inherited DeepSeek completions from sending V4 reasoning fields for unknown model IDs. Add regression coverage for request paths and support boundaries. Signed-off-by: JunyongParkDev --- src/api/providers/__tests__/deepseek.spec.ts | 26 +++++ src/api/providers/__tests__/openai.spec.ts | 113 +++++++++++++++++++ src/api/providers/deepseek.ts | 8 +- src/api/providers/openai.ts | 12 +- 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index b4c5d1f5f6..2f344d8405 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -148,6 +148,32 @@ describe("DeepSeekHandler", () => { clearAllMocks() }) + describe("completePrompt reasoning", () => { + it.each([ + { apiModelId: "custom-deepseek-model", enableReasoningEffort: true, expected: undefined }, + { apiModelId: "deepseek-v4-flash", enableReasoningEffort: false, expected: undefined }, + { apiModelId: "deepseek-v4-flash", enableReasoningEffort: true, expected: "max" }, + ])("respects reasoning support for $apiModelId with enabled=$enableReasoningEffort", async (scenario) => { + const completionHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: scenario.apiModelId, + enableReasoningEffort: scenario.enableReasoningEffort, + reasoningEffort: "max", + }) + + await completionHandler.completePrompt("Hello") + + expect(mockCreate).toHaveBeenCalledOnce() + const request = mockCreate.mock.calls[0][0] + expect(request.model).toBe(scenario.apiModelId) + if (scenario.expected === undefined) { + expect(request).not.toHaveProperty("reasoning_effort") + } else { + expect(request.reasoning_effort).toBe(scenario.expected) + } + }) + }) + describe("constructor", () => { it("should initialize with provided options", () => { expect(handler).toBeInstanceOf(DeepSeekHandler) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 1df8cee2fa..a3dcbcc0d5 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -8,6 +8,7 @@ import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE, azureOpenAiDefaultApiVersion, + type ModelInfo, } from "@roo-code/types" import { Package } from "../../../shared/package" import { makeApiHandlerOptions } from "../../../test-utils/api" @@ -950,7 +951,119 @@ describe("OpenAiHandler", () => { }) }) + describe.each([ + { name: "streaming chat", openAiModelId: "custom-model", streaming: true, singleCompletion: false }, + { name: "non-streaming chat", openAiModelId: "custom-model", streaming: false, singleCompletion: false }, + { name: "streaming O3", openAiModelId: "o3-mini", streaming: true, singleCompletion: false }, + { name: "non-streaming O3", openAiModelId: "o3-mini", streaming: false, singleCompletion: false }, + { name: "single completion", openAiModelId: "custom-model", streaming: false, singleCompletion: true }, + ])("reasoning effort consistency: $name", ({ openAiModelId, streaming, singleCompletion }) => { + async function requestWithSettings(settings: Partial) { + const reasoningHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId, + openAiStreamingEnabled: streaming, + ...settings, + }) + + if (singleCompletion) { + await reasoningHandler.completePrompt("Hello") + } else { + await collectStream( + reasoningHandler.createMessage("System prompt", [{ role: "user", content: "Hello" }]), + ) + } + } + + it.each([ + { selected: "max", stale: "low" }, + { selected: "high", stale: "medium" }, + { selected: "xhigh", stale: "medium" }, + { selected: "max", stale: "disable" }, + { selected: "max", stale: "none" }, + ] as const)( + "uses the custom model's $selected effort despite a stale top-level $stale", + async ({ selected, stale }) => { + await requestWithSettings({ + enableReasoningEffort: true, + reasoningEffort: stale, + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: selected }, + }) + + expect(mockCreate).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ reasoning_effort: selected }), + {}, + ) + }, + ) + + it.each(["low", "medium", "high", "xhigh", "max"] as const)( + "preserves the selected %s effort when the enable flag is unset in a legacy profile", + async (reasoningEffort) => { + await requestWithSettings({ + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort }, + }) + + expect(mockCreate).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ reasoning_effort: reasoningEffort }), + {}, + ) + }, + ) + + it("omits reasoning effort when disabled even if custom model metadata retains max", async () => { + await requestWithSettings({ + enableReasoningEffort: false, + reasoningEffort: "low", + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "max" }, + }) + + expect(mockCreate).toHaveBeenCalledOnce() + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + + it("does not use a hidden top-level effort when no custom effort is configured", async () => { + await requestWithSettings({ + enableReasoningEffort: true, + reasoningEffort: "low", + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, supportsReasoningEffort: true }, + }) + + expect(mockCreate).toHaveBeenCalledOnce() + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + }) + describe("getModel", () => { + it.each([ + { supportsReasoningEffort: undefined }, + { supportsReasoningEffort: true }, + { supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"] }, + ] satisfies Array>)( + "resolves custom effort consistently for capability $supportsReasoningEffort without mutating settings", + ({ supportsReasoningEffort }) => { + const options: ApiHandlerOptions = { + ...mockOptions, + enableReasoningEffort: true, + reasoningEffort: "low", + openAiCustomModelInfo: { + ...openAiModelInfoSaneDefaults, + supportsReasoningEffort, + reasoningEffort: "max", + }, + } + const reasoningHandler = new OpenAiHandler(options) + + expect(reasoningHandler.getModel()).toMatchObject({ + info: { reasoningEffort: "max" }, + reasoningEffort: "max", + reasoning: { reasoning_effort: "max" }, + }) + expect(options.reasoningEffort).toBe("low") + expect(options.openAiCustomModelInfo?.reasoningEffort).toBe("max") + }, + ) + it("should return model info with sane defaults", () => { const model = handler.getModel() expect(model.id).toBe(mockOptions.openAiModelId) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 3df014a05b..c423c2b55c 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -104,7 +104,13 @@ export class DeepSeekHandler extends OpenAiHandler { settings: this.options, defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE, }) - return { id, info, ...params } + return { + id, + info, + ...params, + // Unknown IDs use fallback metadata, but must not inherit its V4 request fields. + reasoning: supportsDeepSeekThinkingToggle(id) ? params.reasoning : undefined, + } } override async *createMessage( diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 76b8a32691..04b12f233d 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -236,6 +236,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: deepseekReasoner ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) : [systemMessage, ...convertToOpenAiMessages(messages)], + ...reasoning, // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, @@ -297,7 +298,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl format: "openai", modelId: id, model: info, - settings: this.options, + // OpenAI Compatible edits effort in custom model info. The shared top-level + // setting may be left over from another provider and must not override it. + settings: { ...this.options, reasoningEffort: info.reasoningEffort }, defaultTemperature: 0, }) return { id, info, ...params } @@ -312,6 +315,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { model: model.id, messages: [{ role: "user", content: prompt }], + ...model.reasoning, } // Add max_tokens if needed @@ -350,7 +354,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelInfo = this.getModel().info + const { info: modelInfo, reasoning } = this.getModel() const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) if (this.options.openAiStreamingEnabled ?? true) { @@ -367,7 +371,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ], stream: true, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, + ...reasoning, temperature: undefined, // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), @@ -402,7 +406,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }, ...convertToOpenAiMessages(messages), ], - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, + ...reasoning, temperature: undefined, // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), From 426a19131ecc849b5bb3f2a4f5b8e45e78028b35 Mon Sep 17 00:00:00 2001 From: Junyong Park Date: Fri, 11 Sep 2026 23:25:19 +0900 Subject: [PATCH 2/2] test(settings): cover compatible reasoning effort round trips Verify that the OpenAI-compatible selector updates custom model metadata, unsaved edits survive live state refreshes, and Save sends the cached value. Cover profile reload and storage-to-webview round trips with reasoning enabled, disabled, and unset while a stale top-level effort remains. Signed-off-by: JunyongParkDev --- .../__tests__/ProviderSettingsManager.spec.ts | 24 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 21 +++++++ .../settings/__tests__/ApiOptions.spec.tsx | 25 ++++---- .../SettingsView.unsaved-changes.spec.tsx | 57 ++++++++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 7bedfc1a25..13e1aeeb2d 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -4,6 +4,7 @@ import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, providerIdentifiers, + openAiModelInfoSaneDefaults, retiredProviderIdentifiers, type ProviderSettings, } from "@roo-code/types" @@ -484,6 +485,29 @@ describe("ProviderSettingsManager", () => { }, ) + it.each([true, false, undefined])( + "round-trips OpenAI-compatible reasoning settings through profile storage when enabled is %s", + async (enableReasoningEffort) => { + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + openAiModelId: "custom-model", + enableReasoningEffort, + reasoningEffort: "low", + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "max" }, + } + await providerSettingsManager.saveConfig("compatible", configuration) + + const serializedProfiles: string = mockSecrets.store.mock.calls.at(-1)![1] + mockSecrets.get.mockResolvedValue(serializedProfiles) + const reloadedManager = new ProviderSettingsManager(mockContext) + const profile = await reloadedManager.getProfile({ name: "compatible" }) + + expect(profile.enableReasoningEffort).toBe(enableReasoningEffort) + expect(profile.reasoningEffort).toBe("low") + expect(profile.openAiCustomModelInfo).toEqual(configuration.openAiCustomModelInfo) + }, + ) + it("persists OpenAI-compatible Extra Body only on OpenAI-compatible profiles", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..bfd4706dcc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -9,6 +9,7 @@ import axios from "axios" import { type ProviderSettingsEntry, + type ProviderSettings, type ClineMessage, type ExtensionMessage, type ExtensionState, @@ -18,6 +19,7 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, providerIdentifiers, + openAiModelInfoSaneDefaults, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -1479,6 +1481,25 @@ describe("ClineProvider", () => { expect(postedState.apiConfiguration).toMatchObject(expectedConfiguration) }) + test.each([true, false, undefined])( + "returns saved OpenAI-compatible reasoning settings to the webview when enabled is %s", + async (enableReasoningEffort) => { + await provider.resolveWebviewView(mockWebviewView) + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + openAiModelId: "custom-model", + enableReasoningEffort, + reasoningEffort: "low", + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "max" }, + } + await provider.contextProxy.setProviderSettings(configuration) + + expect(provider.contextProxy.getProviderSettings()).toMatchObject(configuration) + expect((await provider.getState()).apiConfiguration).toMatchObject(configuration) + expect((await provider.getStateToPostToWebview()).apiConfiguration).toMatchObject(configuration) + }, + ) + test("getState returns the saved destructive command guard setting", async () => { await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index b0f1b6f9ef..341fffe856 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -194,9 +194,14 @@ vi.mock("../ThinkingBudget", () => ({ value={apiConfiguration?.reasoningEffort || ""} onChange={(e) => setApiConfigurationField("reasoningEffort", e.target.value)}> - - - + {(Array.isArray(modelInfo.supportsReasoningEffort) + ? modelInfo.supportsReasoningEffort + : ["low", "medium", "high"] + ).map((effort: string) => ( + + ))} ) @@ -521,11 +526,12 @@ describe("ApiOptions", () => { // However, we've tested the state update call. }) - it("updates reasoningEffort in openAiCustomModelInfo when select value changes", () => { + it("selects max in custom model info even when a top-level low effort remains", () => { const mockSetApiConfigurationField = vi.fn() const initialConfig = { apiProvider: providerIdentifiers.openai, enableReasoningEffort: true, // Initially enabled + reasoningEffort: "low" as const, openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "low" as const, @@ -537,25 +543,18 @@ describe("ApiOptions", () => { setApiConfigurationField: mockSetApiConfigurationField, }) - // Find the reasoning effort select among all comboboxes by its current value - // const allSelects = screen.getAllByRole("combobox") as HTMLSelectElement[] - // const reasoningSelect = allSelects.find( - // (el) => el.value === initialConfig.openAiCustomModelInfo.reasoningEffort, - // ) - // expect(reasoningSelect).toBeDefined() const selectContainer = screen.getByTestId("reasoning-effort") expect(selectContainer).toBeInTheDocument() const reasoningSelect = within(selectContainer).getByRole("combobox") expect(reasoningSelect).toHaveValue("low") - // Simulate changing the reasoning effort to 'high' - fireEvent.change(reasoningSelect, { target: { value: "high" } }) + fireEvent.change(reasoningSelect, { target: { value: "max" } }) // Check if setApiConfigurationField was called correctly for openAiCustomModelInfo expect(mockSetApiConfigurationField).toHaveBeenCalledWith( "openAiCustomModelInfo", - expect.objectContaining({ reasoningEffort: "high" }), + expect.objectContaining({ reasoningEffort: "max" }), ) // Check that other properties were preserved diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 439b859d30..72e1cc7c5d 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -1,4 +1,4 @@ -import { providerIdentifiers } from "@roo-code/types" +import { providerIdentifiers, openAiModelInfoSaneDefaults, type ProviderSettings } from "@roo-code/types" import { screen, fireEvent, waitFor } from "@testing-library/react" import { renderWithExtensionState } from "@/utils/test-utils" @@ -268,7 +268,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { deniedCommands: [], allowedMaxRequests: undefined, allowedMaxCost: undefined, - language: "en", + language: "en" as const, alwaysAllowExecute: false, alwaysAllowMcp: false, alwaysAllowModeSwitch: false, @@ -287,7 +287,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { ttsEnabled: false, ttsSpeed: 1.0, soundVolume: 0.5, - telemetrySetting: "unset", + telemetrySetting: "unset" as const, terminalOutputLineLimit: 500, terminalOutputCharacterLimit: 50000, terminalShellIntegrationTimeout: 3000, @@ -682,6 +682,57 @@ describe("SettingsView - Unsaved Changes Detection", () => { }) }) + it("keeps OpenAI-compatible reasoning edits cached until Save despite a live state refresh", async () => { + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openai, + openAiModelId: "custom-model", + enableReasoningEffort: false, + reasoningEffort: "low", + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "low" }, + } + vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: configuration, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + + )) + const view = renderWithExtensionState(, { queryClient }) + fireEvent.click(await screen.findByTestId("select-compatible-max")) + + vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: { ...configuration }, + }) + view.rerender() + + expect(screen.getByTestId("select-compatible-max")).toHaveTextContent("max") + expect(configuration.enableReasoningEffort).toBe(false) + expect(configuration.openAiCustomModelInfo?.reasoningEffort).toBe("low") + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + + fireEvent.click(screen.getByTestId("save-button")) + expect(postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + ...configuration, + enableReasoningEffort: true, + openAiCustomModelInfo: { ...openAiModelInfoSaneDefaults, reasoningEffort: "max" }, + }, + }) + }) + it("discards NanoGPT cached edits and restores the extension values", async () => { const onDone = vi.fn() ;(useExtensionState as ReturnType).mockReturnValue({