diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 673cd8917..a7fd5d6c4 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -265,6 +265,33 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { high: "high", max: "max", } as const; +const QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP = { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", +} as const; +const QWEN_TOKEN_PLAN_QWEN38_THINKING_LEVEL_MAP = { + minimal: null, + low: "low", + medium: "medium", + high: null, + xhigh: "xhigh", + max: null, +} as const; +const QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS = new Set([ + "MiniMax-M2.5", + "deepseek-v3.2", + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7-code", + "qwen3.6-flash", + "qwen3.6-plus", + "qwen3.7-max", + "qwen3.7-plus", +]); const KIMI_K3_MAX_TOKENS = 131072; const KIMI_K3_COST = { @@ -1971,6 +1998,7 @@ async function loadModelsDevData(): Promise[]> { thinkingFormat: "qwen", supportsDeveloperRole: false, supportsStore: false, + supportsReasoningEffort: true, }; const qwenTokenPlanVariants = [ { @@ -1992,6 +2020,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(providerModels)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + const supportsReasoningEffort = !QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS.has(modelId); models.push({ id: modelId, @@ -1999,7 +2028,17 @@ async function loadModelsDevData(): Promise[]> { api: "openai-completions", provider, baseUrl, - compat: qwenTokenPlanCompat, + compat: supportsReasoningEffort + ? qwenTokenPlanCompat + : { ...qwenTokenPlanCompat, supportsReasoningEffort: false }, + ...(supportsReasoningEffort + ? { + thinkingLevelMap: + modelId === "qwen3.8-max-preview" + ? QWEN_TOKEN_PLAN_QWEN38_THINKING_LEVEL_MAP + : QWEN_TOKEN_PLAN_HIGH_MAX_THINKING_LEVEL_MAP, + } + : {}), reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -2274,7 +2313,12 @@ async function generateModels() { allModels.push(...antLingModels); for (const candidate of allModels) { - if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { + if ( + candidate.api === "openai-completions" && + candidate.id.includes("deepseek-v4") && + candidate.provider !== "qwen-token-plan" && + candidate.provider !== "qwen-token-plan-cn" + ) { const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { ...candidate.compat, @@ -2417,29 +2461,6 @@ async function generateModels() { }); } - // Add qwen3.8-max-preview to Qwen Token Plan providers until models.dev includes it - for (const qwenTpProvider of ["qwen-token-plan", "qwen-token-plan-cn"] as const) { - if (!allModels.some((m) => m.provider === qwenTpProvider && m.id === "qwen3.8-max-preview")) { - const baseUrl = - qwenTpProvider === "qwen-token-plan" - ? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" - : "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; - allModels.push({ - id: "qwen3.8-max-preview", - name: "Qwen3.8 Max Preview", - api: "openai-completions", - provider: qwenTpProvider, - baseUrl, - compat: { thinkingFormat: "qwen", supportsDeveloperRole: false, supportsStore: false } satisfies OpenAICompletionsCompat, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 65536, - }); - } - } - // Add "auto" alias for openrouter/auto if (!allModels.some(m => m.provider === "openrouter" && m.id === "auto")) { allModels.push({ diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 9e81f1e34..410d5fa5e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -752,6 +752,12 @@ function buildParams( } } else if (compat.thinkingFormat === "qwen" && model.reasoning) { (params as any).enable_thinking = !!options?.reasoningEffort; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + const effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + if (typeof effort === "string") { + (params as any).reasoning_effort = effort; + } + } } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { (params as any).chat_template_kwargs = { enable_thinking: !!options?.reasoningEffort, diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts index ac5bbc6a9..cf3d9ff1a 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -1,5 +1,42 @@ -import { describe, expect, it } from "vitest"; -import { getModels } from "../src/compat.ts"; +import { describe, expect, it, vi } from "vitest"; +import { getModels, streamSimple } from "../src/compat.ts"; + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: () => { + const stream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + } + + return { default: FakeOpenAI }; +}); const TEXT_MODELS = [ "MiniMax-M2.5", @@ -21,6 +58,33 @@ const TEXT_MODELS = [ const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; +const QWEN_THINKING_MODELS = [ + "deepseek-v3.2", + "deepseek-v4-flash", + "deepseek-v4-pro", + "glm-5", + "glm-5.1", + "glm-5.2", + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7-code", + "qwen3.6-flash", + "qwen3.6-plus", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max-preview", +] as const; + +const QWEN_THINKING_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), +); + +const QWEN_REASONING_EFFORT_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "glm-5.2"] as const; + +const QWEN_REASONING_EFFORT_MODEL_CASES = (["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), +); + describe("Qwen Token Plan models", () => { it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("exposes all text models on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); @@ -35,4 +99,140 @@ describe("Qwen Token Plan models", () => { expect(modelIds, `${provider} should not include ${excluded}`).not.toContain(excluded); } }); + + // docs: https://modelstudio.console.alibabacloud.com/ap-southeast-1?tab=api&commonbuy=1#/api/?type=model&url=3016807 + it.each(QWEN_THINKING_MODEL_CASES)( + "sends Qwen thinking fields for $provider/$modelId", + async ({ provider, modelId }) => { + const model = getModels(provider).find((candidate) => candidate.id === modelId); + expect(model).toBeDefined(); + if (!model) throw new Error(`Missing model: ${provider}/${modelId}`); + + let payload: unknown; + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params) => { + payload = params; + }, + }, + ).result(); + + expect(payload).toHaveProperty("enable_thinking", true); + expect(payload).not.toHaveProperty("thinking"); + }, + ); + + it.each(QWEN_REASONING_EFFORT_MODEL_CASES)( + "exposes Qwen reasoning_effort levels for $provider/$modelId", + ({ provider, modelId }) => { + const model = getModels(provider).find((candidate) => candidate.id === modelId); + expect(model).toBeDefined(); + if (!model) throw new Error(`Missing model: ${provider}/${modelId}`); + + expect(model.thinkingLevelMap).toMatchObject({ + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + }, + ); + + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + "exposes qwen3.8 reasoning_effort levels on %s", + (provider) => { + const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max-preview"); + expect(model).toBeDefined(); + if (!model) throw new Error(`Missing model: ${provider}/qwen3.8-max-preview`); + + expect(model.thinkingLevelMap).toMatchObject({ + minimal: null, + low: "low", + medium: "medium", + high: null, + xhigh: "xhigh", + max: null, + }); + }, + ); + + it.each(QWEN_REASONING_EFFORT_MODEL_CASES)( + "sends Qwen reasoning_effort for $provider/$modelId", + async ({ provider, modelId }) => { + const model = getModels(provider).find((candidate) => candidate.id === modelId); + expect(model).toBeDefined(); + if (!model) throw new Error(`Missing model: ${provider}/${modelId}`); + + let payload: unknown; + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + reasoning: "high", + onPayload: (params) => { + payload = params; + }, + }, + ).result(); + + expect(payload).toHaveProperty("reasoning_effort", "high"); + }, + ); + + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + "sends qwen3.8 max reasoning_effort on %s", + async (provider) => { + const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max-preview"); + expect(model).toBeDefined(); + if (!model) throw new Error(`Missing model: ${provider}/qwen3.8-max-preview`); + + let payload: unknown; + await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Hi", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: "test", + reasoning: "xhigh", + onPayload: (params) => { + payload = params; + }, + }, + ).result(); + + expect(payload).toHaveProperty("enable_thinking", true); + expect(payload).toHaveProperty("reasoning_effort", "xhigh"); + expect(payload).not.toHaveProperty("thinking"); + }, + ); });