feat: Add Reasoning Effort setting for OpenAI Compatible provider (#2906)

This commit is contained in:
Ryan Pfister 2025-04-28 22:36:47 +02:00 committed by GitHub
parent 21ea433b88
commit 2075f26f18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 318 additions and 28 deletions

View file

@ -0,0 +1,5 @@
---
"roo-cline": minor
---
Add Reasoning Effort setting for OpenAI Compatible provider

11
package-lock.json generated
View file

@ -77,6 +77,7 @@
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",
"@types/node-cache": "^4.1.3",
"@types/node-ipc": "^9.2.3",
"@types/string-similarity": "^4.0.2",
"@typescript-eslint/eslint-plugin": "^7.14.1",
@ -9006,6 +9007,16 @@
"undici-types": "~6.19.2"
}
},
"node_modules/@types/node-cache": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@types/node-cache/-/node-cache-4.1.3.tgz",
"integrity": "sha512-3hsqnv3H1zkOhjygJaJUYmgz5+FcPO3vejBX7cE9/cnuINOJYrzkfOnUCvpwGe9kMZANIHJA7J5pOdeyv52OEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",

View file

@ -469,6 +469,7 @@
"@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",
"@types/node-cache": "^4.1.3",
"@types/node-ipc": "^9.2.3",
"@types/string-similarity": "^4.0.2",
"@typescript-eslint/eslint-plugin": "^7.14.1",

View file

