From c03d78bdce7b154672084dcf286f4fbe2de466ac Mon Sep 17 00:00:00 2001 From: Mehmet Aras Date: Thu, 6 Aug 2026 18:18:02 +0300 Subject: [PATCH] feat(ai): add Qwen Token Plan Individual provider (#7659) --- packages/ai/README.md | 9 +- packages/ai/scripts/generate-models.ts | 51 ++++++++--- packages/ai/scripts/model-data.ts | 7 ++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 3 + packages/ai/src/providers/all.ts | 2 + .../qwen-token-plan-individual.models.ts | 8 ++ .../providers/qwen-token-plan-individual.ts | 15 ++++ packages/ai/src/types.ts | 1 + packages/ai/test/abort.test.ts | 12 +++ packages/ai/test/context-overflow.test.ts | 12 +++ .../ai/test/cross-provider-handoff.test.ts | 15 ++++ packages/ai/test/empty.test.ts | 20 +++++ .../ai/test/generate-models-strict.test.ts | 85 +++++++++++++++++++ packages/ai/test/image-tool-result.test.ts | 12 +++ .../ai/test/model-data-validation.test.ts | 13 +++ .../openai-completions-tool-choice.test.ts | 2 +- .../ai/test/qwen-token-plan-models.test.ts | 55 ++++++++++-- packages/ai/test/stream.test.ts | 31 +++++++ packages/ai/test/tokens.test.ts | 8 ++ .../ai/test/tool-call-without-result.test.ts | 8 ++ packages/ai/test/total-tokens.test.ts | 25 ++++++ packages/ai/test/unicode-surrogate.test.ts | 16 ++++ packages/coding-agent/docs/providers.md | 9 +- .../coding-agent/src/core/model-resolver.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 4 + 26 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 packages/ai/src/providers/qwen-token-plan-individual.models.ts create mode 100644 packages/ai/src/providers/qwen-token-plan-individual.ts create mode 100644 packages/ai/test/generate-models-strict.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 48bee3586..16b3be096 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -85,6 +85,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and - **OpenCode Go** - **Fireworks** (uses OpenAI- and Anthropic-compatible APIs) - **Kimi For Coding** (Moonshot AI subscription endpoint, uses Anthropic-compatible API) +- **Qwen Token Plan** (separate Individual and existing catalogs, with a separate China provider) - **Xiaomi MiMo** (defaults to API billing endpoint, with separate Token Plan providers for `cn`/`ams`/`sgp` regions) - **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc. @@ -438,7 +439,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 (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (Individual) | `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` | @@ -446,6 +448,11 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | | GitHub Copilot | `COPILOT_GITHUB_TOKEN` | +`qwen-token-plan-individual` and `qwen-token-plan` share the international endpoint and +`QWEN_TOKEN_PLAN_API_KEY`. The Individual provider exposes only the models documented for Individual +subscriptions, while the existing provider retains its broader catalog for backward compatibility. +Stored credentials remain provider-scoped, so save the key under the provider ID you register. + Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files. ## Tools diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 754662bcb..90e6bbb2f 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -20,6 +20,7 @@ import type { OpenAIResponsesCompat, } from "../src/types.ts"; import { + assertExactModelIds, createModelDataManifest, type ModelDataStructure, MODEL_DATA_MANIFEST_FILE, @@ -295,6 +296,23 @@ const QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS = new Set([ ]); // Retired preview id — models.dev may still list it after GA ships. const QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS = new Set(["qwen3.8-max-preview"]); +const QWEN_TOKEN_PLAN_PROVIDER_IDS = new Set([ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", +]); +// QwenCloud Token Plan Individual text-model allowlist, verified 2026-08-05. +// Retired models remain excluded above even if the public catalog lags. +// https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview +const QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS = new Set([ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]); const KIMI_K3_MAX_TOKENS = 131072; const KIMI_K3_COST = { @@ -2146,10 +2164,11 @@ 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]". + // Process Alibaba Cloud Model Studio Token Plan models. International and + // China use separate endpoints and API keys (sk-sp- prefix). The Individual + // provider reuses the international source and endpoint with a narrower catalog. + // models.dev keys are "alibaba-token-plan[-cn]"; pi exposes them as + // "qwen-token-plan[-cn]" plus the Individual catalog view. const qwenTokenPlanCompat: OpenAICompletionsCompat = { thinkingFormat: "qwen", supportsDeveloperRole: false, @@ -2161,22 +2180,31 @@ async function loadModelsDevData(): Promise[]> { source: "alibaba-token-plan", provider: "qwen-token-plan", baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, + }, + { + source: "alibaba-token-plan", + provider: "qwen-token-plan-individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + modelIds: QWEN_TOKEN_PLAN_INDIVIDUAL_MODEL_IDS, }, { source: "alibaba-token-plan-cn", provider: "qwen-token-plan-cn", baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + modelIds: undefined, }, ] as const; - for (const { source, provider, baseUrl } of qwenTokenPlanVariants) { + for (const { source, provider, baseUrl, modelIds } of qwenTokenPlanVariants) { const providerModels = data[source]?.models; - if (!providerModels) continue; + const emittedModelIds = modelIds ? new Set() : undefined; - for (const [modelId, model] of Object.entries(providerModels)) { + for (const [modelId, model] of Object.entries(providerModels ?? {})) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; if (QWEN_TOKEN_PLAN_EXCLUDED_MODEL_IDS.has(modelId)) continue; + if (modelIds && !modelIds.has(modelId)) continue; const supportsReasoningEffort = !QWEN_TOKEN_PLAN_REASONING_EFFORT_UNSUPPORTED_MODEL_IDS.has(modelId); models.push({ @@ -2207,8 +2235,13 @@ async function loadModelsDevData(): Promise[]> { contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, }); + emittedModelIds?.add(modelId); recordModelsDevReasoningOptions(provider, modelId, m); } + + if (modelIds && emittedModelIds && generatorOptions.strict) { + assertExactModelIds(provider, modelIds, emittedModelIds); + } } console.log(`Loaded ${models.length} tool-capable models from models.dev`); @@ -2313,7 +2346,6 @@ async function generateModels() { } } - // Add missing gpt models const missingOpenAiModels: Model<"openai-responses">[] = [ { @@ -2474,8 +2506,7 @@ async function generateModels() { if ( candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4") && - candidate.provider !== "qwen-token-plan" && - candidate.provider !== "qwen-token-plan-cn" + !QWEN_TOKEN_PLAN_PROVIDER_IDS.has(candidate.provider) ) { const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; candidate.compat = { diff --git a/packages/ai/scripts/model-data.ts b/packages/ai/scripts/model-data.ts index 4dd94000e..f2081817a 100644 --- a/packages/ai/scripts/model-data.ts +++ b/packages/ai/scripts/model-data.ts @@ -39,6 +39,13 @@ function describeSetDifference(expected: readonly string[], actual: readonly str .join("; "); } +export function assertExactModelIds(label: string, expected: Iterable, actual: Iterable): void { + const expectedIds = Array.from(new Set(expected)).sort(); + const actualIds = Array.from(new Set(actual)).sort(); + if (sameStrings(expectedIds, actualIds)) return; + throw new Error(`${label} model IDs do not match (${describeSetDifference(expectedIds, actualIds)})`); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index f535f53ab..5b952b7b5 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -80,6 +80,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "ant-ling": "ANT_LING_API_KEY", "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", + "qwen-token-plan-individual": "QWEN_TOKEN_PLAN_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 8832e4c83..129520459 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -30,6 +30,7 @@ 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 { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.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"; @@ -70,6 +71,7 @@ export const MODELS: { readonly "openrouter": typeof OPENROUTER_MODELS; readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS; readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS; + readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS; readonly "together": typeof TOGETHER_MODELS; readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS; readonly "xai": typeof XAI_MODELS; @@ -109,6 +111,7 @@ export const MODELS: { "openrouter": OPENROUTER_MODELS, "qwen-token-plan": QWEN_TOKEN_PLAN_MODELS, "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS, + "qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_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 5e62f8d0a..8785e94ae 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -33,6 +33,7 @@ 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 { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -116,6 +117,7 @@ export function builtinProviders(): Provider[] { openrouterProvider(), qwenTokenPlanProvider(), qwenTokenPlanCnProvider(), + qwenTokenPlanIndividualProvider(), radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/qwen-token-plan-individual.models.ts b/packages/ai/src/providers/qwen-token-plan-individual.models.ts new file mode 100644 index 000000000..30e111a29 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.models.ts @@ -0,0 +1,8 @@ +// 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-individual.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ModelCatalog = + flattenModelCatalog("qwen-token-plan-individual", values); diff --git a/packages/ai/src/providers/qwen-token-plan-individual.ts b/packages/ai/src/providers/qwen-token-plan-individual.ts new file mode 100644 index 000000000..a231d1b31 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual.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_INDIVIDUAL_MODELS } from "./qwen-token-plan-individual.models.ts"; + +export function qwenTokenPlanIndividualProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-individual", + name: "Qwen Token Plan Individual", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan Individual API key", ["QWEN_TOKEN_PLAN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 1cdacdbae..75e1d6f3c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -68,6 +68,7 @@ export type KnownProvider = | "cloudflare-ai-gateway" | "qwen-token-plan" | "qwen-token-plan-cn" + | "qwen-token-plan-individual" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index dedee5cc3..767be7d04 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -273,6 +273,18 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Abort", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 6aefb26fa..59a3fb6be 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -479,6 +479,18 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it("qwen3.8-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 1746411c7..ada2b536f 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -135,6 +135,21 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ // 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" }, + { + provider: "qwen-token-plan-individual", + model: "qwen3.8-max", + label: "qwen-token-plan-individual-qwen3.8-max", + }, + { + provider: "qwen-token-plan-individual", + model: "deepseek-v4-flash-0731", + label: "qwen-token-plan-individual-deepseek-v4-flash-0731", + }, + { + provider: "qwen-token-plan-individual", + model: "glm-5.2", + label: "qwen-token-plan-individual-glm-5.2", + }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 310da0508..b45ceed68 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -576,6 +576,26 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/generate-models-strict.test.ts b/packages/ai/test/generate-models-strict.test.ts new file mode 100644 index 000000000..caf75c477 --- /dev/null +++ b/packages/ai/test/generate-models-strict.test.ts @@ -0,0 +1,85 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("strict model generation", () => { + it("fails before mutating generated data when an Individual model loses tool support", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "pi-generate-models-")); + temporaryRoots.push(fixtureRoot); + const isolatedPackageRoot = join(fixtureRoot, "package"); + mkdirSync(isolatedPackageRoot); + for (const entry of ["package.json", "scripts", "src"]) { + cpSync(join(packageRoot, entry), join(isolatedPackageRoot, entry), { recursive: true }); + } + const preloadPath = join(fixtureRoot, "mock-models-dev.mjs"); + const modelIds = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", + "qwen3.8-max-preview", + ]; + const sourceModels = Object.fromEntries( + modelIds.map((id) => [ + id, + { + id, + name: id, + tool_call: id !== "deepseek-v4-flash-0731", + }, + ]), + ); + const catalog = { "alibaba-token-plan": { models: sourceModels } }; + writeFileSync( + preloadPath, + `const catalog = ${JSON.stringify(catalog)};\n` + + `globalThis.fetch = async (input) => {\n` + + ` if (String(input) === "https://models.dev/api.json") {\n` + + ` return new Response(JSON.stringify(catalog), { status: 200 });\n` + + ` }\n` + + ` throw new Error(\`Unexpected fetch: \${String(input)}\`);\n` + + `};\n`, + ); + + const generatedPaths = [ + "src/models.generated.ts", + "src/providers/qwen-token-plan-individual.models.ts", + "src/providers/data/qwen-token-plan-individual.json", + "src/providers/data/.manifest.json", + ]; + const sourceBefore = generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8")); + const isolatedBefore = generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8")); + + const result = spawnSync( + process.execPath, + ["--import", pathToFileURL(preloadPath).href, "scripts/generate-models.ts", "--strict"], + { + cwd: isolatedPackageRoot, + encoding: "utf8", + timeout: 10_000, + }, + ); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "qwen-token-plan-individual model IDs do not match (missing: deepseek-v4-flash-0731)", + ); + expect(generatedPaths.map((path) => readFileSync(join(isolatedPackageRoot, path), "utf8"))).toEqual( + isolatedBefore, + ); + expect(generatedPaths.map((path) => readFileSync(join(packageRoot, path), "utf8"))).toEqual(sourceBefore); + }); +}); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 8b77e793f..a331f2a34 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -406,6 +406,18 @@ describe("Tool Results with Images", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider (qwen3.8-max)", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/model-data-validation.test.ts b/packages/ai/test/model-data-validation.test.ts index fa84fd1ff..f0e1b9fbc 100644 --- a/packages/ai/test/model-data-validation.test.ts +++ b/packages/ai/test/model-data-validation.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + assertExactModelIds, createModelDataManifest, MODEL_DATA_MANIFEST_FILE, MODEL_DATA_SCHEMA_VERSION, @@ -77,6 +78,18 @@ function writeFixtureData( } describe("generated model data validation", () => { + it("rejects a missing upstream model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("qwen-token-plan-individual", ["model-a", "model-b"], ["model-a"])).toThrow( + "qwen-token-plan-individual model IDs do not match (missing: model-b)", + ); + }); + + it("rejects an unexpected model from an exact generated allowlist", () => { + expect(() => assertExactModelIds("test-provider", ["model-a"], ["model-a", "model-b"])).toThrow( + "test-provider model IDs do not match (extra: model-b)", + ); + }); + it("reads and validates API-grouped model data", () => { const { dataDir, packageRoot, structure } = createFixture(); expect(readModelDataStructure(packageRoot)).toEqual(structure); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index f21b74859..844c819c6 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1127,7 +1127,7 @@ 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; + const providers = ["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const; for (const provider of providers) { const model = getModel(provider, "qwen3.7-max")!; diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts index 98c31cc4a..177057057 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { getModels, streamSimple } from "../src/compat.ts"; +import { findEnvKeys } from "../src/env-api-keys.ts"; vi.mock("openai", () => { class FakeOpenAI { @@ -56,6 +57,16 @@ const TEXT_MODELS = [ "qwen3.8-max", ]; +const INDIVIDUAL_TEXT_MODELS = [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", +]; + const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; const QWEN_THINKING_MODELS = [ @@ -75,17 +86,43 @@ const QWEN_THINKING_MODELS = [ "qwen3.8-max", ] 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 })), -); +type QwenTokenPlanProvider = "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual"; +type QwenTokenPlanModelCase = { provider: QwenTokenPlanProvider; modelId: string }; + +const QWEN_THINKING_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...INDIVIDUAL_TEXT_MODELS.map((modelId) => ({ provider: "qwen-token-plan-individual" as const, 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 })), -); +const QWEN_REASONING_EFFORT_MODEL_CASES: QwenTokenPlanModelCase[] = [ + ...(["qwen-token-plan", "qwen-token-plan-cn"] as const).flatMap((provider) => + QWEN_REASONING_EFFORT_MODELS.map((modelId) => ({ provider, modelId })), + ), + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "glm-5.2"].map((modelId) => ({ + provider: "qwen-token-plan-individual" as const, + modelId, + })), +]; describe("Qwen Token Plan models", () => { + it("exposes exactly the documented Individual text models", () => { + const modelIds = getModels("qwen-token-plan-individual") + .map((model) => model.id) + .sort(); + + expect(modelIds).toEqual([...INDIVIDUAL_TEXT_MODELS].sort()); + }); + + it("reuses the international Token Plan environment variable", () => { + expect(findEnvKeys("qwen-token-plan-individual", { QWEN_TOKEN_PLAN_API_KEY: "test" })).toEqual([ + "QWEN_TOKEN_PLAN_API_KEY", + ]); + }); + 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) { @@ -152,7 +189,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "exposes qwen3.8 reasoning_effort levels on %s", (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); @@ -170,7 +207,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "omits retired qwen3.8-max-preview on %s", (provider) => { const modelIds = getModels(provider).map((model) => model.id); @@ -210,7 +247,7 @@ describe("Qwen Token Plan models", () => { }, ); - it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)( + it.each(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const)( "sends qwen3.8 max reasoning_effort on %s", async (provider) => { const model = getModels(provider).find((candidate) => candidate.id === "qwen3.8-max"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index f4e5b4890..cbfb55606 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1224,6 +1224,37 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)( + "Qwen Token Plan Individual Provider (Qwen3.8-Max, international)", + () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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 = { diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 57930f012..95a64c3d1 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -292,6 +292,14 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index ec59c13cd..57945ccad 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -270,6 +270,14 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider", () => { + const model = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index f6785f223..ae4255e10 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -605,6 +605,31 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Qwen Token Plan Individual + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual", () => { + it( + "qwen3.8-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-max"); + + console.log(`\nQwen Token Plan Individual / ${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 // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index a2b47ba2c..b9113c381 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -728,6 +728,22 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan-individual", "qwen3.8-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"); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index cac9dc202..e86f99ce3 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -96,7 +96,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 (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan-individual` | | 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` | @@ -121,6 +122,7 @@ Store credentials in `~/.pi/agent/auth.json`: "opencode-go": { "type": "api_key", "key": "..." }, "together": { "type": "api_key", "key": "..." }, "qwen-token-plan": { "type": "api_key", "key": "sk-sp-..." }, + "qwen-token-plan-individual": { "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": "..." }, @@ -129,6 +131,11 @@ Store credentials in `~/.pi/agent/auth.json`: } ``` +`qwen-token-plan-individual` uses the same international endpoint and `QWEN_TOKEN_PLAN_API_KEY` as +`qwen-token-plan`, but limits the picker to the models documented for Individual subscriptions. The existing +provider keeps its broader catalog for backward compatibility. When using `auth.json`, store the +credential under the provider you select; an environment variable is shared by both international providers. + The file is created with `0600` permissions (user read/write only). Auth file credentials take priority over environment variables. API key credentials can also include provider-scoped environment values. These values are used before process environment variables when resolving the credential key, provider/model headers, and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`. diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 7a35558e9..eb70a573b 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -53,6 +53,7 @@ export const defaultModelPerProvider: Record = { "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", "qwen-token-plan": "qwen3.7-max", "qwen-token-plan-cn": "qwen3.7-max", + "qwen-token-plan-individual": "qwen3.8-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/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 508c949ba..f756172d4 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -711,6 +711,10 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("qwen token plan individual default tracks current model", () => { + expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); + }); + test("findInitialModel accepts explicit provider custom model ids", async () => { const registry = { getModels: () => allModels,