diff --git a/packages/core/src/providers/all-providers.ts b/packages/core/src/providers/all-providers.ts
index 606bd6a37c7..1fad86ebfe2 100644
--- a/packages/core/src/providers/all-providers.ts
+++ b/packages/core/src/providers/all-providers.ts
@@ -44,8 +44,8 @@ export {
/** All known providers, in display order. */
export const ALL_PROVIDERS: readonly ProviderConfig[] = [
- codingPlanProvider,
tokenPlanProvider,
+ codingPlanProvider,
alibabaStandardProvider,
deepseekProvider,
minimaxProvider,
diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json
index 5ea9944f14c..a3d7aa04b1b 100644
--- a/packages/vscode-ide-companion/package.json
+++ b/packages/vscode-ide-companion/package.json
@@ -206,21 +206,23 @@
"order": 0,
"type": "string",
"enum": [
+ "token-plan",
"coding-plan",
"api-key"
],
"enumDescriptions": [
- "Alibaba Cloud Coding Plan — configurable from VS Code Settings",
- "Configured via Qwen Code: Auth or the onboarding button"
+ "Alibaba Cloud Token Plan — for teams and companies",
+ "Alibaba Cloud Coding Plan — for individual developers",
+ "API Key — ModelStudio or OpenAI-compatible providers"
],
- "default": "coding-plan",
- "markdownDescription": "**Coding Plan**: enter API Key + Region here to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers."
+ "default": "token-plan",
+ "markdownDescription": "**Token Plan**: enter API Key to sync `~/.qwen/settings.json`.\n\n**Coding Plan**: enter API Key + Region to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers."
},
"qwen-code.apiKey": {
"order": 1,
"type": "string",
"default": "",
- "markdownDescription": "API key used for **Coding Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**."
+ "markdownDescription": "API key for **Coding Plan** or **Token Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**."
},
"qwen-code.codingPlanRegion": {
"order": 2,
diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts
index 04380d4ec51..86e687f3f48 100644
--- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts
+++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts
@@ -26,7 +26,11 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
});
import { AuthType, type ProviderInstallPlan } from '@qwen-code/qwen-code-core';
-import { CODING_PLAN_ENV_KEY } from './subscriptionPlanDefinitions.js';
+import {
+ CODING_PLAN_ENV_KEY,
+ TOKEN_PLAN_ENV_KEY,
+ getSubscriptionPlanConfig,
+} from './subscriptionPlanDefinitions.js';
import {
applyProviderInstallPlanToFile,
clearPersistedAuth,
@@ -35,6 +39,7 @@ import {
snapshotSettingsForRollback,
writeCodingPlanConfig,
writeModelProvidersConfig,
+ writeTokenPlanConfig,
} from './settingsWriter.js';
describe('settingsWriter', () => {
@@ -104,7 +109,133 @@ describe('settingsWriter', () => {
expect(readQwenSettingsForVSCode()).toEqual({
provider: 'api-key',
apiKey: 'manual-key',
- codingPlanRegion: 'china',
+ });
+ });
+
+ it('writes Token Plan config with the CLI Token Plan model template', () => {
+ writeTokenPlanConfig('token-plan-key');
+
+ const settings = JSON.parse(
+ fs.readFileSync(settingsPath, 'utf-8'),
+ ) as Record;
+ const env = settings.env as Record;
+ const modelProviders = settings.modelProviders as Record;
+ const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
+ Record
+ >;
+ const providerMetadata = settings.providerMetadata as Record<
+ string,
+ Record
+ >;
+ const expectedModelIds = [
+ 'qwen3.6-plus',
+ 'deepseek-v3.2',
+ 'glm-5',
+ 'MiniMax-M2.5',
+ ];
+
+ expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
+ expect(settings.model).toEqual({ name: 'qwen3.6-plus' });
+ expect(openaiModels.map((model) => model.id)).toEqual(expectedModelIds);
+ expect(
+ openaiModels.every((model) => model.envKey === TOKEN_PLAN_ENV_KEY),
+ ).toBe(true);
+ // qwen3.6-plus must keep the CLI's image/video modalities so the
+ // VS Code-configured Token Plan advertises the same multimodal
+ // support as the CLI provider entry.
+ const qwen36 = openaiModels.find(
+ (model) => model.id === 'qwen3.6-plus',
+ ) as unknown as { generationConfig?: Record };
+ expect(qwen36.generationConfig?.modalities).toEqual({
+ image: true,
+ video: true,
+ });
+ const deepseek = openaiModels.find(
+ (model) => model.id === 'deepseek-v3.2',
+ ) as unknown as { generationConfig?: Record };
+ expect(deepseek.generationConfig?.modalities).toBeUndefined();
+ expect(providerMetadata['token-plan']).toMatchObject({
+ baseUrl: getSubscriptionPlanConfig('token').baseUrl,
+ version: expect.any(String),
+ });
+ expect(settings.tokenPlan).toBeUndefined();
+ });
+
+ it('reads Token Plan config without overwriting Coding Plan region', () => {
+ writeTokenPlanConfig('token-plan-key');
+
+ expect(readQwenSettingsForVSCode()).toEqual({
+ provider: 'token-plan',
+ apiKey: 'token-plan-key',
+ });
+ });
+
+ it('preserves api-key credentials and custom models when writing Token Plan', () => {
+ writeModelProvidersConfig({
+ apiKey: 'manual-key',
+ modelProviders: {
+ 'gpt-4o': 'https://api.openai.com/v1',
+ },
+ activeModel: 'gpt-4o',
+ });
+
+ writeTokenPlanConfig('token-plan-key');
+
+ const settings = JSON.parse(
+ fs.readFileSync(settingsPath, 'utf-8'),
+ ) as Record;
+ const env = settings.env as Record;
+ const modelProviders = settings.modelProviders as Record;
+ const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array<
+ Record
+ >;
+
+ // The preserved custom model still references OPENAI_API_KEY, so the
+ // key must survive the plan switch (otherwise it breaks silently).
+ expect(env.OPENAI_API_KEY).toBe('manual-key');
+ expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
+ expect(openaiModels.map((model) => model.id)).toContain('gpt-4o');
+ expect(openaiModels.find((model) => model.id === 'gpt-4o')).toMatchObject({
+ baseUrl: 'https://api.openai.com/v1',
+ envKey: 'OPENAI_API_KEY',
+ });
+ });
+
+ it('clears stale sibling subscription plan credentials when switching plans', () => {
+ writeCodingPlanConfig('global', 'coding-plan-key');
+ writeTokenPlanConfig('token-plan-key');
+
+ let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<
+ string,
+ unknown
+ >;
+ let env = settings.env as Record;
+ let providerMetadata = settings.providerMetadata as Record;
+
+ expect(env[CODING_PLAN_ENV_KEY]).toBeUndefined();
+ expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key');
+ expect(providerMetadata['coding-plan']).toBeUndefined();
+ expect(providerMetadata['token-plan']).toMatchObject({
+ baseUrl: getSubscriptionPlanConfig('token').baseUrl,
+ version: expect.any(String),
+ });
+
+ writeCodingPlanConfig('china', 'new-coding-plan-key');
+
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<
+ string,
+ unknown
+ >;
+ env = settings.env as Record;
+ providerMetadata = settings.providerMetadata as Record;
+
+ expect(env[TOKEN_PLAN_ENV_KEY]).toBeUndefined();
+ expect(env[CODING_PLAN_ENV_KEY]).toBe('new-coding-plan-key');
+ expect(providerMetadata['token-plan']).toBeUndefined();
+ expect(providerMetadata['coding-plan']).toMatchObject({
+ baseUrl: getSubscriptionPlanConfig('coding').baseUrl,
+ region: 'china',
+ version: expect.any(String),
});
});
@@ -137,9 +268,6 @@ describe('settingsWriter', () => {
});
it('rejects __proto__ in install-plan env keys (prototype-pollution guard)', async () => {
- // {__proto__: 'x'} literal sets the object's prototype rather than a
- // real property, so build the env via defineProperty to land an actual
- // "__proto__" own-property that survives Object.entries.
const env: Record = {};
Object.defineProperty(env, '__proto__', {
value: 'polluted',
@@ -156,12 +284,10 @@ describe('settingsWriter', () => {
await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow(
/reserved segment/,
);
- // Ensure prototype was not polluted by the failed call
expect(({} as Record).polluted).toBeUndefined();
});
it('rejects writes that would overwrite an intermediate scalar segment', async () => {
- // Hand-edited settings with `env` as a string (legacy / mistake).
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(
settingsPath,
@@ -177,14 +303,12 @@ describe('settingsWriter', () => {
await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow(
/segment "env" is a string/,
);
- // Original scalar must be untouched
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
expect(after.env).toBe('legacy-string');
});
it('throws on malformed settings file instead of silently overwriting it', async () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
- // Note the broken bracket — neither comments nor trailing commas fix it.
fs.writeFileSync(settingsPath, '{ "broken": [1, 2', 'utf-8');
const plan: ProviderInstallPlan = {
providerId: 'test',
@@ -193,13 +317,11 @@ describe('settingsWriter', () => {
};
await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow();
- // Bad file is preserved, not silently clobbered with {}
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ "broken": [1, 2');
});
it('parses JSONC with trailing commas (and preserves comma inside strings)', async () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
- // Comments + trailing commas + a string containing a literal ",]".
const jsonc = `{
// hand-edited
"preserveMe": ",]",
@@ -215,18 +337,12 @@ describe('settingsWriter', () => {
await applyProviderInstallPlanToFile(plan);
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
- expect(after.preserveMe).toBe(',]'); // literal preserved, not corrupted
+ expect(after.preserveMe).toBe(',]');
expect(after.list).toEqual([1, 2]);
expect(after.env.K).toBe('v');
});
it('treats \\uXXXX as a 6-char escape (no parser differential / key injection)', async () => {
- // If the JSONC string scanner stepped past the backslash with j+=2 for
- // every escape, `"` would leave `0022` in the buffer and the next
- // `"` would close the string early — letting an attacker inject extra
- // top-level keys (e.g. env.NODE_OPTIONS) into settings.json.
- // The corrected scanner consumes \uXXXX as 6 chars, so the value stays
- // a single string with a literal `"` in the middle.
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
const jsonc = `{
// attempted injection
@@ -267,9 +383,6 @@ describe('settingsWriter', () => {
describe('clearPersistedAuth', () => {
it('wipes preset, custom, and subscription-plan env keys without touching unrelated env', () => {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
- // Pre-populate a settings file representing a user who has used
- // multiple providers (so each preset's envKey is set) plus a
- // hand-set NODE_OPTIONS the clear must leave alone.
const initial = {
env: {
OPENAI_API_KEY: 'sk-openai',
@@ -299,19 +412,14 @@ describe('settingsWriter', () => {
clearPersistedAuth();
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
- // Every preset + subscription + OPENAI + every QWEN_CUSTOM_API_KEY_*
- // is gone; NODE_OPTIONS survives.
expect(after.env).toEqual({ NODE_OPTIONS: '--max-old-space-size=8192' });
- // selectedType is wiped.
expect(after.security?.auth?.selectedType).toBeUndefined();
- // providerMetadata is empty (or only holds keys that weren't ours).
expect(after.providerMetadata['coding-plan']).toBeUndefined();
expect(after.providerMetadata['deepseek']).toBeUndefined();
expect(after.providerMetadata['openrouter']).toBeUndefined();
});
it('is a no-op when no settings file exists', () => {
- // No settings file written — clear must not throw.
expect(() => clearPersistedAuth()).not.toThrow();
});
});
@@ -332,7 +440,6 @@ describe('settingsWriter', () => {
const snapshot = snapshotSettingsForRollback();
expect(snapshot).not.toBeNull();
- // Simulate a bad-credential install writing over the file.
fs.writeFileSync(
settingsPath,
JSON.stringify({ env: { OPENAI_API_KEY: 'sk-bad' } }, null, 2),
@@ -352,14 +459,11 @@ describe('settingsWriter', () => {
const snapshot = snapshotSettingsForRollback();
expect(snapshot).toBeNull();
- // No-op restore must not throw and must not clobber the file.
expect(() => restoreSettingsSnapshot(snapshot)).not.toThrow();
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ "broken": [1, 2');
});
it('snapshot returns {} (not null) when no settings file exists', () => {
- // ENOENT → readSettings returns {}, so we get a valid empty snapshot
- // that restore can write (creating the file).
const snapshot = snapshotSettingsForRollback();
expect(snapshot).toEqual({});
});
diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts
index e3a4f5c2a09..dbe8099f846 100644
--- a/packages/vscode-ide-companion/src/services/settingsWriter.ts
+++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts
@@ -21,9 +21,9 @@ import {
type ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import {
- CODING_PLAN_ENV_KEY,
CodingPlanRegion,
SUBSCRIPTION_PLAN_OPTIONS,
+ type SubscriptionPlanConfig,
findSubscriptionPlanByConfig,
getSubscriptionPlanConfig,
isSubscriptionPlanConfig,
@@ -57,9 +57,29 @@ export type VSCodeModelProviders = Record;
* Values extracted from ~/.qwen/settings.json for populating VSCode Settings.
*/
export interface QwenSettingsForVSCode {
- provider: 'coding-plan' | 'api-key';
+ provider: 'coding-plan' | 'token-plan' | 'api-key';
apiKey: string;
- codingPlanRegion: 'china' | 'global';
+ codingPlanRegion?: 'china' | 'global';
+}
+
+const SUBSCRIPTION_PROVIDER_METADATA_KEY_BY_PLAN_ID = {
+ coding: 'coding-plan',
+ token: 'token-plan',
+} as const satisfies Record;
+
+type SubscriptionProviderMetadataKey =
+ (typeof SUBSCRIPTION_PROVIDER_METADATA_KEY_BY_PLAN_ID)[keyof typeof SUBSCRIPTION_PROVIDER_METADATA_KEY_BY_PLAN_ID];
+
+const SUBSCRIPTION_PROVIDER_METADATA_KEYS = Object.values(
+ SUBSCRIPTION_PROVIDER_METADATA_KEY_BY_PLAN_ID,
+) as SubscriptionProviderMetadataKey[];
+
+const API_KEY_ENV_KEY = 'OPENAI_API_KEY';
+
+function getSubscriptionProviderMetadataKey(
+ planId: SubscriptionPlanConfig['id'],
+): SubscriptionProviderMetadataKey {
+ return SUBSCRIPTION_PROVIDER_METADATA_KEY_BY_PLAN_ID[planId];
}
// ---------------------------------------------------------------------------
@@ -285,69 +305,135 @@ function findOpenaiModels(
return [];
}
-// ---------------------------------------------------------------------------
-// Write: VSCode Settings → ~/.qwen/settings.json
-// ---------------------------------------------------------------------------
+function clearInactiveSubscriptionPlanState(
+ settings: Record,
+ active: {
+ envKey: string;
+ legacyMetadataKey: string;
+ providerMetadataKey: SubscriptionProviderMetadataKey;
+ },
+): void {
+ const env = settings.env as Record | undefined;
+ if (env) {
+ for (const plan of SUBSCRIPTION_PLAN_OPTIONS) {
+ if (plan.envKey !== active.envKey) {
+ delete env[plan.envKey];
+ }
+ }
+ // Do not delete API_KEY_ENV_KEY. It belongs to the api-key auth path,
+ // not a subscription plan. writeSubscriptionPlanConfig preserves
+ // non-subscription (custom api-key) model entries, which still
+ // reference this env var — deleting it breaks them silently. The loop
+ // above already removes inactive subscription-plan env keys.
+ }
-/**
- * Write Coding Plan configuration to ~/.qwen/settings.json.
- * Auto-injects model providers from the regional template,
- * preserving any existing non-Coding-Plan entries.
- *
- * @returns The injected models as a VSCode key-value map (modelId → baseUrl)
- */
-export function writeCodingPlanConfig(
- region: 'china' | 'global',
- apiKey: string,
-): VSCodeModelProviders {
+ for (const plan of SUBSCRIPTION_PLAN_OPTIONS) {
+ if (plan.metadataKey !== active.legacyMetadataKey) {
+ delete settings[plan.metadataKey];
+ }
+ }
+
+ const providerMetadata = settings.providerMetadata as
+ | Record
+ | undefined;
+ if (providerMetadata) {
+ for (const key of SUBSCRIPTION_PROVIDER_METADATA_KEYS) {
+ if (key !== active.providerMetadataKey) {
+ delete providerMetadata[key];
+ }
+ }
+ }
+}
+
+function writeSubscriptionPlanConfig(params: {
+ apiKey: string;
+ planConfig: SubscriptionPlanConfig;
+ providerMetadataKey: SubscriptionProviderMetadataKey;
+ metadata?: Record;
+}): void {
+ const { apiKey, planConfig, providerMetadataKey, metadata = {} } = params;
const settings = readSettings();
- const codingRegion =
- region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA;
- const planConfig = getSubscriptionPlanConfig('coding', codingRegion);
- // Auth
const auth = ensureNestedObject(settings, 'security', 'auth');
auth.selectedType = AuthType.USE_OPENAI;
- // API key
const env = ensureNestedObject(settings, 'env');
- env[CODING_PLAN_ENV_KEY] = apiKey;
+ env[planConfig.envKey] = apiKey;
+ clearInactiveSubscriptionPlanState(settings, {
+ envKey: planConfig.envKey,
+ legacyMetadataKey: planConfig.metadataKey,
+ providerMetadataKey,
+ });
- // Model providers — merge Coding Plan templates with existing non-CP entries
const providers = ensureNestedObject(settings, 'modelProviders');
const existing = findOpenaiModels(
settings.modelProviders as Record,
);
- const nonCodingPlan = existing.filter(
- (e) => !isSubscriptionPlanConfig(e.baseUrl as string, e.envKey as string),
+ const nonSubscriptionPlan = existing.filter(
+ (entry) =>
+ !isSubscriptionPlanConfig(
+ entry.baseUrl as string,
+ entry.envKey as string,
+ ),
);
const planModels = planConfig.template.map((model) => ({
...model,
envKey: planConfig.envKey,
}));
- providers[AuthType.USE_OPENAI] = [...planModels, ...nonCodingPlan];
+ providers[AuthType.USE_OPENAI] = [...planModels, ...nonSubscriptionPlan];
- // Coding Plan metadata — write to the providerMetadata namespace that
- // the CLI now reads from. Remove legacy top-level key if present.
const providerMetadata = ensureNestedObject(settings, 'providerMetadata');
- providerMetadata['coding-plan'] = {
- region: codingRegion,
+ providerMetadata[providerMetadataKey] = {
+ baseUrl: planConfig.baseUrl,
version: planConfig.version,
+ ...metadata,
};
- delete settings.codingPlan;
+ delete settings[planConfig.metadataKey];
- // Default model
const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus';
settings.model = { name: defaultModelId };
writeSettings(settings);
+}
- // Return key-value map for VSCode settings
- const result: VSCodeModelProviders = {};
- for (const m of planConfig.template) {
- result[m.id] = m.baseUrl || '';
- }
- return result;
+// ---------------------------------------------------------------------------
+// Write: VSCode Settings → ~/.qwen/settings.json
+// ---------------------------------------------------------------------------
+
+/**
+ * Write Coding Plan configuration to ~/.qwen/settings.json.
+ * Auto-injects model providers from the regional template,
+ * preserving any existing non-Coding-Plan entries.
+ */
+export function writeCodingPlanConfig(
+ region: 'china' | 'global',
+ apiKey: string,
+): void {
+ const codingRegion =
+ region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA;
+ const planConfig = getSubscriptionPlanConfig('coding', codingRegion);
+
+ writeSubscriptionPlanConfig({
+ apiKey,
+ planConfig,
+ providerMetadataKey: getSubscriptionProviderMetadataKey(planConfig.id),
+ metadata: { region: codingRegion },
+ });
+}
+
+/**
+ * Write Token Plan configuration to ~/.qwen/settings.json.
+ * Auto-injects model providers from the token plan template,
+ * preserving any existing non-Token-Plan entries.
+ */
+export function writeTokenPlanConfig(apiKey: string): void {
+ const planConfig = getSubscriptionPlanConfig('token');
+
+ writeSubscriptionPlanConfig({
+ apiKey,
+ planConfig,
+ providerMetadataKey: getSubscriptionProviderMetadataKey(planConfig.id),
+ });
}
/**
@@ -371,7 +457,7 @@ export function writeModelProvidersConfig(params: {
// API key
const env = ensureNestedObject(settings, 'env');
- env['OPENAI_API_KEY'] = params.apiKey;
+ env[API_KEY_ENV_KEY] = params.apiKey;
for (const plan of SUBSCRIPTION_PLAN_OPTIONS) {
delete env[plan.envKey];
}
@@ -385,13 +471,13 @@ export function writeModelProvidersConfig(params: {
id,
name: id,
baseUrl: baseUrl || 'https://api.openai.com/v1',
- envKey: 'OPENAI_API_KEY',
+ envKey: API_KEY_ENV_KEY,
}),
);
const existing = findOpenaiModels(
settings.modelProviders as Record,
);
- const nonTarget = existing.filter((e) => e.envKey !== 'OPENAI_API_KEY');
+ const nonTarget = existing.filter((e) => e.envKey !== API_KEY_ENV_KEY);
providers[AuthType.USE_OPENAI] = [...modelArray, ...nonTarget];
// Active model
@@ -404,8 +490,9 @@ export function writeModelProvidersConfig(params: {
}
const pm = settings.providerMetadata as Record | undefined;
if (pm) {
- delete pm['coding-plan'];
- delete pm['token-plan'];
+ for (const key of SUBSCRIPTION_PROVIDER_METADATA_KEYS) {
+ delete pm[key];
+ }
}
writeSettings(settings);
@@ -623,8 +710,15 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null {
};
}
- // Non-Coding-Plan — find API key from model providers
- const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY';
+ if (subscriptionPlan?.plan.id === 'token') {
+ return {
+ provider: 'token-plan',
+ apiKey: env[subscriptionPlan.plan.envKey] || '',
+ };
+ }
+
+ // Non-subscription-plan — find API key from model providers
+ const firstEnvKey = (openaiModels[0]?.envKey as string) || API_KEY_ENV_KEY;
const apiKey = env[firstEnvKey] || '';
if (!apiKey) {
@@ -634,7 +728,6 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null {
return {
provider: 'api-key',
apiKey,
- codingPlanRegion: 'china',
};
}
@@ -666,7 +759,7 @@ export function clearPersistedAuth(): void {
delete env[plan.envKey];
}
// Standard OpenAI bucket (legacy + the api-key flow's default).
- delete env['OPENAI_API_KEY'];
+ delete env[API_KEY_ENV_KEY];
// Every preset provider with a static string envKey.
for (const p of ALL_PROVIDERS) {
if (typeof p.envKey === 'string') {
@@ -688,12 +781,13 @@ export function clearPersistedAuth(): void {
}
const pm = settings.providerMetadata as Record | undefined;
if (pm) {
+ for (const key of SUBSCRIPTION_PROVIDER_METADATA_KEYS) {
+ delete pm[key];
+ }
// Every preset with a static models[] writes providerMetadata..version
// via resolveProviderState — wipe them all on clear so stale entries
// don't cause phantom "update available" notifications for a provider
- // the user just signed out of. resolveMetadataKey throws when a future
- // provider has '.' in its id; wrap per-iteration so one bad entry
- // can't abort the whole cleanup and leave secrets on disk.
+ // the user just signed out of.
for (const p of ALL_PROVIDERS) {
try {
const key = resolveMetadataKey(p);
diff --git a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts
index e02d914b06c..29aa170f316 100644
--- a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts
+++ b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts
@@ -38,11 +38,25 @@ interface SubscriptionPlanRegionConfig<
modelNamePrefix?: string;
}
+/**
+ * Mirror of the core `InputModalities` shape (kept local so this package
+ * does not need a dependency on @qwen-code/qwen-code-core). These values
+ * are surfaced via `generationConfig.modalities` and tell the core runtime
+ * which non-text inputs a model accepts.
+ */
+export interface SubscriptionPlanModelModalities {
+ image?: boolean;
+ pdf?: boolean;
+ audio?: boolean;
+ video?: boolean;
+}
+
interface SubscriptionPlanModelSpec {
id: string;
contextWindowSize: number;
enableThinking?: boolean;
description?: string;
+ modalities?: SubscriptionPlanModelModalities;
}
export interface SubscriptionPlanDefinition<
@@ -55,7 +69,7 @@ export interface SubscriptionPlanDefinition<
description: string;
envKey: string;
modelNamePrefix: string;
- authEventType: 'coding-plan';
+ authEventType: 'coding-plan' | 'token-plan';
metadataKey: string;
endpoint?: string;
documentationUrl?: string;
@@ -72,7 +86,7 @@ export interface SubscriptionPlanConfig {
displayName: string;
title: string;
description: string;
- authEventType: 'coding-plan';
+ authEventType: 'coding-plan' | 'token-plan';
envKey: string;
metadataKey: string;
template: CodingPlanTemplate;
@@ -86,15 +100,26 @@ export interface SubscriptionPlanConfig {
// keep in sync with packages/cli/src/auth/providers/alibaba/codingPlan.ts MODELSTUDIO_MODELS
const ALIBABA_SUBSCRIPTION_MODELS = [
- { id: 'qwen3.5-plus', contextWindowSize: 1000000, enableThinking: true },
+ {
+ id: 'qwen3.5-plus',
+ contextWindowSize: 1000000,
+ enableThinking: true,
+ modalities: { image: true, video: true },
+ },
{
id: 'qwen3.6-plus',
description: 'Currently available to Pro subscribers only.',
contextWindowSize: 1000000,
enableThinking: true,
+ modalities: { image: true, video: true },
},
{ id: 'glm-5', contextWindowSize: 202752, enableThinking: true },
- { id: 'kimi-k2.5', contextWindowSize: 262144, enableThinking: true },
+ {
+ id: 'kimi-k2.5',
+ contextWindowSize: 262144,
+ enableThinking: true,
+ modalities: { image: true, video: true },
+ },
{ id: 'MiniMax-M2.5', contextWindowSize: 196608, enableThinking: true },
{ id: 'qwen3-coder-plus', contextWindowSize: 1000000 },
{ id: 'qwen3-coder-next', contextWindowSize: 262144 },
@@ -106,6 +131,19 @@ const ALIBABA_SUBSCRIPTION_MODELS = [
{ id: 'glm-4.7', contextWindowSize: 202752, enableThinking: true },
] as const satisfies readonly SubscriptionPlanModelSpec[];
+// keep in sync with packages/cli/src/auth/providers/alibaba/tokenPlan.ts TOKEN_PLAN_MODELS
+const TOKEN_PLAN_MODELS = [
+ {
+ id: 'qwen3.6-plus',
+ contextWindowSize: 1000000,
+ enableThinking: true,
+ modalities: { image: true, video: true },
+ },
+ { id: 'deepseek-v3.2', contextWindowSize: 131072, enableThinking: true },
+ { id: 'glm-5', contextWindowSize: 202752, enableThinking: true },
+ { id: 'MiniMax-M2.5', contextWindowSize: 196608, enableThinking: true },
+] as const satisfies readonly SubscriptionPlanModelSpec[];
+
const CODING_PLAN: SubscriptionPlanDefinition<'coding'> = {
id: 'coding',
option: 'CODING_PLAN',
@@ -143,7 +181,7 @@ const TOKEN_PLAN: SubscriptionPlanDefinition<'token'> = {
'For teams and companies · Usage-based billing with dedicated endpoint',
envKey: TOKEN_PLAN_ENV_KEY,
modelNamePrefix: 'ModelStudio Token Plan',
- authEventType: 'coding-plan',
+ authEventType: 'token-plan',
metadataKey: 'tokenPlan',
endpoint:
'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
@@ -153,7 +191,7 @@ const TOKEN_PLAN: SubscriptionPlanDefinition<'token'> = {
'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856',
usageDocumentationUrl:
'https://bailian.console.aliyun.com/cn-beijing?tab=doc#/doc/?type=model&url=3028856',
- models: ALIBABA_SUBSCRIPTION_MODELS,
+ models: TOKEN_PLAN_MODELS,
};
const SUBSCRIPTION_PLANS = {
@@ -220,6 +258,12 @@ function buildSubscriptionPlanTemplate(
? { extra_body: { enable_thinking: true } }
: {}),
contextWindowSize: model.contextWindowSize,
+ // Carry input modalities so a model configured via the VS Code
+ // companion advertises the same image/video support as the CLI
+ // provider entry. Mirrors the CLI's buildGenerationConfig gating.
+ ...(model.modalities && Object.values(model.modalities).some(Boolean)
+ ? { modalities: model.modalities }
+ : {}),
},
}));
}
diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx
index 021af2f2afd..a3f94593b2f 100644
--- a/packages/vscode-ide-companion/src/webview/App.tsx
+++ b/packages/vscode-ide-companion/src/webview/App.tsx
@@ -434,7 +434,7 @@ export const App: React.FC = () => {
{
id: 'auth',
label: '/auth',
- description: 'Configure Coding Plan or API Key',
+ description: 'Configure Coding Plan, Token Plan, or API Key',
type: 'command',
group: 'Account',
},
diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx
index 1a8a264736a..b231dd3a8aa 100644
--- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx
+++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.test.tsx
@@ -107,7 +107,7 @@ describe('ModelSelector — discontinued state (Issue #3745)', () => {
models: [discontinuedModel],
});
expect(container.textContent).toContain(
- 'Discontinued — switch to Coding Plan or API Key',
+ 'Discontinued — switch to Coding Plan, Token Plan, or API Key',
);
expect(container.textContent).not.toContain(
'Original description should be replaced',
diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx
index bd4691dffca..e40b9e78025 100644
--- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx
+++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx
@@ -65,7 +65,7 @@ export const Onboarding: FC = () => (
className="text-[10px] mt-4 text-center max-w-[260px]"
style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }}
>
- Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and
+ Supports Alibaba Cloud Coding Plan, Token Plan, ModelStudio API Key, and
OpenAI-compatible endpoints
diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
index 69a47e50b36..a621df347f0 100644
--- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
+++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts
@@ -21,6 +21,7 @@ const {
mockOpenExternal,
mockReadQwenSettingsForVSCode,
mockWriteCodingPlanConfig,
+ mockWriteTokenPlanConfig,
mockWriteModelProvidersConfig,
mockClearPersistedAuth,
mockApplyProviderInstallPlanToFile,
@@ -72,12 +73,13 @@ const {
mockOpenExternal: vi.fn(),
mockReadQwenSettingsForVSCode: vi.fn<
() => {
- provider: 'coding-plan' | 'api-key';
+ provider: 'coding-plan' | 'token-plan' | 'api-key';
apiKey: string;
- codingPlanRegion: 'china' | 'global';
+ codingPlanRegion?: 'china' | 'global';
} | null
>(() => null),
mockWriteCodingPlanConfig: vi.fn(() => ({})),
+ mockWriteTokenPlanConfig: vi.fn(() => ({})),
mockWriteModelProvidersConfig: vi.fn(),
mockClearPersistedAuth: vi.fn(),
mockApplyProviderInstallPlanToFile: vi.fn().mockResolvedValue(undefined),
@@ -176,6 +178,7 @@ vi.mock('vscode', () => ({
vi.mock('../../services/settingsWriter.js', () => ({
writeCodingPlanConfig: mockWriteCodingPlanConfig,
+ writeTokenPlanConfig: mockWriteTokenPlanConfig,
writeModelProvidersConfig: mockWriteModelProvidersConfig,
readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode,
clearPersistedAuth: mockClearPersistedAuth,
@@ -1050,6 +1053,35 @@ describe('WebViewProvider settings sync', () => {
expect(synced).toBe(false);
expect(mockWriteCodingPlanConfig).not.toHaveBeenCalled();
+ expect(mockWriteTokenPlanConfig).not.toHaveBeenCalled();
+ expect(mockWriteModelProvidersConfig).not.toHaveBeenCalled();
+ });
+
+ it('syncs Token Plan VS Code settings to Qwen config', async () => {
+ mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
+ if (key === 'apiKey') {
+ return 'token-plan-key';
+ }
+ if (key === 'provider') {
+ return 'token-plan';
+ }
+ return defaultValue;
+ });
+
+ const provider = new WebViewProvider(
+ { subscriptions: [] } as never,
+ { fsPath: '/extension-root' } as never,
+ );
+
+ const synced = await (
+ provider as unknown as {
+ syncVSCodeSettingsToQwenConfig: () => Promise;
+ }
+ ).syncVSCodeSettingsToQwenConfig();
+
+ expect(synced).toBe(true);
+ expect(mockWriteTokenPlanConfig).toHaveBeenCalledWith('token-plan-key');
+ expect(mockWriteCodingPlanConfig).not.toHaveBeenCalled();
expect(mockWriteModelProvidersConfig).not.toHaveBeenCalled();
});
@@ -1101,6 +1133,45 @@ describe('WebViewProvider settings sync', () => {
);
});
+ it('does not overwrite Coding Plan region when syncing Token Plan settings', async () => {
+ mockReadQwenSettingsForVSCode.mockReturnValue({
+ provider: 'token-plan',
+ apiKey: 'token-plan-key',
+ });
+ mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => {
+ if (key === 'provider') {
+ return 'coding-plan';
+ }
+ if (key === 'codingPlanRegion') {
+ return 'global';
+ }
+ return defaultValue;
+ });
+
+ const provider = new WebViewProvider(
+ { subscriptions: [] } as never,
+ { fsPath: '/extension-root' } as never,
+ );
+
+ await (
+ provider as unknown as {
+ syncQwenConfigToVSCodeSettings: () => Promise;
+ }
+ ).syncQwenConfigToVSCodeSettings();
+
+ expect(mockConfigUpdate).toHaveBeenCalledTimes(1);
+ expect(mockConfigUpdate).toHaveBeenCalledWith(
+ 'provider',
+ 'token-plan',
+ expect.anything(),
+ );
+ expect(mockConfigUpdate).not.toHaveBeenCalledWith(
+ 'codingPlanRegion',
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
it('ignores non-auth qwen-code setting changes', async () => {
const provider = new WebViewProvider(
{ subscriptions: [] } as never,
diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
index 5aca96d7c77..92fb5b2619c 100644
--- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
+++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts
@@ -34,6 +34,7 @@ import {
snapshotSettingsForRollback,
restoreSettingsSnapshot,
writeCodingPlanConfig,
+ writeTokenPlanConfig,
readQwenSettingsForVSCode,
clearPersistedAuth,
} from '../../services/settingsWriter.js';
@@ -1079,6 +1080,14 @@ export class WebViewProvider {
try {
const provider = config.get('provider', 'coding-plan');
+ if (provider === 'token-plan') {
+ writeTokenPlanConfig(apiKey);
+ console.log(
+ '[WebViewProvider] Synced VSCode settings → ~/.qwen/settings.json (provider=token-plan)',
+ );
+ return true;
+ }
+
if (provider !== 'coding-plan') {
console.log(
'[WebViewProvider] Skipping VSCode settings sync for api-key provider; interactive auth owns provider details',
@@ -1129,8 +1138,10 @@ export class WebViewProvider {
updates.push(config.update('provider', qwenSettings.provider, target));
}
if (
+ qwenSettings.provider === 'coding-plan' &&
+ qwenSettings.codingPlanRegion &&
config.get<'china' | 'global'>('codingPlanRegion', 'china') !==
- qwenSettings.codingPlanRegion
+ qwenSettings.codingPlanRegion
) {
updates.push(
config.update(
@@ -1386,6 +1397,8 @@ export class WebViewProvider {
const plan = buildInstallPlan(providerConfig, inputs);
await applyProviderInstallPlanToFile(plan);
+ await this.syncQwenConfigToVSCodeSettings();
+
// Disconnect + reconnect
if (this.agentInitialized) {
try {
diff --git a/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts
index eecaa8ff560..0e7f3691e55 100644
--- a/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts
+++ b/packages/vscode-ide-companion/src/webview/utils/discontinuedModel.ts
@@ -25,7 +25,7 @@ export const QWEN_OAUTH_AUTH_TYPE = 'qwen-oauth';
/** User-facing strings for the discontinued state (English-only — webview has no i18n runtime). */
export const DISCONTINUED_MESSAGES = {
badge: '(Discontinued)',
- description: 'Discontinued — switch to Coding Plan or API Key',
+ description: 'Discontinued — switch to Coding Plan, Token Plan, or API Key',
blockedError:
'Qwen OAuth free tier was discontinued on 2026-04-15. Please select a model from another provider or run /auth to switch.',
} as const;