diff --git a/packages/ai/README.md b/packages/ai/README.md index 23896ba97..bc085705e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -524,6 +524,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | 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` | +| Qwen Token Plan (Individual, 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` | @@ -533,6 +534,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex `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. +`qwen-token-plan-individual-cn` applies the same Individual catalog to the China endpoint and +`QWEN_TOKEN_PLAN_CN_API_KEY`. 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. diff --git a/packages/ai/catalog/families/deepseek.ts b/packages/ai/catalog/families/deepseek.ts index 630ed51c9..42b748898 100644 --- a/packages/ai/catalog/families/deepseek.ts +++ b/packages/ai/catalog/families/deepseek.ts @@ -30,7 +30,12 @@ const AZURE_DEEPSEEK_V4_THINKING_LEVEL_MAP = { } as const; // Qwen Token Plan serves DeepSeek V4 through Qwen's own thinking format. -const QWEN_FORMAT_PROVIDERS = new Set(["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"]); +const QWEN_FORMAT_PROVIDERS = new Set([ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", + "qwen-token-plan-individual-cn", +]); // These hosts accept native reasoning efforts instead of DeepSeek's thinking toggle. const NATIVE_EFFORT_PROVIDERS = new Set(["openrouter", "opencode"]); diff --git a/packages/ai/catalog/providers/index.ts b/packages/ai/catalog/providers/index.ts index 60b29cbd5..e76299b78 100644 --- a/packages/ai/catalog/providers/index.ts +++ b/packages/ai/catalog/providers/index.ts @@ -32,6 +32,7 @@ import openrouter from "./openrouter.ts"; import qwenTokenPlan from "./qwen-token-plan.ts"; import qwenTokenPlanCn from "./qwen-token-plan-cn.ts"; import qwenTokenPlanIndividual from "./qwen-token-plan-individual.ts"; +import qwenTokenPlanIndividualCn from "./qwen-token-plan-individual-cn.ts"; import radius from "./radius.ts"; import together from "./together.ts"; import typesafe from "./typesafe.ts"; @@ -75,6 +76,7 @@ export const PROVIDERS: readonly ProviderDefinition[] = [ openrouter, qwenTokenPlanCn, qwenTokenPlanIndividual, + qwenTokenPlanIndividualCn, qwenTokenPlan, radius, together, diff --git a/packages/ai/catalog/providers/qwen-token-plan-cn.ts b/packages/ai/catalog/providers/qwen-token-plan-cn.ts index 33b210dd9..6ac244e52 100644 --- a/packages/ai/catalog/providers/qwen-token-plan-cn.ts +++ b/packages/ai/catalog/providers/qwen-token-plan-cn.ts @@ -1,13 +1,8 @@ import { defineProvider } from "../define.ts"; -import { QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts"; +import { CN_BASE_URL, QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts"; export default defineProvider({ id: "qwen-token-plan-cn", - models: (ctx) => - qwenTokenPlanModels( - ctx, - "alibaba-token-plan-cn", - "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", - ), + models: (ctx) => qwenTokenPlanModels(ctx, "alibaba-token-plan-cn", CN_BASE_URL), compat: { "openai-completions": QWEN_COMPLETIONS_COMPAT }, }); diff --git a/packages/ai/catalog/providers/qwen-token-plan-individual-cn.ts b/packages/ai/catalog/providers/qwen-token-plan-individual-cn.ts new file mode 100644 index 000000000..cc7b23f71 --- /dev/null +++ b/packages/ai/catalog/providers/qwen-token-plan-individual-cn.ts @@ -0,0 +1,21 @@ +import { assertExactModelIds } from "../../scripts/model-data.ts"; +import { defineProvider } from "../define.ts"; +import { CN_BASE_URL, QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts"; +import { INDIVIDUAL_MODEL_IDS } from "./qwen-token-plan-individual.ts"; + +// QwenCloud Token Plan Individual on the China endpoint: the Individual allowlist served from the China catalog. +export default defineProvider({ + id: "qwen-token-plan-individual-cn", + models: (ctx) => { + const models = qwenTokenPlanModels(ctx, "alibaba-token-plan-cn", CN_BASE_URL, INDIVIDUAL_MODEL_IDS); + if (ctx.strict) { + assertExactModelIds( + ctx.providerId, + INDIVIDUAL_MODEL_IDS, + models.map((model) => model.id), + ); + } + return models; + }, + compat: { "openai-completions": QWEN_COMPLETIONS_COMPAT }, +}); diff --git a/packages/ai/catalog/providers/qwen-token-plan-individual.ts b/packages/ai/catalog/providers/qwen-token-plan-individual.ts index fa61c172a..9dd1e4012 100644 --- a/packages/ai/catalog/providers/qwen-token-plan-individual.ts +++ b/packages/ai/catalog/providers/qwen-token-plan-individual.ts @@ -3,9 +3,9 @@ import { defineProvider } from "../define.ts"; import { INTERNATIONAL_BASE_URL, QWEN_COMPLETIONS_COMPAT, qwenTokenPlanModels } from "./qwen-token-plan.ts"; // QwenCloud Token Plan Individual text-model allowlist, verified 2026-11-01. -// It shares the international endpoint with a narrower catalog. +// It shares the international endpoint with a narrower catalog; qwen-token-plan-individual-cn uses it on the China endpoint. // https://docs.qwencloud.com/token-plan/personal/token-plan-personal-overview -const MODEL_IDS = new Set([ +export const INDIVIDUAL_MODEL_IDS = new Set([ "deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", @@ -21,11 +21,11 @@ const MODEL_IDS = new Set([ export default defineProvider({ id: "qwen-token-plan-individual", models: (ctx) => { - const models = qwenTokenPlanModels(ctx, "alibaba-token-plan", INTERNATIONAL_BASE_URL, MODEL_IDS); + const models = qwenTokenPlanModels(ctx, "alibaba-token-plan", INTERNATIONAL_BASE_URL, INDIVIDUAL_MODEL_IDS); if (ctx.strict) { assertExactModelIds( ctx.providerId, - MODEL_IDS, + INDIVIDUAL_MODEL_IDS, models.map((model) => model.id), ); } diff --git a/packages/ai/catalog/providers/qwen-token-plan.ts b/packages/ai/catalog/providers/qwen-token-plan.ts index b526a9e28..602629d1a 100644 --- a/packages/ai/catalog/providers/qwen-token-plan.ts +++ b/packages/ai/catalog/providers/qwen-token-plan.ts @@ -6,6 +6,7 @@ import { getEffortThinkingLevelMap } from "../reasoning.ts"; // endpoints and API keys (sk-sp- prefix). models.dev lists them as // "alibaba-token-plan[-cn]". export const INTERNATIONAL_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"; +export const CN_BASE_URL = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; const COMPAT: OpenAICompletionsCompat = { thinkingFormat: "qwen", diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 560dce8a4..9e588ef6c 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -86,6 +86,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "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", + "qwen-token-plan-individual-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", openai: "OPENAI_API_KEY", azure: "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 93f2889fd..cdb407555 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -32,6 +32,7 @@ import { OPENROUTER_CLASSIFIER_MODELS, OPENROUTER_IMAGE_MODELS, OPENROUTER_MODEL import { QWEN_TOKEN_PLAN_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_IMAGE_MODELS, QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts"; import { QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_CN_IMAGE_MODELS, QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts"; import { QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts"; +import { QWEN_TOKEN_PLAN_INDIVIDUAL_CN_CLASSIFIER_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_CN_IMAGE_MODELS, QWEN_TOKEN_PLAN_INDIVIDUAL_CN_MODELS } from "./providers/qwen-token-plan-individual-cn.models.ts"; import { RADIUS_CLASSIFIER_MODELS, RADIUS_IMAGE_MODELS, RADIUS_MODELS } from "./providers/radius.models.ts"; import { TOGETHER_CLASSIFIER_MODELS, TOGETHER_IMAGE_MODELS, TOGETHER_MODELS } from "./providers/together.models.ts"; import { TYPESAFE_CLASSIFIER_MODELS, TYPESAFE_IMAGE_MODELS, TYPESAFE_MODELS } from "./providers/typesafe.models.ts"; @@ -76,6 +77,7 @@ export const 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 "qwen-token-plan-individual-cn": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_CN_MODELS; readonly "radius": typeof RADIUS_MODELS; readonly "together": typeof TOGETHER_MODELS; readonly "typesafe": typeof TYPESAFE_MODELS; @@ -119,6 +121,7 @@ export const 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, + "qwen-token-plan-individual-cn": QWEN_TOKEN_PLAN_INDIVIDUAL_CN_MODELS, "radius": RADIUS_MODELS, "together": TOGETHER_MODELS, "typesafe": TYPESAFE_MODELS, @@ -164,6 +167,7 @@ export const IMAGE_MODELS: { readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_IMAGE_MODELS; readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_IMAGE_MODELS; readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS; + readonly "qwen-token-plan-individual-cn": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_CN_IMAGE_MODELS; readonly "radius": typeof RADIUS_IMAGE_MODELS; readonly "together": typeof TOGETHER_IMAGE_MODELS; readonly "typesafe": typeof TYPESAFE_IMAGE_MODELS; @@ -207,6 +211,7 @@ export const IMAGE_MODELS: { "qwen-token-plan": QWEN_TOKEN_PLAN_IMAGE_MODELS, "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_IMAGE_MODELS, "qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_IMAGE_MODELS, + "qwen-token-plan-individual-cn": QWEN_TOKEN_PLAN_INDIVIDUAL_CN_IMAGE_MODELS, "radius": RADIUS_IMAGE_MODELS, "together": TOGETHER_IMAGE_MODELS, "typesafe": TYPESAFE_IMAGE_MODELS, @@ -252,6 +257,7 @@ export const CLASSIFIER_MODELS: { readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_CLASSIFIER_MODELS; readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS; readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS; + readonly "qwen-token-plan-individual-cn": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_CN_CLASSIFIER_MODELS; readonly "radius": typeof RADIUS_CLASSIFIER_MODELS; readonly "together": typeof TOGETHER_CLASSIFIER_MODELS; readonly "typesafe": typeof TYPESAFE_CLASSIFIER_MODELS; @@ -295,6 +301,7 @@ export const CLASSIFIER_MODELS: { "qwen-token-plan": QWEN_TOKEN_PLAN_CLASSIFIER_MODELS, "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_CLASSIFIER_MODELS, "qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_CLASSIFIER_MODELS, + "qwen-token-plan-individual-cn": QWEN_TOKEN_PLAN_INDIVIDUAL_CN_CLASSIFIER_MODELS, "radius": RADIUS_CLASSIFIER_MODELS, "together": TOGETHER_CLASSIFIER_MODELS, "typesafe": TYPESAFE_CLASSIFIER_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 297db37c9..ed872eb81 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 { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts"; +import { qwenTokenPlanIndividualCnProvider } from "./qwen-token-plan-individual-cn.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { typesafeProvider } from "./typesafe.ts"; @@ -166,6 +167,7 @@ export function builtinProviders(): Provider[] { qwenTokenPlanProvider(), qwenTokenPlanCnProvider(), qwenTokenPlanIndividualProvider(), + qwenTokenPlanIndividualCnProvider(), radiusProvider(), togetherProvider(), typesafeProvider(), diff --git a/packages/ai/src/providers/qwen-token-plan-individual-cn.models.ts b/packages/ai/src/providers/qwen-token-plan-individual-cn.models.ts new file mode 100644 index 000000000..b74444695 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual-cn.models.ts @@ -0,0 +1,14 @@ +// This file is auto-generated by catalog/generate.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan-individual-cn.json" with { type: "json" }; +import { flattenChatModelCatalog, flattenClassifierModelCatalog, flattenImageModelCatalog, type ChatModelCatalog, type ClassifierModelCatalog, type ImageModelCatalog } from "../model-catalog.ts"; + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_CN_MODELS: ChatModelCatalog = + flattenChatModelCatalog("qwen-token-plan-individual-cn", values); + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_CN_IMAGE_MODELS: ImageModelCatalog = + flattenImageModelCatalog("qwen-token-plan-individual-cn", values); + +export const QWEN_TOKEN_PLAN_INDIVIDUAL_CN_CLASSIFIER_MODELS: ClassifierModelCatalog = + flattenClassifierModelCatalog("qwen-token-plan-individual-cn", values); diff --git a/packages/ai/src/providers/qwen-token-plan-individual-cn.ts b/packages/ai/src/providers/qwen-token-plan-individual-cn.ts new file mode 100644 index 000000000..474c550c9 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-individual-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_INDIVIDUAL_CN_MODELS } from "./qwen-token-plan-individual-cn.models.ts"; + +export function qwenTokenPlanIndividualCnProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-individual-cn", + name: "Qwen Token Plan Individual CN", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan Individual CN API key", ["QWEN_TOKEN_PLAN_CN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_INDIVIDUAL_CN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c1ec24dcb..5aad0e815 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -126,6 +126,7 @@ export type KnownProvider = | "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual" + | "qwen-token-plan-individual-cn" | "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 b457cfa9d..f7d14c3cd 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -297,6 +297,18 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Individual (CN) Provider Abort", () => { + const llm = getModel("qwen-token-plan-individual-cn", "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.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 792208e64..90bd249ed 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -503,6 +503,18 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Individual (CN)", () => { + it("qwen3.8-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-individual-cn", "qwen3.8-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 a15f683d5..e6e4bcf3e 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -152,6 +152,21 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ model: "glm-5.2", label: "qwen-token-plan-individual-glm-5.2", }, + { + provider: "qwen-token-plan-individual-cn", + model: "qwen3.8-max", + label: "qwen-token-plan-individual-cn-qwen3.8-max", + }, + { + provider: "qwen-token-plan-individual-cn", + model: "deepseek-v4-flash-0731", + label: "qwen-token-plan-individual-cn-deepseek-v4-flash-0731", + }, + { + provider: "qwen-token-plan-individual-cn", + model: "glm-5.2", + label: "qwen-token-plan-individual-cn-glm-5.2", + }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 522bfb3ff..37afd3f37 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -616,6 +616,29 @@ describe("AI Providers Empty Message Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)( + "Qwen Token Plan Individual (CN) Provider Empty Messages", + () => { + const llm = getModel("qwen-token-plan-individual-cn", "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.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => { const llm = getModel("kimi-coding", "kimi-for-coding"); diff --git a/packages/ai/test/generate-models-strict.test.ts b/packages/ai/test/generate-models-strict.test.ts index a5f3ffa8e..65b20ac65 100644 --- a/packages/ai/test/generate-models-strict.test.ts +++ b/packages/ai/test/generate-models-strict.test.ts @@ -28,7 +28,9 @@ describe("strict model generation", () => { "deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", + "deepseek-v4.1-flash", "glm-5.2", + "glm-5.3", "qwen3.6-flash", "qwen3.7-max", "qwen3.7-plus", diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 853a02f88..0251acd06 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -417,6 +417,21 @@ describe("Tool Results with Images", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)( + "Qwen Token Plan Individual (CN) Provider (qwen3.8-max)", + () => { + const llm = getModel("qwen-token-plan-individual-cn", "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.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 58c21098c..c2e3b5d99 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1243,7 +1243,12 @@ 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", "qwen-token-plan-individual"] as const; + const providers = [ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", + "qwen-token-plan-individual-cn", + ] 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 eb5256d9a..23245eec4 100644 --- a/packages/ai/test/qwen-token-plan-models.test.ts +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -91,7 +91,11 @@ const QWEN_THINKING_MODELS = [ "qwen3.8-max", ] as const; -type QwenTokenPlanProvider = "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual"; +type QwenTokenPlanProvider = + | "qwen-token-plan" + | "qwen-token-plan-cn" + | "qwen-token-plan-individual" + | "qwen-token-plan-individual-cn"; type QwenTokenPlanModelCase = { provider: QwenTokenPlanProvider; modelId: string }; const QWEN_THINKING_MODEL_CASES: QwenTokenPlanModelCase[] = [ @@ -99,6 +103,7 @@ const QWEN_THINKING_MODEL_CASES: QwenTokenPlanModelCase[] = [ QWEN_THINKING_MODELS.map((modelId) => ({ provider, modelId })), ), ...INDIVIDUAL_TEXT_MODELS.map((modelId) => ({ provider: "qwen-token-plan-individual" as const, modelId })), + ...INDIVIDUAL_TEXT_MODELS.map((modelId) => ({ provider: "qwen-token-plan-individual-cn" as const, modelId })), ]; const QWEN_REASONING_EFFORT_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "glm-5", "glm-5.1", "glm-5.2"] as const; @@ -108,14 +113,18 @@ 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", "deepseek-v4-pro-0813", "glm-5.2"].map((modelId) => ({ + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", "glm-5.2", "glm-5.3"].map((modelId) => ({ provider: "qwen-token-plan-individual" as const, modelId, })), + ...["deepseek-v4-flash-0731", "deepseek-v4-pro", "deepseek-v4-pro-0813", "glm-5.2", "glm-5.3"].map((modelId) => ({ + provider: "qwen-token-plan-individual-cn" as const, + modelId, + })), ]; const QWEN38_MODEL_CASES: QwenTokenPlanModelCase[] = ( - ["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual"] as const + ["qwen-token-plan", "qwen-token-plan-cn", "qwen-token-plan-individual", "qwen-token-plan-individual-cn"] as const ).flatMap((provider) => QWEN38_MODELS.map((modelId) => ({ provider, modelId }))); describe("Qwen Token Plan models", () => { @@ -134,6 +143,20 @@ describe("Qwen Token Plan models", () => { ]); }); + it("exposes exactly the documented Individual text models on the China endpoint", () => { + const modelIds = getModels("qwen-token-plan-individual-cn") + .map((model) => model.id) + .sort(); + + expect(modelIds).toEqual([...INDIVIDUAL_TEXT_MODELS].sort()); + }); + + it("uses the China Token Plan environment variable", () => { + expect(findEnvKeys("qwen-token-plan-individual-cn", { QWEN_TOKEN_PLAN_CN_API_KEY: "test" })).toEqual([ + "QWEN_TOKEN_PLAN_CN_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) { @@ -191,7 +214,7 @@ describe("Qwen Token Plan models", () => { expect(model.thinkingLevelMap).toMatchObject({ minimal: null, - low: null, + low: modelId === "glm-5.3" ? "low" : null, medium: null, high: "high", xhigh: null, @@ -234,13 +257,15 @@ describe("Qwen Token Plan models", () => { }, ); - 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); - expect(modelIds).not.toContain("qwen3.8-max-preview"); - }, - ); + it.each([ + "qwen-token-plan", + "qwen-token-plan-cn", + "qwen-token-plan-individual", + "qwen-token-plan-individual-cn", + ] as const)("omits retired qwen3.8-max-preview on %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + expect(modelIds).not.toContain("qwen3.8-max-preview"); + }); it.each(QWEN_REASONING_EFFORT_MODEL_CASES)( "sends Qwen reasoning_effort for $provider/$modelId", diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 3cc02268b..6d2399abb 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1305,6 +1305,37 @@ describe("Generate E2E Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)( + "Qwen Token Plan Individual Provider (Qwen3.8-Max, CN region)", + () => { + const llm = getModel("qwen-token-plan-individual-cn", "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.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 0815aa71e..9f18e0d9f 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -316,6 +316,14 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Individual (CN) Provider", () => { + const llm = getModel("qwen-token-plan-individual-cn", "qwen3.8-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 022355967..b758b336c 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -286,6 +286,14 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Individual (CN) Provider", () => { + const model = getModel("qwen-token-plan-individual-cn", "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.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 1cbd17d10..dd507f7af 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -655,6 +655,27 @@ describe("totalTokens field", () => { ); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Individual (CN)", () => { + it( + "qwen3.8-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-individual-cn", "qwen3.8-max"); + + console.log(`\nQwen Token Plan Individual 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 182c3499d..00c386487 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -760,6 +760,29 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)( + "Qwen Token Plan Individual (CN) Provider Unicode Handling", + () => { + const llm = getModel("qwen-token-plan-individual-cn", "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.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 0da96ddf6..bf2706e6b 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -58,7 +58,7 @@ This table covers providers with a single primary API-key variable. Providers th | MiniMax (China) | `MINIMAX_CN_API_KEY` | | Moonshot AI (Global and China) | `MOONSHOT_API_KEY` | | Qwen Token Plan and Individual | `QWEN_TOKEN_PLAN_API_KEY` | -| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | +| Qwen Token Plan (China) and Individual (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo | `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/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index d1abd57cf..a48fa7284 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -55,6 +55,7 @@ export const defaultModelPerProvider: Partial> = { "qwen-token-plan": "qwen3.7-max", "qwen-token-plan-cn": "qwen3.7-max", "qwen-token-plan-individual": "qwen3.8-max", + "qwen-token-plan-individual-cn": "qwen3.8-max", xiaomi: "mimo-v2.6-pro", "xiaomi-token-plan-cn": "mimo-v2.6-pro", "xiaomi-token-plan-ams": "mimo-v2.6-pro", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 71cd27f0c..c5973bd22 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -736,6 +736,7 @@ describe("default model selection", () => { test("qwen token plan individual default tracks current model", () => { expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); + expect(defaultModelPerProvider["qwen-token-plan-individual-cn"]).toBe("qwen3.8-max"); }); test("findInitialModel selects ai-gateway default when available", async () => {