Enabled reasoning in Roo provider (#8874)

This commit is contained in:
Matt Rubens 2025-10-27 21:15:02 -04:00 committed by GitHub
parent 3cbdbc2af6
commit b72d55ef45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1223 additions and 9 deletions

View file

@ -65,6 +65,7 @@ export const modelInfoSchema = z.object({
supportsTemperature: z.boolean().optional(),
requiredReasoningBudget: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
requiredReasoningEffort: z.boolean().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),
inputPrice: z.number().optional(),
outputPrice: z.number().optional(),

View file

@ -86,6 +86,28 @@ vitest.mock("../../../i18n", () => ({
}),
}))
// Mock model cache
vitest.mock("../../providers/fetchers/modelCache", () => ({
getModels: vitest.fn(),
flushModels: vitest.fn(),
getModelsFromCache: vitest.fn((provider: string) => {
if (provider === "roo") {
return {
"xai/grok-code-fast-1": {
maxTokens: 16_384,
contextWindow: 262_144,
supportsImages: false,
supportsReasoningEffort: true, // Enable reasoning for tests
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
},
}
}
return {}
}),
}))
// Import after mocks are set up
import { RooHandler } from "../roo"
import { CloudService } from "@roo-code/cloud"
@ -446,4 +468,132 @@ describe("RooHandler", () => {
expect(handler).toBeInstanceOf(RooHandler)
})
})
describe("reasoning effort support", () => {
it("should include reasoning with enabled: false when not enabled", async () => {
handler = new RooHandler(mockOptions)
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: mockOptions.apiModelId,
messages: expect.any(Array),
stream: true,
stream_options: { include_usage: true },
reasoning: { enabled: false },
}),
undefined,
)
})
it("should include reasoning with enabled: false when explicitly disabled", async () => {
handler = new RooHandler({
...mockOptions,
enableReasoningEffort: false,
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning: { enabled: false },
}),
undefined,
)
})
it("should include reasoning with enabled: true and effort: low", async () => {
handler = new RooHandler({
...mockOptions,
reasoningEffort: "low",
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning: { enabled: true, effort: "low" },
}),
undefined,
)
})
it("should include reasoning with enabled: true and effort: medium", async () => {
handler = new RooHandler({
...mockOptions,
reasoningEffort: "medium",
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning: { enabled: true, effort: "medium" },
}),
undefined,
)
})
it("should include reasoning with enabled: true and effort: high", async () => {
handler = new RooHandler({
...mockOptions,
reasoningEffort: "high",
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning: { enabled: true, effort: "high" },
}),
undefined,
)
})
it("should not include reasoning for minimal (treated as none)", async () => {
handler = new RooHandler({
...mockOptions,
reasoningEffort: "minimal",
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
// minimal should result in no reasoning parameter
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.reasoning).toBeUndefined()
})
it("should handle enableReasoningEffort: false overriding reasoningEffort setting", async () => {
handler = new RooHandler({
...mockOptions,
enableReasoningEffort: false,
reasoningEffort: "high",
})
const stream = handler.createMessage(systemPrompt, messages)
for await (const _chunk of stream) {
// Consume stream
}
// When explicitly disabled, should send enabled: false regardless of effort setting
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning: { enabled: false },
}),
undefined,
)
})
})
})

View file

