From 9d545baef649b607ed7dc2ee7d39e09f806986dd Mon Sep 17 00:00:00 2001 From: Chesars Date: Fri, 6 Feb 2026 20:43:40 -0300 Subject: [PATCH 1/3] fix(provider): convert thinking.budgetTokens to snake_case for openai-compatible SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @ai-sdk/openai-compatible SDK only converts its own schema fields (e.g. reasoningEffort → reasoning_effort) but passes custom providerOptions as-is. When users configure thinking.budgetTokens for Claude models behind OpenAI-compatible proxies (LiteLLM, OpenRouter, etc.), the camelCase key is sent on the wire, which the proxy cannot forward correctly. This fix adds a conversion in providerOptions() that rewrites thinking.budgetTokens → thinking.budget_tokens only when the SDK is @ai-sdk/openai-compatible. This approach: - Does not modify variants() or change what options are available to users - Does not use string matching on model names - Only converts the format, not the values — users control what is sent - Leaves @ai-sdk/anthropic untouched (it handles conversion internally) Also updates maxOutputTokens() to read both budgetTokens (camelCase) and budget_tokens (snake_case) for correct token limit calculation with openai-compatible providers. Fixes #858 --- packages/opencode/src/provider/transform.ts | 25 +++- .../opencode/test/provider/transform.test.ts | 126 ++++++++++++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8aab0d4151d8..6e64082ebca8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -706,6 +706,22 @@ export namespace ProviderTransform { export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { const key = sdkKey(model.api.npm) ?? model.providerID + + // The @ai-sdk/openai-compatible SDK does not convert camelCase to snake_case + // for custom providerOptions (only for its own schema fields like reasoningEffort). + // When thinking.budgetTokens is set, we must convert it to budget_tokens so that + // OpenAI-compatible proxies (LiteLLM, OpenRouter, etc.) can forward it correctly. + if (model.api.npm === "@ai-sdk/openai-compatible" && options?.thinking?.budgetTokens != null) { + const { budgetTokens, ...thinkingRest } = options.thinking + options = { + ...options, + thinking: { + ...thinkingRest, + budget_tokens: budgetTokens, + }, + } + } + return { [key]: options } } @@ -718,9 +734,14 @@ export namespace ProviderTransform { const modelCap = modelLimit || globalLimit const standardLimit = Math.min(modelCap, globalLimit) - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic" || npm === "@ai-sdk/openai-compatible") { const thinking = options?.["thinking"] - const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0 + const budgetTokens = + typeof thinking?.["budgetTokens"] === "number" + ? thinking["budgetTokens"] + : typeof thinking?.["budget_tokens"] === "number" + ? thinking["budget_tokens"] + : 0 const enabled = thinking?.["type"] === "enabled" if (enabled && budgetTokens > 0) { // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0e0bb440aa81..61c5209961a3 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -267,6 +267,132 @@ describe("ProviderTransform.maxOutputTokens", () => { expect(result).toBe(OUTPUT_TOKEN_MAX) }) }) + + describe("openai-compatible with thinking options (budget_tokens snake_case)", () => { + test("returns 32k when budget_tokens + 32k <= modelLimit", () => { + const modelLimit = 100000 + const options = { + thinking: { + type: "enabled", + budget_tokens: 10000, + }, + } + const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) + expect(result).toBe(OUTPUT_TOKEN_MAX) + }) + + test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => { + const modelLimit = 50000 + const options = { + thinking: { + type: "enabled", + budget_tokens: 30000, + }, + } + const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) + expect(result).toBe(20000) + }) + + test("returns 32k when thinking type is not enabled", () => { + const modelLimit = 100000 + const options = { + thinking: { + type: "disabled", + budget_tokens: 10000, + }, + } + const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) + expect(result).toBe(OUTPUT_TOKEN_MAX) + }) + + test("returns 32k when budget_tokens is 0", () => { + const modelLimit = 100000 + const options = { + thinking: { + type: "enabled", + budget_tokens: 0, + }, + } + const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) + expect(result).toBe(OUTPUT_TOKEN_MAX) + }) + + test("handles budgetTokens camelCase for openai-compatible", () => { + const modelLimit = 100000 + const options = { + thinking: { + type: "enabled", + budgetTokens: 10000, + }, + } + const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) + expect(result).toBe(OUTPUT_TOKEN_MAX) + }) + }) +}) + +describe("ProviderTransform.providerOptions - openai-compatible snake_case conversion", () => { + test("converts thinking.budgetTokens to budget_tokens for openai-compatible", () => { + const model = { + providerID: "litellm", + api: { npm: "@ai-sdk/openai-compatible" }, + } as any + const options = { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + } + const result = ProviderTransform.providerOptions(model, options) + expect(result.litellm.thinking.budget_tokens).toBe(16000) + expect(result.litellm.thinking.budgetTokens).toBeUndefined() + expect(result.litellm.thinking.type).toBe("enabled") + }) + + test("does not convert for @ai-sdk/anthropic (SDK handles it)", () => { + const model = { + providerID: "anthropic", + api: { npm: "@ai-sdk/anthropic" }, + } as any + const options = { + thinking: { + type: "enabled", + budgetTokens: 16000, + }, + } + const result = ProviderTransform.providerOptions(model, options) + expect(result.anthropic.thinking.budgetTokens).toBe(16000) + expect(result.anthropic.thinking.budget_tokens).toBeUndefined() + }) + + test("passes through options without thinking unchanged", () => { + const model = { + providerID: "litellm", + api: { npm: "@ai-sdk/openai-compatible" }, + } as any + const options = { + reasoningEffort: "high", + } + const result = ProviderTransform.providerOptions(model, options) + expect(result.litellm.reasoningEffort).toBe("high") + expect(result.litellm.thinking).toBeUndefined() + }) + + test("passes through thinking without budgetTokens unchanged", () => { + const model = { + providerID: "litellm", + api: { npm: "@ai-sdk/openai-compatible" }, + } as any + const options = { + thinking: { + type: "enabled", + budget_tokens: 16000, + }, + } + const result = ProviderTransform.providerOptions(model, options) + expect(result.litellm.thinking.budget_tokens).toBe(16000) + expect(result.litellm.thinking.type).toBe("enabled") + }) }) describe("ProviderTransform.schema - gemini array items", () => { From 49d7067b5caaa50b5a6dd48c1fe1020c909837f1 Mon Sep 17 00:00:00 2001 From: Chesars Date: Fri, 6 Feb 2026 22:22:01 -0300 Subject: [PATCH 2/3] fix: remove @ai-sdk/openai-compatible from maxOutputTokens condition The @ai-sdk/anthropic SDK internally sums maxTokens + budgetTokens to build max_tokens for the API. The subtraction in maxOutputTokens() is needed to keep the total within the model cap. But @ai-sdk/openai-compatible sends max_tokens directly to the proxy without summing, so the subtraction unnecessarily reduces text tokens. --- packages/opencode/src/provider/transform.ts | 6 +- .../opencode/test/provider/transform.test.ts | 61 ------------------- 2 files changed, 2 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6e64082ebca8..f2a0a3796df7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -734,14 +734,12 @@ export namespace ProviderTransform { const modelCap = modelLimit || globalLimit const standardLimit = Math.min(modelCap, globalLimit) - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic" || npm === "@ai-sdk/openai-compatible") { + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { const thinking = options?.["thinking"] const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] - : typeof thinking?.["budget_tokens"] === "number" - ? thinking["budget_tokens"] - : 0 + : 0 const enabled = thinking?.["type"] === "enabled" if (enabled && budgetTokens > 0) { // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 61c5209961a3..3ff1c1495b6a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -268,67 +268,6 @@ describe("ProviderTransform.maxOutputTokens", () => { }) }) - describe("openai-compatible with thinking options (budget_tokens snake_case)", () => { - test("returns 32k when budget_tokens + 32k <= modelLimit", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => { - const modelLimit = 50000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 30000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(20000) - }) - - test("returns 32k when thinking type is not enabled", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "disabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns 32k when budget_tokens is 0", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 0, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("handles budgetTokens camelCase for openai-compatible", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budgetTokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai-compatible", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - }) }) describe("ProviderTransform.providerOptions - openai-compatible snake_case conversion", () => { From e76e62c4bec73d300c8266a7c722d06b59e31758 Mon Sep 17 00:00:00 2001 From: Chesars Date: Fri, 6 Feb 2026 22:24:40 -0300 Subject: [PATCH 3/3] style: revert formatting-only change in maxOutputTokens --- packages/opencode/src/provider/transform.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f2a0a3796df7..56401069828d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -736,10 +736,7 @@ export namespace ProviderTransform { if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { const thinking = options?.["thinking"] - const budgetTokens = - typeof thinking?.["budgetTokens"] === "number" - ? thinking["budgetTokens"] - : 0 + const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0 const enabled = thinking?.["type"] === "enabled" if (enabled && budgetTokens > 0) { // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.