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;