@ -0,0 +1,477 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { getRooModels } from "../roo"
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch as any
describe("getRooModels", () => {
const baseUrl = "https://api.roocode.com/proxy"
const apiKey = "test-api-key"
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("should fetch and parse models successfully", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "xai/grok-code-fast-1",
object: "model",
created: 1234567890,
owned_by: "xai",
name: "Grok Code Fast 1",
description: "Fast coding model",
context_window: 262144,
max_tokens: 16384,
type: "language",
tags: ["vision", "reasoning"],
pricing: {
input: "0.0001",
output: "0.0002",
input_cache_read: "0.00005",
input_cache_write: "0.0001",
},
deprecated: false,
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(mockFetch).toHaveBeenCalledWith(
"https://api.roocode.com/proxy/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
}),
}),
)
expect(models).toEqual({
"xai/grok-code-fast-1": {
maxTokens: 16384,
contextWindow: 262144,
supportsImages: true,
supportsReasoningEffort: true,
requiredReasoningEffort: false,
supportsPromptCache: true,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: 100, // 0.0001 * 1_000_000
cacheReadsPrice: 50, // 0.00005 * 1_000_000
description: "Fast coding model",
deprecated: false,
isFree: false,
},
})
})
it("should handle reasoning-required tag", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/reasoning-required-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Reasoning Required Model",
description: "Model that requires reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["reasoning", "reasoning-required"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/reasoning-required-model"]).toEqual({
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsReasoningEffort: true,
requiredReasoningEffort: true,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
description: "Model that requires reasoning",
deprecated: false,
isFree: false,
})
})
it("should handle models without required_reasoning_effort field", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/normal-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Normal Model",
description: "Normal model without reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/normal-model"]).toEqual({
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsReasoningEffort: false,
requiredReasoningEffort: false,
supportsPromptCache: false,
inputPrice: 100, // 0.0001 * 1_000_000
outputPrice: 200, // 0.0002 * 1_000_000
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
description: "Normal model without reasoning",
deprecated: false,
isFree: false,
})
})
it("should work without API key", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/public-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Public Model",
description: "Public model",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl)
expect(mockFetch).toHaveBeenCalledWith(
"https://api.roocode.com/proxy/v1/models",
expect.objectContaining({
headers: expect.not.objectContaining({
Authorization: expect.anything(),
}),
}),
)
expect(models["test/public-model"]).toBeDefined()
})
it("should handle HTTP errors", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
})
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: HTTP 401: Unauthorized",
)
})
it("should handle timeout", async () => {
const abortError = new Error("AbortError")
abortError.name = "AbortError"
mockFetch.mockRejectedValueOnce(abortError)
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: Request timed out",
)
})
it("should handle invalid response format", async () => {
const invalidResponse = {
invalid: "data",
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => invalidResponse,
})
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: Unexpected response format",
)
})
it("should normalize base URL correctly", async () => {
const mockResponse = {
object: "list",
data: [],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
await getRooModels("https://api.roocode.com/proxy/v1", apiKey)
expect(mockFetch).toHaveBeenCalledWith("https://api.roocode.com/proxy/v1/models", expect.any(Object))
})
it("should handle deprecated models", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/deprecated-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Deprecated Model",
description: "Old model",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
deprecated: true,
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/deprecated-model"].deprecated).toBe(true)
})
it("should detect vision support from tags", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/vision-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Vision Model",
description: "Model with vision",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["vision"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/vision-model"].supportsImages).toBe(true)
})
it("should detect reasoning support from tags", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/reasoning-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Reasoning Model",
description: "Model with reasoning",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["reasoning"],
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/reasoning-model"].supportsReasoningEffort).toBe(true)
})
it("should handle models with cache pricing", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/cache-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Cache Model",
description: "Model with cache",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
input_cache_read: "0.00005",
input_cache_write: "0.0001",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/cache-model"].supportsPromptCache).toBe(true)
expect(models["test/cache-model"].cacheReadsPrice).toBe(50) // 0.00005 * 1_000_000
expect(models["test/cache-model"].cacheWritesPrice).toBe(100) // 0.0001 * 1_000_000
})
it("should skip models without ID", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Invalid Model",
description: "Model without ID",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(Object.keys(models)).toHaveLength(0)
})
it("should use model name as description fallback", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/no-description",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model Name",
description: "",
context_window: 128000,
max_tokens: 8192,
type: "language",
pricing: {
input: "0.0001",
output: "0.0002",
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
expect(models["test/no-description"].description).toBe("Model Name")
})
it("should handle network errors", async () => {
mockFetch.mockRejectedValueOnce(new TypeError("Network error"))
await expect(getRooModels(baseUrl, apiKey)).rejects.toThrow(
"Failed to fetch Roo Code Cloud models: No response from server",
)
})
})

View file

@ -71,6 +71,12 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise<Mo
// Determine if the model supports images based on tags
const supportsImages = tags.includes("vision")
// Determine if the model supports reasoning effort based on tags
const supportsReasoningEffort = tags.includes("reasoning")
// Determine if the model requires reasoning effort based on tags
const requiredReasoningEffort = tags.includes("reasoning-required")
// Parse pricing (API returns strings, convert to numbers)
const inputPrice = parseApiPrice(pricing.input)
const outputPrice = parseApiPrice(pricing.output)
@ -81,6 +87,8 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise<Mo
maxTokens,
contextWindow,
supportsImages,
supportsReasoningEffort,
requiredReasoningEffort,
supportsPromptCache: Boolean(cacheReadPrice !== undefined),
inputPrice,
outputPrice,

View file

@ -6,11 +6,16 @@ import { CloudService } from "@roo-code/cloud"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { convertToOpenAiMessages } from "../transform/openai-format"
import type { RooReasoningParams } from "../transform/reasoning"
import { getRooReasoning } from "../transform/reasoning"
import type { ApiHandlerCreateMessageMetadata } from "../index"
import { DEFAULT_HEADERS } from "./constants"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
import { getModels, flushModels, getModelsFromCache } from "../providers/fetchers/modelCache"
import { handleOpenAIError } from "./utils/openai-error-handler"
// Extend OpenAI's CompletionUsage to include Roo specific fields
interface RooUsage extends OpenAI.CompletionUsage {
@ -18,6 +23,11 @@ interface RooUsage extends OpenAI.CompletionUsage {
cost?: number
}
// Add custom interface for Roo params to support reasoning
type RooChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
reasoning?: RooReasoningParams
}
export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
private authStateListener?: (state: { state: AuthState }) => void
private fetcherBaseURL: string
@ -78,6 +88,51 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
}
}
protected override createStream(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
requestOptions?: OpenAI.RequestOptions,
) {
const { id: model, info } = this.getModel()
// Get model parameters including reasoning
const params = getModelParams({
format: "openai",
modelId: model,
model: info,
settings: this.options,
defaultTemperature: this.defaultTemperature,
})
// Get Roo-specific reasoning parameters
const reasoning = getRooReasoning({
model: info,
reasoningBudget: params.reasoningBudget,
reasoningEffort: params.reasoningEffort,
settings: this.options,
})
const max_tokens = params.maxTokens ?? undefined
const temperature = params.temperature ?? this.defaultTemperature
const rooParams: RooChatCompletionParams = {
model,
max_tokens,
temperature,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
...(reasoning && { reasoning }),
}
try {
return this.client.chat.completions.create(rooParams, requestOptions)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
@ -96,19 +151,28 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
const delta = chunk.choices[0]?.delta
if (delta) {
if (delta.content) {
// Check for reasoning content (similar to OpenRouter)
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
yield {
type: "text",
text: delta.content,
type: "reasoning",
text: delta.reasoning,
}
}
// Also check for reasoning_content for backward compatibility
if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
yield {
type: "reasoning",
text: delta.reasoning_content,
}
}
if (delta.content) {
yield {
type: "text",
text: delta.content,
}
}
}
if (chunk.usage) {
@ -163,6 +227,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
maxTokens: 16_384,
contextWindow: 262_144,
supportsImages: false,
supportsReasoningEffort: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,

View file

@ -6,10 +6,12 @@ import {
getOpenRouterReasoning,
getAnthropicReasoning,
getOpenAiReasoning,
getRooReasoning,
GetModelReasoningOptions,
OpenRouterReasoningParams,
AnthropicReasoningParams,
OpenAiReasoningParams,
RooReasoningParams,
} from "../reasoning"
describe("reasoning.ts", () => {
@ -761,4 +763,133 @@ describe("reasoning.ts", () => {
}
})
})
describe("getRooReasoning", () => {
it("should return undefined when model does not support reasoning effort", () => {
const options = { ...baseOptions }
const result = getRooReasoning(options)
expect(result).toBeUndefined()
})
it("should return enabled: false when enableReasoningEffort is explicitly false", () => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const settingsWithDisabled: ProviderSettings = {
enableReasoningEffort: false,
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: settingsWithDisabled,
}
const result = getRooReasoning(options)
expect(result).toEqual({ enabled: false })
})
it("should return enabled: true with effort when reasoningEffort is provided", () => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const settingsWithEffort: ProviderSettings = {
reasoningEffort: "high",
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: settingsWithEffort,
reasoningEffort: "high" as const,
}
const result = getRooReasoning(options)
expect(result).toEqual({ enabled: true, effort: "high" })
})
it("should return enabled: false when reasoningEffort is undefined (None selected)", () => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: {},
reasoningEffort: undefined,
}
const result = getRooReasoning(options)
expect(result).toEqual({ enabled: false })
})
it("should not return reasoning params for minimal effort", () => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const settingsWithMinimal: ProviderSettings = {
reasoningEffort: "minimal",
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: settingsWithMinimal,
reasoningEffort: "minimal" as ReasoningEffortWithMinimal,
}
const result = getRooReasoning(options)
expect(result).toBeUndefined()
})
it("should handle all valid reasoning effort values", () => {
const efforts: Array<"low" | "medium" | "high"> = ["low", "medium", "high"]
efforts.forEach((effort) => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const settingsWithEffort: ProviderSettings = {
reasoningEffort: effort,
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: settingsWithEffort,
reasoningEffort: effort,
}
const result = getRooReasoning(options)
expect(result).toEqual({ enabled: true, effort })
})
})
it("should return enabled: false when model supports reasoning but no effort is provided", () => {
const modelWithSupported: ModelInfo = {
...baseModel,
supportsReasoningEffort: true,
}
const options = {
...baseOptions,
model: modelWithSupported,
settings: {},
reasoningEffort: undefined,
}
const result = getRooReasoning(options)
expect(result).toEqual({ enabled: false })
})
})
})

