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,