Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 2a6bb25

Browse files
committed
fix(openrouter): enable reasoning support via AI SDK extraBody
- Add support for 'reasoning' and 'text' event types in AI SDK stream processing - Pass reasoning parameters via createOpenRouter extraBody instead of providerOptions - Support both effort-based (effort: 'high') and budget-based (max_tokens: N) reasoning - Add comprehensive tests for reasoning parameter passing and stream event handling - Fixes reasoning tokens not being displayed for models like DeepSeek R1 and Gemini Thinking Changes: - src/api/transform/ai-sdk.ts: Add 'text' and 'reasoning' event type handlers - src/api/providers/openrouter.ts: Pass reasoning via extraBody in provider creation - Add tests for new event types and reasoning parameter flow - All 53 tests passing
1 parent f52e1ed commit 2a6bb25

3 files changed

Lines changed: 272 additions & 11 deletions

File tree

src/api/providers/__tests__/openrouter.spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ vitest.mock("../fetchers/modelCache", () => ({
7878
cacheReadsPrice: 0.3,
7979
description: "Claude 3.7 Sonnet with thinking",
8080
},
81+
"deepseek/deepseek-r1": {
82+
maxTokens: 8192,
83+
contextWindow: 64000,
84+
supportsImages: false,
85+
supportsPromptCache: false,
86+
inputPrice: 0.55,
87+
outputPrice: 2.19,
88+
description: "DeepSeek R1",
89+
supportsReasoningEffort: true,
90+
},
8191
"openai/gpt-4o": {
8292
maxTokens: 16384,
8393
contextWindow: 128000,
@@ -638,6 +648,86 @@ describe("OpenRouterHandler", () => {
638648
}),
639649
)
640650
})
651+
652+
it("passes reasoning parameters via extraBody when reasoning effort is enabled", async () => {
653+
const handler = new OpenRouterHandler({
654+
openRouterApiKey: "test-key",
655+
openRouterModelId: "deepseek/deepseek-r1",
656+
reasoningEffort: "high",
657+
enableReasoningEffort: true,
658+
})
659+
660+
const mockFullStream = (async function* () {
661+
yield { type: "reasoning-delta", text: "thinking...", id: "1" }
662+
yield { type: "text-delta", text: "result", id: "2" }
663+
})()
664+
665+
mockStreamText.mockReturnValue({
666+
fullStream: mockFullStream,
667+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
668+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
669+
})
670+
671+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
672+
673+
for await (const _ of generator) {
674+
// consume
675+
}
676+
677+
// Verify that reasoning was passed via extraBody when creating the provider
678+
expect(mockCreateOpenRouter).toHaveBeenCalledWith(
679+
expect.objectContaining({
680+
extraBody: expect.objectContaining({
681+
reasoning: expect.objectContaining({
682+
effort: "high",
683+
}),
684+
}),
685+
}),
686+
)
687+
688+
// Verify that providerOptions does NOT contain extended_thinking
689+
expect(mockStreamText).toHaveBeenCalledWith(
690+
expect.objectContaining({
691+
providerOptions: undefined,
692+
}),
693+
)
694+
})
695+
696+
it("does not pass reasoning via extraBody when reasoning is disabled", async () => {
697+
const handler = new OpenRouterHandler({
698+
openRouterApiKey: "test-key",
699+
openRouterModelId: "anthropic/claude-sonnet-4",
700+
})
701+
702+
const mockFullStream = (async function* () {
703+
yield { type: "text-delta", text: "test", id: "1" }
704+
})()
705+
706+
mockStreamText.mockReturnValue({
707+
fullStream: mockFullStream,
708+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
709+
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }),
710+
})
711+
712+
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
713+
714+
for await (const _ of generator) {
715+
// consume
716+
}
717+
718+
// Verify that createOpenRouter was NOT called with extraBody
719+
expect(mockCreateOpenRouter).toHaveBeenCalledWith({
720+
apiKey: "test-key",
721+
baseURL: "https://openrouter.ai/api/v1",
722+
})
723+
724+
// Verify that providerOptions is undefined when no provider routing
725+
expect(mockStreamText).toHaveBeenCalledWith(
726+
expect.objectContaining({
727+
providerOptions: undefined,
728+
}),
729+
)
730+
})
641731
})
642732

