add litellm reasoning effort control

This commit is contained in:
slytechnical 2025-05-29 19:42:42 -05:00
parent 8de608a0df
commit bb9a7460b4
23 changed files with 325 additions and 18 deletions

View file

@ -6,6 +6,8 @@ import { z } from "zod"
export const reasoningEfforts = ["low", "medium", "high"] as const
export const reasoningEffortsWithDefault = ["default", "low", "medium", "high"] as const
export const reasoningEffortsSchema = z.enum(reasoningEfforts)
export type ReasoningEffort = z.infer<typeof reasoningEffortsSchema>
@ -37,6 +39,7 @@ export const modelInfoSchema = z.object({
supportsReasoningBudget: z.boolean().optional(),
requiredReasoningBudget: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
shouldExposeDefaultReasoningEffort: z.boolean().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),
inputPrice: z.number().optional(),
outputPrice: z.number().optional(),

View file

@ -0,0 +1,186 @@
import { LiteLLMHandler } from "../litellm"
import { ApiHandlerOptions } from "../../../shared/api"
// Mock the getModelParams function
jest.mock("../../transform/model-params", () => ({
getModelParams: jest.fn(),
}))
// Mock the RouterProvider's fetchModel method
jest.mock("../router-provider", () => {
return {
RouterProvider: class MockRouterProvider {
protected options: any
protected models: any = {}
protected client: any = {
chat: {
completions: {
create: jest.fn(),
},
},
}
constructor(config: any) {
this.options = config.options
}
async fetchModel() {
return { id: "test-model", info: { maxTokens: 4096 } }
}
getModel() {
return { id: "test-model", info: { maxTokens: 4096 } }
}
supportsTemperature() {
return true
}
},
}
})
describe("LiteLLMHandler", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("should include reasoning_effort in request when configured", async () => {
const { getModelParams } = require("../../transform/model-params")
getModelParams.mockReturnValue({
maxTokens: 4096,
temperature: 0,
reasoningEffort: "high",
})
const mockCreate = jest.fn().mockReturnValue({
withResponse: () =>
Promise.resolve({
data: (async function* () {
yield {
choices: [{ delta: { content: "test response" } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
})(),
}),
})
const options: ApiHandlerOptions = {
reasoningEffort: "high",
}
const handler = new LiteLLMHandler(options)
// Override the client mock
;(handler as any).client.chat.completions.create = mockCreate
// Call createMessage to trigger the request
const generator = handler.createMessage("test system", [{ role: "user", content: "test message" }])
// Consume the generator
const results = []
for await (const chunk of generator) {
results.push(chunk)
}
// Verify that reasoning_effort was included in the request
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
reasoning_effort: "high",
}),
)
})
it("should not include reasoning_effort when not configured", async () => {
const { getModelParams } = require("../../transform/model-params")
getModelParams.mockReturnValue({
maxTokens: 4096,
temperature: 0,
reasoningEffort: undefined,
})
const mockCreate = jest.fn().mockReturnValue({
withResponse: () =>
Promise.resolve({
data: (async function* () {
yield {
choices: [{ delta: { content: "test response" } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
})(),
}),
})
const options: ApiHandlerOptions = {}
const handler = new LiteLLMHandler(options)
// Override the client mock
;(handler as any).client.chat.completions.create = mockCreate
// Call createMessage to trigger the request
const generator = handler.createMessage("test system", [{ role: "user", content: "test message" }])
// Consume the generator
const results = []
for await (const chunk of generator) {
results.push(chunk)
}
// Verify that reasoning_effort was not included in the request
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("reasoning_effort")
})
it("should handle reasoning content in response stream", async () => {
const { getModelParams } = require("../../transform/model-params")
getModelParams.mockReturnValue({
maxTokens: 4096,
temperature: 0,
reasoningEffort: "medium",
})
const mockCreate = jest.fn().mockReturnValue({
withResponse: () =>
Promise.resolve({
data: (async function* () {
yield {
choices: [{ delta: { content: "regular content" } }],
}
yield {
choices: [{ delta: { reasoning_content: "reasoning content" } }],
}
yield {
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
})(),
}),
})
const options: ApiHandlerOptions = {
reasoningEffort: "medium",
}
const handler = new LiteLLMHandler(options)
// Override the client mock
;(handler as any).client.chat.completions.create = mockCreate
// Call createMessage to trigger the request
const generator = handler.createMessage("test system", [{ role: "user", content: "test message" }])
// Consume the generator and collect results
const results = []
for await (const chunk of generator) {
results.push(chunk)
}
// Verify we got both text and reasoning content
expect(results).toEqual([
{ type: "text", text: "regular content" },
{ type: "reasoning", text: "reasoning content" },
{
type: "usage",
inputTokens: 10,
outputTokens: 5,
},
])
})
})

View file

@ -69,6 +69,8 @@ describe("getLiteLLMModels", () => {
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: 3,
outputPrice: 15,
description: "claude-3-5-sonnet via LiteLLM proxy",
@ -79,6 +81,8 @@ describe("getLiteLLMModels", () => {
supportsImages: false,
supportsComputerUse: false,
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: 10,
outputPrice: 30,
description: "gpt-4-turbo via LiteLLM proxy",
@ -147,6 +151,8 @@ describe("getLiteLLMModels", () => {
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "test-computer-model via LiteLLM proxy",
@ -158,6 +164,8 @@ describe("getLiteLLMModels", () => {
supportsImages: false,
supportsComputerUse: false,
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "test-non-computer-model via LiteLLM proxy",
@ -293,6 +301,8 @@ describe("getLiteLLMModels", () => {
supportsImages: true,
supportsComputerUse: true, // Should be true due to fallback
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "claude-3-5-sonnet-latest via LiteLLM proxy",
@ -304,6 +314,8 @@ describe("getLiteLLMModels", () => {
supportsImages: false,
supportsComputerUse: false, // Should be false as it's not in fallback list
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "gpt-4-turbo via LiteLLM proxy",
@ -367,6 +379,8 @@ describe("getLiteLLMModels", () => {
supportsImages: true,
supportsComputerUse: false, // False because explicitly set to false (fallback ignored)
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "claude-3-5-sonnet-latest via LiteLLM proxy",
@ -378,6 +392,8 @@ describe("getLiteLLMModels", () => {
supportsImages: false,
supportsComputerUse: true, // True because explicitly set to true
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "custom-model via LiteLLM proxy",
@ -389,12 +405,53 @@ describe("getLiteLLMModels", () => {
supportsImages: false,
supportsComputerUse: false, // False because explicitly set to false
supportsPromptCache: false,
supportsReasoningEffort: false,
shouldExposeDefaultReasoningEffort: false,
inputPrice: undefined,
outputPrice: undefined,
description: "another-custom-model via LiteLLM proxy",
})
})
it("sets shouldExposeDefaultReasoningEffort when supports_reasoning is true", async () => {
const mockResponse = {
data: {
data: [
{
model_name: "reasoning-model",
model_info: {
max_tokens: 4096,
max_input_tokens: 200000,
supports_vision: true,
supports_prompt_caching: false,
supports_reasoning: true, // This should set shouldExposeDefaultReasoningEffort to true
},
litellm_params: {
model: "openai/o1-preview",
},
},
],
},
}
mockedAxios.get.mockResolvedValue(mockResponse)
const result = await getLiteLLMModels("test-api-key", "http://localhost:4000")
expect(result["reasoning-model"]).toEqual({
maxTokens: 4096,
contextWindow: 200000,
supportsImages: true,
supportsComputerUse: false,
supportsPromptCache: false,
supportsReasoningEffort: true,
shouldExposeDefaultReasoningEffort: true,
inputPrice: undefined,
outputPrice: undefined,
description: "reasoning-model via LiteLLM proxy",
})
})
it("handles fallback detection with various model name formats", async () => {
const mockResponse = {
data: {

View file

@ -52,6 +52,8 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise
// litellm_params.model may have a prefix like openrouter/
supportsComputerUse,
supportsPromptCache: Boolean(modelInfo.supports_prompt_caching),
supportsReasoningEffort: Boolean(modelInfo.supports_reasoning),
shouldExposeDefaultReasoningEffort: Boolean(modelInfo.supports_reasoning),
inputPrice: modelInfo.input_cost_per_token ? modelInfo.input_cost_per_token * 1000000 : undefined,
outputPrice: modelInfo.output_cost_per_token
? modelInfo.output_cost_per_token * 1000000

View file

@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only
import { ApiHandlerOptions, litellmDefaultModelId, litellmDefaultModelInfo } from "../../shared/api"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { getModelParams } from "../transform/model-params"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { RouterProvider } from "./router-provider"
@ -26,21 +27,32 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
})
}
override getModel() {
const { id, info } = super.getModel()
const params = getModelParams({
format: "openai",
modelId: id,
model: info,
settings: this.options,
})
return { id, info, ...params }
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const { id: modelId, info } = await this.fetchModel()
await this.fetchModel() // Ensure models are loaded
const { id: modelId, maxTokens, temperature, reasoningEffort: reasoning_effort } = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// Required by some providers; others default to max tokens allowed
let maxTokens: number | undefined = info.maxTokens ?? undefined
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
max_tokens: maxTokens,
@ -49,10 +61,11 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
stream_options: {
include_usage: true,
},
...(reasoning_effort && { reasoning_effort }),
}
if (this.supportsTemperature(modelId)) {
requestOptions.temperature = this.options.modelTemperature ?? 0
requestOptions.temperature = temperature
}
try {
@ -62,12 +75,27 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta
const usage = chunk.usage as OpenAI.CompletionUsage
// Log all available fields in delta
console.log("[LiteLLM] Delta fields:", Object.keys(delta || {}))
console.log("[LiteLLM] Full delta:", JSON.stringify(delta, null, 2))
// Check for any field that might contain reasoning
if (delta) {
for (const [key, value] of Object.entries(delta)) {
if (typeof value === "string" && value.length > 0 && key.includes("reason")) {
console.log(`[LiteLLM] Found potential reasoning field '${key}':`, value)
yield { type: "reasoning", text: value }
}
}
}
if (delta?.content) {
yield { type: "text", text: delta.content }
}
const usage = chunk.usage as OpenAI.CompletionUsage
if (usage) {
lastUsage = usage
}
@ -91,20 +119,21 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
}
async completePrompt(prompt: string): Promise<string> {
const { id: modelId, info } = await this.fetchModel()
await this.fetchModel() // Ensure models are loaded
const { id: modelId, maxTokens, temperature, reasoningEffort: reasoning_effort } = this.getModel()
try {
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokens,
...(reasoning_effort && { reasoning_effort }),
}
if (this.supportsTemperature(modelId)) {
requestOptions.temperature = this.options.modelTemperature ?? 0
requestOptions.temperature = temperature
}
requestOptions.max_tokens = info.maxTokens
const response = await this.client.chat.completions.create(requestOptions)
return response.choices[0]?.message.content || ""
} catch (error) {

View file

@ -1,7 +1,13 @@
import { useEffect } from "react"
import { Checkbox } from "vscrui"
import { type ProviderSettings, type ModelInfo, type ReasoningEffort, reasoningEfforts } from "@roo-code/types"
import {
type ProviderSettings,
type ModelInfo,
type ReasoningEffort,
reasoningEfforts,
reasoningEffortsWithDefault,
} from "@roo-code/types"
import { DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS, DEFAULT_HYBRID_REASONING_MODEL_THINKING_TOKENS } from "@roo/api"
@ -20,6 +26,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
const isReasoningBudgetSupported = !!modelInfo && modelInfo.supportsReasoningBudget
const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget
const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort
const shouldExposeDefaultReasoningEffort = !!modelInfo && modelInfo.shouldExposeDefaultReasoningEffort
const enableReasoningEffort = apiConfiguration.enableReasoningEffort
const customMaxOutputTokens = apiConfiguration.modelMaxTokens || DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS
@ -95,17 +102,23 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
<label className="block font-medium mb-1">{t("settings:providers.reasoningEffort.label")}</label>
</div>
<Select
value={apiConfiguration.reasoningEffort}
onValueChange={(value) => setApiConfigurationField("reasoningEffort", value as ReasoningEffort)}>
value={apiConfiguration.reasoningEffort || (shouldExposeDefaultReasoningEffort ? "default" : undefined)}
onValueChange={(value) => {
// If "default" is selected, set reasoningEffort to undefined so it's not sent in the request
const reasoningEffort = value === "default" ? undefined : (value as ReasoningEffort)
setApiConfigurationField("reasoningEffort", reasoningEffort)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
{reasoningEfforts.map((value) => (
<SelectItem key={value} value={value}>
{t(`settings:providers.reasoningEffort.${value}`)}
</SelectItem>
))}
{(shouldExposeDefaultReasoningEffort ? reasoningEffortsWithDefault : reasoningEfforts).map(
(value) => (
<SelectItem key={value} value={value}>
{t(`settings:providers.reasoningEffort.${value}`)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Esforç de raonament del model",
"default": "Per defecte",
"high": "Alt",
"medium": "Mitjà",
"low": "Baix"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Modell-Denkaufwand",
"default": "Standard",
"high": "Hoch",
"medium": "Mittel",
"low": "Niedrig"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Model Reasoning Effort",
"default": "Default",
"high": "High",
"medium": "Medium",
"low": "Low"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Esfuerzo de razonamiento del modelo",
"default": "Por defecto",
"high": "Alto",
"medium": "Medio",
"low": "Bajo"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Effort de raisonnement du modèle",
"default": "Par défaut",
"high": "Élevé",
"medium": "Moyen",
"low": "Faible"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "मॉडल तर्क प्रयास",
"default": "डिफ़ॉल्ट",
"high": "उच्च",
"medium": "मध्यम",
"low": "निम्न"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Sforzo di ragionamento del modello",
"default": "Predefinito",
"high": "Alto",
"medium": "Medio",
"low": "Basso"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "モデル推論の労力",
"default": "デフォルト",
"high": "高",
"medium": "中",
"low": "低"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "모델 추론 노력",
"default": "기본값",
"high": "높음",
"medium": "중간",
"low": "낮음"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Model redeneervermogen",
"default": "Standaard",
"high": "Hoog",
"medium": "Middel",
"low": "Laag"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Wysiłek rozumowania modelu",
"default": "Domyślny",
"high": "Wysoki",
"medium": "Średni",
"low": "Niski"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Esforço de raciocínio do modelo",
"default": "Padrão",
"high": "Alto",
"medium": "Médio",
"low": "Baixo"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Усилия по рассуждению модели",
"default": "По умолчанию",
"high": "Высокие",
"medium": "Средние",
"low": "Низкие"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Model Akıl Yürütme Çabası",
"default": "Varsayılan",
"high": "Yüksek",
"medium": "Orta",
"low": "Düşük"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "Nỗ lực suy luận của mô hình",
"default": "Mặc định",
"high": "Cao",
"medium": "Trung bình",
"low": "Thấp"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "模型推理强度",
"default": "默认",
"high": "高",
"medium": "中",
"low": "低"

View file

@ -283,6 +283,7 @@
},
"reasoningEffort": {
"label": "模型推理強度",
"default": "預設",
"high": "高",
"medium": "中",
"low": "低"