Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/api/providers/__tests__/deepseek.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<ApiHandlerOptions>) {
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<Pick<ModelInfo, "supportsReasoningEffort">>)(
"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)
Expand Down
8 changes: 7 additions & 1 deletion src/api/providers/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
OPEN_AI_CODEX_SERVICE_TIER_KEY,
OpenAiCodexServiceTier,
providerIdentifiers,
openAiModelInfoSaneDefaults,
retiredProviderIdentifiers,
type ProviderSettings,
} from "@roo-code/types"
Expand Down Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import axios from "axios"

import {
type ProviderSettingsEntry,
type ProviderSettings,
type ClineMessage,
type ExtensionMessage,
type ExtensionState,
Expand All @@ -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"

Expand Down Expand Up @@ -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)

Expand Down
25 changes: 12 additions & 13 deletions webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,14 @@ vi.mock("../ThinkingBudget", () => ({
value={apiConfiguration?.reasoningEffort || ""}
onChange={(e) => setApiConfigurationField("reasoningEffort", e.target.value)}>
<option value="">Select...</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
{(Array.isArray(modelInfo.supportsReasoningEffort)
? modelInfo.supportsReasoningEffort
: ["low", "medium", "high"]
).map((effort: string) => (
<option key={effort} value={effort}>
{effort}
</option>
))}
</select>
</div>
)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading