diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index a97519af1f13..43add380a15a 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -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(), diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index c209aa51cdc8..cd1ab3330104 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -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, + ) + }) + }) }) diff --git a/src/api/providers/fetchers/__tests__/roo.spec.ts b/src/api/providers/fetchers/__tests__/roo.spec.ts new file mode 100644 index 000000000000..dcc79e941fa3 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/roo.spec.ts @@ -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", + ) + }) +}) diff --git a/src/api/providers/fetchers/roo.ts b/src/api/providers/fetchers/roo.ts index 1238f86b9a31..17aec4253b31 100644 --- a/src/api/providers/fetchers/roo.ts +++ b/src/api/providers/fetchers/roo.ts @@ -71,6 +71,12 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise { private authStateListener?: (state: { state: AuthState }) => void private fetcherBaseURL: string @@ -78,6 +88,51 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { } } + 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 { 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 { maxTokens: 16_384, contextWindow: 262_144, supportsImages: false, + supportsReasoningEffort: false, supportsPromptCache: true, inputPrice: 0, outputPrice: 0, diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index fc0983d74169..ae565e9628bb 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -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 }) + }) + }) }) diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 100b1c268464..8d64fe46b114 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -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, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 7efa253abc4d..9e4d585c97c2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -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 = ({ )} - + {selectedProvider === "roo" ? ( + + ) : ( + + )} {/* Gate Verbosity UI by capability flag */} {selectedModelInfo?.supportsVerbosity && ( diff --git a/webview-ui/src/components/settings/SimpleThinkingBudget.tsx b/webview-ui/src/components/settings/SimpleThinkingBudget.tsx new file mode 100644 index 000000000000..5b46ae831993 --- /dev/null +++ b/webview-ui/src/components/settings/SimpleThinkingBudget.tsx @@ -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: ( + 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 = 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 ( +
+
+ +
+ +
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/SimpleThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/SimpleThinkingBudget.spec.tsx new file mode 100644 index 000000000000..c5d0a0c3d3ce --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/SimpleThinkingBudget.spec.tsx @@ -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 = { + "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( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("should not render when modelInfo is undefined", () => { + const { container } = render( + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("should render with None option when reasoning effort is not required", () => { + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + // Should set default reasoning effort + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high", false) + }) + + it("should not set default reasoning effort when not required", () => { + render( + , + ) + + // Should not set any default value + expect(mockSetApiConfigurationField).not.toHaveBeenCalled() + }) + + it("should include None option in available efforts when not required", () => { + render( + , + ) + + // 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( + , + ) + + // Component should render with the select + expect(screen.getByRole("combobox")).toBeInTheDocument() + }) + + it("should display current reasoning effort value", () => { + render( + , + ) + + expect(screen.getByText("Low")).toBeInTheDocument() + }) + + it("should display None when no reasoning effort is set", () => { + render( + , + ) + + 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( + , + ) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "medium", false) + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 60a59f0296a2..c97345f5e46c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -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à", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index ed3a9e3d1454..7588982b368c 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Modell-Denkaufwand", + "none": "Keine", "minimal": "Minimal (schnellste)", "high": "Hoch", "medium": "Mittel", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7c84a57607d1..789452fadcfe 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -465,6 +465,7 @@ }, "reasoningEffort": { "label": "Model Reasoning Effort", + "none": "None", "minimal": "Minimal (Fastest)", "low": "Low", "medium": "Medium", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 38691ce65694..fa5ce550e85c 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 15b2f4d2f179..d1c2ccd40f80 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Effort de raisonnement du modèle", + "none": "Aucun", "minimal": "Minimal (le plus rapide)", "high": "Élevé", "medium": "Moyen", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 5b60b6fa48ee..bcc45d5db75b 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "मॉडल तर्क प्रयास", + "none": "कोई नहीं", "minimal": "न्यूनतम (सबसे तेज़)", "high": "उच्च", "medium": "मध्यम", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b5e783cc5e92..01298d93ddf9 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -464,6 +464,7 @@ }, "reasoningEffort": { "label": "Upaya Reasoning Model", + "none": "Tidak Ada", "minimal": "Minimal (Tercepat)", "high": "Tinggi", "medium": "Sedang", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index a822ef8656ed..7b3eb560b9c8 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Sforzo di ragionamento del modello", + "none": "Nessuno", "minimal": "Minimo (più veloce)", "high": "Alto", "medium": "Medio", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index ef42f1a945fb..9ad7035b5464 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "モデル推論の労力", + "none": "なし", "minimal": "最小 (最速)", "high": "高", "medium": "中", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index d66d88584ff2..c0b8bce736c9 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "모델 추론 노력", + "none": "없음", "minimal": "최소 (가장 빠름)", "high": "높음", "medium": "중간", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 69360878e76f..539e214e81b8 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Model redeneervermogen", + "none": "Geen", "minimal": "Minimaal (Snelst)", "high": "Hoog", "medium": "Middel", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 09a67f8e2ab4..ebc192e23024 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Wysiłek rozumowania modelu", + "none": "Brak", "minimal": "Minimalny (najszybszy)", "high": "Wysoki", "medium": "Średni", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 75f08371aa0c..fb68793e4180 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 775ccc993014..09fedab8f46f 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "Усилия по рассуждению модели", + "none": "Нет", "minimal": "Минимальный (самый быстрый)", "high": "Высокие", "medium": "Средние", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 74b1fab2d3d6..2ce4732ff3a3 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 7c3791f4a007..e9b15db7d7a4 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 3a05ad1845f9..25a889939f20 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "模型推理强度", + "none": "无", "minimal": "最小 (最快)", "high": "高", "medium": "中", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index f50784d2a8dc..26225e2278fe 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -460,6 +460,7 @@ }, "reasoningEffort": { "label": "模型推理強度", + "none": "無", "minimal": "最小 (最快)", "high": "高", "medium": "中",