Skip to content
Draft
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
42 changes: 42 additions & 0 deletions src/api/providers/__tests__/nanogpt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
27 changes: 19 additions & 8 deletions src/api/providers/nanogpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
nanoGptDefaultModelId,
nanoGptDefaultModelInfo,
providerIdentifiers,
type ModelInfo,
type NanoGptRoutingPreference,
} from "@roo-code/types"

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
14 changes: 3 additions & 11 deletions webview-ui/src/components/settings/ThinkingBudget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{}}
setApiConfigurationField={setApiConfigurationField}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["low", "high"],
Expand All @@ -287,6 +289,47 @@ describe("ThinkingBudget", () => {

// 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(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{}}
setApiConfigurationField={setApiConfigurationField}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["disable", "high", "max"],
reasoningEffort: "high",
}}
/>,
)

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(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ reasoningEffort: "disable", enableReasoningEffort: false }}
setApiConfigurationField={setApiConfigurationField}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["disable", "high", "max"],
reasoningEffort: "high",
}}
/>,
)

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", () => {
Expand All @@ -309,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(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{}}
setApiConfigurationField={setApiConfigurationField}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["low", "high"],
requiredReasoningEffort: true,
}}
/>,
)

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(
<ThinkingBudget
{...defaultProps}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={modelInfo}
/>,
)

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(
Expand Down
Loading