diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ae36fc1399..7a0f664b25 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -321,3 +321,71 @@ describe("OpenRouterHandler", () => { }) }) }) + +describe("reasoning effort mapping (OpenRouter)", () => { + it("passes 'minimal' through in reasoning.effort for OpenRouter requests", async () => { + const handler = new OpenRouterHandler({ + openRouterApiKey: "test-key", + openRouterModelId: "openai/o1-pro", + reasoningEffort: "minimal", + } as ApiHandlerOptions) + + // Prepare a model that supports reasoning effort (not budget) + ;(handler as any).models = { + "openai/o1-pro": { + maxTokens: 8192, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.0, + outputPrice: 0.0, + description: "o1-pro test", + supportsReasoningEffort: true, + }, + } + + // Ensure endpoints map is empty so base model info is used + ;(handler as any).endpoints = {} + + // Mock OpenAI client call + const mockCreate = vitest.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + id: "openai/o1-pro", + choices: [{ delta: { content: "ok" } }], + } + yield { + id: "usage-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 1, completion_tokens: 1, cost: 0 }, + } + }, + }) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const systemPrompt = "system" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "hello" }] + + // Stub fetchModel to use the handler's getModel (which applies getModelParams -> getOpenRouterReasoning) + const realGetModel = (handler as any).getModel.bind(handler) + ;(handler as any).fetchModel = vitest.fn().mockImplementation(async () => realGetModel()) + + // Trigger a request + const gen = handler.createMessage(systemPrompt, messages) + // Drain iterator to ensure call is made + for await (const _ of gen) { + // noop + } + + // Verify the API call included the normalized effort + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "openai/o1-pro", + reasoning: { effort: "minimal" }, // 'minimal' should be preserved for OpenRouter + stream: true, + }), + ) + }) +}) diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index fc0983d741..078d2a3549 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -530,21 +530,54 @@ describe("reasoning.ts", () => { expect(result).toEqual({ reasoning_effort: undefined }) }) - it("should handle all reasoning effort values", () => { + it("should handle standard reasoning effort values", () => { const efforts: Array<"low" | "medium" | "high"> = ["low", "medium", "high"] efforts.forEach((effort) => { const modelWithEffort: ModelInfo = { ...baseModel, + supportsReasoningEffort: true, + } + + const settingsWithEffort: ProviderSettings = { reasoningEffort: effort, } - const options = { ...baseOptions, model: modelWithEffort, reasoningEffort: effort } + const options = { + ...baseOptions, + model: modelWithEffort, + settings: settingsWithEffort, + reasoningEffort: effort, + } const result = getOpenAiReasoning(options) expect(result).toEqual({ reasoning_effort: effort }) }) }) + it("should filter out minimal reasoning effort for OpenAI SDK compatibility", () => { + const modelWithEffort: ModelInfo = { + ...baseModel, + supportsReasoningEffort: true, + } + + const settingsWithMinimal: ProviderSettings = { + reasoningEffort: "minimal", + } + + const options = { + ...baseOptions, + model: modelWithEffort, + settings: settingsWithMinimal, + reasoningEffort: "minimal" as ReasoningEffortWithMinimal, + } + + const result = getOpenAiReasoning(options) + + // "minimal" is filtered out for OpenAI SDK compatibility + // OpenRouter handles "minimal" correctly in its own function + expect(result).toBeUndefined() + }) + it("should not be affected by reasoningBudget parameter", () => { const modelWithEffort: ModelInfo = { ...baseModel, diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 100b1c2684..2e95c52591 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -30,14 +30,21 @@ export const getOpenRouterReasoning = ({ reasoningBudget, reasoningEffort, settings, -}: GetModelReasoningOptions): OpenRouterReasoningParams | undefined => - shouldUseReasoningBudget({ model, settings }) - ? { max_tokens: reasoningBudget } - : shouldUseReasoningEffort({ model, settings }) - ? reasoningEffort - ? { effort: reasoningEffort } - : undefined - : undefined +}: GetModelReasoningOptions): OpenRouterReasoningParams | undefined => { + // If the model uses a budget-style reasoning config on OpenRouter, pass it through. + if (shouldUseReasoningBudget({ model, settings })) { + return { max_tokens: reasoningBudget! } + } + + // Otherwise, if we support traditional reasoning effort, pass through the effort. + // Note: Some models (e.g., GPT‑5 via OpenRouter) may support "minimal". + if (shouldUseReasoningEffort({ model, settings })) { + if (!reasoningEffort) return undefined + return { effort: reasoningEffort } + } + + return undefined +} export const getAnthropicReasoning = ({ model, @@ -55,8 +62,10 @@ export const getOpenAiReasoning = ({ return undefined } - // If model has reasoning effort capability, return object even if effort is undefined - // This preserves the reasoning_effort field in the API call + // Note: The OpenAI SDK doesn't include "minimal" in its type definitions, + // but GPT-5 via OpenRouter does support it. Since this function is for + // direct OpenAI API usage (not OpenRouter), we filter out "minimal" here. + // OpenRouter handles "minimal" correctly in getOpenRouterReasoning. if (reasoningEffort === "minimal") { return undefined }