From 674daec93e8e1eed9c3864d7b21e2d1e5bddd9db Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:41:28 +0100 Subject: [PATCH 1/3] refactor(provider): align reasoning transform with upstream - restore upstream provider transform behavior for generic reasoning handling - keep fork import-path adaptation for generated model/provider IDs - realign transform tests with upstream coverage available in this fork --- packages/opencode/src/provider/transform.ts | 344 +++--- .../opencode/test/provider/transform.test.ts | 1098 +++++++++-------- 2 files changed, 764 insertions(+), 678 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 748a87ed3..dbfa0e25d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -4,7 +4,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models" import { iife } from "@/util/iife" -import { Flag } from "@opencode-ai/core/flag/flag" type Modality = NonNullable["input"][number] @@ -16,7 +15,12 @@ function mimeToModality(mime: string): Modality | undefined { return undefined } -export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 +export const OUTPUT_TOKEN_MAX = 32_000 + +// OpenAI Responses `include` value that returns the encrypted reasoning state +// needed for stateless multi-turn reasoning (store: false). Hoisted so every +// branch that requests it stays in lockstep. +const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] - - const parts = msg.content - const first = parts.findIndex((part) => part.type === "tool-call") - if (first === -1) return [msg] - if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] - return [ - { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, - { ...msg, content: parts.filter((part) => part.type === "tool-call") }, - ] - }) - } + if ( model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral") || @@ -427,6 +409,24 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes }) } +function mapProviderOptions( + msgs: ModelMessage[], + transform: (options: Record | undefined) => Record | undefined, +) { + return msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: transform(msg.providerOptions) } + return { + ...msg, + providerOptions: transform(msg.providerOptions), + content: msg.content.map((part) => + part.type === "tool-approval-request" || part.type === "tool-approval-response" + ? part + : { ...part, providerOptions: transform(part.providerOptions) }, + ), + } as typeof msg + }) +} + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) @@ -456,18 +456,20 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return result } - msgs = msgs.map((msg) => { - if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } - return { - ...msg, - providerOptions: remap(msg.providerOptions), - content: msg.content.map((part) => { - if (part.type === "tool-approval-request" || part.type === "tool-approval-response") { - return { ...part } - } - return { ...part, providerOptions: remap(part.providerOptions) } - }), - } as typeof msg + msgs = mapProviderOptions(msgs, remap) + } + + // Strip Responses item IDs before serialization, following Codex and keeping signed request bodies immutable. + if ( + options.store !== true && + key && + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + ) { + msgs = mapProviderOptions(msgs, (options) => { + if (!options?.[key] || !("itemId" in options[key])) return options + const metadata = { ...options[key] } + delete metadata.itemId + return { ...options, [key]: metadata } }) } @@ -476,6 +478,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re export function temperature(model: Provider.Model) { const id = model.id.toLowerCase() + if (id.includes("north-mini-code")) return 1.0 if (id.includes("qwen")) return 0.55 if (id.includes("claude")) return undefined if (id.includes("gemini")) return 1.0 @@ -520,14 +523,6 @@ const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] -const OPENROUTER_GEMINI_REASONING_BUDGETS = { - none: { reasoning: { enabled: false } }, - minimal: { reasoning: { max_tokens: 64 } }, - low: { reasoning: { max_tokens: 256 } }, - medium: { reasoning: { max_tokens: 512 } }, - high: { reasoning: { max_tokens: 768 } }, - xhigh: { reasoning: { max_tokens: 900 } }, -} // OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API). // Models released before it 400 on `reasoning_effort: "none"`, so we only expose @@ -600,16 +595,34 @@ function openaiCompatibleReasoningEfforts(id: string) { return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS } +function anthropicOpus47OrLater(apiId: string) { + // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). + // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. + const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) + if (!version) return false + const major = Number(version[1] ?? version[3]) + const minor = Number(version[2] ?? version[4]) + return major > 4 || (major === 4 && minor >= 7) +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { + if (anthropicOpus47OrLater(apiId) || apiId.includes("fable-5")) { return ["low", "medium", "high", "xhigh", "max"] } - if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { + if ( + ["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) => + apiId.includes(v), + ) + ) { return ["low", "medium", "high", "max"] } return null } +function anthropicOmitsThinking(apiId: string) { + return anthropicOpus47OrLater(apiId) || apiId.includes("fable-5") +} + function googleThinkingLevelEfforts(apiId: string) { const id = apiId.toLowerCase() if (!id.includes("gemini-3")) return ["low", "high"] @@ -625,22 +638,45 @@ function googleThinkingBudgetMax(apiId: string) { return 24_576 } -function isGemini25(id: string) { - return id.toLowerCase().includes("gemini-2.5") +// SAP's Zod schema drops unknown top-level keys; reasoning controls survive +// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs. +function wrapInSapModelParams(variants: Record>): Record> { + return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }])) } -function isGemini3(id: string) { - return id.toLowerCase().includes("gemini-3") +function googleThinkingVariants(model: Provider.Model): Record> { + const id = model.api.id.toLowerCase() + if (id.includes("2.5")) { + return { + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + max: { + thinkingConfig: { includeThoughts: true, thinkingBudget: googleThinkingBudgetMax(id) }, + }, + } + } + return Object.fromEntries( + googleThinkingLevelEfforts(id).map((effort) => [ + effort, + { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }, + ]), + ) } export function variants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} const id = model.id.toLowerCase() - const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id) - function isGrok43(id: string): boolean { - return id.includes("grok-4.3") || id.includes("grok-4-3") + if ( + model.api.id.toLowerCase().includes("minimax-m3") && + ["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm) + ) { + return { + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + } } + const adaptiveThinkingOmitted = anthropicOmitsThinking(model.api.id) + const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id) if ( id.includes("deepseek-chat") || id.includes("deepseek-reasoner") || @@ -668,24 +704,15 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) - } - return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) - } if (id.includes("grok")) return {} switch (model.api.npm) { case "@openrouter/ai-sdk-provider": - if (!id.includes("gpt") && !id.includes("claude") && !isGemini25(id) && !isGemini3(id)) return {} - if (isGemini25(id)) return OPENROUTER_GEMINI_REASONING_BUDGETS return Object.fromEntries( - (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [ - effort, - { reasoning: { effort } }, - ]), + (model.api.id.startsWith("openai/") || id.includes("gpt") + ? openaiCompatibleReasoningEfforts(model.api.id) + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [effort, { reasoning: { effort } }]), ) case "ai-gateway-provider": { @@ -711,6 +738,10 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + } const efforts = [...WIDELY_SUPPORTED_EFFORTS] if (model.api.id.toLowerCase().includes("deepseek-v4")) { efforts.push("max") @@ -810,18 +844,16 @@ export function variants(model: Provider.Model): Record [ + openaiReasoningEfforts(id, model.release_date).map((effort) => [ effort, { reasoningEffort: effort, reasoningSummary: "auto", - include: ["reasoning.encrypted_content"], + include: INCLUDE_ENCRYPTED_REASONING, }, ]), ) + case "@ai-sdk/amazon-bedrock/mantle": case "@ai-sdk/openai": { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai const efforts = openaiReasoningEfforts(model.api.id, model.release_date) @@ -831,7 +863,7 @@ export function variants(model: Provider.Model): Record [ - effort, - { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: effort, - }, - }, - ]), - ) + return googleThinkingVariants(model) case "@ai-sdk/mistral": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral @@ -1004,56 +1005,39 @@ export function variants(model: Provider.Model): Record [ - effort, - { - thinking: { - type: "adaptive", - }, + // Bedrock adaptive splits `effort` out into `output_config` (vs Anthropic + // native which inlines it). Opus 4.7+ flipped `display` default to "omitted". + return wrapInSapModelParams( + Object.fromEntries( + adaptiveEfforts.map((effort) => [ effort, - }, - ]), + { + thinking: { type: "adaptive", ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}) }, + output_config: { effort }, + }, + ]), + ), ) } - return { - high: { - thinking: { - type: "enabled", - budgetTokens: 16000, - }, - }, - max: { - thinking: { - type: "enabled", - budgetTokens: 31999, - }, - }, - } + return wrapInSapModelParams({ + high: { thinking: { type: "enabled", budget_tokens: 16000 } }, + max: { thinking: { type: "enabled", budget_tokens: 31999 } }, + }) } - if (model.api.id.includes("gemini") && id.includes("2.5")) { - return { - high: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, - }, - }, - max: { - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, - }, - }, - } + if (id.includes("gemini") && id.includes("2.5")) { + return wrapInSapModelParams(googleThinkingVariants(model)) } - if (model.api.id.includes("gpt") || /\bo[1-9]/.test(model.api.id)) { - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + if (id.includes("gpt") || /\bo[1-9]/.test(id)) { + const efforts = openaiReasoningEfforts(id, model.release_date) + return wrapInSapModelParams(Object.fromEntries(efforts.map((effort) => [effort, { reasoning_effort: effort }]))) } - return {} + return wrapInSapModelParams( + Object.fromEntries(["low", "medium", "high"].map((effort) => [effort, { reasoning_effort: effort }])), + ) + } } return {} } @@ -1076,7 +1060,8 @@ export function options(input: { if ( input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai" || - input.model.api.npm === "@ai-sdk/github-copilot" + input.model.api.npm === "@ai-sdk/github-copilot" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ) { result["store"] = false } @@ -1090,12 +1075,8 @@ export function options(input: { result["usage"] = { include: true, } - if (input.model.capabilities.reasoning) { - if (input.model.api.npm === "@openrouter/ai-sdk-provider" && isGemini25(input.model.api.id)) { - result["reasoning"] = OPENROUTER_GEMINI_REASONING_BUDGETS.high.reasoning - } else if (isGemini3(input.model.api.id)) { - result["reasoning"] = { effort: "high" } - } + if (input.model.api.id.includes("gemini-3")) { + result["reasoning"] = { effort: "high" } } } @@ -1131,8 +1112,14 @@ export function options(input: { } } - // Enable thinking by default for kimi models using anthropic SDK const modelId = input.model.api.id.toLowerCase() + + // MiniMax's Anthropic interface defaults thinking off, unlike Chat Completions. + if (modelId.includes("minimax-m3") && input.model.api.npm === "@ai-sdk/anthropic") { + result["thinking"] = { type: "adaptive" } + } + + // Enable thinking by default for kimi models using anthropic SDK if ( (input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") && (modelId.includes("k2p") || modelId.includes("kimi-k2.") || modelId.includes("kimi-k2p")) @@ -1168,10 +1155,14 @@ export function options(input: { if ( input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/azure" || - input.model.api.npm === "@ai-sdk/github-copilot" + input.model.api.npm === "@ai-sdk/github-copilot" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ) { result["reasoningSummary"] = "auto" } + if (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") { + result["include"] = INCLUDE_ENCRYPTED_REASONING + } } // Only set textVerbosity for non-chat gpt-5.x models @@ -1187,7 +1178,7 @@ export function options(input: { if (input.model.providerID.startsWith("opencode")) { result["promptCacheKey"] = input.sessionID - result["include"] = ["reasoning.encrypted_content"] + result["include"] = INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } @@ -1219,6 +1210,9 @@ export function smallOptions(model: Provider.Model) { return mergeDeep(base, small) } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { + if (model.providerID === "openrouter" && small.reasoning?.effort === "low") { + return { reasoning: { effort: "none" } } + } if (Object.keys(small).length === 0 && model.api.id.includes("google")) { return { reasoning: { enabled: false } } } @@ -1288,8 +1282,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a return { [key]: options } } -export function maxOutputTokens(model: Provider.Model): number { - return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX +export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { + return Math.min(model.limit.output, outputTokenMax) || outputTokenMax } export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { @@ -1381,6 +1375,24 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } } + // Gemini requires a single `type`, not a JSON Schema type array such as + // `["number","string"]` (emitted by some MCP servers). Plain `@ai-sdk/google` + // rewrites these into an `anyOf` of single-type schemas, but OpenAI-compatible + // transports (e.g. GitHub Copilot proxying to Gemini) forward them verbatim + // and the backend rejects the array form. Mirror the SDK: split non-null + // types into `anyOf`, and lift `null` into `nullable`. + if (Array.isArray(result.type)) { + const hasNull = result.type.includes("null") + const nonNull = result.type.filter((entry: unknown) => entry !== "null") + if (nonNull.length === 0) { + result.type = "null" + } else { + delete result.type + result.anyOf = nonNull.map((entry: unknown) => ({ type: entry })) + if (hasNull) result.nullable = true + } + } + // Filter required array to only include fields that exist in properties if (result.type === "object" && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 22500c2f5..4b462f892 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -172,6 +172,39 @@ describe("ProviderTransform.options - zai/zhipuai thinking", () => { } }) +describe("ProviderTransform.options - minimax m3 thinking", () => { + const createModel = (npm: string) => + ({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "minimax-m3", + url: "https://api.minimax.com", + npm, + }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + }) as any + + test("explicitly enables adaptive thinking with the anthropic SDK", () => { + expect( + ProviderTransform.options({ + model: createModel("@ai-sdk/anthropic"), + sessionID: "test-session-123", + }).thinking, + ).toEqual({ type: "adaptive" }) + }) + + test("uses the native default with the openai-compatible SDK", () => { + expect( + ProviderTransform.options({ + model: createModel("@ai-sdk/openai-compatible"), + sessionID: "test-session-123", + }).thinking, + ).toBeUndefined() + }) +}) + describe("ProviderTransform.options - google thinkingConfig gating", () => { const sessionID = "test-session-123" @@ -238,105 +271,6 @@ describe("ProviderTransform.options - google thinkingConfig gating", () => { }) }) -describe("ProviderTransform.options - OpenRouter Gemini reasoning", () => { - const sessionID = "ses_test" - const createOpenRouterModel = (apiId: string) => - ({ - id: `openrouter/${apiId}`, - providerID: "openrouter", - api: { - id: apiId, - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - name: apiId, - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { text: true, audio: false, image: true, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, - limit: { context: 128000, output: 8192 }, - status: "active", - options: {}, - headers: {}, - }) as any - - test("enables OpenRouter Gemini reasoning by default", () => { - const model = createOpenRouterModel("google/gemini-2.5-flash") - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - prompt_cache_key: sessionID, - reasoning: { max_tokens: 768 }, - }) - }) - - test("keeps OpenRouter Gemini 3 reasoning effort by default", () => { - const model = createOpenRouterModel("google/gemini-3-pro-preview") - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - prompt_cache_key: sessionID, - reasoning: { effort: "high" }, - }) - }) - - test("does not enable default reasoning for non-Gemini OpenRouter models", () => { - const model = createOpenRouterModel("anthropic/claude-haiku-4.5") - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - prompt_cache_key: sessionID, - }) - }) - - test("does not enable default reasoning for OpenRouter Gemini models without reasoning", () => { - const model = createOpenRouterModel("google/gemini-2.5-flash-nothinking") - model.capabilities.reasoning = false - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - prompt_cache_key: sessionID, - }) - }) - - test("keeps llmgateway Gemini defaults unchanged", () => { - const model = createOpenRouterModel("google/gemini-2.5-flash") - model.id = "llmgateway/google/gemini-2.5-flash" - model.providerID = "llmgateway" - model.api = { - id: "google/gemini-2.5-flash", - url: "https://llmgateway.ai", - npm: "@llmgateway/ai-sdk-provider", - } - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - }) - }) - - test("keeps llmgateway Gemini 3 reasoning effort by default", () => { - const model = createOpenRouterModel("google/gemini-3-pro-preview") - model.id = "llmgateway/google/gemini-3-pro-preview" - model.providerID = "llmgateway" - model.api = { - id: "google/gemini-3-pro-preview", - url: "https://llmgateway.ai", - npm: "@llmgateway/ai-sdk-provider", - } - - expect(ProviderTransform.options({ model, sessionID, providerOptions: {} })).toEqual({ - usage: { include: true }, - reasoning: { effort: "high" }, - }) - }) -}) - describe("ProviderTransform.options - gpt-5 textVerbosity", () => { const sessionID = "test-session-123" @@ -370,6 +304,43 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { const model = createGpt5Model("gpt-5.2") const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) expect(result.textVerbosity).toBe("low") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + }) + + test("Bedrock Mantle gpt-5.5 uses OpenAI Responses defaults", () => { + const model = { + ...createGpt5Model("openai.gpt-5.5"), + id: "amazon-bedrock/openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + } + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.store).toBe(false) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBe("auto") + expect(result.include).toEqual(["reasoning.encrypted_content"]) + expect(result.textVerbosity).toBe("low") + }) + + test("openai-compatible gpt-5 models omit Responses-only reasoningSummary", () => { + const model = { + ...createGpt5Model("gpt-5.4"), + id: "cortecs/gpt-5.4", + providerID: "cortecs", + api: { + id: "gpt-5.4", + url: "https://api.cortecs.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + } + const result = ProviderTransform.options({ model, sessionID, providerOptions: {} }) + expect(result.reasoningEffort).toBe("medium") + expect(result.reasoningSummary).toBeUndefined() + expect(result.include).toBeUndefined() }) test("gpt-5.1 should have textVerbosity set to low", () => { @@ -467,26 +438,6 @@ describe("ProviderTransform.options - gpt-5 reasoningEffort", () => { expect(result.reasoningEffort).toBeUndefined() }) - test("openai-compatible gpt-5 should not set reasoningSummary", () => { - const model = createModel("gpt-5.2") - model.id = "litellm/gpt-5.2" - model.providerID = "litellm" - model.api = { - id: "gpt-5.2", - url: "https://litellm.example/v1", - npm: "@ai-sdk/openai-compatible", - } - - const result = ProviderTransform.options({ - model, - sessionID, - providerOptions: {}, - }) - - expect(result.reasoningEffort).toBe("medium") - expect(result.reasoningSummary).toBeUndefined() - }) - test("gpt-5.5 should NOT set reasoningEffort", () => { const result = ProviderTransform.options({ model: createModel("gpt-5.5"), @@ -680,6 +631,21 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("maps Bedrock Mantle provider options to OpenAI namespace", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ + openai: { reasoningEffort: "medium" }, + }) + }) + test("uses groq slug for groq models", () => { const model = createModel({ providerID: "vercel", @@ -833,6 +799,93 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini type arrays", () => { + // Mirrors @ai-sdk/google's convertJSONSchemaToOpenAPISchema: JSON Schema type + // arrays (e.g. `["number","string"]`, common in MCP tool schemas) become an + // `anyOf` of single-type schemas, with `null` lifted into `nullable`. Plain + // @ai-sdk/google rewrites these, but OpenAI-compatible transports such as + // GitHub Copilot (proxying to Gemini) forward them verbatim and the backend + // rejects the array form. + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("splits a multi-type array into anyOf and drops the type array", () => { + const schema = { + type: "object", + properties: { + status: { type: ["number", "string"], description: "status filter" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.nullable).toBeUndefined() + // Sibling keywords stay alongside the generated anyOf. + expect(result.properties.status.description).toBe("status filter") + }) + + test("lifts null into nullable for a nullable type array", () => { + const schema = { + type: "object", + properties: { + maybe: { type: ["string", "null"], description: "nullable string" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.maybe.type).toBeUndefined() + expect(result.properties.maybe.anyOf).toEqual([{ type: "string" }]) + expect(result.properties.maybe.nullable).toBe(true) + }) + + test("collapses an all-null type array to type null", () => { + const schema = { + type: "object", + properties: { + nothing: { type: ["null"] }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.nothing.type).toBe("null") + expect(result.properties.nothing.anyOf).toBeUndefined() + }) + + test("rewrites type arrays for gemini served through github-copilot", () => { + const copilotGeminiModel = { + providerID: "github-copilot", + api: { + id: "gemini-3.5-flash", + npm: "@ai-sdk/github-copilot", + }, + } as any + + const schema = { + type: "object", + properties: { + hook_id: { type: "number", description: "ID of the webhook" }, + status: { type: ["number", "string"], description: "Filter by response status code" }, + }, + required: ["hook_id"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(copilotGeminiModel, schema) as any + + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.hook_id.type).toBe("number") + }) +}) + describe("ProviderTransform.schema - gemini combiner nodes", () => { const geminiModel = { providerID: "google", @@ -1732,50 +1785,6 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content).toHaveLength(1) }) - test("splits anthropic assistant messages when text trails tool calls", () => { - const msgs = [ - { - role: "user", - content: [{ type: "text", text: "Check my home directory for PDFs" }], - }, - { - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - { type: "text", text: "I checked your home directory and looked for PDF files." }, - ], - }, - { - role: "tool", - content: [ - { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } }, - { - type: "tool-result", - toolCallId: "toolu_2", - toolName: "glob", - output: { type: "text", value: "No files found" }, - }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] - - expect(result).toHaveLength(4) - expect(result[1]).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], - }) - expect(result[2]).toMatchObject({ - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - ], - }) - }) - test("leaves valid anthropic assistant tool ordering unchanged", () => { const msgs = [ { @@ -1797,44 +1806,6 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, ]) }) - - test("splits vertex anthropic assistant messages when text trails tool calls", () => { - const model = { - ...anthropicModel, - providerID: "google-vertex-anthropic", - api: { - id: "claude-sonnet-4@20250514", - url: "https://us-central1-aiplatform.googleapis.com", - npm: "@ai-sdk/google-vertex/anthropic", - }, - } - - const msgs = [ - { - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - { type: "text", text: "I checked your home directory and looked for PDF files." }, - ], - }, - ] as any[] - - const result = ProviderTransform.message(msgs, model, {}) as any[] - - expect(result).toHaveLength(2) - expect(result[0]).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }], - }) - expect(result[1]).toMatchObject({ - role: "assistant", - content: [ - { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, - { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, - ], - }) - }) }) describe("ProviderTransform.message - strip openai metadata when store=false", () => { @@ -1863,7 +1834,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("preserves itemId and reasoningEncryptedContent when store=false", () => { + test("strips OpenAI itemId and preserves reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -1894,11 +1865,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("uses the SDK package namespace rather than provider ID", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -1933,11 +1905,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() }) - test("preserves other openai options including itemId", () => { + test("preserves other OpenAI options", () => { const msgs = [ { role: "assistant", @@ -1958,11 +1931,20 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("preserves metadata for openai package when store is true", () => { + test("strips Azure itemId from the Azure namespace", () => { + const azureModel = { + ...openaiModel, + providerID: "azure", + api: { + id: "gpt-5", + url: "https://example.openai.azure.com", + npm: "@ai-sdk/azure", + }, + } const msgs = [ { role: "assistant", @@ -1971,60 +1953,118 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( type: "text", text: "Hello", providerOptions: { - openai: { - itemId: "msg_123", - }, + azure: { itemId: "msg_123", otherOption: "value" }, + openai: { itemId: "msg_openai" }, }, }, ], }, ] as any[] - // openai package preserves itemId regardless of store value - const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] + const result = ProviderTransform.message(msgs, azureModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].content[0].providerOptions?.azure?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.azure?.otherOption).toBe("value") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai") }) - test("preserves metadata for non-openai packages when store is false", () => { - const anthropicModel = { + test("strips Bedrock Mantle itemId from the OpenAI namespace", () => { + const mantleModel = { ...openaiModel, - providerID: "anthropic", + providerID: "amazon-bedrock", api: { - id: "claude-3", - url: "https://api.anthropic.com", - npm: "@ai-sdk/anthropic", + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", }, } const msgs = [ { role: "assistant", + providerOptions: { openai: { itemId: "msg_root", otherOption: "root-value" } }, content: [ { - type: "text", - text: "Hello", + type: "reasoning", + text: "thinking...", providerOptions: { - openai: { - itemId: "msg_123", - }, + openai: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, }, }, ], }, ] as any[] - // store=false preserves metadata for non-openai packages - const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] + const result = ProviderTransform.message(msgs, mantleModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.otherOption).toBe("root-value") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) - test("preserves metadata using providerID key when store is false", () => { - const opencodeModel = { - ...openaiModel, - providerID: "opencode", - api: { - id: "opencode-test", + test("preserves metadata for openai package when store is true", () => { + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // openai package preserves itemId regardless of store value + const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) + + test("preserves metadata for non-openai packages when store is false", () => { + const anthropicModel = { + ...openaiModel, + providerID: "anthropic", + api: { + id: "claude-3", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + ], + }, + ] as any[] + + // store=false preserves metadata for non-openai packages + const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + }) + + test("preserves metadata using providerID key when store is false", () => { + const opencodeModel = { + ...openaiModel, + providerID: "opencode", + api: { + id: "opencode-test", url: "https://api.opencode.ai", npm: "@ai-sdk/openai-compatible", }, @@ -2472,6 +2512,12 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) +describe("ProviderTransform.temperature - Cohere North", () => { + test("defaults north-mini-code models to 1.0", () => { + expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0) + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", @@ -2543,6 +2589,39 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) + test("minimax m3 using anthropic returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "MiniMax-M3", + url: "https://api.minimax.com/anthropic/v1", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test("minimax m3 using openai-compatible returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "minimax-m3", + url: "https://api.minimax.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + test("glm returns empty object", () => { const model = createMockModel({ id: "glm/glm-4", @@ -2622,7 +2701,7 @@ describe("ProviderTransform.variants", () => { }) describe("@openrouter/ai-sdk-provider", () => { - test("returns empty object for non-qualifying models", () => { + test("returns widely supported efforts for other reasoning models", () => { const model = createMockModel({ id: "openrouter/test-model", providerID: "openrouter", @@ -2633,7 +2712,8 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) }) test("gpt models return OPENAI_EFFORTS with reasoning", () => { @@ -2653,6 +2733,7 @@ describe("ProviderTransform.variants", () => { }) for (const testCase of [ + { id: "openai/o3-mini", efforts: ["none", "minimal", "low", "medium", "high", "xhigh"] }, { id: "openai/gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, { id: "openai/gpt-5-pro", efforts: ["high"] }, { id: "openai/gpt-5.5-pro", efforts: ["medium", "high", "xhigh"] }, @@ -2678,7 +2759,7 @@ describe("ProviderTransform.variants", () => { }) } - test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => { + test("gemini-3 returns widely supported efforts with reasoning", () => { const model = createMockModel({ id: "openrouter/gemini-3-5-pro", providerID: "openrouter", @@ -2689,90 +2770,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - }) - - test("gemini-2.5 returns fixed OpenRouter reasoning budgets", () => { - const model = createMockModel({ - id: "openrouter/google/gemini-2.5-flash", - providerID: "openrouter", - api: { - id: "google/gemini-2.5-flash", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]) - expect(result.high).toEqual({ reasoning: { max_tokens: 768 } }) - }) - - test("does not expose reasoning variants for unsupported OpenRouter Gemini models", () => { - const model = createMockModel({ - id: "openrouter/google/gemini-2.0-flash", - providerID: "openrouter", - api: { - id: "google/gemini-2.0-flash", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - }) - - expect(ProviderTransform.variants(model)).toEqual({}) - }) - - test.each([ - "openrouter/google/gemini-2.5-flash", - "openrouter/google/gemini-3-5-pro", - "openrouter/anthropic/claude-haiku-4.5", - "openrouter/x-ai/grok-4.3", - ])( - "does not cap output tokens for OpenRouter reasoning model %s", - (id) => { - const model = createMockModel({ - id, - providerID: "openrouter", - api: { - id: id.replace("openrouter/", ""), - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - limit: { output: 64_000 }, - }) - - expect(ProviderTransform.maxOutputTokens(model)).toBe(32_000) - }, - ) - - test("does not cap OpenRouter GPT output tokens", () => { - const model = createMockModel({ - id: "openrouter/openai/gpt-5.4-mini", - providerID: "openrouter", - api: { - id: "openai/gpt-5.4-mini", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - limit: { output: 64_000 }, - }) - - expect(ProviderTransform.maxOutputTokens(model)).toBe(32_000) - }) - - test("does not cap non-reasoning OpenRouter Grok output tokens", () => { - const model = createMockModel({ - id: "openrouter/x-ai/grok-4", - providerID: "openrouter", - api: { - id: "x-ai/grok-4", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - capabilities: { reasoning: false }, - limit: { output: 64_000 }, - }) - - expect(ProviderTransform.maxOutputTokens(model)).toBe(32_000) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) test("grok-4 returns empty object", () => { @@ -2804,37 +2802,6 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoning: { effort: "low" } }) expect(result.high).toEqual({ reasoning: { effort: "high" } }) }) - - test("grok-4.3 returns reasoning effort variants", () => { - const model = createMockModel({ - id: "openrouter/grok-4.3", - providerID: "openrouter", - api: { - id: "grok-4.3", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) - expect(result.none).toEqual({ reasoning: { effort: "none" } }) - expect(result.high).toEqual({ reasoning: { effort: "high" } }) - }) - - test("grok-4-3 returns reasoning effort variants", () => { - const model = createMockModel({ - id: "openrouter/grok-4-3-20260430", - providerID: "openrouter", - api: { - id: "grok-4-3-20260430", - url: "https://openrouter.ai", - npm: "@openrouter/ai-sdk-provider", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) - expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) - }) }) describe("@ai-sdk/gateway", () => { @@ -2913,12 +2880,14 @@ describe("ProviderTransform.variants", () => { expect(result.xhigh).toEqual({ thinking: { type: "adaptive", + display: "summarized", }, effort: "xhigh", }) expect(result.max).toEqual({ thinking: { type: "adaptive", + display: "summarized", }, effort: "max", }) @@ -2938,6 +2907,47 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) }) + test("anthropic opus 4.8 forces display summarized for adaptive reasoning", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-8", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-8", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { + const model = createMockModel({ + id: "anthropic/claude-opus-4-6", + providerID: "gateway", + api: { + id: "anthropic/claude-opus-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + test("anthropic models return anthropic thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4", @@ -3198,37 +3208,6 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) - - test("grok-4.3 returns reasoningEffort variants", () => { - const model = createMockModel({ - id: "xai/grok-4.3", - providerID: "xai", - api: { - id: "grok-4.3", - url: "https://api.x.ai", - npm: "@ai-sdk/xai", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) - expect(result.none).toEqual({ reasoningEffort: "none" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) - - test("grok-4-3 returns reasoningEffort variants", () => { - const model = createMockModel({ - id: "xai/grok-4-3-20260430", - providerID: "xai", - api: { - id: "grok-4-3-20260430", - url: "https://api.x.ai", - npm: "@ai-sdk/xai", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"]) - expect(result.medium).toEqual({ reasoningEffort: "medium" }) - }) }) describe("@ai-sdk/deepinfra", () => { @@ -3265,6 +3244,23 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) + + test("north-mini-code-1-0 returns only none and high", () => { + const model = createMockModel({ + id: "cohere/north-mini-code-1-0", + providerID: "cohere", + api: { + id: "North-Mini-Code-1-0-latest", + url: "https://api.cohere.com/compatibility/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + none: { reasoningEffort: "none" }, + high: { reasoningEffort: "high" }, + }) + }) }) describe("@ai-sdk/azure", () => { @@ -3315,20 +3311,25 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) }) - for (const id of ["gpt-5-4", "gpt-5-5"]) { - test(`${id} does not add minimal effort`, () => { + for (const testCase of [ + { id: "gpt-5-1", efforts: ["none", "low", "medium", "high"] }, + { id: "gpt-5-4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5.4", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { id: "gpt-5-5", efforts: ["none", "low", "medium", "high", "xhigh"] }, + ]) { + test(`${testCase.id} returns supported Azure reasoning efforts`, () => { const result = ProviderTransform.variants( createMockModel({ - id, + id: testCase.id, providerID: "azure", api: { - id, + id: testCase.id, url: "https://azure.com", npm: "@ai-sdk/azure", }, }), ) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(Object.keys(result)).toEqual(testCase.efforts) }) } }) @@ -3460,6 +3461,28 @@ describe("ProviderTransform.variants", () => { }) }) + describe("@ai-sdk/amazon-bedrock/mantle", () => { + test("gpt-5.5 returns OpenAI-style reasoning variants", () => { + const model = createMockModel({ + id: "openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + release_date: "2026-04-23", + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(result.medium).toEqual({ + reasoningEffort: "medium", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) + }) + describe("@ai-sdk/anthropic", () => { for (const testCase of [ { @@ -3486,6 +3509,18 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "opus 4.8", + apiIds: ["claude-opus-4-8", "claude-opus-4.8"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "fable 5", + apiIds: ["claude-fable-5"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { @@ -3555,6 +3590,30 @@ describe("ProviderTransform.variants", () => { }) }) + describe("@ai-sdk/google-vertex/anthropic", () => { + test("opus 4.8 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-4-8@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-4-8@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + }) + describe("@ai-sdk/amazon-bedrock", () => { test("anthropic sonnet 4.6 returns adaptive reasoning options", () => { const model = createMockModel({ @@ -3604,6 +3663,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-4.8", + providerID: "bedrock", + api: { + id: "anthropic.claude-opus-4.8", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -3749,143 +3830,119 @@ describe("ProviderTransform.variants", () => { }) describe("@jerome-benoit/sap-ai-provider-v2", () => { - test("anthropic models return thinking variants", () => { - const model = createMockModel({ - id: "sap-ai-core/anthropic--claude-sonnet-4", - providerID: "sap-ai-core", - api: { - id: "anthropic--claude-sonnet-4", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budgetTokens: 16000, - }, - }) - expect(result.max).toEqual({ - thinking: { - type: "enabled", - budgetTokens: 31999, - }, - }) - }) - - test("anthropic 4.6 models return adaptive thinking variants", () => { - const model = createMockModel({ - id: "sap-ai-core/anthropic--claude-sonnet-4-6", + const sapModel = (apiId: string, releaseDate = "2024-01-01") => + createMockModel({ + id: `sap-ai-core/${apiId}`, providerID: "sap-ai-core", api: { - id: "anthropic--claude-sonnet-4-6", + id: apiId, url: "https://api.ai.sap", npm: "@jerome-benoit/sap-ai-provider-v2", }, + release_date: releaseDate, }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) - expect(result.low).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "low", - }) - expect(result.max).toEqual({ - thinking: { - type: "adaptive", - }, - effort: "max", - }) - }) - test("gemini 2.5 models return thinkingConfig variants", () => { - const model = createMockModel({ - id: "sap-ai-core/gcp--gemini-2.5-pro", - providerID: "sap-ai-core", - api: { - id: "gcp--gemini-2.5-pro", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, - }, - }) - expect(result.max).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, - }, - }) - }) + for (const testCase of [ + { + name: "sonnet 4.6", + apiIds: ["anthropic--claude-sonnet-4-6"], + efforts: ["low", "medium", "high", "max"], + thinking: { type: "adaptive" }, + }, + { + name: "opus 4.6", + apiIds: ["anthropic--claude-4.6-opus", "anthropic--claude-4-6-opus"], + efforts: ["low", "medium", "high", "max"], + thinking: { type: "adaptive" }, + }, + { + name: "opus 4.7", + apiIds: ["anthropic--claude-4.7-opus", "anthropic--claude-4-7-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + { + name: "opus 4.8", + apiIds: ["anthropic--claude-4.8-opus", "anthropic--claude-4-8-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + ]) { + for (const apiId of testCase.apiIds) { + test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(testCase.efforts) + for (const effort of testCase.efforts) { + expect(result[effort]).toEqual({ + modelParams: { + thinking: testCase.thinking, + output_config: { effort }, + }, + }) + } + }) + } + } - test("gpt models return reasoningEffort variants", () => { - const model = createMockModel({ - id: "sap-ai-core/azure-openai--gpt-4o", - providerID: "sap-ai-core", - api: { - id: "azure-openai--gpt-4o", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const apiId of ["anthropic--claude-sonnet-4", "anthropic--claude-4.5-opus"]) { + test(`${apiId} returns budget_tokens variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + modelParams: { thinking: { type: "enabled", budget_tokens: 16000 } }, + }) + expect(result.max).toEqual({ + modelParams: { thinking: { type: "enabled", budget_tokens: 31999 } }, + }) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) + } - test("o-series models return reasoningEffort variants", () => { - const model = createMockModel({ - id: "sap-ai-core/azure-openai--o3-mini", - providerID: "sap-ai-core", - api: { - id: "azure-openai--o3-mini", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const testCase of [ + { apiId: "gemini-2.5-pro", maxBudget: 32768 }, + { apiId: "gemini-2.5-flash", maxBudget: 24576 }, + ]) { + test(`${testCase.apiId} returns thinkingConfig variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(testCase.apiId)) + expect(Object.keys(result)).toEqual(["high", "max"]) + expect(result.high).toEqual({ + modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } }, + }) + expect(result.max).toEqual({ + modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: testCase.maxBudget } }, + }) }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "medium", "high"]) - expect(result.low).toEqual({ reasoningEffort: "low" }) - expect(result.high).toEqual({ reasoningEffort: "high" }) - }) + } - test("sonar models return empty object", () => { - const model = createMockModel({ - id: "sap-ai-core/perplexity--sonar-pro", - providerID: "sap-ai-core", - api: { - id: "perplexity--sonar-pro", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const testCase of [ + { apiId: "gpt-5", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5-mini", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5-nano", releaseDate: "2025-08-07", efforts: ["minimal", "low", "medium", "high"] }, + { apiId: "gpt-5.4", releaseDate: "2026-01-15", efforts: ["none", "low", "medium", "high", "xhigh"] }, + { apiId: "azure-openai--o3-mini", releaseDate: "2024-01-01", efforts: ["low", "medium", "high"] }, + ]) { + test(`${testCase.apiId} returns reasoning_effort variants under modelParams`, () => { + const result = ProviderTransform.variants(sapModel(testCase.apiId, testCase.releaseDate)) + expect(Object.keys(result)).toEqual(testCase.efforts) + for (const effort of testCase.efforts) { + expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } }) + } }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) - }) + } - test("mistral models return empty object", () => { - const model = createMockModel({ - id: "sap-ai-core/mistral--mistral-large", - providerID: "sap-ai-core", - api: { - id: "mistral--mistral-large", - url: "https://api.ai.sap", - npm: "@jerome-benoit/sap-ai-provider-v2", - }, + for (const apiId of [ + "gemini-3.1-flash-lite", + "cohere--command-a-reasoning", + "sonar-deep-research", + "aws--llama-opus-4.7-fake", + ]) { + test(`${apiId} falls through to harmonized reasoning_effort fallback`, () => { + const result = ProviderTransform.variants(sapModel(apiId)) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + for (const effort of ["low", "medium", "high"]) { + expect(result[effort]).toEqual({ modelParams: { reasoning_effort: effort } }) + } }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) - }) + } }) describe("ai-gateway-provider (cloudflare-ai-gateway)", () => { @@ -3988,6 +4045,23 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { } }) +test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weakest effort is low", () => { + expect( + ProviderTransform.smallOptions({ + providerID: "openrouter", + api: { + id: "anthropic/claude-sonnet-4.6", + npm: "@openrouter/ai-sdk-provider", + }, + variants: { + low: { reasoning: { effort: "low" } }, + medium: { reasoning: { effort: "medium" } }, + high: { reasoning: { effort: "high" } }, + }, + } as any), + ).toEqual({ reasoning: { effort: "none" } }) +}) + describe("ProviderTransform.smallOptions - google thinking controls", () => { const createGoogleModel = (apiId: string) => { const model = { From efe93c1d340b14f22dcf52eefc2d3d7773ba7308 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:53:14 +0100 Subject: [PATCH 2/3] refactor(provider): remove generic LLM reasoning override - drop fork-only merge precedence for disabled reasoning options - keep tested Anthropic tool ordering guard while provider transforms align upstream --- packages/opencode/src/provider/transform.ts | 14 ++++ packages/opencode/src/session/llm.ts | 24 +----- packages/opencode/test/session/llm.test.ts | 86 +-------------------- 3 files changed, 18 insertions(+), 106 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index dbfa0e25d..42d03d7c7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -214,6 +214,20 @@ function normalizeMessages( return msg }) } + if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) { + msgs = msgs.flatMap((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg] + + const parts = msg.content + const first = parts.findIndex((part) => part.type === "tool-call") + if (first === -1) return [msg] + if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg] + return [ + { ...msg, content: parts.filter((part) => part.type !== "tool-call") }, + { ...msg, content: parts.filter((part) => part.type === "tool-call") }, + ] + }) + } if ( model.providerID === "mistral" || diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ddbf7f8ed..505fdcb5d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -29,28 +29,8 @@ export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX type Result = Awaited> // Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. -const mergeOptions = (target: Record, source: Record | undefined): Record => { - let sourceOptions = source ?? {} - if (isReasoningDisabled(target.reasoning) && source?.reasoning !== undefined) { - sourceOptions = { ...source } - delete sourceOptions.reasoning - } - const result = mergeDeep(target, sourceOptions) as Record - const reasoning = source?.reasoning - if (isReasoningDisabled(reasoning)) { - result.reasoning = reasoning - } - return result -} - -function isReasoningDisabled(value: unknown) { - return ( - typeof value === "object" && - value !== null && - "enabled" in value && - (value as Record).enabled === false - ) -} +const mergeOptions = (target: Record, source: Record | undefined): Record => + mergeDeep(target, source ?? {}) as Record export type StreamInput = { user: MessageV2.User diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 7335e0de9..aab67a154 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -319,7 +319,7 @@ function createEventResponse(chunks: unknown[], includeDone = false) { } describe("session.llm.stream", () => { - test("OpenRouter Gemini 2.5 none variant replaces the default reasoning budget", async () => { + test("OpenRouter Gemini 2.5 none variant does not send a stale default reasoning budget", async () => { const server = state.server if (!server) { throw new Error("Server not initialized") @@ -390,89 +390,7 @@ describe("session.llm.stream", () => { }) const capture = await request - expect(capture.body.reasoning).toEqual({ enabled: false }) - }, - }) - }) - - test("OpenRouter Gemini 2.5 disabled model reasoning overrides later variant budgets", async () => { - const server = state.server - if (!server) { - throw new Error("Server not initialized") - } - - const providerID = "openrouter" - const modelID = "google/gemini-2.5-flash" - const fixture = await loadFixture(providerID, modelID) - const model = fixture.model - - const request = waitRequest( - "/chat/completions", - new Response(createChatStream("Hello"), { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ) - - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: [providerID], - provider: { - [providerID]: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) - const sessionID = SessionID.make("session-test-openrouter-gemini-disabled") - const agent = { - name: "test", - mode: "primary", - options: {}, - permission: [{ permission: "*", pattern: "*", action: "allow" }], - } satisfies Agent.Info - - const user = { - id: MessageID.make("msg_user-openrouter-gemini-disabled"), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: agent.name, - model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, - } satisfies MessageV2.User - - await drain({ - user, - sessionID, - model: { - ...resolved, - options: { - ...resolved.options, - reasoning: { enabled: false }, - }, - }, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) - - const capture = await request - expect(capture.body.reasoning).toEqual({ enabled: false }) + expect(capture.body.reasoning).toBeUndefined() }, }) }) From 0565ce4d9b86cf59e7c6a85c34c55e66ac4ccdb5 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:05:58 +0100 Subject: [PATCH 3/3] fix(provider): preserve output token override Restore OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX while keeping the provider reasoning cleanup. Add a focused startup regression test for the exported token maximum. --- packages/opencode/src/provider/transform.ts | 3 +- .../opencode/test/provider/transform.test.ts | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 42d03d7c7..00729d628 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -4,6 +4,7 @@ import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models" import { iife } from "@/util/iife" +import { Flag } from "@opencode-ai/core/flag/flag" type Modality = NonNullable["input"][number] @@ -15,7 +16,7 @@ function mimeToModality(mime: string): Modality | undefined { return undefined } -export const OUTPUT_TOKEN_MAX = 32_000 +export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 // OpenAI Responses `include` value that returns the encrypted reasoning state // needed for stateless multi-turn reasoning (store: false). Hoisted so every diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4b462f892..4bbdccebf 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,7 +1,37 @@ import { describe, expect, test } from "bun:test" +import path from "node:path" import { ProviderTransform } from "@/provider/transform" import { ModelID, ProviderID } from "../../src/provider/schema" +describe("ProviderTransform.OUTPUT_TOKEN_MAX", () => { + test("honors OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX at startup", async () => { + const child = Bun.spawn({ + cmd: [ + process.execPath, + "--conditions=browser", + "-e", + `import { ProviderTransform } from "./src/provider/transform.ts"; process.stdout.write(String(ProviderTransform.OUTPUT_TOKEN_MAX))`, + ], + cwd: path.resolve(import.meta.dir, "../.."), + env: { + ...process.env, + OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: "12345", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + + expect(code).toBe(0) + expect(stderr).toBe("") + expect(stdout).toBe("12345") + }) +}) + describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123"