diff --git a/bun.lock b/bun.lock index 225c4e31a..95ec80de1 100644 --- a/bun.lock +++ b/bun.lock @@ -222,7 +222,7 @@ "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.5.1", + "@openrouter/ai-sdk-provider": "2.8.1", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1104,7 +1104,7 @@ "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3efc1fb25..35e8fe3c3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -115,7 +115,7 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.5.1", + "@openrouter/ai-sdk-provider": "2.8.1", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6cc56708c..342b054bf 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1208,7 +1208,7 @@ const layer: Layer.Layer< model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, - interleaved: model.interleaved ?? false, + interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? false, }, cost: { input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index da180581a..678dc3e20 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -45,6 +45,18 @@ function sdkKey(npm: string): string | undefined { return undefined } +function isDeepSeekModelID(id: string) { + return /(^|[/:])deepseek(?:[-/]|$)/.test(id.toLowerCase()) +} + +function isLegacyDeepSeekVariantID(id: string) { + return /(^|[/:])deepseek-(?:chat|reasoner|r1|v3)(?:[-/]|$)/.test(id.toLowerCase()) +} + +function isDeepSeekV4ID(id: string) { + return /(^|[/:])deepseek-v4(?:[-/]|$)/.test(id.toLowerCase()) +} + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -175,7 +187,28 @@ function normalizeMessages( return result } - if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) { + if (isDeepSeekModelID(model.api.id)) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant") return msg + if (Array.isArray(msg.content)) { + if (msg.content.some((part) => part.type === "reasoning")) return msg + return { ...msg, content: [...msg.content, { type: "reasoning", text: "" }] } + } + return { + ...msg, + content: [ + ...(msg.content ? [{ type: "text" as const, text: msg.content }] : []), + { type: "reasoning" as const, text: "" }, + ], + } + }) + } + + if ( + typeof model.capabilities.interleaved === "object" && + model.capabilities.interleaved.field && + model.api.npm !== "@openrouter/ai-sdk-provider" + ) { const field = model.capabilities.interleaved.field return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { @@ -400,7 +433,7 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + case "@ai-sdk/openai-compatible": { + const openaiCompatibleEfforts = [...WIDELY_SUPPORTED_EFFORTS] + if (isDeepSeekV4ID(model.api.id)) openaiCompatibleEfforts.push("max") + return Object.fromEntries(openaiCompatibleEfforts.map((effort) => [effort, { reasoningEffort: effort }])) + } case "@ai-sdk/azure": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure @@ -788,6 +824,13 @@ export function options(input: { }): Record { const result: Record = {} + if ( + input.model.api.npm === "@ai-sdk/google-vertex/anthropic" || + (!input.model.api.id.includes("claude") && input.model.api.npm === "@ai-sdk/anthropic") + ) { + result["toolStreaming"] = false + } + // openai and providers using openai package should set store to false by default. if ( input.model.providerID === "openai" || @@ -1023,6 +1066,26 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS } */ + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { + const sanitizeMoonshot = (obj: unknown): unknown => { + if (obj === null || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sanitizeMoonshot) + const result = Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)]), + ) as Record + if (typeof result.$ref === "string") { + const refOnly: Record = { $ref: result.$ref } + if (result.$defs && typeof result.$defs === "object") refOnly.$defs = result.$defs + if (result.definitions && typeof result.definitions === "object") refOnly.definitions = result.definitions + return refOnly + } + if (Array.isArray(result.items)) result.items = result.items[0] ?? {} + return result + } + + schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + } + // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { const isPlainObject = (node: unknown): node is Record => diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 084689c47..acefb5c0f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1161,6 +1161,42 @@ test("uses doubao-seed-2.0-code as the Volcano Coding Plan default model", async expect(Provider.defaultModelID(Provider.fromModelsDevProvider(provider))).toBe(VOLCENGINE_PLAN_DEFAULT_MODEL_ID) }) +test("config model overrides preserve existing interleaved metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + zhipuai: { + models: { + "glm-5": { + name: "GLM 5 Override", + }, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + set("ZHIPU_API_KEY", "test-zhipu-key") + }, + fn: async () => { + const providers = await list() + const model = providers[ProviderID.make("zhipuai")].models[ModelID.make("glm-5")] + + expect(model.name).toBe("GLM 5 Override") + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" }) + }, + }) +}) + test("does not change defaults for non-Volcano providers with the same Doubao model id", () => { const providers = { "qiniu-ai": { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e195d9b17..ee4ad7452 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -238,6 +238,73 @@ describe("ProviderTransform.options - google thinkingConfig gating", () => { }) }) +describe("ProviderTransform.options - Anthropic SDK tool streaming", () => { + const sessionID = "test-session-123" + + const createAnthropicSdkModel = (apiId: string, npm: "@ai-sdk/anthropic" | "@ai-sdk/google-vertex/anthropic") => + ({ + id: `test/${apiId}`, + providerID: npm === "@ai-sdk/google-vertex/anthropic" ? "google-vertex-anthropic" : "test", + api: { + id: apiId, + url: "https://api.test.com", + npm, + }, + name: apiId, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + 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: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("disables tool streaming for Google Vertex Anthropic", () => { + const result = ProviderTransform.options({ + model: createAnthropicSdkModel("claude-sonnet-4@20250514", "@ai-sdk/google-vertex/anthropic"), + sessionID, + providerOptions: {}, + }) + + expect(result.toolStreaming).toBe(false) + }) + + test("disables tool streaming for non-Claude models using the Anthropic SDK", () => { + const result = ProviderTransform.options({ + model: createAnthropicSdkModel("kimi-k2.5", "@ai-sdk/anthropic"), + sessionID, + providerOptions: {}, + }) + + expect(result.toolStreaming).toBe(false) + }) + + test("keeps tool streaming unset for Claude models using the Anthropic SDK", () => { + const result = ProviderTransform.options({ + model: createAnthropicSdkModel("claude-sonnet-4-5", "@ai-sdk/anthropic"), + sessionID, + providerOptions: {}, + }) + + expect(result.toolStreaming).toBeUndefined() + }) +}) + describe("ProviderTransform.options - gpt-5 textVerbosity", () => { const sessionID = "test-session-123" @@ -855,6 +922,124 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - Moonshot/Kimi tool schemas", () => { + const moonshotModel = { + providerID: "moonshotai", + api: { + id: "kimi-k2", + }, + } as any + + test("removes sibling keywords from referenced schemas", () => { + const schema = { + type: "object", + properties: { + variantOptions: { + $ref: "#/$defs/VariantOptions", + description: "Moonshot rejects this sibling after ref expansion.", + }, + }, + $defs: { + VariantOptions: { + description: "Referenced schema description stays here.", + type: "object", + }, + }, + } as any + + const result = ProviderTransform.schema(moonshotModel, schema) as any + + expect(result.properties.variantOptions).toEqual({ + $ref: "#/$defs/VariantOptions", + }) + expect(result.$defs.VariantOptions.description).toBe("Referenced schema description stays here.") + }) + + test("preserves root definitions when sanitizing referenced schemas", () => { + const result = ProviderTransform.schema( + moonshotModel, + { + $ref: "#/$defs/VariantOptions", + description: "Moonshot rejects this sibling after ref expansion.", + $defs: { + VariantOptions: { + type: "object", + properties: { + value: { + type: "string", + }, + }, + }, + }, + } as any, + ) as any + + expect(result).toEqual({ + $ref: "#/$defs/VariantOptions", + $defs: { + VariantOptions: { + type: "object", + properties: { + value: { + type: "string", + }, + }, + }, + }, + }) + }) + + test("also sanitizes Kimi models outside the Moonshot provider", () => { + const result = ProviderTransform.schema( + { + providerID: "openrouter", + api: { + id: "moonshotai/kimi-k2", + }, + } as any, + { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Moonshot rejects this sibling after ref expansion.", + }, + }, + $defs: { + Value: { + type: "object", + }, + }, + } as any, + ) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + }) + }) + + test("converts tuple-style array items to a single item schema", () => { + const result = ProviderTransform.schema( + moonshotModel, + { + type: "object", + properties: { + renderedSize: { + type: "array", + items: [{ type: "number" }, { type: "number" }], + minItems: 2, + maxItems: 2, + }, + }, + } as any, + ) as any + + expect(result.properties.renderedSize.items).toEqual({ + type: "number", + }) + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ @@ -977,6 +1162,122 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { ]) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) + + test("DeepSeek assistant array messages include empty reasoning when missing", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [{ type: "text", text: "Done." }], + }, + ] as any[], + { + id: ModelID.make("deepseek/deepseek-chat"), + providerID: ProviderID.make("deepseek"), + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + interleaved: false, + }, + } as any, + {}, + ) as any[] + + expect(result[0].content).toEqual([ + { type: "text", text: "Done." }, + { type: "reasoning", text: "" }, + ]) + }) + + test("DeepSeek assistant string messages include empty reasoning", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: "Done.", + }, + ] as any[], + { + id: ModelID.make("deepseek/deepseek-chat"), + providerID: ProviderID.make("deepseek"), + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + interleaved: false, + }, + } as any, + {}, + ) as any[] + + expect(result[0].content).toEqual([ + { type: "text", text: "Done." }, + { type: "reasoning", text: "" }, + ]) + }) + + test("DeepSeek-like substrings do not receive empty reasoning", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [{ type: "text", text: "Done." }], + }, + ] as any[], + { + id: ModelID.make("test/notdeepseek-chat"), + providerID: ProviderID.make("test"), + api: { + id: "notdeepseek-chat", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + interleaved: false, + }, + } as any, + {}, + ) as any[] + + expect(result[0].content).toEqual([{ type: "text", text: "Done." }]) + }) + + test("DeepSeek assistant messages keep existing reasoning", () => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Done." }, + ], + }, + ] as any[], + { + id: ModelID.make("deepseek/deepseek-chat"), + providerID: ProviderID.make("deepseek"), + api: { + id: "deepseek-chat", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + interleaved: false, + }, + } as any, + {}, + ) as any[] + + expect(result[0].content).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Done." }, + ]) + }) }) describe("ProviderTransform.message - empty image handling", () => { @@ -2071,7 +2372,7 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("deepseek returns empty object", () => { + test("DeepSeek Chat returns empty object", () => { const model = createMockModel({ id: "deepseek/deepseek-chat", providerID: "deepseek", @@ -2085,6 +2386,49 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) + test("DeepSeek V3 returns empty object", () => { + const model = createMockModel({ + id: "deepseek/deepseek-v3", + providerID: "deepseek", + api: { + id: "deepseek-v3", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("DeepSeek V4 supports max reasoning effort", () => { + const model = createMockModel({ + id: "deepseek/deepseek-v4", + providerID: "deepseek", + api: { + id: "deepseek-v4", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.max).toEqual({ reasoningEffort: "max" }) + }) + + test("DeepSeek-like substrings use standard OpenAI-compatible efforts", () => { + const model = createMockModel({ + id: "test/notdeepseek-v4", + providerID: "test", + api: { + id: "notdeepseek-v4", + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + test("minimax returns empty object", () => { const model = createMockModel({ id: "minimax/minimax-model", diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 8518d4112..0aba4f5d7 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" -import type { Provider } from "../../src/provider" +import { ProviderTransform, type Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { Question } from "../../src/question" @@ -836,6 +836,79 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) + test("preserves OpenRouter reasoning details through provider transform", async () => { + const assistantID = "m-assistant" + const openrouterModel: Provider.Model = { + ...model, + id: ModelID.make("deepseek/deepseek-v4-pro"), + providerID: ProviderID.make("openrouter"), + api: { + id: "deepseek/deepseek-v4-pro", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + capabilities: { + ...model.capabilities, + reasoning: true, + interleaved: { field: "reasoning_details" }, + }, + } + const reasoningDetails = [ + { + type: "reasoning.text", + text: "thinking", + format: "unknown", + index: 0, + }, + ] + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: openrouterModel.providerID, + modelID: openrouterModel.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + time: { start: 0 }, + metadata: { + openrouter: { + reasoning_details: reasoningDetails, + }, + }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "answer", + }, + ] as MessageV2.Part[], + }, + ] + + expect( + ProviderTransform.message(await MessageV2.toModelMessages(input, openrouterModel), openrouterModel, {}), + ).toStrictEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking", + providerOptions: { + openrouter: { + reasoning_details: reasoningDetails, + }, + }, + }, + { type: "text", text: "answer" }, + ], + }, + ]) + }) + test("converts pending/running tool calls to error results to prevent dangling tool_use", async () => { const userID = "m-user" const assistantID = "m-assistant"