643733
describe("completePrompt", () => {
@@ -660,6 +750,41 @@ describe("OpenRouterHandler", () => {
660750
)
661751
})
662752

753+
it("passes reasoning parameters via extraBody when reasoning effort is enabled", async () => {
754+
const handler = new OpenRouterHandler({
755+
openRouterApiKey: "test-key",
756+
openRouterModelId: "deepseek/deepseek-r1",
757+
reasoningEffort: "medium",
758+
enableReasoningEffort: true,
759+
})
760+
761+
mockGenerateText.mockResolvedValue({
762+
text: "test completion with reasoning",
763+
})
764+
765+
const result = await handler.completePrompt("test prompt")
766+
767+
expect(result).toBe("test completion with reasoning")
768+
769+
// Verify that reasoning was passed via extraBody when creating the provider
770+
expect(mockCreateOpenRouter).toHaveBeenCalledWith(
771+
expect.objectContaining({
772+
extraBody: expect.objectContaining({
773+
reasoning: expect.objectContaining({
774+
effort: "medium",
775+
}),
776+
}),
777+
}),
778+
)
779+
780+
// Verify that providerOptions does NOT contain extended_thinking
781+
expect(mockGenerateText).toHaveBeenCalledWith(
782+
expect.objectContaining({
783+
providerOptions: undefined,
784+
}),
785+
)
786+
})
787+
663788
it("handles API errors", async () => {
664789
const handler = new OpenRouterHandler(mockOptions)
665790

src/api/providers/openrouter.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
8686

8787
/**
8888
* Create the OpenRouter provider instance using the AI SDK
89+
* @param reasoning - Optional reasoning parameters to pass via extraBody
8990
*/
90-
private createOpenRouterProvider() {
91+
private createOpenRouterProvider(reasoning?: { effort?: string; max_tokens?: number; exclude?: boolean }) {
9192
const apiKey = this.options.openRouterApiKey ?? "not-provided"
9293
const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1"
9394

9495
return createOpenRouter({
9596
apiKey,
9697
baseURL,
98+
...(reasoning && { extraBody: { reasoning } }),
9799
})
98100
}
99101

@@ -182,14 +184,25 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
182184
this.currentReasoningDetails = []
183185

184186
const model = await this.fetchModel()
185-
const { id: modelId, maxTokens, temperature } = model
187+
const { id: modelId, maxTokens, temperature, reasoning } = model
186188

187-
const openrouter = this.createOpenRouterProvider()
189+
// Pass reasoning parameters to extraBody when creating the provider
190+
const openrouter = this.createOpenRouterProvider(reasoning)
188191
const coreMessages = convertToAiSdkMessages(messages)
189192
const tools = convertToolsForAiSdk(metadata?.tools)
190193

191194
// Build provider options for specific provider routing
192-
const providerOptions =
195+
const providerOptions:
196+
| {
197+
openrouter?: {
198+
provider?: {
199+
order: string[]
200+
only: string[]
201+
allow_fallbacks: boolean
202+
}
203+
}
204+
}
205+
| undefined =
193206
this.options.openRouterSpecificProvider &&
194207
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME
195208
? {
@@ -255,7 +268,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
255268
model.info,
256269
)
257270
yield usageChunk
258-
} catch (error) {
271+
} catch (error: any) {
259272
const errorMessage = error instanceof Error ? error.message : String(error)
260273
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
261274
TelemetryService.instance.captureException(apiError)
@@ -325,12 +338,23 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
325338
}
326339

327340
async completePrompt(prompt: string): Promise<string> {
328-
const { id: modelId, maxTokens, temperature } = await this.fetchModel()
341+
const { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel()
329342

330-
const openrouter = this.createOpenRouterProvider()
343+
// Pass reasoning parameters to extraBody when creating the provider
344+
const openrouter = this.createOpenRouterProvider(reasoning)
331345

332346
// Build provider options for specific provider routing
333-
const providerOptions =
347+
const providerOptions:
348+
| {
349+
openrouter?: {
350+
provider?: {
351+
order: string[]
352+
only: string[]
353+
allow_fallbacks: boolean
354+
}
355+
}
356+
}
357+
| undefined =
334358
this.options.openRouterSpecificProvider &&
335359
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME
336360
? {

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,39 @@ describe("AI SDK conversion utilities", () => {
8181
})
8282
})
8383

84-
it("converts tool results into separate tool messages with resolved tool names", () => {
84+
it("converts user messages with URL image content", () => {
85+
const messages: Anthropic.Messages.MessageParam[] = [
86+
{
87+
role: "user",
88+
content: [
89+
{ type: "text", text: "What is in this image?" },
90+
{
91+
type: "image",
92+
source: {
93+
type: "url",
94+
url: "https://example.com/image.png",
95+
},
96+
} as any,
97+
],
98+
},
99+
]
100+
101+
const result = convertToAiSdkMessages(messages)
102+
103+
expect(result).toHaveLength(1)
104+
expect(result[0]).toEqual({
105+
role: "user",
106+
content: [
107+
{ type: "text", text: "What is in this image?" },
108+
{
109+
type: "image",
110+
image: "https://example.com/image.png",
111+
},
112+
],
113+
})
114+
})
115+
116+
it("converts tool results into separate tool role messages with resolved tool names", () => {
85117
const messages: Anthropic.Messages.MessageParam[] = [
86118
{
87119
role: "assistant",
@@ -116,10 +148,11 @@ describe("AI SDK conversion utilities", () => {
116148
type: "tool-call",
117149
toolCallId: "call_123",
118150
toolName: "read_file",
119-
args: { path: "test.ts" },
151+
input: { path: "test.ts" },
120152
},
121153
],
122154
})
155+
// Tool results now go to role: "tool" messages per AI SDK v6 schema
123156
expect(result[1]).toEqual({
124157
role: "tool",
125158
content: [
@@ -150,6 +183,7 @@ describe("AI SDK conversion utilities", () => {
150183
const result = convertToAiSdkMessages(messages)
151184

152185
expect(result).toHaveLength(1)
186+
// Tool results go to role: "tool" messages
153187
expect(result[0]).toEqual({
154188
role: "tool",
155189
content: [
@@ -163,6 +197,68 @@ describe("AI SDK conversion utilities", () => {
163197
})
164198
})
165199

200+
it("separates tool results and text content into different messages", () => {
201+
const messages: Anthropic.Messages.MessageParam[] = [
202+
{
203+
role: "assistant",
204+
content: [
205+
{
206+
type: "tool_use",
207+
id: "call_123",
208+
name: "read_file",
209+
input: { path: "test.ts" },
210+
},
211+
],
212+
},
213+
{
214+
role: "user",
215+
content: [
216+
{
217+
type: "tool_result",
218+
tool_use_id: "call_123",
219+
content: "File contents here",
220+
},
221+
{
222+
type: "text",
223+
text: "Please analyze this file",
224+
},
225+
],
226+
},
227+
]
228+
229+
const result = convertToAiSdkMessages(messages)
230+
231+
expect(result).toHaveLength(3)
232+
expect(result[0]).toEqual({
233+
role: "assistant",
234+
content: [
235+
{
236+
type: "tool-call",
237+
toolCallId: "call_123",
238+
toolName: "read_file",
239+
input: { path: "test.ts" },
240+
},
241+
],
242+
})
243+
// Tool results go first in a "tool" message
244+
expect(result[1]).toEqual({
245+
role: "tool",
246+
content: [
247+
{
248+
type: "tool-result",
249+
toolCallId: "call_123",
250+
toolName: "read_file",
251+
output: { type: "text", value: "File contents here" },
252+
},
253+
],
254+
})
255+
// Text content goes in a separate "user" message
256+
expect(result[2]).toEqual({
257+
role: "user",
258+
content: [{ type: "text", text: "Please analyze this file" }],
259+
})
260+
})
261+
166262
it("converts assistant messages with tool use", () => {
167263
const messages: Anthropic.Messages.MessageParam[] = [
168264
{
@@ -190,7 +286,7 @@ describe("AI SDK conversion utilities", () => {
190286
type: "tool-call",
191287
toolCallId: "call_456",
192288
toolName: "read_file",
193-
args: { path: "test.ts" },
289+
input: { path: "test.ts" },
194290
},
195291
],
196292
})
@@ -476,6 +572,14 @@ describe("AI SDK conversion utilities", () => {
476572
expect(chunks[0]).toEqual({ type: "text", text: "Hello" })
477573
})
478574

575+
it("processes text chunks (fullStream format)", () => {
576+
const part = { type: "text" as const, text: "Hello from fullStream" }
577+
const chunks = [...processAiSdkStreamPart(part as any)]
578+
579+
expect(chunks).toHaveLength(1)
580+
expect(chunks[0]).toEqual({ type: "text", text: "Hello from fullStream" })
581+
})
582+
479583
it("processes reasoning-delta chunks", () => {
480584
const part = { type: "reasoning-delta" as const, id: "1", text: "thinking..." }
481585
const chunks = [...processAiSdkStreamPart(part)]
@@ -484,6 +588,14 @@ describe("AI SDK conversion utilities", () => {
484588
expect(chunks[0]).toEqual({ type: "reasoning", text: "thinking..." })
485589
})
486590

591+
it("processes reasoning chunks (fullStream format)", () => {
592+
const part = { type: "reasoning" as const, text: "reasoning from fullStream" }
593+
const chunks = [...processAiSdkStreamPart(part as any)]
594+
595+
expect(chunks).toHaveLength(1)
596+
expect(chunks[0]).toEqual({ type: "reasoning", text: "reasoning from fullStream" })
597+
})
598+
487599
it("processes tool-input-start chunks", () => {
488600
const part = { type: "tool-input-start" as const, id: "call_1", toolName: "read_file" }
489601
const chunks = [...processAiSdkStreamPart(part)]

0 commit comments

Comments
 (0)