diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index dcc2605ab6..be06340d6c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -377,6 +377,31 @@ export namespace ProviderTransform { case "@ai-sdk/deepinfra": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra case "@ai-sdk/openai-compatible": + // When using openai-compatible SDK with Claude/Anthropic models, + // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names + // and the OpenAI-compatible API spec uses snake_case + if ( + model.providerID === "anthropic" || + model.api.id.includes("anthropic") || + model.api.id.includes("claude") || + model.id.includes("anthropic") || + model.id.includes("claude") + ) { + return { + high: { + thinking: { + type: "enabled", + budget_tokens: 16000, + }, + }, + max: { + thinking: { + type: "enabled", + budget_tokens: 31999, + }, + }, + } + } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/azure": @@ -656,9 +681,15 @@ export namespace ProviderTransform { const modelCap = modelLimit || globalLimit const standardLimit = Math.min(modelCap, globalLimit) - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens) + // and @ai-sdk/openai-compatible with Claude (budget_tokens) + 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 + // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible) + 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 b818ab98cf..22d1a70707 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -267,6 +267,56 @@ describe("ProviderTransform.maxOutputTokens", () => { expect(result).toBe(OUTPUT_TOKEN_MAX) }) }) + + describe("openai-compatible with thinking options (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) + }) + }) }) describe("ProviderTransform.schema - gemini array items", () => { @@ -1494,6 +1544,67 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-4-5", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-5-20250929", + url: "http://localhost:4000", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "enabled", + budget_tokens: 16000, + }, + }) + expect(result.max).toEqual({ + thinking: { + type: "enabled", + budget_tokens: 31999, + }, + }) + }) + + test("Claude model (by model.id) via openai-compatible uses snake_case", () => { + const model = createMockModel({ + id: "litellm/claude-3-opus", + providerID: "litellm", + api: { + id: "claude-3-opus-20240229", + url: "http://localhost:4000", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "enabled", + budget_tokens: 16000, + }, + }) + }) + + test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => { + const model = createMockModel({ + id: "custom/my-model", + providerID: "custom", + api: { + id: "anthropic.claude-sonnet", + url: "http://localhost:4000", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high.thinking.budget_tokens).toBe(16000) + }) }) describe("@ai-sdk/azure", () => {