From fd47343b07a712d7d3c302fdd866e77c04c21ddf Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 9 May 2026 15:27:43 +0800 Subject: [PATCH 1/9] feat(vscode): add Token Plan as first-class auth provider Expand VS Code companion auth to match CLI's provider-first model: - Add 'token-plan' to qwen-code.provider settings enum - Add writeTokenPlanConfig() to settingsWriter, patterned after writeCodingPlanConfig() - Detect token-plan in readQwenSettingsForVSCode() - Add Token Plan as auth option in interactive Auth flow - Handle token-plan in WebViewProvider config sync and auth dispatch This aligns VS Code with CLI where Coding Plan is just one of several Alibaba providers rather than the only subscription option. --- packages/vscode-ide-companion/package.json | 10 +- .../src/services/settingsWriter.ts | 67 ++++++- .../vscode-ide-companion/src/webview/App.tsx | 2 +- .../components/layout/ModelSelector.test.tsx | 2 +- .../webview/components/layout/Onboarding.tsx | 2 +- .../webview/handlers/AuthMessageHandler.ts | 187 +++++++++--------- .../src/webview/providers/WebViewProvider.ts | 59 ++++-- .../src/webview/utils/discontinuedModel.ts | 2 +- 8 files changed, 215 insertions(+), 116 deletions(-) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 5ea9944f14c..0fcbdb642b2 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -207,20 +207,22 @@ "type": "string", "enum": [ "coding-plan", + "token-plan", "api-key" ], "enumDescriptions": [ - "Alibaba Cloud Coding Plan — configurable from VS Code Settings", - "Configured via Qwen Code: Auth or the onboarding button" + "Alibaba Cloud Coding Plan — for individual developers", + "Alibaba Cloud Token Plan — for teams and companies", + "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." + "markdownDescription": "**Coding Plan**: enter API Key + Region to sync `~/.qwen/settings.json`.\n\n**Token Plan**: enter API Key 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.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index e3a4f5c2a09..07424b8af9f 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -24,6 +24,7 @@ import { CODING_PLAN_ENV_KEY, CodingPlanRegion, SUBSCRIPTION_PLAN_OPTIONS, + TOKEN_PLAN_ENV_KEY, findSubscriptionPlanByConfig, getSubscriptionPlanConfig, isSubscriptionPlanConfig, @@ -57,7 +58,7 @@ 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'; } @@ -350,6 +351,60 @@ export function writeCodingPlanConfig( return result; } +/** + * Write Token Plan configuration to ~/.qwen/settings.json. + * Auto-injects model providers from the token plan template, + * preserving any existing non-Token-Plan entries. + * + * @returns The injected models as a VSCode key-value map (modelId → baseUrl) + */ +export function writeTokenPlanConfig(apiKey: string): VSCodeModelProviders { + const settings = readSettings(); + const planConfig = getSubscriptionPlanConfig('token'); + + // Auth + const auth = ensureNestedObject(settings, 'security', 'auth'); + auth.selectedType = AuthType.USE_OPENAI; + + // API key + const env = ensureNestedObject(settings, 'env'); + env[TOKEN_PLAN_ENV_KEY] = apiKey; + + // Model providers — merge Token Plan templates with existing non-TP entries + const providers = ensureNestedObject(settings, 'modelProviders'); + const existing = findOpenaiModels( + settings.modelProviders as Record, + ); + const nonTokenPlan = existing.filter( + (e) => !isSubscriptionPlanConfig(e.baseUrl as string, e.envKey as string), + ); + const planModels = planConfig.template.map((model) => ({ + ...model, + envKey: planConfig.envKey, + })); + providers[AuthType.USE_OPENAI] = [...planModels, ...nonTokenPlan]; + + // Token Plan metadata + const providerMetadata = ensureNestedObject(settings, 'providerMetadata'); + providerMetadata['token-plan'] = { + version: planConfig.version, + }; + delete settings.tokenPlan; + + // 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 model providers from VSCode Settings (key-value map) to ~/.qwen/settings.json. * Used when provider = "api-key" and user edits the modelProviders map. @@ -623,7 +678,15 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { }; } - // Non-Coding-Plan — find API key from model providers + if (subscriptionPlan?.plan.id === 'token') { + return { + provider: 'token-plan', + apiKey: env[subscriptionPlan.plan.envKey] || '', + codingPlanRegion: 'china', + }; + } + + // Non-subscription-plan — find API key from model providers const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY'; const apiKey = env[firstEnvKey] || ''; 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/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index aa99749bae1..c1db8029787 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -162,43 +162,38 @@ export class AuthMessageHandler extends BaseMessageHandler { /** * Handle auth — full interactive auth flow. - * Dynamically generates provider choices from the shared registry. + * + * Tree (mirrors CLI AuthDialog alibaba group): + * |- Coding Plan -> Region (China/Global) -> API Key -> done + * |- Token Plan -> API Key -> done + * \- API Key + * |- Alibaba Standard -> Region (4 regions) -> API Key -> Model IDs -> done + * \- Custom -> Base URL -> API Key -> Model -> done */ private async handleAuthInteractive(): Promise { try { - // Build grouped provider menu - const items: Array<{ - label: string; - description?: string; - value: string; - kind?: vscode.QuickPickItemKind; - }> = []; - - const addGroup = ( - label: string, - providers: readonly ProviderConfig[], - ) => { - if (providers.length === 0) return; - items.push({ - label, - value: '', - kind: vscode.QuickPickItemKind.Separator, - }); - for (const p of providers) { - items.push({ - label: p.label, - description: p.description, - value: p.id, - }); - } - }; - - addGroup('Alibaba Cloud', ALIBABA_PROVIDERS); - addGroup('Third Party', THIRD_PARTY_PROVIDERS); - - // Custom provider is always last - const customProviders = ALL_PROVIDERS.filter( - (p) => p.uiGroup === 'custom', + // Main menu + const provider = await this.pick( + [ + { + label: 'Alibaba Cloud Coding Plan', + description: + 'Paid · Up to 6,000 requests/5 hrs · All Coding Plan Models', + value: 'coding-plan' as const, + }, + { + label: 'Alibaba Cloud Token Plan', + description: 'For teams · Usage-based billing · Dedicated endpoint', + value: 'token-plan' as const, + }, + { + label: 'API Key', + description: 'Bring your own API key', + value: 'api-key' as const, + }, + ], + 'Qwen Code: Auth', + 'Select authentication method', ); if (customProviders.length > 0) { addGroup('Custom', customProviders); @@ -219,8 +214,13 @@ export class AuthMessageHandler extends BaseMessageHandler { return; } - // Run generic setup flow - await this.runProviderSetupFlow(provider); + if (provider === 'coding-plan') { + await this.authCodingPlan(); + } else if (provider === 'token-plan') { + await this.authTokenPlan(); + } else { + await this.authApiKey(); + } } catch (error) { const errorMsg = getErrorMessage(error); console.error('[AuthMessageHandler] auth failed:', error); @@ -265,58 +265,69 @@ export class AuthMessageHandler extends BaseMessageHandler { protocol = selected as AuthType; } - // Step 1: Base URL (if needed) - let baseUrl: string; - if (shouldShowStep(provider, 'baseUrl')) { - if (Array.isArray(provider.baseUrl)) { - const options = provider.baseUrl as BaseUrlOption[]; - const stepTitle = provider.uiLabels?.baseUrlStepTitle ?? 'Endpoint'; - const selected = await this.pick( - options.map((opt) => ({ - label: opt.label, - description: opt.url, - value: opt.url, - })), - `${flowTitle}: ${stepTitle}`, - `Select ${stepTitle.toLowerCase()}`, - ); - if (!selected) return; - baseUrl = selected; - } else { - // Free-form URL input. Show a protocol-specific default as - // placeholder (NOT pre-filled value) so picking Anthropic/Gemini - // doesn't silently write the OpenAI endpoint when the user hits - // Enter on the OpenAI default. Defaults come from core's shared - // getDefaultBaseUrlForProtocol so CLI and VS Code stay in sync. - const effectiveProtocol = protocol ?? provider.protocol; - // No local fallback: getDefaultBaseUrlForProtocol owns the defaults. - // Adding an OpenAI fallback here would silently mask a new AuthType - // that core hadn't been taught about, diverging from the CLI flow - // (which shows an empty placeholder + scheme error in the same case). - const placeholder = getDefaultBaseUrlForProtocol(effectiveProtocol); - const urlInput = await this.input({ - title: `${flowTitle}: Base URL`, - prompt: 'Enter API base URL', - placeHolder: placeholder, - value: '', - }); - if (urlInput === undefined) return; - baseUrl = urlInput.trim() || placeholder; - if (!/^https?:\/\//i.test(baseUrl)) { - // authError already clears the webview's connecting state; do NOT - // also send authCancelled — the webview clears the error on - // cancel, so the two messages race and the error flashes away - // before the user can read it. authCancelled is reserved for - // user-initiated dismissals (Escape on a QuickPick/InputBox). - this.sendToWebView({ - type: 'authError', - data: { - message: 'Base URL must start with http:// or https://.', - }, - }); - return; - } - } + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your Coding Plan API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler('coding-plan', region, apiKey); + } + } + + /** + * Token Plan: API key -> connect. Fixed endpoint, no region selection. + */ + private async authTokenPlan(): Promise { + const apiKey = await this.input({ + title: 'Qwen Code: Token Plan API Key', + prompt: 'Enter your Token Plan API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler('token-plan', undefined, apiKey); + } + } + + /** + * API Key: select type -> Alibaba Standard or Custom. + */ + private async authApiKey(): Promise { + const keyType = await this.pick( + [ + { + label: 'Standard API Key', + description: 'Connect with an existing ModelStudio API key', + value: 'alibaba-standard' as const, + }, + { + label: 'Custom API Key', + description: + 'For other OpenAI / Anthropic / Gemini-compatible providers', + value: 'custom' as const, + }, + ], + 'Qwen Code: Select API Key Type', + 'Select API key type', + ); + if (!keyType) { + return; + } + + if (keyType === 'alibaba-standard') { + await this.authAlibabaStandard(); } else { baseUrl = resolveBaseUrl(provider); } diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 5aca96d7c77..4e1c17f2211 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -34,6 +34,8 @@ import { snapshotSettingsForRollback, restoreSettingsSnapshot, writeCodingPlanConfig, + writeTokenPlanConfig, + writeModelProvidersConfig, readQwenSettingsForVSCode, clearPersistedAuth, } from '../../services/settingsWriter.js'; @@ -1079,6 +1081,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', @@ -1348,24 +1358,37 @@ export class WebViewProvider { `[WebViewProvider] authInteractive: provider=${providerConfig.id}, host=${baseUrlHost}`, ); - // Snapshot the pre-write settings so we can roll back bad credentials if - // the reconnect below rejects them. applyProviderInstallPlanToFile's own - // backup/restore only covers failures *inside* the plan; the - // disconnect/reconnect runs after the plan commits (cleanupBackup), so - // without this a rejected key would persist and every VS Code restart - // would keep retrying it. - const rollbackSnapshot = snapshotSettingsForRollback(); - // restoreSettingsSnapshot → writeSettings can itself throw (EPERM on - // Windows renameSync, disk full, EACCES). Never let a rollback failure - // mask the original auth error or skip the user-facing error message. - const safeRollback = () => { - try { - restoreSettingsSnapshot(rollbackSnapshot); - } catch (rollbackErr) { - console.error( - '[WebViewProvider] settings rollback failed:', - rollbackErr, - ); + try { + if (provider === 'coding-plan') { + writeCodingPlanConfig(region === 'global' ? 'global' : 'china', apiKey); + } else if (provider === 'token-plan') { + writeTokenPlanConfig(apiKey); + } else if (provider === 'alibaba-standard') { + // Alibaba Standard — multiple models sharing the same base URL + const modelBaseUrl = + baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const ids = (modelIds || model || 'qwen3.5-plus') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const providers: Record = {}; + for (const id of ids) { + providers[id] = modelBaseUrl; + } + writeModelProvidersConfig({ + apiKey, + modelProviders: providers, + activeModel: ids[0] || 'qwen3.5-plus', + }); + } else { + // Custom API Key — single model entry + const modelId = model || 'default'; + const modelBaseUrl = baseUrl || 'https://api.openai.com/v1'; + writeModelProvidersConfig({ + apiKey, + modelProviders: { [modelId]: modelBaseUrl }, + activeModel: modelId, + }); } }; // Tear down an agent left holding rejected/partial credentials in memory 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; From 4bcf7d212635c20bcf85e4328a33e39364c1d491 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 11 May 2026 21:08:45 +0800 Subject: [PATCH 2/9] test(vscode): cover token plan auth paths --- .../src/services/settingsWriter.test.ts | 303 ++++-------------- .../src/services/settingsWriter.ts | 50 +++ .../services/subscriptionPlanDefinitions.ts | 10 +- .../handlers/AuthMessageHandler.test.ts | 245 +------------- .../webview/providers/WebViewProvider.test.ts | 34 +- 5 files changed, 158 insertions(+), 484 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 04380d4ec51..53ba927e97f 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -25,8 +25,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 { AuthType } from '@qwen-code/qwen-code-core'; +import { + CODING_PLAN_ENV_KEY, + TOKEN_PLAN_ENV_KEY, +} from './subscriptionPlanDefinitions.js'; import { applyProviderInstallPlanToFile, clearPersistedAuth, @@ -35,6 +38,7 @@ import { snapshotSettingsForRollback, writeCodingPlanConfig, writeModelProvidersConfig, + writeTokenPlanConfig, } from './settingsWriter.js'; describe('settingsWriter', () => { @@ -108,260 +112,61 @@ describe('settingsWriter', () => { }); }); - describe('applyProviderInstallPlanToFile', () => { - it('writes env, auth selection, and model providers to settings.json', async () => { - const plan: ProviderInstallPlan = { - providerId: 'test', - authType: AuthType.USE_OPENAI, - env: { TEST_API_KEY: 'sk-test' }, - modelSelection: { modelId: 'gpt-4o' }, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [{ id: 'gpt-4o', envKey: 'TEST_API_KEY' }], - mergeStrategy: 'prepend-and-remove-owned', - ownsModel: (m) => m.envKey === 'TEST_API_KEY', - }, - ], - }; - - await applyProviderInstallPlanToFile(plan); - - const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - expect(written.env.TEST_API_KEY).toBe('sk-test'); - expect(written.security.auth.selectedType).toBe(AuthType.USE_OPENAI); - expect(written.model.name).toBe('gpt-4o'); - expect(written.modelProviders[AuthType.USE_OPENAI]).toEqual([ - { id: 'gpt-4o', envKey: 'TEST_API_KEY' }, - ]); - }); - - 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', - enumerable: true, - writable: true, - configurable: true, - }); - const plan: ProviderInstallPlan = { - providerId: 'evil', - authType: AuthType.USE_OPENAI, - env, - }; - - 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, - JSON.stringify({ env: 'legacy-string' }), - 'utf-8', - ); - const plan: ProviderInstallPlan = { - providerId: 'test', - authType: AuthType.USE_OPENAI, - env: { NEW_KEY: 'value' }, - }; - - 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', - authType: AuthType.USE_OPENAI, - env: { K: 'v' }, - }; - - 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": ",]", - "list": [1, 2,], -}`; - fs.writeFileSync(settingsPath, jsonc, 'utf-8'); - const plan: ProviderInstallPlan = { - providerId: 'test', - authType: AuthType.USE_OPENAI, - env: { K: 'v' }, - }; - - await applyProviderInstallPlanToFile(plan); - - const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - expect(after.preserveMe).toBe(',]'); // literal preserved, not corrupted - 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 - "API_KEY": "sk-abc\\u0022,\\n\\"INJECTED\\": \\"pwned", -}`; - fs.writeFileSync(settingsPath, jsonc, 'utf-8'); - const plan: ProviderInstallPlan = { - providerId: 'test', - authType: AuthType.USE_OPENAI, - env: { K: 'v' }, - }; - - await applyProviderInstallPlanToFile(plan); - - const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - // Value is preserved as a single string with the literal quote. - expect(after.API_KEY).toBe('sk-abc",\n"INJECTED": "pwned'); - // No injected top-level key landed in the file. - expect(after.INJECTED).toBeUndefined(); - expect(after.env.K).toBe('v'); - }); - - it('writes atomically — no .tmp residue on success', async () => { - const plan: ProviderInstallPlan = { - providerId: 'test', - authType: AuthType.USE_OPENAI, - env: { K: 'v' }, - }; - await applyProviderInstallPlanToFile(plan); - const dir = path.dirname(settingsPath); - const leftovers = fs - .readdirSync(dir) - .filter((f) => f.startsWith('settings.json.') && f.endsWith('.tmp')); - expect(leftovers).toEqual([]); - }); - }); - - 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', - DEEPSEEK_API_KEY: 'sk-deepseek', - MINIMAX_API_KEY: 'sk-minimax', - ZAI_API_KEY: 'sk-zai', - IDEALAB_API_KEY: 'sk-idealab', - MODELSCOPE_API_KEY: 'sk-modelscope', - OPENROUTER_API_KEY: 'sk-openrouter', - BAILIAN_CODING_PLAN_API_KEY: 'sk-coding', - BAILIAN_TOKEN_PLAN_API_KEY: 'sk-token', - QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_FOO_COM_ABC123DEF456: - 'sk-custom-1', - QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_BAR_COM_DEAD0BEEF000: - 'sk-custom-2', - NODE_OPTIONS: '--max-old-space-size=8192', - }, - security: { auth: { selectedType: 'openai' } }, - providerMetadata: { - 'coding-plan': { version: '1' }, - deepseek: { version: '1' }, - openrouter: { version: '2' }, - }, - }; - fs.writeFileSync(settingsPath, JSON.stringify(initial, null, 2), 'utf-8'); - - clearPersistedAuth(); + it('writes Token Plan config with the CLI Token Plan model template', () => { + const vscodeModelProviders = writeTokenPlanConfig('token-plan-key'); - 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(); - }); + 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 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(Object.keys(vscodeModelProviders)).toEqual(expectedModelIds); + expect(openaiModels.map((model) => model.id)).toEqual(expectedModelIds); + expect( + openaiModels.every((model) => model.envKey === TOKEN_PLAN_ENV_KEY), + ).toBe(true); }); - describe('snapshotSettingsForRollback / restoreSettingsSnapshot', () => { - it('round-trips: snapshot → mutate → restore brings the old state back', () => { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - const original = { - env: { OPENAI_API_KEY: 'sk-good' }, - security: { auth: { selectedType: 'openai' } }, - }; - fs.writeFileSync( - settingsPath, - JSON.stringify(original, null, 2), - 'utf-8', - ); - - const snapshot = snapshotSettingsForRollback(); - expect(snapshot).not.toBeNull(); + it('clears stale sibling subscription plan credentials when switching plans', () => { + writeCodingPlanConfig('global', 'coding-plan-key'); + writeTokenPlanConfig('token-plan-key'); - // Simulate a bad-credential install writing over the file. - fs.writeFileSync( - settingsPath, - JSON.stringify({ env: { OPENAI_API_KEY: 'sk-bad' } }, null, 2), - 'utf-8', - ); - - restoreSettingsSnapshot(snapshot); - - const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); - expect(after).toEqual(original); - }); + 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; - it('snapshot returns null on a malformed file and restore is then a no-op', () => { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, '{ "broken": [1, 2', 'utf-8'); + 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']).toBeDefined(); - const snapshot = snapshotSettingsForRollback(); - expect(snapshot).toBeNull(); + writeCodingPlanConfig('china', 'new-coding-plan-key'); - // 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'); - }); + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record< + string, + unknown + >; + env = settings.env as Record; + providerMetadata = settings.providerMetadata as Record; - 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({}); - }); + 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']).toBeDefined(); }); }); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 07424b8af9f..724e951d25b 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -63,6 +63,11 @@ export interface QwenSettingsForVSCode { codingPlanRegion: 'china' | 'global'; } +const SUBSCRIPTION_PROVIDER_METADATA_KEYS = [ + 'coding-plan', + 'token-plan', +] as const; + // --------------------------------------------------------------------------- // Low-level read/write helpers // --------------------------------------------------------------------------- @@ -286,6 +291,41 @@ function findOpenaiModels( return []; } +function clearInactiveSubscriptionPlanState( + settings: Record, + active: { + envKey: string; + legacyMetadataKey: string; + providerMetadataKey: (typeof SUBSCRIPTION_PROVIDER_METADATA_KEYS)[number]; + }, +): 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]; + } + } + } + + 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]; + } + } + } +} + // --------------------------------------------------------------------------- // Write: VSCode Settings → ~/.qwen/settings.json // --------------------------------------------------------------------------- @@ -313,6 +353,11 @@ export function writeCodingPlanConfig( // API key const env = ensureNestedObject(settings, 'env'); env[CODING_PLAN_ENV_KEY] = apiKey; + clearInactiveSubscriptionPlanState(settings, { + envKey: CODING_PLAN_ENV_KEY, + legacyMetadataKey: planConfig.metadataKey, + providerMetadataKey: 'coding-plan', + }); // Model providers — merge Coding Plan templates with existing non-CP entries const providers = ensureNestedObject(settings, 'modelProviders'); @@ -369,6 +414,11 @@ export function writeTokenPlanConfig(apiKey: string): VSCodeModelProviders { // API key const env = ensureNestedObject(settings, 'env'); env[TOKEN_PLAN_ENV_KEY] = apiKey; + clearInactiveSubscriptionPlanState(settings, { + envKey: TOKEN_PLAN_ENV_KEY, + legacyMetadataKey: planConfig.metadataKey, + providerMetadataKey: 'token-plan', + }); // Model providers — merge Token Plan templates with existing non-TP entries const providers = ensureNestedObject(settings, 'modelProviders'); diff --git a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts index e02d914b06c..1049543dc40 100644 --- a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts +++ b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts @@ -106,6 +106,14 @@ 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 }, + { 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', @@ -153,7 +161,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 = { diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts index 57672289e4a..8bad9ca5163 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -68,255 +68,34 @@ describe('AuthMessageHandler', () => { expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); }); - it('drives a fixed-baseUrl third-party provider through to authInteractiveHandler', async () => { - // Provider pick → DeepSeek (fixed baseUrl, models step shown) - mockShowQuickPick.mockResolvedValueOnce({ value: 'deepseek' }); - // API key input + comma-separated model IDs - mockShowInputBox - .mockResolvedValueOnce('sk-deepseek') - .mockResolvedValueOnce('deepseek-v4-flash, deepseek-v4-pro'); + it('routes Token Plan auth directly to api key collection', async () => { + mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); + mockShowInputBox.mockResolvedValueOnce('token-plan-key'); const sendToWebView = vi.fn(); + const authInteractiveHandler = vi.fn(); const handler = new AuthMessageHandler( {} as never, {} as never, null, sendToWebView, ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); handler.setAuthInteractiveHandler(authInteractiveHandler); await handler.handle({ type: 'auth' }); - // No base URL picker should have been shown (DeepSeek baseUrl is a string) - expect(mockShowQuickPick).toHaveBeenCalledTimes(1); - expect(authInteractiveHandler).toHaveBeenCalledWith( - expect.objectContaining({ id: 'deepseek' }), + expect(mockShowInputBox).toHaveBeenCalledWith( expect.objectContaining({ - baseUrl: 'https://api.deepseek.com', - apiKey: 'sk-deepseek', - modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], + title: 'Qwen Code: Token Plan API Key', + prompt: 'Enter your Token Plan API key', + password: true, }), ); - expect(sendToWebView).not.toHaveBeenCalledWith({ type: 'authCancelled' }); - }); - - it('sends authError and aborts when validateApiKey rejects the key', async () => { - // coding-plan validateApiKey requires keys starting with sk-sp- - mockShowQuickPick - .mockResolvedValueOnce({ value: 'coding-plan' }) - .mockResolvedValueOnce({ - value: 'https://coding.dashscope.aliyuncs.com/v1', - }); - mockShowInputBox.mockResolvedValueOnce('not-a-coding-plan-key'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); - handler.setAuthInteractiveHandler(authInteractiveHandler); - - await handler.handle({ type: 'auth' }); - - expect(sendToWebView).toHaveBeenCalledWith({ - type: 'authError', - data: { message: expect.stringContaining('Coding Plan') }, - }); - expect(authInteractiveHandler).not.toHaveBeenCalled(); - }); - - it('shows a baseUrl picker for providers with BaseUrlOption arrays', async () => { - // coding-plan has baseUrl: BaseUrlOption[] (China / Singapore) - mockShowQuickPick - .mockResolvedValueOnce({ value: 'coding-plan' }) - .mockResolvedValueOnce({ - value: 'https://coding-intl.dashscope.aliyuncs.com/v1', - }); - // User cancels at API key step to keep the test focused on the picker call - mockShowInputBox.mockResolvedValueOnce(undefined); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - - await handler.handle({ type: 'auth' }); - - // Second pick is the base URL selector; verify it was shown with the - // BaseUrlOption entries (China + Singapore international). - const baseUrlPickerCall = mockShowQuickPick.mock.calls[1]; - expect(baseUrlPickerCall?.[0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - description: 'https://coding.dashscope.aliyuncs.com/v1', - }), - expect.objectContaining({ - description: 'https://coding-intl.dashscope.aliyuncs.com/v1', - }), - ]), - ); - }); - - // -- Custom provider flow ------------------------------------------------ - // The custom provider exercises every step in runProviderSetupFlow: - // protocol pick, free-form URL input + scheme validation, API key, - // comma-split model IDs + empty-input guard, and advanced config. - - it('drives custom provider through protocol + url + key + models + advanced', async () => { - // 1) Provider pick → custom (custom-openai-compatible) - // 2) Protocol pick → Anthropic - // 3) Advanced config pick → modality-only (no thinking) - mockShowQuickPick - .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) - .mockResolvedValueOnce({ value: 'anthropic' }) - .mockResolvedValueOnce({ value: 'no' }); - // URL → API key → model IDs (advanced is a separate pick already mocked) - mockShowInputBox - .mockResolvedValueOnce('https://my-proxy.example.com/v1') - .mockResolvedValueOnce('sk-custom-anthropic') - .mockResolvedValueOnce('claude-3-opus, claude-3-sonnet'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); - handler.setAuthInteractiveHandler(authInteractiveHandler); - - await handler.handle({ type: 'auth' }); - - expect(authInteractiveHandler).toHaveBeenCalledTimes(1); - const [providerConfig, inputs] = authInteractiveHandler.mock.calls[0]!; - expect(providerConfig.id).toBe('custom-openai-compatible'); - expect(inputs).toMatchObject({ - // Protocol from the picker is threaded through. - protocol: 'anthropic', - baseUrl: 'https://my-proxy.example.com/v1', - apiKey: 'sk-custom-anthropic', - modelIds: ['claude-3-opus', 'claude-3-sonnet'], - }); - }); - - it('rejects a non-http(s) custom base URL with authError', async () => { - mockShowQuickPick - .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) - .mockResolvedValueOnce({ value: 'openai' }); - // file:// URL must be rejected before reaching authInteractiveHandler. - mockShowInputBox.mockResolvedValueOnce('file:///etc/passwd'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); - handler.setAuthInteractiveHandler(authInteractiveHandler); - - await handler.handle({ type: 'auth' }); - - expect(sendToWebView).toHaveBeenCalledWith({ - type: 'authError', - data: { message: expect.stringContaining('http') }, - }); - expect(authInteractiveHandler).not.toHaveBeenCalled(); - }); - - it('falls back to the protocol-specific default when custom URL input is blank', async () => { - // User picks Anthropic protocol and hits Enter on the URL with no input. - mockShowQuickPick - .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) - .mockResolvedValueOnce({ value: 'anthropic' }) - .mockResolvedValueOnce({ value: 'no' }); - mockShowInputBox - .mockResolvedValueOnce('') // blank URL → fallback to Anthropic default - .mockResolvedValueOnce('sk-anthropic') - .mockResolvedValueOnce('claude-3-opus'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); - handler.setAuthInteractiveHandler(authInteractiveHandler); - - await handler.handle({ type: 'auth' }); - expect(authInteractiveHandler).toHaveBeenCalledWith( - expect.objectContaining({ id: 'custom-openai-compatible' }), - expect.objectContaining({ - // Empty input resolved to Anthropic's default, not the OpenAI one. - baseUrl: 'https://api.anthropic.com/v1', - protocol: 'anthropic', - }), - ); - }); - - it('rejects whitespace-only model IDs with authError', async () => { - mockShowQuickPick - .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) - .mockResolvedValueOnce({ value: 'openai' }); - mockShowInputBox - .mockResolvedValueOnce('https://api.example.com/v1') - .mockResolvedValueOnce('sk-test') - // Only whitespace + commas — must not reach authInteractiveHandler. - .mockResolvedValueOnce(' , , ,'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, + 'token-plan', + undefined, + 'token-plan-key', ); - const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); - handler.setAuthInteractiveHandler(authInteractiveHandler); - - await handler.handle({ type: 'auth' }); - - expect(sendToWebView).toHaveBeenCalledWith({ - type: 'authError', - data: { message: expect.stringContaining('Model IDs') }, - }); - expect(authInteractiveHandler).not.toHaveBeenCalled(); - }); - - it('does not send authCancelled after a validation authError (would clear the message)', async () => { - // Pick custom + openai, then enter a non-http(s) URL → scheme validation - // fails. The webview clears the error on authCancelled, so a validation - // failure must send ONLY authError, never a trailing authCancelled. - mockShowQuickPick - .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) - .mockResolvedValueOnce({ value: 'openai' }); - mockShowInputBox.mockResolvedValueOnce('file:///etc/passwd'); - - const sendToWebView = vi.fn(); - const handler = new AuthMessageHandler( - {} as never, - {} as never, - null, - sendToWebView, - ); - handler.setAuthInteractiveHandler(vi.fn().mockResolvedValue(undefined)); - - await handler.handle({ type: 'auth' }); - - const types = sendToWebView.mock.calls.map((c) => c[0]?.type); - expect(types).toContain('authError'); - expect(types).not.toContain('authCancelled'); + expect(sendToWebView).not.toHaveBeenCalledWith({ type: 'authCancelled' }); }); }); 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..6ff9fa55422 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'; } | 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(); }); From 95146082703f54782dbe83ce228c0525ef3f440e Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 16:07:29 +0800 Subject: [PATCH 3/9] fix(vscode): address token plan auth review feedback --- .../src/services/settingsWriter.test.ts | 62 ++++- .../src/services/settingsWriter.ts | 214 +++++++----------- .../handlers/AuthMessageHandler.test.ts | 45 ++++ .../webview/handlers/AuthMessageHandler.ts | 8 + .../webview/providers/WebViewProvider.test.ts | 94 +++++++- .../src/webview/providers/WebViewProvider.ts | 6 +- 6 files changed, 292 insertions(+), 137 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 53ba927e97f..fa02a362c33 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -29,6 +29,7 @@ import { AuthType } from '@qwen-code/qwen-code-core'; import { CODING_PLAN_ENV_KEY, TOKEN_PLAN_ENV_KEY, + getSubscriptionPlanConfig, } from './subscriptionPlanDefinitions.js'; import { applyProviderInstallPlanToFile, @@ -113,7 +114,7 @@ describe('settingsWriter', () => { }); it('writes Token Plan config with the CLI Token Plan model template', () => { - const vscodeModelProviders = writeTokenPlanConfig('token-plan-key'); + writeTokenPlanConfig('token-plan-key'); const settings = JSON.parse( fs.readFileSync(settingsPath, 'utf-8'), @@ -123,6 +124,10 @@ describe('settingsWriter', () => { 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', @@ -132,11 +137,53 @@ describe('settingsWriter', () => { expect(env[TOKEN_PLAN_ENV_KEY]).toBe('token-plan-key'); expect(settings.model).toEqual({ name: 'qwen3.6-plus' }); - expect(Object.keys(vscodeModelProviders)).toEqual(expectedModelIds); expect(openaiModels.map((model) => model.id)).toEqual(expectedModelIds); expect( openaiModels.every((model) => model.envKey === TOKEN_PLAN_ENV_KEY), ).toBe(true); + 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('clears api-key credentials but preserves 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 + >; + + expect(env.OPENAI_API_KEY).toBeUndefined(); + 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', () => { @@ -153,7 +200,10 @@ describe('settingsWriter', () => { 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']).toBeDefined(); + expect(providerMetadata['token-plan']).toMatchObject({ + baseUrl: getSubscriptionPlanConfig('token').baseUrl, + version: expect.any(String), + }); writeCodingPlanConfig('china', 'new-coding-plan-key'); @@ -167,6 +217,10 @@ describe('settingsWriter', () => { 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']).toBeDefined(); + expect(providerMetadata['coding-plan']).toMatchObject({ + baseUrl: getSubscriptionPlanConfig('coding').baseUrl, + region: 'china', + version: expect.any(String), + }); }); }); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 724e951d25b..422b7b93eb2 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -21,10 +21,9 @@ import { type ModelProvidersConfig, } from '@qwen-code/qwen-code-core'; import { - CODING_PLAN_ENV_KEY, CodingPlanRegion, SUBSCRIPTION_PLAN_OPTIONS, - TOKEN_PLAN_ENV_KEY, + type SubscriptionPlanConfig, findSubscriptionPlanByConfig, getSubscriptionPlanConfig, isSubscriptionPlanConfig, @@ -60,13 +59,28 @@ export type VSCodeModelProviders = Record; export interface QwenSettingsForVSCode { provider: 'coding-plan' | 'token-plan' | 'api-key'; apiKey: string; - codingPlanRegion: 'china' | 'global'; + codingPlanRegion?: 'china' | 'global'; } -const SUBSCRIPTION_PROVIDER_METADATA_KEYS = [ - 'coding-plan', - 'token-plan', -] as const; +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]; +} // --------------------------------------------------------------------------- // Low-level read/write helpers @@ -296,7 +310,7 @@ function clearInactiveSubscriptionPlanState( active: { envKey: string; legacyMetadataKey: string; - providerMetadataKey: (typeof SUBSCRIPTION_PROVIDER_METADATA_KEYS)[number]; + providerMetadataKey: SubscriptionProviderMetadataKey; }, ): void { const env = settings.env as Record | undefined; @@ -306,6 +320,7 @@ function clearInactiveSubscriptionPlanState( delete env[plan.envKey]; } } + delete env[API_KEY_ENV_KEY]; } for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { @@ -326,133 +341,95 @@ function clearInactiveSubscriptionPlanState( } } -// --------------------------------------------------------------------------- -// 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. - * - * @returns The injected models as a VSCode key-value map (modelId → baseUrl) - */ -export function writeCodingPlanConfig( - region: 'china' | 'global', - apiKey: string, -): VSCodeModelProviders { +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: CODING_PLAN_ENV_KEY, + envKey: planConfig.envKey, legacyMetadataKey: planConfig.metadataKey, - providerMetadataKey: 'coding-plan', + 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. - * - * @returns The injected models as a VSCode key-value map (modelId → baseUrl) */ -export function writeTokenPlanConfig(apiKey: string): VSCodeModelProviders { - const settings = readSettings(); +export function writeTokenPlanConfig(apiKey: string): void { const planConfig = getSubscriptionPlanConfig('token'); - // Auth - const auth = ensureNestedObject(settings, 'security', 'auth'); - auth.selectedType = AuthType.USE_OPENAI; - - // API key - const env = ensureNestedObject(settings, 'env'); - env[TOKEN_PLAN_ENV_KEY] = apiKey; - clearInactiveSubscriptionPlanState(settings, { - envKey: TOKEN_PLAN_ENV_KEY, - legacyMetadataKey: planConfig.metadataKey, - providerMetadataKey: 'token-plan', + writeSubscriptionPlanConfig({ + apiKey, + planConfig, + providerMetadataKey: getSubscriptionProviderMetadataKey(planConfig.id), }); - - // Model providers — merge Token Plan templates with existing non-TP entries - const providers = ensureNestedObject(settings, 'modelProviders'); - const existing = findOpenaiModels( - settings.modelProviders as Record, - ); - const nonTokenPlan = existing.filter( - (e) => !isSubscriptionPlanConfig(e.baseUrl as string, e.envKey as string), - ); - const planModels = planConfig.template.map((model) => ({ - ...model, - envKey: planConfig.envKey, - })); - providers[AuthType.USE_OPENAI] = [...planModels, ...nonTokenPlan]; - - // Token Plan metadata - const providerMetadata = ensureNestedObject(settings, 'providerMetadata'); - providerMetadata['token-plan'] = { - version: planConfig.version, - }; - delete settings.tokenPlan; - - // 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; } /** @@ -476,7 +453,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]; } @@ -490,13 +467,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 @@ -509,8 +486,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); @@ -732,12 +710,11 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { return { provider: 'token-plan', apiKey: env[subscriptionPlan.plan.envKey] || '', - codingPlanRegion: 'china', }; } // Non-subscription-plan — find API key from model providers - const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY'; + const firstEnvKey = (openaiModels[0]?.envKey as string) || API_KEY_ENV_KEY; const apiKey = env[firstEnvKey] || ''; if (!apiKey) { @@ -778,21 +755,7 @@ export function clearPersistedAuth(): void { for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { delete env[plan.envKey]; } - // Standard OpenAI bucket (legacy + the api-key flow's default). - delete env['OPENAI_API_KEY']; - // Every preset provider with a static string envKey. - for (const p of ALL_PROVIDERS) { - if (typeof p.envKey === 'string') { - delete env[p.envKey]; - } - } - // Custom-provider env keys are derived dynamically by - // generateCustomEnvKey — match the prefix instead of enumerating. - for (const key of Object.keys(env)) { - if (key.startsWith(CUSTOM_API_KEY_ENV_PREFIX)) { - delete env[key]; - } - } + delete env[API_KEY_ENV_KEY]; } // Remove subscription plan metadata (legacy + new namespace) @@ -801,19 +764,8 @@ export function clearPersistedAuth(): void { } const pm = settings.providerMetadata as Record | undefined; if (pm) { - // 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. - for (const p of ALL_PROVIDERS) { - try { - const key = resolveMetadataKey(p); - if (key) delete pm[key]; - } catch { - /* skip metadata cleanup for a misconfigured provider id */ - } + for (const key of SUBSCRIPTION_PROVIDER_METADATA_KEYS) { + delete pm[key]; } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts index 8bad9ca5163..f5730cccec4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -98,4 +98,49 @@ describe('AuthMessageHandler', () => { ); expect(sendToWebView).not.toHaveBeenCalledWith({ type: 'authCancelled' }); }); + + it('sends authCancelled when Token Plan api key input is dismissed', async () => { + mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); + mockShowInputBox.mockResolvedValue(undefined); + + const sendToWebView = vi.fn(); + const authInteractiveHandler = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + expect(authInteractiveHandler).not.toHaveBeenCalled(); + }); + + it('reports an error when Token Plan auth has no interactive handler', async () => { + mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); + mockShowInputBox.mockResolvedValueOnce('token-plan-key'); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + try { + await handler.handle({ type: 'auth' }); + } finally { + errorSpy.mockRestore(); + } + + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'authError', + data: { message: 'Internal error: auth handler not initialized.' }, + }); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index c1db8029787..990fd4b6a0b 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -298,6 +298,14 @@ export class AuthMessageHandler extends BaseMessageHandler { if (this.authInteractiveHandler) { await this.authInteractiveHandler('token-plan', undefined, apiKey); + } else { + console.error( + '[AuthMessageHandler] authInteractiveHandler not set; token-plan config was not written.', + ); + this.sendToWebView({ + type: 'authError', + data: { message: 'Internal error: auth handler not initialized.' }, + }); } } 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 6ff9fa55422..00ab4a5f55b 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -75,7 +75,7 @@ const { () => { provider: 'coding-plan' | 'token-plan' | 'api-key'; apiKey: string; - codingPlanRegion: 'china' | 'global'; + codingPlanRegion?: 'china' | 'global'; } | null >(() => null), mockWriteCodingPlanConfig: vi.fn(() => ({})), @@ -1133,6 +1133,98 @@ 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('syncs VS Code provider settings after Token Plan interactive auth', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const syncSpy = vi + .spyOn( + provider as unknown as { + syncQwenConfigToVSCodeSettings: () => Promise; + }, + 'syncQwenConfigToVSCodeSettings', + ) + .mockResolvedValue(); + const initSpy = vi + .spyOn( + provider as unknown as { + doInitializeAgentConnection: (options: { + autoAuthenticate: boolean; + }) => Promise; + }, + 'doInitializeAgentConnection', + ) + .mockResolvedValue(); + const sendSpy = vi + .spyOn( + provider as unknown as { + sendMessageToWebView: (message: unknown) => void; + }, + 'sendMessageToWebView', + ) + .mockImplementation(() => undefined); + + (provider as unknown as { authState: boolean }).authState = true; + + await ( + provider as unknown as { + handleAuthInteractive: ( + provider: string, + region?: string, + apiKey?: string, + ) => Promise; + } + ).handleAuthInteractive('token-plan', undefined, 'token-plan-key'); + + expect(mockWriteTokenPlanConfig).toHaveBeenCalledWith('token-plan-key'); + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(initSpy).toHaveBeenCalledWith({ autoAuthenticate: false }); + expect(sendSpy).toHaveBeenCalledWith({ + type: 'authSuccess', + data: { message: 'Provider configured successfully!' }, + }); + }); + 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 4e1c17f2211..40af6603fc2 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1139,8 +1139,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( @@ -1409,6 +1411,8 @@ export class WebViewProvider { const plan = buildInstallPlan(providerConfig, inputs); await applyProviderInstallPlanToFile(plan); + await this.syncQwenConfigToVSCodeSettings(); + // Disconnect + reconnect if (this.agentInitialized) { try { From eafaad1b7a98d988842debc36ce921cd725b64de Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 19 May 2026 11:32:09 +0800 Subject: [PATCH 4/9] fix(vscode): preserve api-key credential and Token Plan modalities Two reviewer-flagged correctness issues in the Token Plan auth flow: 1. clearInactiveSubscriptionPlanState unconditionally deleted env[OPENAI_API_KEY] when switching to any subscription plan. writeSubscriptionPlanConfig preserves non-subscription (custom api-key) model entries that still reference this env var, so those models broke silently with auth errors. OPENAI_API_KEY belongs to the api-key path, not a subscription plan; the existing loop already removes inactive subscription-plan env keys. Stop deleting it. 2. The VS Code Token Plan template copied the CLI model list but dropped the `modalities` metadata, so qwen3.6-plus (and the Coding Plan multimodal models) configured via the companion were treated as text-only despite the CLI advertising image/video support. Carry `modalities` through SubscriptionPlanModelSpec and buildSubscriptionPlanTemplate (mirroring the CLI's gating) and sync it onto the qwen3.5-plus / qwen3.6-plus / kimi-k2.5 specs. Tests updated: the Token Plan test now asserts OPENAI_API_KEY survives a plan switch and that qwen3.6-plus keeps image/video modalities. --- .../src/services/settingsWriter.test.ts | 20 ++++++++- .../src/services/settingsWriter.ts | 6 ++- .../services/subscriptionPlanDefinitions.ts | 42 +++++++++++++++++-- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index fa02a362c33..e931609178d 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -141,6 +141,20 @@ describe('settingsWriter', () => { 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), @@ -157,7 +171,7 @@ describe('settingsWriter', () => { }); }); - it('clears api-key credentials but preserves custom models when writing Token Plan', () => { + it('preserves api-key credentials and custom models when writing Token Plan', () => { writeModelProvidersConfig({ apiKey: 'manual-key', modelProviders: { @@ -177,7 +191,9 @@ describe('settingsWriter', () => { Record >; - expect(env.OPENAI_API_KEY).toBeUndefined(); + // 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({ diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 422b7b93eb2..66cff733abd 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -320,7 +320,11 @@ function clearInactiveSubscriptionPlanState( delete env[plan.envKey]; } } - delete env[API_KEY_ENV_KEY]; + // 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. } for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { diff --git a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts index 1049543dc40..436349d3659 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< @@ -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 }, @@ -108,7 +133,12 @@ const ALIBABA_SUBSCRIPTION_MODELS = [ // 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 }, + { + 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 }, @@ -228,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 } + : {}), }, })); } From 0d7512868f7737a87bac47e375bbd047f935a2c8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 01:34:59 +0800 Subject: [PATCH 5/9] fix(vscode): stop hardcoding codingPlanRegion for api-key provider [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readQwenSettingsForVSCode returned codingPlanRegion: 'china' for the api-key provider path, which could overwrite the user's Coding Plan region when syncQwenConfigToVSCodeSettings ran for non-coding-plan providers. Remove the hardcoded field — codingPlanRegion is only meaningful for the coding-plan provider. --- .../vscode-ide-companion/src/services/settingsWriter.test.ts | 1 - packages/vscode-ide-companion/src/services/settingsWriter.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index e931609178d..5a202f3dbe0 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -109,7 +109,6 @@ describe('settingsWriter', () => { expect(readQwenSettingsForVSCode()).toEqual({ provider: 'api-key', apiKey: 'manual-key', - codingPlanRegion: 'china', }); }); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 66cff733abd..178b92c69d4 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -728,7 +728,6 @@ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { return { provider: 'api-key', apiKey, - codingPlanRegion: 'china', }; } From 97b913e8cf835ac9b7dd019ea0f75eab8659b9d7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 01:55:41 +0800 Subject: [PATCH 6/9] fix(vscode): correct TOKEN_PLAN authEventType and clear stale codingPlanRegion [skip ci] - Change TOKEN_PLAN authEventType from 'coding-plan' to 'token-plan' so telemetry/auth events report the correct authentication type. - Widen the authEventType type literal to 'coding-plan' | 'token-plan' in both SubscriptionPlanDefinition and SubscriptionPlanConfig interfaces. - Clear codingPlanRegion from VS Code settings when the active provider is not coding-plan, preventing stale region values from persisting after switching to token-plan or api-key providers. --- .../src/services/subscriptionPlanDefinitions.ts | 6 +++--- .../src/webview/providers/WebViewProvider.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts index 436349d3659..29aa170f316 100644 --- a/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts +++ b/packages/vscode-ide-companion/src/services/subscriptionPlanDefinitions.ts @@ -69,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; @@ -86,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; @@ -181,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', diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 40af6603fc2..47e14424eb0 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1151,6 +1151,12 @@ export class WebViewProvider { target, ), ); + } else if ( + qwenSettings.provider !== 'coding-plan' && + config.get('codingPlanRegion') !== undefined + ) { + // Clear stale codingPlanRegion when provider is not coding-plan + updates.push(config.update('codingPlanRegion', undefined, target)); } if (updates.length === 0) { From 8100f7a42424d2fa06c1e56a23adc4b7a138689b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 11:01:38 +0800 Subject: [PATCH 7/9] fix(vscode): restore dynamic auth menu and fix test infrastructure - Reset AuthMessageHandler to origin/main's dynamic registry-driven flow (reverts the hardcoded 3-option menu that was accidentally kept during rebase) - Reset AuthMessageHandler.test.ts to origin/main version - Remove unused writeModelProvidersConfig import from WebViewProvider - Remove stale codingPlanRegion clearing for non-coding-plan providers (fixes review blocker: VS Code schema default makes config.get never return undefined) - Restore rollback snapshot infrastructure in handleAuthInteractive - Remove obsolete "syncs VS Code provider settings after Token Plan interactive auth" test (used old handleAuthInteractive signature) - Remove unused imports from settingsWriter.test.ts --- .../src/services/settingsWriter.test.ts | 129 +++++++++- .../src/services/settingsWriter.ts | 26 ++ .../handlers/AuthMessageHandler.test.ts | 232 +++++++++++++++--- .../webview/handlers/AuthMessageHandler.ts | 195 +++++++-------- .../webview/providers/WebViewProvider.test.ts | 53 ---- .../src/webview/providers/WebViewProvider.ts | 56 ++--- 6 files changed, 461 insertions(+), 230 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 5a202f3dbe0..0a5d04d2dba 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -25,7 +25,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, type ProviderInstallPlan } from '@qwen-code/qwen-code-core'; import { CODING_PLAN_ENV_KEY, TOKEN_PLAN_ENV_KEY, @@ -33,10 +33,7 @@ import { } from './subscriptionPlanDefinitions.js'; import { applyProviderInstallPlanToFile, - clearPersistedAuth, readQwenSettingsForVSCode, - restoreSettingsSnapshot, - snapshotSettingsForRollback, writeCodingPlanConfig, writeModelProvidersConfig, writeTokenPlanConfig, @@ -238,4 +235,128 @@ describe('settingsWriter', () => { version: expect.any(String), }); }); + + describe('applyProviderInstallPlanToFile', () => { + it('writes env, auth selection, and model providers to settings.json', async () => { + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'sk-test' }, + modelSelection: { modelId: 'gpt-4o' }, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'gpt-4o', envKey: 'TEST_API_KEY' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (m) => m.envKey === 'TEST_API_KEY', + }, + ], + }; + + await applyProviderInstallPlanToFile(plan); + + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(written.env.TEST_API_KEY).toBe('sk-test'); + expect(written.security.auth.selectedType).toBe(AuthType.USE_OPENAI); + expect(written.model.name).toBe('gpt-4o'); + expect(written.modelProviders[AuthType.USE_OPENAI]).toEqual([ + { id: 'gpt-4o', envKey: 'TEST_API_KEY' }, + ]); + }); + + it('rejects __proto__ in install-plan env keys (prototype-pollution guard)', async () => { + const env: Record = {}; + Object.defineProperty(env, '__proto__', { + value: 'polluted', + enumerable: true, + writable: true, + configurable: true, + }); + const plan: ProviderInstallPlan = { + providerId: 'evil', + authType: AuthType.USE_OPENAI, + env, + }; + + await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow( + /reserved segment/, + ); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('rejects writes that would overwrite an intermediate scalar segment', async () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ env: 'legacy-string' }), + 'utf-8', + ); + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { NEW_KEY: 'value' }, + }; + + await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow( + /segment "env" is a string/, + ); + 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 }); + fs.writeFileSync(settingsPath, '{ "broken": [1, 2', 'utf-8'); + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { K: 'v' }, + }; + + await expect(applyProviderInstallPlanToFile(plan)).rejects.toThrow(); + 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 }); + const jsonc = `{ + // hand-edited + "preserveMe": ",]", + "list": [1, 2,], +}`; + fs.writeFileSync(settingsPath, jsonc, 'utf-8'); + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { K: 'v' }, + }; + + await applyProviderInstallPlanToFile(plan); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + 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 () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const jsonc = `{ + // attempted injection + "API_KEY": "sk-abc\\u0022,\\n\\"INJECTED\\": \\"pwned", +}`; + fs.writeFileSync(settingsPath, jsonc, 'utf-8'); + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { K: 'v' }, + }; + + await applyProviderInstallPlanToFile(plan); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after.INJECTED).toBeUndefined(); + expect(after.env.K).toBe('v'); + }); + }); }); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 178b92c69d4..dbe8099f846 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -758,7 +758,21 @@ export function clearPersistedAuth(): void { for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { delete env[plan.envKey]; } + // Standard OpenAI bucket (legacy + the api-key flow's default). 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') { + delete env[p.envKey]; + } + } + // Custom-provider env keys are derived dynamically by + // generateCustomEnvKey — match the prefix instead of enumerating. + for (const key of Object.keys(env)) { + if (key.startsWith(CUSTOM_API_KEY_ENV_PREFIX)) { + delete env[key]; + } + } } // Remove subscription plan metadata (legacy + new namespace) @@ -770,6 +784,18 @@ export function clearPersistedAuth(): void { 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. + for (const p of ALL_PROVIDERS) { + try { + const key = resolveMetadataKey(p); + if (key) delete pm[key]; + } catch { + /* skip metadata cleanup for a misconfigured provider id */ + } + } } writeSettings(settings); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts index f5730cccec4..57672289e4a 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -68,62 +68,214 @@ describe('AuthMessageHandler', () => { expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); }); - it('routes Token Plan auth directly to api key collection', async () => { - mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); - mockShowInputBox.mockResolvedValueOnce('token-plan-key'); + it('drives a fixed-baseUrl third-party provider through to authInteractiveHandler', async () => { + // Provider pick → DeepSeek (fixed baseUrl, models step shown) + mockShowQuickPick.mockResolvedValueOnce({ value: 'deepseek' }); + // API key input + comma-separated model IDs + mockShowInputBox + .mockResolvedValueOnce('sk-deepseek') + .mockResolvedValueOnce('deepseek-v4-flash, deepseek-v4-pro'); const sendToWebView = vi.fn(); - const authInteractiveHandler = vi.fn(); const handler = new AuthMessageHandler( {} as never, {} as never, null, sendToWebView, ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); handler.setAuthInteractiveHandler(authInteractiveHandler); await handler.handle({ type: 'auth' }); - expect(mockShowInputBox).toHaveBeenCalledWith( + // No base URL picker should have been shown (DeepSeek baseUrl is a string) + expect(mockShowQuickPick).toHaveBeenCalledTimes(1); + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek' }), expect.objectContaining({ - title: 'Qwen Code: Token Plan API Key', - prompt: 'Enter your Token Plan API key', - password: true, + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], }), ); - expect(authInteractiveHandler).toHaveBeenCalledWith( - 'token-plan', - undefined, - 'token-plan-key', - ); expect(sendToWebView).not.toHaveBeenCalledWith({ type: 'authCancelled' }); }); - it('sends authCancelled when Token Plan api key input is dismissed', async () => { - mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); - mockShowInputBox.mockResolvedValue(undefined); + it('sends authError and aborts when validateApiKey rejects the key', async () => { + // coding-plan validateApiKey requires keys starting with sk-sp- + mockShowQuickPick + .mockResolvedValueOnce({ value: 'coding-plan' }) + .mockResolvedValueOnce({ + value: 'https://coding.dashscope.aliyuncs.com/v1', + }); + mockShowInputBox.mockResolvedValueOnce('not-a-coding-plan-key'); const sendToWebView = vi.fn(); - const authInteractiveHandler = vi.fn(); const handler = new AuthMessageHandler( {} as never, {} as never, null, sendToWebView, ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); handler.setAuthInteractiveHandler(authInteractiveHandler); await handler.handle({ type: 'auth' }); - expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'authError', + data: { message: expect.stringContaining('Coding Plan') }, + }); + expect(authInteractiveHandler).not.toHaveBeenCalled(); + }); + + it('shows a baseUrl picker for providers with BaseUrlOption arrays', async () => { + // coding-plan has baseUrl: BaseUrlOption[] (China / Singapore) + mockShowQuickPick + .mockResolvedValueOnce({ value: 'coding-plan' }) + .mockResolvedValueOnce({ + value: 'https://coding-intl.dashscope.aliyuncs.com/v1', + }); + // User cancels at API key step to keep the test focused on the picker call + mockShowInputBox.mockResolvedValueOnce(undefined); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ type: 'auth' }); + + // Second pick is the base URL selector; verify it was shown with the + // BaseUrlOption entries (China + Singapore international). + const baseUrlPickerCall = mockShowQuickPick.mock.calls[1]; + expect(baseUrlPickerCall?.[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'https://coding.dashscope.aliyuncs.com/v1', + }), + expect.objectContaining({ + description: 'https://coding-intl.dashscope.aliyuncs.com/v1', + }), + ]), + ); + }); + + // -- Custom provider flow ------------------------------------------------ + // The custom provider exercises every step in runProviderSetupFlow: + // protocol pick, free-form URL input + scheme validation, API key, + // comma-split model IDs + empty-input guard, and advanced config. + + it('drives custom provider through protocol + url + key + models + advanced', async () => { + // 1) Provider pick → custom (custom-openai-compatible) + // 2) Protocol pick → Anthropic + // 3) Advanced config pick → modality-only (no thinking) + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'anthropic' }) + .mockResolvedValueOnce({ value: 'no' }); + // URL → API key → model IDs (advanced is a separate pick already mocked) + mockShowInputBox + .mockResolvedValueOnce('https://my-proxy.example.com/v1') + .mockResolvedValueOnce('sk-custom-anthropic') + .mockResolvedValueOnce('claude-3-opus, claude-3-sonnet'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(authInteractiveHandler).toHaveBeenCalledTimes(1); + const [providerConfig, inputs] = authInteractiveHandler.mock.calls[0]!; + expect(providerConfig.id).toBe('custom-openai-compatible'); + expect(inputs).toMatchObject({ + // Protocol from the picker is threaded through. + protocol: 'anthropic', + baseUrl: 'https://my-proxy.example.com/v1', + apiKey: 'sk-custom-anthropic', + modelIds: ['claude-3-opus', 'claude-3-sonnet'], + }); + }); + + it('rejects a non-http(s) custom base URL with authError', async () => { + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }); + // file:// URL must be rejected before reaching authInteractiveHandler. + mockShowInputBox.mockResolvedValueOnce('file:///etc/passwd'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ + type: 'authError', + data: { message: expect.stringContaining('http') }, + }); expect(authInteractiveHandler).not.toHaveBeenCalled(); }); - it('reports an error when Token Plan auth has no interactive handler', async () => { - mockShowQuickPick.mockResolvedValueOnce({ value: 'token-plan' }); - mockShowInputBox.mockResolvedValueOnce('token-plan-key'); + it('falls back to the protocol-specific default when custom URL input is blank', async () => { + // User picks Anthropic protocol and hits Enter on the URL with no input. + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'anthropic' }) + .mockResolvedValueOnce({ value: 'no' }); + mockShowInputBox + .mockResolvedValueOnce('') // blank URL → fallback to Anthropic default + .mockResolvedValueOnce('sk-anthropic') + .mockResolvedValueOnce('claude-3-opus'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); + + await handler.handle({ type: 'auth' }); + + expect(authInteractiveHandler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-openai-compatible' }), + expect.objectContaining({ + // Empty input resolved to Anthropic's default, not the OpenAI one. + baseUrl: 'https://api.anthropic.com/v1', + protocol: 'anthropic', + }), + ); + }); + + it('rejects whitespace-only model IDs with authError', async () => { + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }); + mockShowInputBox + .mockResolvedValueOnce('https://api.example.com/v1') + .mockResolvedValueOnce('sk-test') + // Only whitespace + commas — must not reach authInteractiveHandler. + .mockResolvedValueOnce(' , , ,'); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const sendToWebView = vi.fn(); const handler = new AuthMessageHandler( {} as never, @@ -131,16 +283,40 @@ describe('AuthMessageHandler', () => { null, sendToWebView, ); + const authInteractiveHandler = vi.fn().mockResolvedValue(undefined); + handler.setAuthInteractiveHandler(authInteractiveHandler); - try { - await handler.handle({ type: 'auth' }); - } finally { - errorSpy.mockRestore(); - } + await handler.handle({ type: 'auth' }); expect(sendToWebView).toHaveBeenCalledWith({ type: 'authError', - data: { message: 'Internal error: auth handler not initialized.' }, + data: { message: expect.stringContaining('Model IDs') }, }); + expect(authInteractiveHandler).not.toHaveBeenCalled(); + }); + + it('does not send authCancelled after a validation authError (would clear the message)', async () => { + // Pick custom + openai, then enter a non-http(s) URL → scheme validation + // fails. The webview clears the error on authCancelled, so a validation + // failure must send ONLY authError, never a trailing authCancelled. + mockShowQuickPick + .mockResolvedValueOnce({ value: 'custom-openai-compatible' }) + .mockResolvedValueOnce({ value: 'openai' }); + mockShowInputBox.mockResolvedValueOnce('file:///etc/passwd'); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + handler.setAuthInteractiveHandler(vi.fn().mockResolvedValue(undefined)); + + await handler.handle({ type: 'auth' }); + + const types = sendToWebView.mock.calls.map((c) => c[0]?.type); + expect(types).toContain('authError'); + expect(types).not.toContain('authCancelled'); }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 990fd4b6a0b..aa99749bae1 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -162,38 +162,43 @@ export class AuthMessageHandler extends BaseMessageHandler { /** * Handle auth — full interactive auth flow. - * - * Tree (mirrors CLI AuthDialog alibaba group): - * |- Coding Plan -> Region (China/Global) -> API Key -> done - * |- Token Plan -> API Key -> done - * \- API Key - * |- Alibaba Standard -> Region (4 regions) -> API Key -> Model IDs -> done - * \- Custom -> Base URL -> API Key -> Model -> done + * Dynamically generates provider choices from the shared registry. */ private async handleAuthInteractive(): Promise { try { - // Main menu - const provider = await this.pick( - [ - { - label: 'Alibaba Cloud Coding Plan', - description: - 'Paid · Up to 6,000 requests/5 hrs · All Coding Plan Models', - value: 'coding-plan' as const, - }, - { - label: 'Alibaba Cloud Token Plan', - description: 'For teams · Usage-based billing · Dedicated endpoint', - value: 'token-plan' as const, - }, - { - label: 'API Key', - description: 'Bring your own API key', - value: 'api-key' as const, - }, - ], - 'Qwen Code: Auth', - 'Select authentication method', + // Build grouped provider menu + const items: Array<{ + label: string; + description?: string; + value: string; + kind?: vscode.QuickPickItemKind; + }> = []; + + const addGroup = ( + label: string, + providers: readonly ProviderConfig[], + ) => { + if (providers.length === 0) return; + items.push({ + label, + value: '', + kind: vscode.QuickPickItemKind.Separator, + }); + for (const p of providers) { + items.push({ + label: p.label, + description: p.description, + value: p.id, + }); + } + }; + + addGroup('Alibaba Cloud', ALIBABA_PROVIDERS); + addGroup('Third Party', THIRD_PARTY_PROVIDERS); + + // Custom provider is always last + const customProviders = ALL_PROVIDERS.filter( + (p) => p.uiGroup === 'custom', ); if (customProviders.length > 0) { addGroup('Custom', customProviders); @@ -214,13 +219,8 @@ export class AuthMessageHandler extends BaseMessageHandler { return; } - if (provider === 'coding-plan') { - await this.authCodingPlan(); - } else if (provider === 'token-plan') { - await this.authTokenPlan(); - } else { - await this.authApiKey(); - } + // Run generic setup flow + await this.runProviderSetupFlow(provider); } catch (error) { const errorMsg = getErrorMessage(error); console.error('[AuthMessageHandler] auth failed:', error); @@ -265,77 +265,58 @@ export class AuthMessageHandler extends BaseMessageHandler { protocol = selected as AuthType; } - const apiKey = await this.input({ - title: 'Qwen Code: API Key', - prompt: 'Enter your Coding Plan API key', - placeHolder: 'sk-...', - password: true, - required: true, - }); - if (!apiKey) { - return; - } - - if (this.authInteractiveHandler) { - await this.authInteractiveHandler('coding-plan', region, apiKey); - } - } - - /** - * Token Plan: API key -> connect. Fixed endpoint, no region selection. - */ - private async authTokenPlan(): Promise { - const apiKey = await this.input({ - title: 'Qwen Code: Token Plan API Key', - prompt: 'Enter your Token Plan API key', - placeHolder: 'sk-...', - password: true, - required: true, - }); - if (!apiKey) { - return; - } - - if (this.authInteractiveHandler) { - await this.authInteractiveHandler('token-plan', undefined, apiKey); - } else { - console.error( - '[AuthMessageHandler] authInteractiveHandler not set; token-plan config was not written.', - ); - this.sendToWebView({ - type: 'authError', - data: { message: 'Internal error: auth handler not initialized.' }, - }); - } - } - - /** - * API Key: select type -> Alibaba Standard or Custom. - */ - private async authApiKey(): Promise { - const keyType = await this.pick( - [ - { - label: 'Standard API Key', - description: 'Connect with an existing ModelStudio API key', - value: 'alibaba-standard' as const, - }, - { - label: 'Custom API Key', - description: - 'For other OpenAI / Anthropic / Gemini-compatible providers', - value: 'custom' as const, - }, - ], - 'Qwen Code: Select API Key Type', - 'Select API key type', - ); - if (!keyType) { - return; - } - - if (keyType === 'alibaba-standard') { - await this.authAlibabaStandard(); + // Step 1: Base URL (if needed) + let baseUrl: string; + if (shouldShowStep(provider, 'baseUrl')) { + if (Array.isArray(provider.baseUrl)) { + const options = provider.baseUrl as BaseUrlOption[]; + const stepTitle = provider.uiLabels?.baseUrlStepTitle ?? 'Endpoint'; + const selected = await this.pick( + options.map((opt) => ({ + label: opt.label, + description: opt.url, + value: opt.url, + })), + `${flowTitle}: ${stepTitle}`, + `Select ${stepTitle.toLowerCase()}`, + ); + if (!selected) return; + baseUrl = selected; + } else { + // Free-form URL input. Show a protocol-specific default as + // placeholder (NOT pre-filled value) so picking Anthropic/Gemini + // doesn't silently write the OpenAI endpoint when the user hits + // Enter on the OpenAI default. Defaults come from core's shared + // getDefaultBaseUrlForProtocol so CLI and VS Code stay in sync. + const effectiveProtocol = protocol ?? provider.protocol; + // No local fallback: getDefaultBaseUrlForProtocol owns the defaults. + // Adding an OpenAI fallback here would silently mask a new AuthType + // that core hadn't been taught about, diverging from the CLI flow + // (which shows an empty placeholder + scheme error in the same case). + const placeholder = getDefaultBaseUrlForProtocol(effectiveProtocol); + const urlInput = await this.input({ + title: `${flowTitle}: Base URL`, + prompt: 'Enter API base URL', + placeHolder: placeholder, + value: '', + }); + if (urlInput === undefined) return; + baseUrl = urlInput.trim() || placeholder; + if (!/^https?:\/\//i.test(baseUrl)) { + // authError already clears the webview's connecting state; do NOT + // also send authCancelled — the webview clears the error on + // cancel, so the two messages race and the error flashes away + // before the user can read it. authCancelled is reserved for + // user-initiated dismissals (Escape on a QuickPick/InputBox). + this.sendToWebView({ + type: 'authError', + data: { + message: 'Base URL must start with http:// or https://.', + }, + }); + return; + } + } } else { baseUrl = resolveBaseUrl(provider); } 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 00ab4a5f55b..a621df347f0 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -1172,59 +1172,6 @@ describe('WebViewProvider settings sync', () => { ); }); - it('syncs VS Code provider settings after Token Plan interactive auth', async () => { - const provider = new WebViewProvider( - { subscriptions: [] } as never, - { fsPath: '/extension-root' } as never, - ); - const syncSpy = vi - .spyOn( - provider as unknown as { - syncQwenConfigToVSCodeSettings: () => Promise; - }, - 'syncQwenConfigToVSCodeSettings', - ) - .mockResolvedValue(); - const initSpy = vi - .spyOn( - provider as unknown as { - doInitializeAgentConnection: (options: { - autoAuthenticate: boolean; - }) => Promise; - }, - 'doInitializeAgentConnection', - ) - .mockResolvedValue(); - const sendSpy = vi - .spyOn( - provider as unknown as { - sendMessageToWebView: (message: unknown) => void; - }, - 'sendMessageToWebView', - ) - .mockImplementation(() => undefined); - - (provider as unknown as { authState: boolean }).authState = true; - - await ( - provider as unknown as { - handleAuthInteractive: ( - provider: string, - region?: string, - apiKey?: string, - ) => Promise; - } - ).handleAuthInteractive('token-plan', undefined, 'token-plan-key'); - - expect(mockWriteTokenPlanConfig).toHaveBeenCalledWith('token-plan-key'); - expect(syncSpy).toHaveBeenCalledTimes(1); - expect(initSpy).toHaveBeenCalledWith({ autoAuthenticate: false }); - expect(sendSpy).toHaveBeenCalledWith({ - type: 'authSuccess', - data: { message: 'Provider configured successfully!' }, - }); - }); - 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 47e14424eb0..92fb5b2619c 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -35,7 +35,6 @@ import { restoreSettingsSnapshot, writeCodingPlanConfig, writeTokenPlanConfig, - writeModelProvidersConfig, readQwenSettingsForVSCode, clearPersistedAuth, } from '../../services/settingsWriter.js'; @@ -1151,12 +1150,6 @@ export class WebViewProvider { target, ), ); - } else if ( - qwenSettings.provider !== 'coding-plan' && - config.get('codingPlanRegion') !== undefined - ) { - // Clear stale codingPlanRegion when provider is not coding-plan - updates.push(config.update('codingPlanRegion', undefined, target)); } if (updates.length === 0) { @@ -1366,37 +1359,24 @@ export class WebViewProvider { `[WebViewProvider] authInteractive: provider=${providerConfig.id}, host=${baseUrlHost}`, ); - try { - if (provider === 'coding-plan') { - writeCodingPlanConfig(region === 'global' ? 'global' : 'china', apiKey); - } else if (provider === 'token-plan') { - writeTokenPlanConfig(apiKey); - } else if (provider === 'alibaba-standard') { - // Alibaba Standard — multiple models sharing the same base URL - const modelBaseUrl = - baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; - const ids = (modelIds || model || 'qwen3.5-plus') - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const providers: Record = {}; - for (const id of ids) { - providers[id] = modelBaseUrl; - } - writeModelProvidersConfig({ - apiKey, - modelProviders: providers, - activeModel: ids[0] || 'qwen3.5-plus', - }); - } else { - // Custom API Key — single model entry - const modelId = model || 'default'; - const modelBaseUrl = baseUrl || 'https://api.openai.com/v1'; - writeModelProvidersConfig({ - apiKey, - modelProviders: { [modelId]: modelBaseUrl }, - activeModel: modelId, - }); + // Snapshot the pre-write settings so we can roll back bad credentials if + // the reconnect below rejects them. applyProviderInstallPlanToFile's own + // backup/restore only covers failures *inside* the plan; the + // disconnect/reconnect runs after the plan commits (cleanupBackup), so + // without this a rejected key would persist and every VS Code restart + // would keep retrying it. + const rollbackSnapshot = snapshotSettingsForRollback(); + // restoreSettingsSnapshot → writeSettings can itself throw (EPERM on + // Windows renameSync, disk full, EACCES). Never let a rollback failure + // mask the original auth error or skip the user-facing error message. + const safeRollback = () => { + try { + restoreSettingsSnapshot(rollbackSnapshot); + } catch (rollbackErr) { + console.error( + '[WebViewProvider] settings rollback failed:', + rollbackErr, + ); } }; // Tear down an agent left holding rejected/partial credentials in memory From 03d944e0dbdccd4ee052e02973ad254a0bda12f0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 11:33:13 +0800 Subject: [PATCH 8/9] fix(vscode): restore accidentally removed origin/main tests Restore clearPersistedAuth, snapshotSettingsForRollback/restoreSettingsSnapshot, and atomic-write tests that were inadvertently dropped during the Token Plan test additions. Also restore the API_KEY assertion in the \uXXXX escape test. --- .../src/services/settingsWriter.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 0a5d04d2dba..86e687f3f48 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -33,7 +33,10 @@ import { } from './subscriptionPlanDefinitions.js'; import { applyProviderInstallPlanToFile, + clearPersistedAuth, readQwenSettingsForVSCode, + restoreSettingsSnapshot, + snapshotSettingsForRollback, writeCodingPlanConfig, writeModelProvidersConfig, writeTokenPlanConfig, @@ -355,8 +358,114 @@ describe('settingsWriter', () => { await applyProviderInstallPlanToFile(plan); const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + // Value is preserved as a single string with the literal quote. + expect(after.API_KEY).toBe('sk-abc",\n"INJECTED": "pwned'); + // No injected top-level key landed in the file. expect(after.INJECTED).toBeUndefined(); expect(after.env.K).toBe('v'); }); + + it('writes atomically — no .tmp residue on success', async () => { + const plan: ProviderInstallPlan = { + providerId: 'test', + authType: AuthType.USE_OPENAI, + env: { K: 'v' }, + }; + await applyProviderInstallPlanToFile(plan); + const dir = path.dirname(settingsPath); + const leftovers = fs + .readdirSync(dir) + .filter((f) => f.startsWith('settings.json.') && f.endsWith('.tmp')); + expect(leftovers).toEqual([]); + }); + }); + + describe('clearPersistedAuth', () => { + it('wipes preset, custom, and subscription-plan env keys without touching unrelated env', () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const initial = { + env: { + OPENAI_API_KEY: 'sk-openai', + DEEPSEEK_API_KEY: 'sk-deepseek', + MINIMAX_API_KEY: 'sk-minimax', + ZAI_API_KEY: 'sk-zai', + IDEALAB_API_KEY: 'sk-idealab', + MODELSCOPE_API_KEY: 'sk-modelscope', + OPENROUTER_API_KEY: 'sk-openrouter', + BAILIAN_CODING_PLAN_API_KEY: 'sk-coding', + BAILIAN_TOKEN_PLAN_API_KEY: 'sk-token', + QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_FOO_COM_ABC123DEF456: + 'sk-custom-1', + QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_BAR_COM_DEAD0BEEF000: + 'sk-custom-2', + NODE_OPTIONS: '--max-old-space-size=8192', + }, + security: { auth: { selectedType: 'openai' } }, + providerMetadata: { + 'coding-plan': { version: '1' }, + deepseek: { version: '1' }, + openrouter: { version: '2' }, + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(initial, null, 2), 'utf-8'); + + clearPersistedAuth(); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after.env).toEqual({ NODE_OPTIONS: '--max-old-space-size=8192' }); + expect(after.security?.auth?.selectedType).toBeUndefined(); + 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', () => { + expect(() => clearPersistedAuth()).not.toThrow(); + }); + }); + + describe('snapshotSettingsForRollback / restoreSettingsSnapshot', () => { + it('round-trips: snapshot → mutate → restore brings the old state back', () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + const original = { + env: { OPENAI_API_KEY: 'sk-good' }, + security: { auth: { selectedType: 'openai' } }, + }; + fs.writeFileSync( + settingsPath, + JSON.stringify(original, null, 2), + 'utf-8', + ); + + const snapshot = snapshotSettingsForRollback(); + expect(snapshot).not.toBeNull(); + + fs.writeFileSync( + settingsPath, + JSON.stringify({ env: { OPENAI_API_KEY: 'sk-bad' } }, null, 2), + 'utf-8', + ); + + restoreSettingsSnapshot(snapshot); + + const after = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(after).toEqual(original); + }); + + it('snapshot returns null on a malformed file and restore is then a no-op', () => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, '{ "broken": [1, 2', 'utf-8'); + + const snapshot = snapshotSettingsForRollback(); + expect(snapshot).toBeNull(); + + 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', () => { + const snapshot = snapshotSettingsForRollback(); + expect(snapshot).toEqual({}); + }); }); }); From db74c56022d663e2a34692446f5639aee3ccd7df Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 21 May 2026 13:56:22 +0800 Subject: [PATCH 9/9] feat(auth): prioritize Token Plan over Coding Plan in provider menu Swap the order in ALL_PROVIDERS so Token Plan appears first in both the CLI auth dialog and VS Code QuickPick. Also update the VS Code settings enum order and default to token-plan. --- packages/core/src/providers/all-providers.ts | 2 +- packages/vscode-ide-companion/package.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 0fcbdb642b2..a3d7aa04b1b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -206,17 +206,17 @@ "order": 0, "type": "string", "enum": [ - "coding-plan", "token-plan", + "coding-plan", "api-key" ], "enumDescriptions": [ - "Alibaba Cloud Coding Plan — for individual developers", "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 to sync `~/.qwen/settings.json`.\n\n**Token Plan**: enter API Key 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,