From bbb91fa8ae56ecbf0e6ad4c3fe3bdc4a7f8bc696 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Mon, 20 Jul 2026 19:53:30 +0800 Subject: [PATCH] feat(ai): add Qwen Token Plan as built-in provider (#6858) Add Alibaba Cloud Model Studio Token Plan subscription service as two built-in API-key providers: qwen-token-plan (international, Singapore) and qwen-token-plan-cn (China, Beijing). Each provider exposes 15 text-generation models (Qwen, DeepSeek, GLM, Kimi, MiniMax) via the OpenAI-compatible endpoint with DashScope enable_thinking support. Model metadata is sourced from models.dev; qwen3.8-max-preview is hardcoded until models.dev includes it. Also fixes kimi-coding test references (k2p7 -> kimi-for-coding) after models.dev catalog update picked up by generate-models. Closes #6850 --- packages/ai/README.md | 2 + packages/ai/scripts/generate-models.ts | 74 +++++++++++++++++++ packages/ai/src/env-api-keys.ts | 2 + packages/ai/src/models.generated.ts | 4 + packages/ai/src/providers/all.ts | 4 + .../providers/qwen-token-plan-cn.models.ts | 68 +++++++++++++++++ .../ai/src/providers/qwen-token-plan-cn.ts | 15 ++++ .../src/providers/qwen-token-plan.models.ts | 68 +++++++++++++++++ packages/ai/src/providers/qwen-token-plan.ts | 15 ++++ packages/ai/src/types.ts | 2 + packages/ai/src/utils/overflow.ts | 3 + packages/ai/test/abort.test.ts | 24 ++++++ packages/ai/test/context-overflow.test.ts | 24 ++++++ .../ai/test/cross-provider-handoff.test.ts | 3 + packages/ai/test/empty.test.ts | 40 ++++++++++ packages/ai/test/image-tool-result.test.ts | 24 ++++++ .../openai-completions-tool-choice.test.ts | 12 +++ .../ai/test/qwen-token-plan-models.test.ts | 38 ++++++++++ packages/ai/test/stream.test.ts | 59 +++++++++++++++ packages/ai/test/tokens.test.ts | 16 ++++ .../ai/test/tool-call-without-result.test.ts | 16 ++++ packages/ai/test/total-tokens.test.ts | 50 +++++++++++++ packages/ai/test/unicode-surrogate.test.ts | 32 ++++++++ packages/coding-agent/docs/providers.md | 4 + packages/coding-agent/src/cli/args.ts | 2 + .../coding-agent/src/core/model-resolver.ts | 2 + test.sh | 2 + 27 files changed, 605 insertions(+) create mode 100644 packages/ai/src/providers/qwen-token-plan-cn.models.ts create mode 100644 packages/ai/src/providers/qwen-token-plan-cn.ts create mode 100644 packages/ai/src/providers/qwen-token-plan.models.ts create mode 100644 packages/ai/src/providers/qwen-token-plan.ts create mode 100644 packages/ai/test/qwen-token-plan-models.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index ee047aeb..3c791621 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -434,6 +434,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Hugging Face | `HF_TOKEN` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | +| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 95d11332..40e7275c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1825,6 +1825,57 @@ async function loadModelsDevData(): Promise[]> { } } + // Process Alibaba Cloud Model Studio Token Plan models + // Two regions (international / cn) with identical catalogs, separate + // endpoints and API keys (sk-sp- prefix). models.dev keys are + // "alibaba-token-plan[-cn]"; pi exposes them as "qwen-token-plan[-cn]". + const qwenTokenPlanCompat: OpenAICompletionsCompat = { + thinkingFormat: "qwen", + supportsDeveloperRole: false, + supportsStore: false, + }; + const qwenTokenPlanVariants = [ + { + source: "alibaba-token-plan", + provider: "qwen-token-plan", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + }, + { + source: "alibaba-token-plan-cn", + provider: "qwen-token-plan-cn", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + }, + ] as const; + + for (const { source, provider, baseUrl } of qwenTokenPlanVariants) { + const providerModels = data[source]?.models; + if (!providerModels) continue; + + for (const [modelId, model] of Object.entries(providerModels)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + compat: qwenTokenPlanCompat, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + console.log(`Loaded ${models.length} tool-capable models from models.dev`); return models; } catch (error) { @@ -2227,6 +2278,29 @@ 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/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 7a4f8f08..e6fa23dc 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -73,6 +73,8 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { const envMap: Record = { "ant-ling": "ANT_LING_API_KEY", + "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", + "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 0129ddee..035c50b0 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -27,6 +27,8 @@ import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts"; import { OPENCODE_MODELS } from "./providers/opencode.models.ts"; import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts"; import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts"; +import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts"; +import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts"; import { TOGETHER_MODELS } from "./providers/together.models.ts"; import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts"; import { XAI_MODELS } from "./providers/xai.models.ts"; @@ -64,6 +66,8 @@ export const MODELS = { "opencode": OPENCODE_MODELS, "opencode-go": OPENCODE_GO_MODELS, "openrouter": OPENROUTER_MODELS, + "qwen-token-plan": QWEN_TOKEN_PLAN_MODELS, + "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS, "together": TOGETHER_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, "xai": XAI_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 1261ed09..fd7bea58 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -29,6 +29,8 @@ import { opencodeProvider } from "./opencode.ts"; import { opencodeGoProvider } from "./opencode-go.ts"; import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; +import { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; +import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -103,6 +105,8 @@ export function builtinProviders(): Provider[] { opencodeProvider(), opencodeGoProvider(), openrouterProvider(), + qwenTokenPlanProvider(), + qwenTokenPlanCnProvider(), radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/qwen-token-plan-cn.models.ts b/packages/ai/src/providers/qwen-token-plan-cn.models.ts new file mode 100644 index 00000000..84976ef9 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-cn.models.ts @@ -0,0 +1,68 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan-cn.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QWEN_TOKEN_PLAN_CN_MODELS = values as { + "MiniMax-M2.5": Model<"openai-completions"> & { + id: "MiniMax-M2.5"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v3.2": Model<"openai-completions"> & { + id: "deepseek-v3.2"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v4-flash": Model<"openai-completions"> & { + id: "deepseek-v4-flash"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v4-pro": Model<"openai-completions"> & { + id: "deepseek-v4-pro"; + provider: "qwen-token-plan-cn"; + }; + "glm-5": Model<"openai-completions"> & { + id: "glm-5"; + provider: "qwen-token-plan-cn"; + }; + "glm-5.1": Model<"openai-completions"> & { + id: "glm-5.1"; + provider: "qwen-token-plan-cn"; + }; + "glm-5.2": Model<"openai-completions"> & { + id: "glm-5.2"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.6-flash": Model<"openai-completions"> & { + id: "qwen3.6-flash"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.6-plus": Model<"openai-completions"> & { + id: "qwen3.6-plus"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.7-max": Model<"openai-completions"> & { + id: "qwen3.7-max"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.7-plus": Model<"openai-completions"> & { + id: "qwen3.7-plus"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.8-max-preview": Model<"openai-completions"> & { + id: "qwen3.8-max-preview"; + provider: "qwen-token-plan-cn"; + }; +}; diff --git a/packages/ai/src/providers/qwen-token-plan-cn.ts b/packages/ai/src/providers/qwen-token-plan-cn.ts new file mode 100644 index 00000000..259b5a7f --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-cn.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_CN_MODELS } from "./qwen-token-plan-cn.models.ts"; + +export function qwenTokenPlanCnProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-cn", + name: "Qwen Token Plan CN", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan CN API key", ["QWEN_TOKEN_PLAN_CN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_CN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/qwen-token-plan.models.ts b/packages/ai/src/providers/qwen-token-plan.models.ts new file mode 100644 index 00000000..e8f91f01 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan.models.ts @@ -0,0 +1,68 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QWEN_TOKEN_PLAN_MODELS = values as { + "MiniMax-M2.5": Model<"openai-completions"> & { + id: "MiniMax-M2.5"; + provider: "qwen-token-plan"; + }; + "deepseek-v3.2": Model<"openai-completions"> & { + id: "deepseek-v3.2"; + provider: "qwen-token-plan"; + }; + "deepseek-v4-flash": Model<"openai-completions"> & { + id: "deepseek-v4-flash"; + provider: "qwen-token-plan"; + }; + "deepseek-v4-pro": Model<"openai-completions"> & { + id: "deepseek-v4-pro"; + provider: "qwen-token-plan"; + }; + "glm-5": Model<"openai-completions"> & { + id: "glm-5"; + provider: "qwen-token-plan"; + }; + "glm-5.1": Model<"openai-completions"> & { + id: "glm-5.1"; + provider: "qwen-token-plan"; + }; + "glm-5.2": Model<"openai-completions"> & { + id: "glm-5.2"; + provider: "qwen-token-plan"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "qwen-token-plan"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "qwen-token-plan"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "qwen-token-plan"; + }; + "qwen3.6-flash": Model<"openai-completions"> & { + id: "qwen3.6-flash"; + provider: "qwen-token-plan"; + }; + "qwen3.6-plus": Model<"openai-completions"> & { + id: "qwen3.6-plus"; + provider: "qwen-token-plan"; + }; + "qwen3.7-max": Model<"openai-completions"> & { + id: "qwen3.7-max"; + provider: "qwen-token-plan"; + }; + "qwen3.7-plus": Model<"openai-completions"> & { + id: "qwen3.7-plus"; + provider: "qwen-token-plan"; + }; + "qwen3.8-max-preview": Model<"openai-completions"> & { + id: "qwen3.8-max-preview"; + provider: "qwen-token-plan"; + }; +}; diff --git a/packages/ai/src/providers/qwen-token-plan.ts b/packages/ai/src/providers/qwen-token-plan.ts new file mode 100644 index 00000000..295560ba --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_MODELS } from "./qwen-token-plan.models.ts"; + +export function qwenTokenPlanProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan", + name: "Qwen Token Plan", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan API key", ["QWEN_TOKEN_PLAN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c123fd89..9fa1ab20 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -64,6 +64,8 @@ export type KnownProvider = | "kimi-coding" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" + | "qwen-token-plan" + | "qwen-token-plan-cn" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index c10ee80d..6554b0b0 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -31,6 +31,7 @@ import type { AssistantMessage } from "../types.ts"; * - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length" * with output=0 (no room left to generate). Detected via stopReason "length" + zero output + * input filling the context window. + * - DashScope/Qwen: "Range of input length should be [1, X]" (HTTP 400 invalid_parameter_error) * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" */ const OVERFLOW_PATTERNS = [ @@ -54,6 +55,7 @@ const OVERFLOW_PATTERNS = [ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /range of input length should be/i, // DashScope / Qwen Token Plan /context[_ ]length[_ ]exceeded/i, // Generic fallback /too many tokens/i, // Generic fallback /token limit exceeded/i, // Generic fallback @@ -101,6 +103,7 @@ const NON_OVERFLOW_PATTERNS = [ * - LM Studio: "greater than the context length" * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" * - DS4: "Prompt has X tokens, but the configured context size is Y tokens" + * - DashScope/Qwen: "Range of input length should be [1, X]" * * **Unreliable detection:** * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index c79cf540..df1904db 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -249,6 +249,30 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Abort", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 2a8b6284..ab5fd927 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -466,6 +466,30 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan", () => { + it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan", "qwen3.7-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { + it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_CN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + // ============================================================================= // Kimi For Coding // ============================================================================= diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 08da44de..f8151386 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -130,6 +130,9 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" }, + // Qwen Token Plan + { provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" }, + { provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 502764c9..0a0705f5 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -535,6 +535,46 @@ describe("AI Providers Empty Message Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 946a443a..150f3d8b 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -381,6 +381,30 @@ describe("Tool Results with Images", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider (qwen3.7-max)", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index b1f07773..a74849d8 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1088,6 +1088,18 @@ describe("openai-completions tool_choice", () => { } }); + it("stores Qwen Token Plan reasoning replay compat in built-in metadata", () => { + const providers = ["qwen-token-plan", "qwen-token-plan-cn"] as const; + + for (const provider of providers) { + const model = getModel(provider, "qwen3.7-max")!; + expect(model.compat?.thinkingFormat).toBe("qwen"); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined(); + expect(model.compat?.supportsDeveloperRole).toBe(false); + expect(model.compat?.supportsStore).toBe(false); + } + }); + it("replays Xiaomi MiMo assistant tool calls with empty reasoning_content when thinking is missing", async () => { const model = getModel("xiaomi", "mimo-v2.5-pro")!; const assistantMessage: AssistantMessage = { diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts new file mode 100644 index 00000000..ac5bbc6a --- /dev/null +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { getModels } from "../src/compat.ts"; + +const TEXT_MODELS = [ + "MiniMax-M2.5", + "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", +]; + +const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; + +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); + for (const expected of TEXT_MODELS) { + expect(modelIds, `${provider} should include ${expected}`).toContain(expected); + } + }); + + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("omits image models from %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const excluded of IMAGE_MODELS) { + expect(modelIds, `${provider} should not include ${excluded}`).not.toContain(excluded); + } + }); +}); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 484b7451..232df7a0 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1168,6 +1168,65 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)( + "Qwen Token Plan Provider (Qwen3.7-Max, international)", + () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Provider (Qwen3.7-Max, CN region)", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }); + describe.skipIf(!process.env.ANT_LING_API_KEY)("Ant Ling Provider (Ling 2.6 Flash via OpenAI Completions)", () => { const llm = getModel("ant-ling", "Ling-2.6-flash"); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 42e6932d..665e9009 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -276,6 +276,22 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + // ========================================================================= // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // ========================================================================= diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 439b9758..f089e7a8 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -254,6 +254,22 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider", () => { + const model = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { + const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { const model = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index ed5cae29..00ba4ab2 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -562,6 +562,56 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Qwen Token Plan + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan", () => { + it( + "qwen3.7-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + console.log(`\nQwen Token Plan / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + + // ========================================================================= + // Qwen Token Plan CN + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { + it( + "qwen3.7-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + console.log(`\nQwen Token Plan CN / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_CN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + // ========================================================================= // Kimi For Coding // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 8e059d1d..89f9602e 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -695,6 +695,38 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Unicode Handling", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 9517dd0c..e8758a3d 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -87,6 +87,8 @@ pi | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | +| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | `qwen-token-plan-cn` | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` | | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` | @@ -109,6 +111,8 @@ Store credentials in `~/.pi/agent/auth.json`: "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, "together": { "type": "api_key", "key": "..." }, + "qwen-token-plan": { "type": "api_key", "key": "sk-sp-..." }, + "qwen-token-plan-cn": { "type": "api_key", "key": "sk-sp-..." }, "xiaomi": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-ams": { "type": "api_key", "key": "..." }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 0ca33969..c194579c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -362,6 +362,8 @@ ${chalk.bold("Environment Variables:")} CLOUDFLARE_API_KEY - Cloudflare API token (Workers AI and AI Gateway) CLOUDFLARE_ACCOUNT_ID - Cloudflare account id (required for both) CLOUDFLARE_GATEWAY_ID - Cloudflare AI Gateway slug (required for AI Gateway) + QWEN_TOKEN_PLAN_API_KEY - Qwen Token Plan API key (international region) + QWEN_TOKEN_PLAN_CN_API_KEY - Qwen Token Plan API key (China region) XIAOMI_API_KEY - Xiaomi MiMo API key (api.xiaomimimo.com billing) XIAOMI_TOKEN_PLAN_CN_API_KEY - Xiaomi MiMo Token Plan API key (China region) XIAOMI_TOKEN_PLAN_AMS_API_KEY - Xiaomi MiMo Token Plan API key (Amsterdam region) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 1f4dceaf..bcf668e5 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -44,6 +44,8 @@ export const defaultModelPerProvider: Record = { "kimi-coding": "kimi-for-coding", "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", + "qwen-token-plan": "qwen3.7-max", + "qwen-token-plan-cn": "qwen3.7-max", xiaomi: "mimo-v2.5-pro", "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-ams": "mimo-v2.5-pro", diff --git a/test.sh b/test.sh index 9b563b79..e6315a0e 100755 --- a/test.sh +++ b/test.sh @@ -55,6 +55,8 @@ unset XIAOMI_API_KEY unset XIAOMI_TOKEN_PLAN_CN_API_KEY unset XIAOMI_TOKEN_PLAN_AMS_API_KEY unset XIAOMI_TOKEN_PLAN_SGP_API_KEY +unset QWEN_TOKEN_PLAN_API_KEY +unset QWEN_TOKEN_PLAN_CN_API_KEY unset RADIUS_API_KEY unset PI_GATEWAY unset PI_EXPERIMENTAL