View file

@ -12,6 +12,11 @@ export type OpenRouterReasoningParams = {
exclude?: boolean
}
export type RooReasoningParams = {
enabled?: boolean
effort?: ReasoningEffortWithMinimal
}
export type AnthropicReasoningParams = BetaThinkingConfigParam
export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] }
@ -39,6 +44,36 @@ export const getOpenRouterReasoning = ({
: undefined
: undefined
export const getRooReasoning = ({
model,
reasoningEffort,
settings,
}: GetModelReasoningOptions): RooReasoningParams | undefined => {
// Check if model supports reasoning effort
if (!model.supportsReasoningEffort) {
return undefined
}
// If enableReasoningEffort is explicitly false, return enabled: false
if (settings.enableReasoningEffort === false) {
return { enabled: false }
}
// If reasoning effort is provided, return it with enabled: true
if (reasoningEffort && reasoningEffort !== "minimal") {
return { enabled: true, effort: reasoningEffort }
}
// If reasoningEffort is explicitly undefined (None selected), disable reasoning
// This ensures we explicitly tell the backend not to use reasoning
if (reasoningEffort === undefined) {
return { enabled: false }
}
// Default: no reasoning parameter (reasoning not enabled)
return undefined
}
export const getAnthropicReasoning = ({
model,
reasoningBudget,

View file

@ -103,6 +103,7 @@ import { inputEventTransform, noTransform } from "./transforms"
import { ModelInfoView } from "./ModelInfoView"
import { ApiErrorMessage } from "./ApiErrorMessage"
import { ThinkingBudget } from "./ThinkingBudget"
import { SimpleThinkingBudget } from "./SimpleThinkingBudget"
import { Verbosity } from "./Verbosity"
import { DiffSettingsControl } from "./DiffSettingsControl"
import { TodoListSettingsControl } from "./TodoListSettingsControl"
@ -747,12 +748,21 @@ const ApiOptions = ({
</>
)}
<ThinkingBudget
key={`${selectedProvider}-${selectedModelId}`}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
{selectedProvider === "roo" ? (
<SimpleThinkingBudget
key={`${selectedProvider}-${selectedModelId}`}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
) : (
<ThinkingBudget
key={`${selectedProvider}-${selectedModelId}`}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
modelInfo={selectedModelInfo}
/>
)}
{/* Gate Verbosity UI by capability flag */}
{selectedModelInfo?.supportsVerbosity && (

View file

@ -0,0 +1,107 @@
import { useEffect } from "react"
import { type ProviderSettings, type ModelInfo, type ReasoningEffort, reasoningEfforts } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui"
interface SimpleThinkingBudgetProps {
apiConfiguration: ProviderSettings
setApiConfigurationField: <K extends keyof ProviderSettings>(
field: K,
value: ProviderSettings[K],
isUserAction?: boolean,
) => void
modelInfo?: ModelInfo
}
// Extended type to include "none" option
type ReasoningEffortWithNone = ReasoningEffort | "none"
export const SimpleThinkingBudget = ({
apiConfiguration,
setApiConfigurationField,
modelInfo,
}: SimpleThinkingBudgetProps) => {
const { t } = useAppTranslation()
// Check model capabilities
const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort
const isReasoningEffortRequired = !!modelInfo && modelInfo.requiredReasoningEffort
// Build available reasoning efforts list
// Include "none" option unless reasoning effort is required
const baseEfforts = [...reasoningEfforts] as ReasoningEffort[]
const availableReasoningEfforts: ReadonlyArray<ReasoningEffortWithNone> = isReasoningEffortRequired
? baseEfforts
: (["none", ...baseEfforts] as ReasoningEffortWithNone[])
// Default reasoning effort - use model's default if available, otherwise "medium"
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffort | undefined
const defaultReasoningEffort: ReasoningEffortWithNone = isReasoningEffortRequired
? modelDefaultReasoningEffort || "medium"
: "none"
// Current reasoning effort - treat undefined/null as "none"
const currentReasoningEffort: ReasoningEffortWithNone =
(apiConfiguration.reasoningEffort as ReasoningEffort | undefined) || defaultReasoningEffort
// Set default reasoning effort when model supports it and no value is set
useEffect(() => {
if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort) {
// Only set a default if reasoning is required, otherwise leave as undefined (which maps to "none")
if (isReasoningEffortRequired && defaultReasoningEffort !== "none") {
setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffort, false)
}
}
}, [
isReasoningEffortSupported,
isReasoningEffortRequired,
apiConfiguration.reasoningEffort,
defaultReasoningEffort,
setApiConfigurationField,
])
if (!modelInfo || !isReasoningEffortSupported) {
return null
}
return (
<div className="flex flex-col gap-1" data-testid="simple-reasoning-effort">
<div className="flex justify-between items-center">
<label className="block font-medium mb-1">{t("settings:providers.reasoningEffort.label")}</label>
</div>
<Select
value={currentReasoningEffort}
onValueChange={(value: ReasoningEffortWithNone) => {
// If "none" is selected, clear the reasoningEffort field
if (value === "none") {
setApiConfigurationField("reasoningEffort", undefined)
} else {
setApiConfigurationField("reasoningEffort", value as ReasoningEffort)
}
}}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
currentReasoningEffort
? currentReasoningEffort === "none"
? t("settings:providers.reasoningEffort.none")
: t(`settings:providers.reasoningEffort.${currentReasoningEffort}`)
: t("settings:common.select")
}
/>
</SelectTrigger>
<SelectContent>
{availableReasoningEfforts.map((value) => (
<SelectItem key={value} value={value}>
{value === "none"
? t("settings:providers.reasoningEffort.none")
: t(`settings:providers.reasoningEffort.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import { SimpleThinkingBudget } from "../SimpleThinkingBudget"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
// Mock the translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"settings:providers.reasoningEffort.label": "Model Reasoning Effort",
"settings:providers.reasoningEffort.none": "None",
"settings:providers.reasoningEffort.low": "Low",
"settings:providers.reasoningEffort.medium": "Medium",
"settings:providers.reasoningEffort.high": "High",
"settings:common.select": "Select",
}
return translations[key] || key
},
}),
}))
// Mock the useSelectedModel hook
vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({
useSelectedModel: () => ({ id: "test-model" }),
}))
describe("SimpleThinkingBudget", () => {
const mockSetApiConfigurationField = vi.fn()
const baseApiConfiguration: ProviderSettings = {
apiProvider: "roo",
}
const modelWithReasoningEffort: ModelInfo = {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoningEffort: true,
inputPrice: 0,
outputPrice: 0,
}
beforeEach(() => {
vi.clearAllMocks()
})
it("should not render when model does not support reasoning effort", () => {
const modelWithoutReasoningEffort: ModelInfo = {
...modelWithReasoningEffort,
supportsReasoningEffort: false,
}
const { container } = render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithoutReasoningEffort}
/>,
)
expect(container.firstChild).toBeNull()
})
it("should not render when modelInfo is undefined", () => {
const { container } = render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={undefined}
/>,
)
expect(container.firstChild).toBeNull()
})
it("should render with None option when reasoning effort is not required", () => {
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithReasoningEffort}
/>,
)
expect(screen.getByTestId("simple-reasoning-effort")).toBeInTheDocument()
expect(screen.getByText("Model Reasoning Effort")).toBeInTheDocument()
})
it("should not render None option when reasoning effort is required", () => {
const modelWithRequiredReasoningEffort: ModelInfo = {
...modelWithReasoningEffort,
requiredReasoningEffort: true,
}
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithRequiredReasoningEffort}
/>,
)
expect(screen.getByTestId("simple-reasoning-effort")).toBeInTheDocument()
})
it("should set default reasoning effort when required and no value is set", () => {
const modelWithRequiredReasoningEffort: ModelInfo = {
...modelWithReasoningEffort,
requiredReasoningEffort: true,
reasoningEffort: "high",
}
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithRequiredReasoningEffort}
/>,
)
// Should set default reasoning effort
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high", false)
})
it("should not set default reasoning effort when not required", () => {
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithReasoningEffort}
/>,
)
// Should not set any default value
expect(mockSetApiConfigurationField).not.toHaveBeenCalled()
})
it("should include None option in available efforts when not required", () => {
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithReasoningEffort}
/>,
)
// Component should render with the select
expect(screen.getByRole("combobox")).toBeInTheDocument()
})
it("should exclude None option when reasoning effort is required", () => {
const modelWithRequiredReasoningEffort: ModelInfo = {
...modelWithReasoningEffort,
requiredReasoningEffort: true,
}
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithRequiredReasoningEffort}
/>,
)
// Component should render with the select
expect(screen.getByRole("combobox")).toBeInTheDocument()
})
it("should display current reasoning effort value", () => {
render(
<SimpleThinkingBudget
apiConfiguration={{ ...baseApiConfiguration, reasoningEffort: "low" }}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithReasoningEffort}
/>,
)
expect(screen.getByText("Low")).toBeInTheDocument()
})
it("should display None when no reasoning effort is set", () => {
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithReasoningEffort}
/>,
)
expect(screen.getByText("None")).toBeInTheDocument()
})
it("should use model default reasoning effort when required and available", () => {
const modelWithDefaultEffort: ModelInfo = {
...modelWithReasoningEffort,
requiredReasoningEffort: true,
reasoningEffort: "medium",
}
render(
<SimpleThinkingBudget
apiConfiguration={baseApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
modelInfo={modelWithDefaultEffort}
/>,
)
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "medium", false)
})
})

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Esforç de raonament del model",
"none": "Cap",
"minimal": "Mínim (el més ràpid)",
"high": "Alt",
"medium": "Mitjà",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Modell-Denkaufwand",
"none": "Keine",
"minimal": "Minimal (schnellste)",
"high": "Hoch",
"medium": "Mittel",

View file

@ -465,6 +465,7 @@
},
"reasoningEffort": {
"label": "Model Reasoning Effort",
"none": "None",
"minimal": "Minimal (Fastest)",
"low": "Low",
"medium": "Medium",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Esfuerzo de razonamiento del modelo",
"none": "Ninguno",
"minimal": "Mínimo (el más rápido)",
"high": "Alto",
"medium": "Medio",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Effort de raisonnement du modèle",
"none": "Aucun",
"minimal": "Minimal (le plus rapide)",
"high": "Élevé",
"medium": "Moyen",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "मॉडल तर्क प्रयास",
"none": "कोई नहीं",
"minimal": "न्यूनतम (सबसे तेज़)",
"high": "उच्च",
"medium": "मध्यम",

View file

@ -464,6 +464,7 @@
},
"reasoningEffort": {
"label": "Upaya Reasoning Model",
"none": "Tidak Ada",
"minimal": "Minimal (Tercepat)",
"high": "Tinggi",
"medium": "Sedang",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Sforzo di ragionamento del modello",
"none": "Nessuno",
"minimal": "Minimo (più veloce)",
"high": "Alto",
"medium": "Medio",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "モデル推論の労力",
"none": "なし",
"minimal": "最小 (最速)",
"high": "高",
"medium": "中",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "모델 추론 노력",
"none": "없음",
"minimal": "최소 (가장 빠름)",
"high": "높음",
"medium": "중간",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Model redeneervermogen",
"none": "Geen",
"minimal": "Minimaal (Snelst)",
"high": "Hoog",
"medium": "Middel",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Wysiłek rozumowania modelu",
"none": "Brak",
"minimal": "Minimalny (najszybszy)",
"high": "Wysoki",
"medium": "Średni",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Esforço de raciocínio do modelo",
"none": "Nenhum",
"minimal": "Mínimo (mais rápido)",
"high": "Alto",
"medium": "Médio",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Усилия по рассуждению модели",
"none": "Нет",
"minimal": "Минимальный (самый быстрый)",
"high": "Высокие",
"medium": "Средние",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Model Akıl Yürütme Çabası",
"none": "Yok",
"minimal": "Minimal (en hızlı)",
"high": "Yüksek",
"medium": "Orta",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "Nỗ lực suy luận của mô hình",
"none": "Không",
"minimal": "Tối thiểu (nhanh nhất)",
"high": "Cao",
"medium": "Trung bình",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "模型推理强度",
"none": "无",
"minimal": "最小 (最快)",
"high": "高",
"medium": "中",

View file

@ -460,6 +460,7 @@
},
"reasoningEffort": {
"label": "模型推理強度",
"none": "無",
"minimal": "最小 (最快)",
"high": "高",
"medium": "中",