From 7f245b0968b8455edbcbdbe8d71c9a11e44b142b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:42:10 -0500 Subject: [PATCH] fix(ai): fit OpenRouter reasoning budgets to the output limit (#51189) --- packages/ai/src/providers/openrouter.ts | 15 ++++++++++---- packages/ai/test/provider/openrouter.test.ts | 21 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 5cc2f0ce6eb..3a09c0826e5 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -8,7 +8,7 @@ import type { ProviderPackage } from "../provider-package.js" import { SystemOne } from "../experimental/system-one.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js" -import { isRecord } from "../protocols/shared.js" +import { isRecord, ProviderShared } from "../protocols/shared.js" export const id = ProviderID.make("openrouter") const baseURL = "https://openrouter.ai/api/v1" @@ -123,7 +123,7 @@ export const protocol = Protocol.make({ return { ...body, messages, - ...bodyOptions(request.providerOptions), + ...bodyOptions(request.providerOptions, request.generation?.maxTokens), } as OpenRouterBody }), ), @@ -143,7 +143,14 @@ const cacheControl = () => { } } -const bodyOptions = (input: unknown) => { +// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba +// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget. +const fitReasoning = (reasoning: Record, maxTokens: number | undefined) => + typeof reasoning.max_tokens === "number" + ? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) } + : reasoning + +const bodyOptions = (input: unknown, maxTokens: number | undefined) => { const openrouter = isRecord(input) ? input : {} const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } = openrouter @@ -162,7 +169,7 @@ const bodyOptions = (input: unknown) => { ...(isRecord(web_search_options) ? { web_search_options } : {}), ...(isRecord(debug) ? { debug } : {}), ...(typeof user === "string" ? { user } : {}), - ...(isRecord(reasoning) ? { reasoning } : {}), + ...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}), } } diff --git a/packages/ai/test/provider/openrouter.test.ts b/packages/ai/test/provider/openrouter.test.ts index 7c320df3420..7e717b96bde 100644 --- a/packages/ai/test/provider/openrouter.test.ts +++ b/packages/ai/test/provider/openrouter.test.ts @@ -152,6 +152,27 @@ describe("OpenRouter", () => { }), ) + it.effect("fits the reasoning budget to half the output limit", () => + Effect.gen(function* () { + const reasoning = (maxTokens: number | undefined, value: Record) => + compileRequest( + LLM.request({ + model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3.8-flash"), + cache: "none", + prompt: "Hello", + ...(maxTokens === undefined ? {} : { generation: { maxTokens } }), + providerOptions: { reasoning: value }, + }), + ).pipe(Effect.map((prepared) => prepared.body.reasoning)) + + expect(yield* reasoning(32_000, { max_tokens: 131_071 })).toEqual({ max_tokens: 16_000 }) + expect(yield* reasoning(131_072, { max_tokens: 65_536 })).toEqual({ max_tokens: 65_536 }) + expect(yield* reasoning(1_500, { max_tokens: 65_536 })).toEqual({ max_tokens: 1_024 }) + expect(yield* reasoning(undefined, { max_tokens: 131_071 })).toEqual({ max_tokens: 131_071 }) + expect(yield* reasoning(32_000, { effort: "high" })).toEqual({ effort: "high" }) + }), + ) + it.effect("applies OpenRouter payload options from the model helper", () => Effect.gen(function* () { const prepared = yield* compileRequest(