@ -1,3 +1,5 @@
// npx jest src/api/providers/__tests__/openai.test.ts
import { OpenAiHandler } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
@ -155,6 +157,39 @@ describe("OpenAiHandler", () => {
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
it("should include reasoning_effort when reasoning effort is enabled", async () => {
const reasoningOptions: ApiHandlerOptions = {
...mockOptions,
enableReasoningEffort: true,
openAiCustomModelInfo: { contextWindow: 128_000, supportsPromptCache: false, reasoningEffort: "high" },
}
const reasoningHandler = new OpenAiHandler(reasoningOptions)
const stream = reasoningHandler.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called with reasoning_effort
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.reasoning_effort).toBe("high")
})
it("should not include reasoning_effort when reasoning effort is disabled", async () => {
const noReasoningOptions: ApiHandlerOptions = {
...mockOptions,
enableReasoningEffort: false,
openAiCustomModelInfo: { contextWindow: 128_000, supportsPromptCache: false },
}
const noReasoningHandler = new OpenAiHandler(noReasoningOptions)
const stream = noReasoningHandler.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called without reasoning_effort
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.reasoning_effort).toBeUndefined()
})
})
describe("error handling", () => {

View file

@ -87,6 +87,7 @@ type ProviderSettings = {
openAiUseAzure?: boolean | undefined
azureApiVersion?: string | undefined
openAiStreamingEnabled?: boolean | undefined
enableReasoningEffort?: boolean | undefined
ollamaModelId?: string | undefined
ollamaBaseUrl?: string | undefined
vsCodeLmModelSelector?:

View file

@ -88,6 +88,7 @@ type ProviderSettings = {
openAiUseAzure?: boolean | undefined
azureApiVersion?: string | undefined
openAiStreamingEnabled?: boolean | undefined
enableReasoningEffort?: boolean | undefined
ollamaModelId?: string | undefined
ollamaBaseUrl?: string | undefined
vsCodeLmModelSelector?:

View file

@ -355,6 +355,7 @@ export const providerSettingsSchema = z.object({
openAiUseAzure: z.boolean().optional(),
azureApiVersion: z.string().optional(),
openAiStreamingEnabled: z.boolean().optional(),
enableReasoningEffort: z.boolean().optional(),
// Ollama
ollamaModelId: z.string().optional(),
ollamaBaseUrl: z.string().optional(),
@ -453,6 +454,7 @@ const providerSettingsRecord: ProviderSettingsRecord = {
openAiUseAzure: undefined,
azureApiVersion: undefined,
openAiStreamingEnabled: undefined,
enableReasoningEffort: undefined,
// Ollama
ollamaModelId: undefined,
ollamaBaseUrl: undefined,

View file

@ -13,6 +13,7 @@ describe("enhancePrompt", () => {
apiProvider: "openai",
openAiApiKey: "test-key",
openAiBaseUrl: "https://api.openai.com/v1",
enableReasoningEffort: false,
}
beforeEach(() => {
@ -97,6 +98,7 @@ describe("enhancePrompt", () => {
apiProvider: "openrouter",
openRouterApiKey: "test-key",
openRouterModelId: "test-model",
enableReasoningEffort: false,
}
// Mock successful enhancement

View file

@ -1,13 +1,12 @@
import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import { getRequestyAuthUrl, getOpenRouterAuthUrl, getGlamaAuthUrl } from "@src/oauth/urls"
import { useDebounce, useEvent } from "react-use"
import { Trans } from "react-i18next"
import { LanguageModelChatSelector } from "vscode"
import { Checkbox } from "vscrui"
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { ExternalLinkIcon } from "@radix-ui/react-icons"
import { ReasoningEffort as ReasoningEffortType } from "@roo/schemas"
import {
ApiConfiguration,
ModelInfo,
@ -21,21 +20,22 @@ import {
ApiProvider,
} from "@roo/shared/api"
import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
import { AWS_REGIONS } from "@roo/shared/aws_regions"
import { vscode } from "@src/utils/vscode"
import { validateApiConfiguration, validateModelId, validateBedrockArn } from "@src/utils/validate"
import { useRouterModels } from "@/components/ui/hooks/useRouterModels"
import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
import {
useOpenRouterModelProviders,
OPENROUTER_DEFAULT_PROVIDER_NAME,
} from "@src/components/ui/hooks/useOpenRouterModelProviders"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button } from "@src/components/ui"
import { getRequestyAuthUrl, getOpenRouterAuthUrl, getGlamaAuthUrl } from "@src/oauth/urls"
import { VSCodeButtonLink } from "../common/VSCodeButtonLink"
import { MODELS_BY_PROVIDER, PROVIDERS, VERTEX_REGIONS, REASONING_MODELS } from "./constants"
import { MODELS_BY_PROVIDER, PROVIDERS, VERTEX_REGIONS, REASONING_MODELS, AWS_REGIONS } from "./constants"
import { ModelInfoView } from "./ModelInfoView"
import { ModelPicker } from "./ModelPicker"
import { ApiErrorMessage } from "./ApiErrorMessage"
@ -851,6 +851,41 @@ const ApiOptions = ({
)}
</div>
<div className="flex flex-col gap-1">
<Checkbox
checked={apiConfiguration.enableReasoningEffort ?? false}
onChange={(checked: boolean) => {
setApiConfigurationField("enableReasoningEffort", checked)
if (!checked) {
const { reasoningEffort: _, ...openAiCustomModelInfo } =
apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults
setApiConfigurationField("openAiCustomModelInfo", openAiCustomModelInfo)
}
}}>
{t("settings:providers.setReasoningLevel")}
</Checkbox>
{!!apiConfiguration.enableReasoningEffort && (
<ReasoningEffort
apiConfiguration={{
...apiConfiguration,
reasoningEffort: apiConfiguration.openAiCustomModelInfo?.reasoningEffort,
}}
setApiConfigurationField={(field, value) => {
if (field === "reasoningEffort") {
const openAiCustomModelInfo =
apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults
setApiConfigurationField("openAiCustomModelInfo", {
...openAiCustomModelInfo,
reasoningEffort: value as ReasoningEffortType,
})
}
}}
/>
)}
</div>
<div className="flex flex-col gap-3">
<div className="text-sm text-vscode-descriptionForeground whitespace-pre-line">
{t("settings:providers.customModel.capabilities")}

View file

@ -1,11 +1,12 @@
// npx jest src/components/settings/__tests__/ApiOptions.test.ts
import { render, screen } from "@testing-library/react"
import { render, screen, fireEvent } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ApiConfiguration } from "@roo/shared/api"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import { openAiModelInfoSaneDefaults } from "@roo/shared/api"
import ApiOptions, { ApiOptionsProps } from "../ApiOptions"
@ -26,8 +27,13 @@ jest.mock("@vscode/webview-ui-toolkit/react", () => ({
// Mock other components
jest.mock("vscrui", () => ({
Checkbox: ({ children, checked, onChange }: any) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<label data-testid={`checkbox-${children?.toString().replace(/\s+/g, "-").toLowerCase()}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
data-testid={`checkbox-input-${children?.toString().replace(/\s+/g, "-").toLowerCase()}`}
/>
{children}
</label>
),
@ -51,11 +57,32 @@ jest.mock("@/components/ui", () => ({
</option>
),
SelectSeparator: ({ children }: any) => <div className="select-separator-mock">{children}</div>,
Button: ({ children, onClick }: any) => (
<button onClick={onClick} className="button-mock">
Button: ({ children, onClick, _variant, role, className }: any) => (
<button onClick={onClick} className={`button-mock ${className || ""}`} role={role}>
{children}
</button>
),
// Add missing components used by ModelPicker
Command: ({ children }: any) => <div className="command-mock">{children}</div>,
CommandEmpty: ({ children }: any) => <div className="command-empty-mock">{children}</div>,
CommandGroup: ({ children }: any) => <div className="command-group-mock">{children}</div>,
CommandInput: ({ value, onValueChange, placeholder, className, _ref }: any) => (
<input
value={value}
onChange={(e) => onValueChange && onValueChange(e.target.value)}
placeholder={placeholder}
className={className}
/>
),
CommandItem: ({ children, value, onSelect }: any) => (
<div className="command-item-mock" onClick={() => onSelect && onSelect(value)}>
{children}
</div>
),
CommandList: ({ children }: any) => <div className="command-list-mock">{children}</div>,
Popover: ({ children, _open, _onOpenChange }: any) => <div className="popover-mock">{children}</div>,
PopoverContent: ({ children, _className }: any) => <div className="popover-content-mock">{children}</div>,
PopoverTrigger: ({ children, _asChild }: any) => <div className="popover-trigger-mock">{children}</div>,
Slider: ({ value, onChange }: any) => (
<div data-testid="slider">
<input type="range" value={value || 0} onChange={(e) => onChange(parseFloat(e.target.value))} />
@ -136,6 +163,21 @@ jest.mock("@src/components/ui/hooks/useSelectedModel", () => ({
}),
}))
jest.mock("../ReasoningEffort", () => ({
ReasoningEffort: ({ apiConfiguration, setApiConfigurationField, value }: any) => (
<div data-testid="reasoning-effort-select">
<select
value={value ?? apiConfiguration.openAiCustomModelInfo?.reasoningEffort}
onChange={(e) => setApiConfigurationField("reasoningEffort", e.target.value)}>
<option value="auto">Auto</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
),
}))
const renderApiOptions = (props: Partial<ApiOptionsProps> = {}) => {
const queryClient = new QueryClient()
@ -215,4 +257,141 @@ describe("ApiOptions", () => {
// since we have separate tests for that component. We just need to verify that
// it's included in the ApiOptions component when appropriate.
})
describe("OpenAI provider tests", () => {
it("removes reasoningEffort from openAiCustomModelInfo when unchecked", () => {
const mockSetApiConfigurationField = jest.fn()
const initialConfig = {
apiProvider: "openai" as const,
enableReasoningEffort: true,
openAiCustomModelInfo: {
...openAiModelInfoSaneDefaults, // Start with defaults
reasoningEffort: "low" as const, // Set an initial value
},
// Add other necessary default fields for openai provider if needed
}
renderApiOptions({
apiConfiguration: initialConfig,
setApiConfigurationField: mockSetApiConfigurationField,
})
// Find the checkbox by its test ID instead of label text
// This is more reliable than using the label text which might be affected by translations
const checkbox =
screen.getByTestId("checkbox-input-settings:providers.setreasoninglevel") ||
screen.getByTestId("checkbox-input-set-reasoning-level")
// Simulate unchecking the checkbox
fireEvent.click(checkbox)
// 1. Check if enableReasoningEffort was set to false
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", false)
// 2. Check if openAiCustomModelInfo was updated
const updateCall = mockSetApiConfigurationField.mock.calls.find(
(call) => call[0] === "openAiCustomModelInfo",
)
expect(updateCall).toBeDefined()
// 3. Check if reasoningEffort property is absent in the updated info
const updatedInfo = updateCall[1]
expect(updatedInfo).not.toHaveProperty("reasoningEffort")
// Optional: Check if other properties were preserved (example)
expect(updatedInfo).toHaveProperty("contextWindow", openAiModelInfoSaneDefaults.contextWindow)
})
it("does not render ReasoningEffort component when initially disabled", () => {
const mockSetApiConfigurationField = jest.fn()
const initialConfig = {
apiProvider: "openai" as const,
enableReasoningEffort: false, // Initially disabled
openAiCustomModelInfo: {
...openAiModelInfoSaneDefaults,
},
}
renderApiOptions({
apiConfiguration: initialConfig,
setApiConfigurationField: mockSetApiConfigurationField,
})
// Check that the ReasoningEffort select component is not rendered
expect(screen.queryByTestId("reasoning-effort-select")).not.toBeInTheDocument()
// Or, if the mock is simpler:
// expect(screen.queryByRole("combobox", { name: /reasoning effort/i })).not.toBeInTheDocument();
})
it("renders ReasoningEffort component and sets flag when checkbox is checked", () => {
const mockSetApiConfigurationField = jest.fn()
const initialConfig = {
apiProvider: "openai" as const,
enableReasoningEffort: false, // Initially disabled
openAiCustomModelInfo: {
...openAiModelInfoSaneDefaults,
},
}
renderApiOptions({
apiConfiguration: initialConfig,
setApiConfigurationField: mockSetApiConfigurationField,
})
const checkbox = screen.getByTestId("checkbox-input-settings:providers.setreasoninglevel")
// Simulate checking the checkbox
fireEvent.click(checkbox)
// 1. Check if enableReasoningEffort was set to true
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true)
// We can't directly test the rendering of the ReasoningEffort component after the state change
// without a more complex setup involving state management mocks or re-rendering.
// However, we've tested the state update call.
})
it("updates reasoningEffort in openAiCustomModelInfo when select value changes", () => {
const mockSetApiConfigurationField = jest.fn()
const initialConfig = {
apiProvider: "openai" as const,
enableReasoningEffort: true, // Initially enabled
openAiCustomModelInfo: {
...openAiModelInfoSaneDefaults,
reasoningEffort: "low" as const,
},
}
renderApiOptions({
apiConfiguration: initialConfig,
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()
// Simulate changing the reasoning effort to 'high'
fireEvent.change(reasoningSelect!, { target: { value: "high" } })
// Check if setApiConfigurationField was called correctly for openAiCustomModelInfo
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(
"openAiCustomModelInfo",
expect.objectContaining({
reasoningEffort: "high",
}),
)
// Check that other properties were preserved
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(
"openAiCustomModelInfo",
expect.objectContaining({
contextWindow: openAiModelInfoSaneDefaults.contextWindow,
}),
)
})
})
})

View file

@ -13,6 +13,8 @@ import {
export { REASONING_MODELS, PROMPT_CACHING_MODELS } from "@roo/shared/api"
export { AWS_REGIONS } from "@roo/shared/aws_regions"
export const MODELS_BY_PROVIDER: Partial<Record<ApiProvider, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
bedrock: bedrockModels,

View file

@ -233,7 +233,8 @@
"high": "Alt",
"medium": "Mitjà",
"low": "Baix"
}
},
"setReasoningLevel": "Activa l'esforç de raonament"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Hoch",
"medium": "Mittel",
"low": "Niedrig"
}
},
"setReasoningLevel": "Denkaufwand aktivieren"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"setReasoningLevel": "Enable Reasoning Effort"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Alto",
"medium": "Medio",
"low": "Bajo"
}
},
"setReasoningLevel": "Habilitar esfuerzo de razonamiento"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Élevé",
"medium": "Moyen",
"low": "Faible"
}
},
"setReasoningLevel": "Activer l'effort de raisonnement"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "उच्च",
"medium": "मध्यम",
"low": "निम्न"
}
},
"setReasoningLevel": "तर्क प्रयास सक्षम करें"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Alto",
"medium": "Medio",
"low": "Basso"
}
},
"setReasoningLevel": "Abilita sforzo di ragionamento"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "高",
"medium": "中",
"low": "低"
}
},
"setReasoningLevel": "推論労力を有効にする"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "높음",
"medium": "중간",
"low": "낮음"
}
},
"setReasoningLevel": "추론 노력 활성화"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Wysoki",
"medium": "Średni",
"low": "Niski"
}
},
"setReasoningLevel": "Włącz wysiłek rozumowania"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Alto",
"medium": "Médio",
"low": "Baixo"
}
},
"setReasoningLevel": "Habilitar esforço de raciocínio"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Высокие",
"medium": "Средние",
"low": "Низкие"
}
},
"setReasoningLevel": "Включить усилие рассуждения"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Yüksek",
"medium": "Orta",
"low": "Düşük"
}
},
"setReasoningLevel": "Akıl Yürütme Çabasını Etkinleştir"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "Cao",
"medium": "Trung bình",
"low": "Thấp"
}
},
"setReasoningLevel": "Kích hoạt nỗ lực suy luận"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "高",
"medium": "中",
"low": "低"
}
},
"setReasoningLevel": "启用推理工作量"
},
"browser": {
"enable": {

View file

@ -233,7 +233,8 @@
"high": "高",
"medium": "中",
"low": "低"
}
},
"setReasoningLevel": "啟用推理工作量"
},
"browser": {
"enable": {