diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts deleted file mode 100644 index 20206b3339..0000000000 --- a/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; -import { applyProviderInstallPlan } from './applyProviderInstallPlan.js'; -import type { ProviderInstallPlan } from '../types.js'; - -vi.mock('../../utils/settingsUtils.js', () => ({ - backupSettingsFile: vi.fn(), - restoreSettingsFromBackup: vi.fn(), - cleanupSettingsBackup: vi.fn(), -})); - -vi.mock('../../config/modelProvidersScope.js', () => ({ - getPersistScopeForModelSelection: vi.fn(() => SettingScope.User), -})); - -function createSettings(modelProviders = {}) { - const settingsObj = { - settings: {}, - originalSettings: {}, - path: '/tmp/settings.json', - }; - return { - merged: { - modelProviders, - }, - setValue: vi.fn(), - forScope: vi.fn(() => settingsObj), - recomputeMerged: vi.fn(), - }; -} - -function createConfig() { - const modelsConfig = { - syncAfterAuthRefresh: vi.fn(), - }; - return { - reloadModelProvidersConfig: vi.fn(), - refreshAuth: vi.fn(async () => undefined), - getModelsConfig: vi.fn(() => modelsConfig), - }; -} - -describe('applyProviderInstallPlan', () => { - beforeEach(() => { - vi.clearAllMocks(); - delete process.env['TEST_API_KEY']; - }); - - it('persists env, auth selection, selected model, and merged model providers', async () => { - const settings = createSettings({ - [AuthType.USE_OPENAI]: [ - { - id: 'old-owned', - envKey: 'TEST_API_KEY', - generationConfig: { contextWindowSize: 123 }, - }, - { - id: 'preserved', - envKey: 'OTHER_API_KEY', - generationConfig: { contextWindowSize: 456 }, - }, - ], - }); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - env: { - TEST_API_KEY: 'sk-test', - }, - modelSelection: { - modelId: 'new-model', - }, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [{ id: 'new-model', envKey: 'TEST_API_KEY' }], - mergeStrategy: 'prepend-and-remove-owned', - ownsModel: (model) => model.envKey === 'TEST_API_KEY', - }, - ], - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }); - - expect(settings.forScope).toHaveBeenCalledWith(SettingScope.User); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'env.TEST_API_KEY', - 'sk-test', - ); - expect(process.env['TEST_API_KEY']).toBe('sk-test'); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'modelProviders.openai', - [ - { id: 'new-model', envKey: 'TEST_API_KEY' }, - { - id: 'preserved', - envKey: 'OTHER_API_KEY', - generationConfig: { contextWindowSize: 456 }, - }, - ], - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'model.name', - 'new-model', - ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ - [AuthType.USE_OPENAI]: [ - { id: 'new-model', envKey: 'TEST_API_KEY' }, - { - id: 'preserved', - envKey: 'OTHER_API_KEY', - generationConfig: { contextWindowSize: 456 }, - }, - ], - }); - expect(config.getModelsConfig().syncAfterAuthRefresh).toHaveBeenCalledWith( - AuthType.USE_OPENAI, - 'new-model', - ); - expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - }); - - it('can skip immediate auth refresh after persisting a provider plan', async () => { - const settings = createSettings(); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - env: { - TEST_API_KEY: 'sk-test', - }, - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - refreshAuth: false, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'env.TEST_API_KEY', - 'sk-test', - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'security.auth.selectedType', - AuthType.USE_OPENAI, - ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(config.refreshAuth).not.toHaveBeenCalled(); - }); - - it('uses patch ownsModel for merge filtering', async () => { - const settings = createSettings({ - [AuthType.USE_OPENAI]: [ - { id: 'old-a', envKey: 'A' }, - { id: 'old-b', envKey: 'B' }, - ], - }); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [{ id: 'new-a', envKey: 'A' }], - mergeStrategy: 'prepend-and-remove-owned', - ownsModel(model) { - return model.envKey === 'A'; - }, - }, - ], - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'modelProviders.openai', - [ - { id: 'new-a', envKey: 'A' }, - { id: 'old-b', envKey: 'B' }, - ], - ); - }); - - it('writes provider state and legacy credentials', async () => { - const settings = createSettings(); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - legacyCredentials: { - apiKey: 'legacy-key', - baseUrl: 'https://example.com/v1', - }, - providerState: { - codingPlan: { - baseUrl: 'https://coding.example.com/v1', - version: 'v1', - }, - }, - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'security.auth.apiKey', - 'legacy-key', - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'security.auth.baseUrl', - 'https://example.com/v1', - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'codingPlan.baseUrl', - 'https://coding.example.com/v1', - ); - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'codingPlan.version', - 'v1', - ); - }); - - it('appends models with append merge strategy', async () => { - const settings = createSettings({ - [AuthType.USE_OPENAI]: [ - { id: 'existing-1', envKey: 'A' }, - { id: 'existing-2', envKey: 'B' }, - ], - }); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [{ id: 'new-model', envKey: 'C' }], - mergeStrategy: 'append', - }, - ], - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'modelProviders.openai', - [ - { id: 'existing-1', envKey: 'A' }, - { id: 'existing-2', envKey: 'B' }, - { id: 'new-model', envKey: 'C' }, - ], - ); - }); - - it('replaces owned models with replace-owned strategy (appends new at end)', async () => { - const settings = createSettings({ - [AuthType.USE_OPENAI]: [ - { id: 'owned-1', envKey: 'A' }, - { id: 'unrelated', envKey: 'B' }, - { id: 'owned-2', envKey: 'A' }, - ], - }); - const config = createConfig(); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [{ id: 'new-a', envKey: 'A' }], - mergeStrategy: 'replace-owned', - ownsModel: (model) => model.envKey === 'A', - }, - ], - }; - - await applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }); - - expect(settings.setValue).toHaveBeenCalledWith( - SettingScope.User, - 'modelProviders.openai', - [ - { id: 'unrelated', envKey: 'B' }, - { id: 'new-a', envKey: 'A' }, - ], - ); - }); - - it('rolls back process.env on error', async () => { - process.env['TEST_API_KEY'] = 'old-value'; - const settings = createSettings(); - const config = createConfig(); - config.refreshAuth.mockRejectedValueOnce(new Error('network error')); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - env: { TEST_API_KEY: 'new-value' }, - }; - - await expect( - applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }), - ).rejects.toThrow('network error'); - - expect(process.env['TEST_API_KEY']).toBe('old-value'); - }); - - it('deletes env var on rollback if it did not exist before', async () => { - delete process.env['BRAND_NEW_KEY']; - const settings = createSettings(); - const config = createConfig(); - config.refreshAuth.mockRejectedValueOnce(new Error('fail')); - const plan: ProviderInstallPlan = { - providerId: 'test-provider', - authType: AuthType.USE_OPENAI, - env: { BRAND_NEW_KEY: 'value' }, - }; - - await expect( - applyProviderInstallPlan(plan, { - settings: settings as never, - config: config as never, - }), - ).rejects.toThrow('fail'); - - expect(process.env['BRAND_NEW_KEY']).toBeUndefined(); - }); -}); diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.ts deleted file mode 100644 index bea863f4f5..0000000000 --- a/packages/cli/src/auth/install/applyProviderInstallPlan.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { ModelProvidersConfig } from '@qwen-code/qwen-code-core'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import { - backupSettingsFile, - cleanupSettingsBackup, - restoreSettingsFromBackup, -} from '../../utils/settingsUtils.js'; -import type { - ApplyProviderInstallPlanOptions, - ApplyProviderInstallPlanResult, - ProviderInstallPlan, - ProviderModelProvidersPatch, -} from '../types.js'; - -function isSameModelIdentity( - a: { id: string; baseUrl?: string }, - b: { id: string; baseUrl?: string }, -): boolean { - return a.id === b.id && (a.baseUrl ?? '') === (b.baseUrl ?? ''); -} - -function applyModelProvidersPatch( - existingModelProviders: ModelProvidersConfig, - patch: ProviderModelProvidersPatch, -): ModelProvidersConfig { - const existingModels = existingModelProviders[patch.authType] ?? []; - - let updatedModels = patch.models; - if (patch.mergeStrategy === 'append') { - updatedModels = [...existingModels, ...patch.models]; - } else { - const ownsModel = patch.ownsModel; - const preservedModels = existingModels.filter((model) => { - if (ownsModel) { - return !ownsModel(model); - } - return !patch.models.some((newModel) => - isSameModelIdentity(newModel, model), - ); - }); - - updatedModels = - patch.mergeStrategy === 'replace-owned' - ? [...preservedModels, ...patch.models] - : [...patch.models, ...preservedModels]; - } - - return { - ...existingModelProviders, - [patch.authType]: updatedModels, - }; -} - -export async function applyProviderInstallPlan( - plan: ProviderInstallPlan, - { - settings, - config, - scope, - refreshAuth = true, - }: ApplyProviderInstallPlanOptions, -): Promise { - const persistScope = scope ?? getPersistScopeForModelSelection(settings); - const settingsFile = settings.forScope(persistScope); - backupSettingsFile(settingsFile.path); - - const previousEnvValues = new Map(); - const previousSettingsSnapshot = structuredClone(settingsFile.settings); - const previousOriginalSnapshot = structuredClone( - settingsFile.originalSettings, - ); - const previousModelProviders: ModelProvidersConfig = { - ...((settings.merged.modelProviders as ModelProvidersConfig | undefined) ?? - {}), - }; - - try { - for (const [key, value] of Object.entries(plan.env ?? {})) { - previousEnvValues.set(key, process.env[key]); - settings.setValue(persistScope, `env.${key}`, value); - process.env[key] = value; - } - - let updatedModelProviders: ModelProvidersConfig = { - ...((settings.merged.modelProviders as - | ModelProvidersConfig - | undefined) ?? {}), - }; - - for (const patch of plan.modelProviders ?? []) { - updatedModelProviders = applyModelProvidersPatch( - updatedModelProviders, - patch, - ); - settings.setValue( - persistScope, - `modelProviders.${patch.authType}`, - updatedModelProviders[patch.authType] ?? [], - ); - } - - settings.setValue( - persistScope, - 'security.auth.selectedType', - plan.authType, - ); - - if (plan.legacyCredentials?.apiKey != null) { - settings.setValue( - persistScope, - 'security.auth.apiKey', - plan.legacyCredentials.apiKey, - ); - } - - if (plan.legacyCredentials?.baseUrl != null) { - settings.setValue( - persistScope, - 'security.auth.baseUrl', - plan.legacyCredentials.baseUrl, - ); - } - - if (plan.modelSelection?.modelId) { - settings.setValue( - persistScope, - 'model.name', - plan.modelSelection.modelId, - ); - } - - for (const [key, entries] of Object.entries(plan.providerState ?? {})) { - for (const [field, value] of Object.entries(entries)) { - settings.setValue(persistScope, `${key}.${field}`, value); - } - } - - config.reloadModelProvidersConfig(updatedModelProviders); - if (plan.modelSelection?.modelId) { - config - .getModelsConfig() - .syncAfterAuthRefresh(plan.authType, plan.modelSelection.modelId); - } - if (refreshAuth) { - await config.refreshAuth(plan.authType); - } - - cleanupSettingsBackup(settingsFile.path); - - return { - persistScope, - updatedModelProviders, - }; - } catch (error) { - restoreSettingsFromBackup(settingsFile.path); - - // Restore in-memory settings state - settingsFile.settings = previousSettingsSnapshot; - settingsFile.originalSettings = previousOriginalSnapshot; - settings.recomputeMerged(); - - // Restore in-memory config state - config.reloadModelProvidersConfig(previousModelProviders); - - for (const [key, prev] of previousEnvValues) { - if (prev === undefined) { - delete process.env[key]; - } else { - process.env[key] = prev; - } - } - throw error; - } -} diff --git a/packages/cli/src/auth/providers/custom/customProvider.ts b/packages/cli/src/auth/providers/custom/customProvider.ts deleted file mode 100644 index 4b1ad1b790..0000000000 --- a/packages/cli/src/auth/providers/custom/customProvider.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; - -export const CUSTOM_API_KEY_ENV_PREFIX = 'QWEN_CUSTOM_API_KEY_'; - -export function generateCustomEnvKey( - protocol: AuthType, - baseUrl: string, -): string { - const normalize = (value: string) => - value - .trim() - .toUpperCase() - .replace(/[^A-Z0-9]+/g, '_') - .replace(/_+/g, '_') - .replace(/^_+|_+$/g, ''); - - return `${CUSTOM_API_KEY_ENV_PREFIX}${normalize(protocol)}_${normalize(baseUrl)}`; -} - -export const customProvider: ProviderConfig = { - id: 'custom-openai-compatible', - label: 'Custom Provider', - description: - 'Manually connect a local server, proxy, or unsupported provider', - protocol: AuthType.USE_OPENAI, - protocolOptions: [ - AuthType.USE_OPENAI, - AuthType.USE_ANTHROPIC, - AuthType.USE_GEMINI, - ], - baseUrl: undefined, - envKey: generateCustomEnvKey, - authMethod: 'input', - models: undefined, - modelNamePrefix: '', - showAdvancedConfig: true, - uiGroup: 'custom', -}; diff --git a/packages/cli/src/auth/providers/oauth/openrouter.test.ts b/packages/cli/src/auth/providers/oauth/openrouter.test.ts deleted file mode 100644 index 16c515dffb..0000000000 --- a/packages/cli/src/auth/providers/oauth/openrouter.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { - createOpenRouterProviderInstallPlan, - openRouterProvider, -} from './openrouter.js'; - -vi.mock('./openrouterOAuth.js', () => ({ - getOpenRouterModelsWithFallback: vi.fn(), - getPreferredOpenRouterModelId: vi.fn((models) => models[0]?.id), - OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', - OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', - selectRecommendedOpenRouterModels: vi.fn((models) => models.slice(0, 1)), -})); - -describe('openRouterProvider', () => { - it('creates an install plan for recommended OpenRouter models', async () => { - const plan = await createOpenRouterProviderInstallPlan({ - apiKey: 'or-key', - models: [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - }); - - expect(plan).toEqual({ - providerId: 'openrouter', - authType: AuthType.USE_OPENAI, - env: { - OPENROUTER_API_KEY: 'or-key', - }, - modelSelection: { - modelId: 'z-ai/glm-4.5-air:free', - }, - modelProviders: [ - { - authType: AuthType.USE_OPENAI, - models: [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - mergeStrategy: 'prepend-and-remove-owned', - ownsModel: expect.any(Function), - }, - ], - }); - }); - - it('owns models by OpenRouter base URL', () => { - expect( - openRouterProvider.ownsModel?.({ - id: 'openrouter-model', - baseUrl: 'https://openrouter.ai/api/v1', - }), - ).toBe(true); - expect( - openRouterProvider.ownsModel?.({ - id: 'other-model', - baseUrl: 'https://api.example.com/v1', - }), - ).toBe(false); - }); -}); diff --git a/packages/cli/src/auth/providers/oauth/openrouter.ts b/packages/cli/src/auth/providers/oauth/openrouter.ts deleted file mode 100644 index 787406135a..0000000000 --- a/packages/cli/src/auth/providers/oauth/openrouter.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AuthType, type ProviderModelConfig } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; -import { buildInstallPlan } from '../../providerConfig.js'; -import { - OPENROUTER_ENV_KEY, - OPENROUTER_BASE_URL, - getOpenRouterModelsWithFallback, - selectRecommendedOpenRouterModels, - getPreferredOpenRouterModelId, -} from './openrouterOAuth.js'; -import type { ProviderInstallPlan } from '../../types.js'; - -export { OPENROUTER_ENV_KEY, OPENROUTER_BASE_URL }; - -export const openRouterProvider: ProviderConfig = { - id: 'openrouter', - label: 'OpenRouter', - description: 'Browser OAuth · Auto-configure API key and OpenRouter models', - protocol: AuthType.USE_OPENAI, - baseUrl: OPENROUTER_BASE_URL, - envKey: OPENROUTER_ENV_KEY, - authMethod: 'oauth', - models: undefined, - modelNamePrefix: 'OpenRouter', - ownsModel: (model) => (model.baseUrl ?? '').includes('openrouter.ai'), - uiGroup: 'oauth', -}; - -export async function createOpenRouterProviderInstallPlan({ - apiKey, - models, -}: { - apiKey: string; - models?: ProviderModelConfig[]; -}): Promise { - const catalog = models ?? (await getOpenRouterModelsWithFallback()); - const recommended = selectRecommendedOpenRouterModels(catalog); - const preferredId = getPreferredOpenRouterModelId(recommended); - - return buildInstallPlan(openRouterProvider, { - baseUrl: OPENROUTER_BASE_URL, - apiKey, - modelIds: preferredId ? [preferredId] : [], - prebuiltModels: recommended, - }); -} diff --git a/packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts b/packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts deleted file mode 100644 index 207667e075..0000000000 --- a/packages/cli/src/auth/providers/oauth/openrouterOAuth.test.ts +++ /dev/null @@ -1,774 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - buildOpenRouterAuthorizationUrl, - createOpenRouterOAuthSession, - createOAuthState, - createPkcePair, - exchangeAuthCodeForApiKey, - fetchOpenRouterModels, - getOpenRouterModelsWithFallback, - getPreferredOpenRouterModelId, - mergeOpenRouterConfigs, - OPENROUTER_DEFAULT_MODELS, - OPENROUTER_MODELS_URL, - OPENROUTER_OAUTH_AUTHORIZE_URL, - OPENROUTER_OAUTH_CALLBACK_PORT, - OPENROUTER_OAUTH_EXCHANGE_URL, - runOpenRouterOAuthLogin, - selectRecommendedOpenRouterModels, - startOAuthCallbackListener, - startOAuthCallbackListenerWithRetry, - type OAuthCallbackListenerWithPort, -} from './openrouterOAuth.js'; -import { request } from 'node:http'; - -describe('openrouterOAuth', () => { - beforeEach(() => { - vi.unstubAllGlobals(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('creates a valid PKCE pair', () => { - const pkce = createPkcePair(); - - expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9\-_]+$/); - expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9\-_]+$/); - expect(pkce.codeVerifier.length).toBeGreaterThan(20); - expect(pkce.codeChallenge.length).toBeGreaterThan(20); - }); - - it('builds OpenRouter authorization URL with required params', () => { - const url = buildOpenRouterAuthorizationUrl({ - callbackUrl: 'http://localhost:3000/openrouter/callback', - codeChallenge: 'challenge123', - state: 'state-123', - codeChallengeMethod: 'S256', - limit: 100, - }); - - const parsed = new URL(url); - expect(parsed.origin + parsed.pathname).toBe( - OPENROUTER_OAUTH_AUTHORIZE_URL, - ); - expect(parsed.searchParams.get('callback_url')).toBe( - 'http://localhost:3000/openrouter/callback', - ); - expect(parsed.searchParams.get('code_challenge')).toBe('challenge123'); - expect(parsed.searchParams.get('state')).toBe('state-123'); - expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); - expect(parsed.searchParams.get('limit')).toBe('100'); - }); - - it('creates a random OAuth state token', () => { - const state = createOAuthState(); - - expect(state).toMatch(/^[A-Za-z0-9\-_]+$/); - expect(state.length).toBeGreaterThan(20); - }); - - it('exchanges auth code for API key', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - key: 'or-key-123', - user_id: 'user-1', - }), - })); - vi.stubGlobal('fetch', fetchMock); - - const result = await exchangeAuthCodeForApiKey({ - code: 'auth-code-123', - codeVerifier: 'verifier-123', - }); - - expect(fetchMock).toHaveBeenCalledWith( - OPENROUTER_OAUTH_EXCHANGE_URL, - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - }), - ); - expect(result).toEqual({ - apiKey: 'or-key-123', - userId: 'user-1', - }); - expect(typeof result.apiKey).toBe('string'); - }); - - it('throws when exchange response does not contain key', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => ({ - ok: true, - json: async () => ({}), - })), - ); - - await expect( - exchangeAuthCodeForApiKey({ - code: 'auth-code-123', - codeVerifier: 'verifier-123', - }), - ).rejects.toThrow('no key was returned'); - }); - - it('resolves callback code without waiting for server close completion', async () => { - const listener = startOAuthCallbackListener( - 'http://localhost:3100/openrouter/callback', - 5000, - 'state-123', - ); - await listener.ready; - - const codePromise = listener.waitForCode; - await new Promise((resolve, reject) => { - const req = request( - 'http://localhost:3100/openrouter/callback?code=fast-code-123&state=state-123', - (res) => { - res.resume(); - res.on('end', resolve); - }, - ); - req.on('error', reject); - req.end(); - }); - - await expect(codePromise).resolves.toBe('fast-code-123'); - }); - - it('rejects callback codes with mismatched OAuth state', async () => { - const listener = startOAuthCallbackListener( - 'http://localhost:3101/openrouter/callback', - 5000, - 'expected-state', - ); - await listener.ready; - - const codePromise = listener.waitForCode.catch((error: unknown) => error); - await new Promise((resolve, reject) => { - const req = request( - 'http://localhost:3101/openrouter/callback?code=fast-code-123&state=wrong-state', - (res) => { - expect(res.statusCode).toBe(400); - res.resume(); - res.on('end', resolve); - }, - ); - req.on('error', reject); - req.end(); - }); - - await expect(codePromise).resolves.toEqual( - expect.objectContaining({ - message: expect.stringContaining('Invalid OAuth state'), - }), - ); - }, 15_000); - - it('creates a reusable OAuth session for manual fallback links', () => { - const session = createOpenRouterOAuthSession( - 'http://localhost:3000/openrouter/callback', - { - codeVerifier: 'verifier-123', - codeChallenge: 'challenge-123', - }, - 'state-123', - ); - - expect(session).toEqual({ - callbackUrl: 'http://localhost:3000/openrouter/callback', - codeVerifier: 'verifier-123', - state: 'state-123', - authorizationUrl: expect.stringContaining('code_challenge=challenge-123'), - }); - expect(session.authorizationUrl).toContain('state=state-123'); - }); - - it('returns OAuth result without waiting for slow listener close', async () => { - let resolveClose!: () => void; - const listener: OAuthCallbackListenerWithPort = { - ready: Promise.resolve(), - waitForCode: Promise.resolve('auth-code-123'), - close: vi.fn( - () => - new Promise((resolve) => { - resolveClose = resolve; - }), - ), - port: OPENROUTER_OAUTH_CALLBACK_PORT, - }; - const openBrowser = vi.fn(async () => ({}) as never); - const exchangeApiKey = vi.fn(async () => ({ - apiKey: 'or-key-123', - userId: 'user-1', - })); - const resultPromise = runOpenRouterOAuthLogin( - 'http://localhost:3000/openrouter/callback', - { - openBrowser, - startListener: vi.fn(async () => listener), - exchangeApiKey, - now: () => 1000, - }, - ); - - await expect(resultPromise).resolves.toMatchObject({ - apiKey: 'or-key-123', - userId: 'user-1', - authorizationUrl: expect.stringContaining('https://openrouter.ai/auth'), - }); - expect(listener.close).toHaveBeenCalled(); - resolveClose(); - }); - - it('passes the session state to the OAuth callback listener', async () => { - const listener: OAuthCallbackListenerWithPort = { - ready: Promise.resolve(), - waitForCode: Promise.resolve('auth-code-123'), - close: vi.fn(async () => undefined), - port: OPENROUTER_OAUTH_CALLBACK_PORT, - }; - const openBrowser = vi.fn(async () => ({}) as never); - const startListener = vi.fn(async () => listener); - const exchangeApiKey = vi.fn(async () => ({ - apiKey: 'or-key-123', - userId: 'user-1', - })); - - await runOpenRouterOAuthLogin('http://localhost:3000/openrouter/callback', { - openBrowser, - startListener, - exchangeApiKey, - session: { - callbackUrl: 'http://localhost:3000/openrouter/callback', - codeVerifier: 'verifier-123', - state: 'state-123', - authorizationUrl: - 'https://openrouter.ai/auth?state=state-123&code_challenge=challenge-123', - }, - }); - - expect(startListener).toHaveBeenCalledWith( - 'http://localhost:3000/openrouter/callback', - expect.any(Number), - 'state-123', - ); - }); - - it('records wait and exchange timings during OAuth login', async () => { - const listener: OAuthCallbackListenerWithPort = { - ready: Promise.resolve(), - waitForCode: Promise.resolve('auth-code-123'), - close: vi.fn(async () => undefined), - port: OPENROUTER_OAUTH_CALLBACK_PORT, - }; - const openBrowser = vi.fn(async () => ({}) as never); - const exchangeApiKey = vi.fn(async () => ({ - apiKey: 'or-key-123', - userId: 'user-1', - })); - const now = vi - .fn<() => number>() - .mockReturnValueOnce(1000) - .mockReturnValueOnce(2200) - .mockReturnValueOnce(3000) - .mockReturnValueOnce(3450); - - const result = await runOpenRouterOAuthLogin( - 'http://localhost:3000/openrouter/callback', - { - openBrowser, - startListener: async () => listener, - exchangeApiKey, - now, - }, - ); - - expect(openBrowser).toHaveBeenCalledWith( - expect.stringContaining('https://openrouter.ai/auth'), - ); - expect(exchangeApiKey).toHaveBeenCalledWith({ - code: 'auth-code-123', - codeVerifier: expect.any(String), - }); - expect(result).toEqual({ - apiKey: 'or-key-123', - userId: 'user-1', - authorizationUrl: expect.stringContaining('https://openrouter.ai/auth'), - authorizationCodeWaitMs: 1200, - apiKeyExchangeMs: 450, - }); - expect(listener.close).toHaveBeenCalled(); - }); - - it('allows cancelling OAuth wait with process signals after opening the browser', async () => { - let sigintHandler: ((signal: NodeJS.Signals) => void) | undefined; - let sigtermHandler: ((signal: NodeJS.Signals) => void) | undefined; - const signalTarget = { - once: vi.fn( - ( - event: 'SIGINT' | 'SIGTERM', - handler: (signal: NodeJS.Signals) => void, - ) => { - if (event === 'SIGINT') { - sigintHandler = handler; - } else { - sigtermHandler = handler; - } - }, - ), - removeListener: vi.fn( - ( - _event: 'SIGINT' | 'SIGTERM', - _handler: (signal: NodeJS.Signals) => void, - ) => undefined, - ), - }; - const listener: OAuthCallbackListenerWithPort = { - ready: Promise.resolve(), - waitForCode: new Promise(() => undefined), - close: vi.fn(async () => undefined), - port: OPENROUTER_OAUTH_CALLBACK_PORT, - }; - const openBrowser = vi.fn(async () => ({}) as never); - const exchangeApiKey = vi.fn(); - - const resultPromise = runOpenRouterOAuthLogin( - 'http://localhost:3000/openrouter/callback', - { - openBrowser, - startListener: async () => listener, - exchangeApiKey, - signalTarget, - }, - ); - - await vi.waitFor(() => { - expect(openBrowser).toHaveBeenCalledTimes(1); - expect(sigintHandler).toBeTypeOf('function'); - expect(sigtermHandler).toBeTypeOf('function'); - }); - - sigintHandler?.('SIGINT'); - - await expect(resultPromise).rejects.toThrow( - 'OpenRouter OAuth cancelled by user (SIGINT) while waiting for browser authorization.', - ); - expect(exchangeApiKey).not.toHaveBeenCalled(); - expect(listener.close).toHaveBeenCalled(); - expect(signalTarget.removeListener).toHaveBeenCalledWith( - 'SIGINT', - sigintHandler, - ); - expect(signalTarget.removeListener).toHaveBeenCalledWith( - 'SIGTERM', - sigtermHandler, - ); - }); - - it('allows cancelling OAuth wait with an abort signal', async () => { - const abortController = new AbortController(); - const listener: OAuthCallbackListenerWithPort = { - ready: Promise.resolve(), - waitForCode: new Promise(() => undefined), - close: vi.fn(async () => undefined), - port: OPENROUTER_OAUTH_CALLBACK_PORT, - }; - const openBrowser = vi.fn(async () => ({}) as never); - const exchangeApiKey = vi.fn(); - - const resultPromise = runOpenRouterOAuthLogin( - 'http://localhost:3000/openrouter/callback', - { - openBrowser, - startListener: async () => listener, - exchangeApiKey, - abortSignal: abortController.signal, - }, - ); - - await vi.waitFor(() => { - expect(openBrowser).toHaveBeenCalledTimes(1); - }); - - abortController.abort(); - - await expect(resultPromise).rejects.toMatchObject({ - name: 'AbortError', - message: 'OpenRouter OAuth cancelled.', - }); - expect(exchangeApiKey).not.toHaveBeenCalled(); - expect(listener.close).toHaveBeenCalled(); - }); - - it('fetches dynamic OpenRouter text models with free-first ordering', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - data: [ - { - id: 'openai/gpt-5-mini', - name: 'GPT-5 Mini', - context_length: 128000, - architecture: { - input_modalities: ['text', 'image'], - output_modalities: ['text'], - }, - pricing: { - prompt: '0.000001', - completion: '0.000003', - }, - }, - { - id: 'minimax/minimax-m1', - name: 'MiniMax M1', - architecture: { - input_modalities: ['text'], - output_modalities: ['text'], - }, - pricing: { - prompt: '0', - completion: '0', - }, - }, - { - id: 'qwen/qwen3-coder:free', - name: 'Qwen3 Coder', - architecture: { - input_modalities: ['text'], - output_modalities: ['text'], - }, - pricing: { - prompt: '0', - completion: '0', - }, - }, - { - id: 'zhipu/glm-4.5', - name: 'GLM 4.5', - architecture: { - input_modalities: ['text'], - output_modalities: ['text'], - }, - pricing: { - prompt: '0.000002', - completion: '0.000004', - }, - }, - { - id: 'black-forest-labs/flux', - name: 'Flux', - architecture: { - input_modalities: ['text'], - output_modalities: ['image'], - }, - }, - ], - }), - })); - vi.stubGlobal('fetch', fetchMock); - - const models = await fetchOpenRouterModels(); - - expect(fetchMock).toHaveBeenCalledWith( - OPENROUTER_MODELS_URL, - expect.objectContaining({ method: 'GET' }), - ); - expect(models).toEqual([ - { - id: 'qwen/qwen3-coder:free', - name: 'OpenRouter · Qwen3 Coder', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'minimax/minimax-m1', - name: 'OpenRouter · MiniMax M1', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-5-mini', - name: 'OpenRouter · GPT-5 Mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - capabilities: { vision: true }, - generationConfig: { contextWindowSize: 128000 }, - }, - { - id: 'zhipu/glm-4.5', - name: 'OpenRouter · GLM 4.5', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ]); - }); - - it('selects verified free OpenRouter models', () => { - const recommended = selectRecommendedOpenRouterModels([ - { - id: 'qwen/qwen3-max', - name: 'OpenRouter · Qwen3 Max', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter · GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-5-mini', - name: 'OpenRouter · GPT-5 Mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - capabilities: { vision: true }, - }, - ]); - - expect(recommended.map((model) => model.id)).toEqual([ - 'z-ai/glm-4.5-air:free', - 'openai/gpt-oss-120b:free', - ]); - }); - - it('fills missing preferred free OpenRouter models with other free models', () => { - const recommended = selectRecommendedOpenRouterModels([ - { - id: 'custom/experimental-free-model:free', - name: 'OpenRouter · Experimental Free Model', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'anthropic/claude-3.7-sonnet', - name: 'OpenRouter · Claude 3.7 Sonnet', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ]); - - expect(recommended.map((model) => model.id)).toEqual([ - 'z-ai/glm-4.5-air:free', - 'custom/experimental-free-model:free', - ]); - }); - - it('prefers the default OpenRouter model when it remains enabled', () => { - expect( - getPreferredOpenRouterModelId([ - { id: 'openai/gpt-oss-120b:free' }, - { id: 'z-ai/glm-4.5-air:free' }, - ] as never), - ).toBe('z-ai/glm-4.5-air:free'); - }); - - it('falls back to the first enabled OpenRouter model when the default is unavailable', () => { - expect( - getPreferredOpenRouterModelId([ - { id: 'openai/gpt-oss-120b:free' }, - ] as never), - ).toBe('openai/gpt-oss-120b:free'); - }); - - it('falls back to default models when dynamic fetch fails', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => ({ - ok: false, - status: 500, - text: async () => 'server error', - })), - ); - - await expect(getOpenRouterModelsWithFallback()).resolves.toEqual( - OPENROUTER_DEFAULT_MODELS, - ); - }); - - it('replaces only existing OpenRouter configs when merging dynamic models', () => { - const merged = mergeOpenRouterConfigs( - [ - { - id: 'old/model', - name: 'Old OpenRouter Model', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ], - [ - { - id: 'openai/gpt-5-mini', - name: 'OpenRouter · GPT-5 Mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - ); - - expect(merged).toEqual([ - { - id: 'openai/gpt-5-mini', - name: 'OpenRouter · GPT-5 Mini', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'gpt-4.1', - name: 'OpenAI GPT-4.1', - baseUrl: 'https://api.openai.com/v1', - envKey: 'OPENAI_API_KEY', - }, - ]); - }); - - it('returns 404 for non-callback paths', async () => { - const listener = startOAuthCallbackListener( - 'http://localhost:3102/openrouter/callback', - 5000, - 'state-123', - ); - await listener.ready; - - const status = await new Promise((resolve, reject) => { - const req = request('http://localhost:3102/wrong-path', (res) => { - resolve(res.statusCode!); - res.resume(); - }); - req.on('error', reject); - req.end(); - }); - - expect(status).toBe(404); - await listener.close(); - }); - - it('rejects with error when OpenRouter returns an error parameter', async () => { - const listener = startOAuthCallbackListener( - 'http://localhost:3103/openrouter/callback', - 5000, - 'state-123', - ); - await listener.ready; - - const codePromise = listener.waitForCode.catch((err: unknown) => err); - await new Promise((resolve, reject) => { - const req = request( - 'http://localhost:3103/openrouter/callback?error=access_denied&state=state-123', - (res) => { - expect(res.statusCode).toBe(400); - res.resume(); - res.on('end', resolve); - }, - ); - req.on('error', reject); - req.end(); - }); - - await expect(codePromise).resolves.toEqual( - expect.objectContaining({ - message: expect.stringContaining('access_denied'), - }), - ); - }); - - it('rejects with missing code error', async () => { - const listener = startOAuthCallbackListener( - 'http://localhost:3104/openrouter/callback', - 5000, - 'state-123', - ); - await listener.ready; - - const codePromise = listener.waitForCode.catch((err: unknown) => err); - await new Promise((resolve, reject) => { - const req = request( - 'http://localhost:3104/openrouter/callback?state=state-123', - (res) => { - expect(res.statusCode).toBe(400); - res.resume(); - res.on('end', resolve); - }, - ); - req.on('error', reject); - req.end(); - }); - - await expect(codePromise).resolves.toEqual( - expect.objectContaining({ - message: expect.stringContaining('Missing authorization code'), - }), - ); - }); - - it('retries ports when address is in use', async () => { - const blockingListener = startOAuthCallbackListener( - 'http://localhost:3150/openrouter/callback', - 10000, - 'block-state', - ); - await blockingListener.ready; - - try { - const retried = await startOAuthCallbackListenerWithRetry( - 'http://localhost:3150/openrouter/callback', - 5000, - 'retry-state', - 5, - ); - - expect(retried.port).toBeGreaterThan(3150); - await retried.close(); - } finally { - await blockingListener.close(); - } - }); - - it('throws non-http protocol error', () => { - expect(() => - startOAuthCallbackListener( - 'https://localhost:3000/callback', - 5000, - 'state-123', - ), - ).toThrow('Only http localhost callback URLs are currently supported.'); - }); -}); diff --git a/packages/cli/src/auth/providers/oauth/openrouterOAuth.ts b/packages/cli/src/auth/providers/oauth/openrouterOAuth.ts deleted file mode 100644 index a10dd5186d..0000000000 --- a/packages/cli/src/auth/providers/oauth/openrouterOAuth.ts +++ /dev/null @@ -1,734 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createServer, type Server } from 'node:http'; -import { createHash, randomBytes } from 'node:crypto'; -import open from 'open'; - -import { type ProviderModelConfig as ModelConfig } from '@qwen-code/qwen-code-core'; - -export const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; -export const OPENROUTER_DEFAULT_MODEL = 'z-ai/glm-4.5-air:free'; -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; -export const OPENROUTER_OAUTH_AUTHORIZE_URL = 'https://openrouter.ai/auth'; -export const OPENROUTER_OAUTH_EXCHANGE_URL = - 'https://openrouter.ai/api/v1/auth/keys'; -export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'; -export const OPENROUTER_OAUTH_CALLBACK_PORT = 3000; -const OPENROUTER_OAUTH_CALLBACK_PORT_RETRIES = 10; -export const OPENROUTER_OAUTH_CALLBACK_URL = `http://localhost:${OPENROUTER_OAUTH_CALLBACK_PORT}/openrouter/callback`; -const OPENROUTER_CODE_CHALLENGE_METHOD = 'S256'; -const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; -const OPENROUTER_MINIMUM_TEXT_MODELS = 1; - -export const OPENROUTER_DEFAULT_MODELS: ModelConfig[] = [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: OPENROUTER_BASE_URL, - envKey: OPENROUTER_ENV_KEY, - generationConfig: { contextWindowSize: 128000 }, - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter · GPT OSS 120B', - baseUrl: OPENROUTER_BASE_URL, - envKey: OPENROUTER_ENV_KEY, - generationConfig: { contextWindowSize: 131072 }, - }, -]; - -export interface OpenRouterOAuthResult { - apiKey: string; - userId?: string; - authorizationUrl?: string; - authorizationCodeWaitMs?: number; - apiKeyExchangeMs?: number; -} - -export interface PkcePair { - codeVerifier: string; - codeChallenge: string; -} - -export interface OpenRouterOAuthSession { - callbackUrl: string; - codeVerifier: string; - state: string; - authorizationUrl: string; -} - -export interface OAuthCallbackListener { - ready: Promise; - waitForCode: Promise; - close: () => Promise; -} - -interface OpenRouterModelApiRecord { - id?: string; - name?: string; - description?: string; - context_length?: number; - architecture?: { - input_modalities?: string[]; - output_modalities?: string[]; - }; - pricing?: { - prompt?: string; - completion?: string; - }; -} - -function toBase64Url(input: Buffer): string { - return input - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} - -export function createPkcePair(): PkcePair { - const codeVerifier = toBase64Url(randomBytes(32)); - const codeChallenge = toBase64Url( - createHash('sha256').update(codeVerifier).digest(), - ); - return { codeVerifier, codeChallenge }; -} - -export function buildOpenRouterAuthorizationUrl(params: { - callbackUrl: string; - codeChallenge: string; - state: string; - codeChallengeMethod?: 'S256'; - limit?: number; -}): string { - const url = new URL(OPENROUTER_OAUTH_AUTHORIZE_URL); - url.searchParams.set('callback_url', params.callbackUrl); - url.searchParams.set('code_challenge', params.codeChallenge); - url.searchParams.set('state', params.state); - url.searchParams.set( - 'code_challenge_method', - params.codeChallengeMethod || OPENROUTER_CODE_CHALLENGE_METHOD, - ); - if (typeof params.limit === 'number') { - url.searchParams.set('limit', String(params.limit)); - } - return url.toString(); -} - -export function createOAuthState(): string { - return toBase64Url(randomBytes(32)); -} - -export function createOpenRouterOAuthSession( - callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, - pkcePair = createPkcePair(), - state = createOAuthState(), -): OpenRouterOAuthSession { - return { - callbackUrl, - codeVerifier: pkcePair.codeVerifier, - state, - authorizationUrl: buildOpenRouterAuthorizationUrl({ - callbackUrl, - codeChallenge: pkcePair.codeChallenge, - state, - codeChallengeMethod: OPENROUTER_CODE_CHALLENGE_METHOD, - }), - }; -} - -export interface OAuthCallbackListenerWithPort extends OAuthCallbackListener { - /** The actual port the server bound to (may differ from the requested port). */ - port: number; -} - -function createOAuthCallbackServer( - parsedUrl: URL, - expectedState: string, - port: number, - timeoutMs: number, -): OAuthCallbackListenerWithPort { - let server: Server | undefined; - let timeout: NodeJS.Timeout | undefined; - let settled = false; - - const close = async () => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - if (!server) { - return; - } - await new Promise((resolve, reject) => { - server!.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - server = undefined; - }; - - let resolveReady!: () => void; - let rejectReady!: (error: Error) => void; - const ready = new Promise((resolve, reject) => { - resolveReady = resolve; - rejectReady = reject; - }); - - let resolveCode!: (code: string) => void; - let rejectCode!: (error: Error) => void; - const waitForCode = new Promise((resolve, reject) => { - resolveCode = resolve; - rejectCode = reject; - }); - - const finish = (action: 'resolve' | 'reject', payload: string | Error) => { - if (settled) { - return; - } - settled = true; - - if (action === 'resolve') { - resolveCode(payload as string); - } else { - rejectCode(payload as Error); - } - - void close().catch(() => undefined); - }; - - server = createServer((req, res) => { - const requestUrl = new URL(req.url || '/', parsedUrl.origin); - if (requestUrl.pathname !== parsedUrl.pathname) { - res.statusCode = 404; - res.end('Not found'); - return; - } - - const error = requestUrl.searchParams.get('error'); - if (error) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end(`OpenRouter authorization failed: ${error}`); - void finish( - 'reject', - new Error(`OpenRouter authorization failed: ${error}`), - ); - return; - } - - const callbackState = requestUrl.searchParams.get('state'); - if (callbackState !== expectedState) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Invalid OAuth state.'); - void finish( - 'reject', - new Error('Invalid OAuth state from OpenRouter callback.'), - ); - return; - } - - const code = requestUrl.searchParams.get('code'); - if (!code) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Missing authorization code.'); - void finish( - 'reject', - new Error('Missing authorization code from OpenRouter callback.'), - ); - return; - } - - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end( - '

OpenRouter authentication complete.

You can return to Qwen Code.

', - ); - void finish('resolve', code); - }); - - server.once('error', (error) => { - const err = error instanceof Error ? error : new Error(String(error)); - rejectReady(err); - void finish('reject', err); - waitForCode.catch(() => undefined); - }); - - server.listen(port, parsedUrl.hostname, () => { - resolveReady(); - }); - - timeout = setTimeout(() => { - void finish( - 'reject', - new Error('Timed out waiting for OpenRouter OAuth callback.'), - ); - }, timeoutMs); - - return { - ready, - waitForCode, - close, - port, - }; -} - -export function startOAuthCallbackListener( - callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, - timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, - expectedState: string, -): OAuthCallbackListenerWithPort { - const parsedUrl = new URL(callbackUrl); - if (parsedUrl.protocol !== 'http:') { - throw new Error( - 'Only http localhost callback URLs are currently supported.', - ); - } - - const port = parsedUrl.port ? Number(parsedUrl.port) : 80; - return createOAuthCallbackServer(parsedUrl, expectedState, port, timeoutMs); -} - -export async function startOAuthCallbackListenerWithRetry( - callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, - timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, - expectedState: string, - maxRetries = OPENROUTER_OAUTH_CALLBACK_PORT_RETRIES, -): Promise { - const parsedUrl = new URL(callbackUrl); - if (parsedUrl.protocol !== 'http:') { - throw new Error( - 'Only http localhost callback URLs are currently supported.', - ); - } - - const basePort = parsedUrl.port ? Number(parsedUrl.port) : 80; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const port = basePort + attempt; - const listener = createOAuthCallbackServer( - parsedUrl, - expectedState, - port, - timeoutMs, - ); - try { - await listener.ready; - return listener; - } catch (error: unknown) { - const isAddrInUse = - error instanceof Error && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'EADDRINUSE'; - if (!isAddrInUse || attempt === maxRetries) { - throw error; - } - } - } - - throw new Error( - `Could not find an available port (tried ${basePort}–${basePort + maxRetries}).`, - ); -} - -function buildOpenRouterHeaders() { - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-OpenRouter-Title': 'Qwen Code', - }; -} - -const OPENROUTER_RECOMMENDED_FREE_MODEL_IDS = [ - 'z-ai/glm-4.5-air:free', - 'openai/gpt-oss-120b:free', -]; -const OPENROUTER_RECOMMENDED_MODEL_LIMIT = - OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.length; -const OPENROUTER_FREE_MODEL_ID_HINT = ':free'; - -export function getPreferredOpenRouterModelId( - models: ModelConfig[], -): string | undefined { - return ( - models.find((model) => model.id === OPENROUTER_DEFAULT_MODEL)?.id || - models[0]?.id - ); -} - -function isOpenRouterFreeModelId(modelId: string): boolean { - const normalizedId = modelId.toLowerCase(); - return ( - normalizedId.includes(OPENROUTER_FREE_MODEL_ID_HINT) || - normalizedId === 'openrouter/free' - ); -} - -function getOpenRouterRecommendedFreeModelPriority(modelId: string): number { - const normalizedId = modelId.toLowerCase(); - const matchedIndex = OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.findIndex( - (recommendedId) => recommendedId === normalizedId, - ); - return matchedIndex === -1 - ? OPENROUTER_RECOMMENDED_FREE_MODEL_IDS.length - : matchedIndex; -} - -function isOpenRouterFreeConfig(model: ModelConfig): boolean { - return isOpenRouterFreeModelId(model.id); -} - -function compareOpenRouterModels(a: ModelConfig, b: ModelConfig): number { - const recommendedFreeDiff = - getOpenRouterRecommendedFreeModelPriority(a.id) - - getOpenRouterRecommendedFreeModelPriority(b.id); - if (recommendedFreeDiff !== 0) { - return recommendedFreeDiff; - } - - const freeDiff = - Number(isOpenRouterFreeConfig(b)) - Number(isOpenRouterFreeConfig(a)); - if (freeDiff !== 0) { - return freeDiff; - } - - return a.id.localeCompare(b.id); -} - -function toOpenRouterModelConfig( - model: OpenRouterModelApiRecord, -): ModelConfig | null { - if (!model.id) { - return null; - } - - const outputModalities = model.architecture?.output_modalities || []; - const supportsTextOutput = outputModalities.length - ? outputModalities.includes('text') - : true; - - if (!supportsTextOutput) { - return null; - } - - const inputModalities = model.architecture?.input_modalities || []; - const supportsVision = inputModalities.includes('image'); - - return { - id: model.id, - name: model.name - ? `OpenRouter · ${model.name}` - : `OpenRouter · ${model.id}`, - baseUrl: OPENROUTER_BASE_URL, - envKey: OPENROUTER_ENV_KEY, - capabilities: supportsVision ? { vision: true } : undefined, - generationConfig: - typeof model.context_length === 'number' - ? { contextWindowSize: model.context_length } - : undefined, - }; -} - -function addRecommendedModel( - target: ModelConfig[], - model: ModelConfig | undefined, - selectedIds: Set, - limit: number, -): void { - if (!model || selectedIds.has(model.id) || target.length >= limit) { - return; - } - target.push(model); - selectedIds.add(model.id); -} - -export function selectRecommendedOpenRouterModels( - models: ModelConfig[], - limit = OPENROUTER_RECOMMENDED_MODEL_LIMIT, -): ModelConfig[] { - const sorted = [...models].sort(compareOpenRouterModels); - const recommended: ModelConfig[] = []; - const selectedIds = new Set(); - - for (const recommendedId of OPENROUTER_RECOMMENDED_FREE_MODEL_IDS) { - addRecommendedModel( - recommended, - sorted.find( - (model) => - model.id.toLowerCase() === recommendedId && - isOpenRouterFreeConfig(model), - ), - selectedIds, - limit, - ); - } - - for (const model of sorted) { - if (recommended.length >= limit) { - break; - } - if (isOpenRouterFreeConfig(model)) { - addRecommendedModel(recommended, model, selectedIds, limit); - } - } - - // Fallback: if no free models found, pick top non-free models so the user - // has at least something usable after completing OAuth. - if (recommended.length === 0) { - for (const model of sorted) { - if (recommended.length >= limit) { - break; - } - addRecommendedModel(recommended, model, selectedIds, limit); - } - } - - return recommended; -} - -export function isOpenRouterConfig(config: ModelConfig): boolean { - return (config.baseUrl || '').includes('openrouter.ai'); -} - -export function mergeOpenRouterConfigs( - existingConfigs: ModelConfig[], - openRouterModels = OPENROUTER_DEFAULT_MODELS, -): ModelConfig[] { - const nonOpenRouterConfigs = existingConfigs.filter( - (existing) => !isOpenRouterConfig(existing), - ); - return [...openRouterModels, ...nonOpenRouterConfigs]; -} - -export async function fetchOpenRouterModels(): Promise { - const response = await fetch(OPENROUTER_MODELS_URL, { - method: 'GET', - headers: buildOpenRouterHeaders(), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `OpenRouter models request failed (${response.status}): ${errorText}`, - ); - } - - const data = (await response.json()) as { - data?: OpenRouterModelApiRecord[]; - }; - const records = Array.isArray(data.data) ? data.data : []; - const models = records - .map((record) => toOpenRouterModelConfig(record)) - .filter((model): model is ModelConfig => model !== null) - .sort(compareOpenRouterModels); - - if (models.length < OPENROUTER_MINIMUM_TEXT_MODELS) { - throw new Error( - 'OpenRouter models request returned no usable text models.', - ); - } - - return models; -} - -export async function getOpenRouterModelsWithFallback(): Promise< - ModelConfig[] -> { - try { - return await fetchOpenRouterModels(); - } catch { - return OPENROUTER_DEFAULT_MODELS; - } -} - -export async function exchangeAuthCodeForApiKey(params: { - code: string; - codeVerifier: string; -}): Promise { - const response = await fetch(OPENROUTER_OAUTH_EXCHANGE_URL, { - method: 'POST', - headers: buildOpenRouterHeaders(), - body: JSON.stringify({ - code: params.code, - code_verifier: params.codeVerifier, - code_challenge_method: OPENROUTER_CODE_CHALLENGE_METHOD, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `OpenRouter API key exchange failed (${response.status}): ${errorText}`, - ); - } - - const data = (await response.json()) as { - key?: string; - user_id?: string; - }; - - if (!data.key) { - throw new Error( - 'OpenRouter API key exchange succeeded but no key was returned.', - ); - } - - return { - apiKey: data.key, - userId: data.user_id, - }; -} - -interface OAuthSignalTarget { - once(event: NodeJS.Signals, listener: (signal: NodeJS.Signals) => void): void; - removeListener( - event: NodeJS.Signals, - listener: (signal: NodeJS.Signals) => void, - ): void; -} - -export interface OpenRouterOAuthLoginDeps { - openBrowser?: typeof open; - startListener?: typeof startOAuthCallbackListenerWithRetry; - exchangeApiKey?: typeof exchangeAuthCodeForApiKey; - now?: () => number; - signalTarget?: OAuthSignalTarget; - abortSignal?: AbortSignal; - session?: OpenRouterOAuthSession; -} - -export async function runOpenRouterOAuthLogin( - callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, - deps: OpenRouterOAuthLoginDeps = {}, -): Promise { - const openBrowser = deps.openBrowser || open; - const startListener = - deps.startListener || startOAuthCallbackListenerWithRetry; - const exchangeApiKey = deps.exchangeApiKey || exchangeAuthCodeForApiKey; - const now = deps.now || Date.now; - const signalTarget = deps.signalTarget || process; - const abortSignal = deps.abortSignal; - - const pkcePair = createPkcePair(); - const state = createOAuthState(); - - const preSession = deps.session || { - callbackUrl, - codeVerifier: pkcePair.codeVerifier, - state, - }; - - const listener = await startListener( - preSession.callbackUrl, - OPENROUTER_OAUTH_TIMEOUT_MS, - preSession.state, - ); - - const portChanged = - listener.port !== - (new URL(preSession.callbackUrl).port - ? Number(new URL(preSession.callbackUrl).port) - : 80); - const actualCallbackUrl = portChanged - ? preSession.callbackUrl.replace(/:\d+/, `:${String(listener.port)}`) - : preSession.callbackUrl; - - let authUrl: string; - if (deps.session?.authorizationUrl && !portChanged) { - authUrl = deps.session.authorizationUrl; - } else { - const challenge = - deps.session != null - ? new URL(deps.session.authorizationUrl).searchParams.get( - 'code_challenge', - )! - : pkcePair.codeChallenge; - authUrl = buildOpenRouterAuthorizationUrl({ - callbackUrl: actualCallbackUrl, - codeChallenge: challenge, - state: preSession.state, - codeChallengeMethod: OPENROUTER_CODE_CHALLENGE_METHOD, - }); - } - - const codeVerifier = preSession.codeVerifier; - - let cleanupSignalHandlers = () => {}; - let cleanupAbortListener = () => {}; - try { - await openBrowser(authUrl); - - const waitForCancel = new Promise((_, reject) => { - const handleSignal = (signal: NodeJS.Signals) => { - reject( - new Error( - `OpenRouter OAuth cancelled by user (${signal}) while waiting for browser authorization.`, - ), - ); - }; - - signalTarget.once('SIGINT', handleSignal); - signalTarget.once('SIGTERM', handleSignal); - cleanupSignalHandlers = () => { - signalTarget.removeListener('SIGINT', handleSignal); - signalTarget.removeListener('SIGTERM', handleSignal); - }; - }); - - const waitForAbort = new Promise((_, reject) => { - if (!abortSignal) { - return; - } - - const handleAbort = () => { - reject(new DOMException('OpenRouter OAuth cancelled.', 'AbortError')); - }; - - if (abortSignal.aborted) { - handleAbort(); - return; - } - - abortSignal.addEventListener('abort', handleAbort, { once: true }); - cleanupAbortListener = () => { - abortSignal.removeEventListener('abort', handleAbort); - }; - }); - - const waitStartMs = now(); - const code = await Promise.race([ - listener.waitForCode, - waitForCancel, - waitForAbort, - ]); - cleanupSignalHandlers(); - cleanupAbortListener(); - const authorizationCodeWaitMs = now() - waitStartMs; - - const exchangeStartMs = now(); - const exchangeResult = await exchangeApiKey({ code, codeVerifier }); - const apiKeyExchangeMs = now() - exchangeStartMs; - - return { - ...exchangeResult, - authorizationUrl: authUrl, - authorizationCodeWaitMs, - apiKeyExchangeMs, - }; - } finally { - cleanupSignalHandlers(); - cleanupAbortListener(); - void listener.close().catch(() => undefined); - } -} diff --git a/packages/cli/src/auth/types.ts b/packages/cli/src/auth/types.ts deleted file mode 100644 index b0f6d3b96e..0000000000 --- a/packages/cli/src/auth/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - AuthType, - ModelProvidersConfig, - ProviderModelConfig, -} from '@qwen-code/qwen-code-core'; -import type { SettingScope, LoadedSettings } from '../config/settings.js'; - -export type ProviderId = string; - -export interface ProviderInstallPlan { - providerId: ProviderId; - authType: AuthType; - env?: Record; - legacyCredentials?: { - apiKey?: string; - baseUrl?: string; - }; - modelSelection?: { - modelId: string; - }; - modelProviders?: ProviderModelProvidersPatch[]; - providerState?: ProviderInstallState; - display?: { - successMessage?: string; - nextSteps?: string[]; - }; -} - -export interface ProviderModelProvidersPatch { - authType: AuthType; - models: ProviderModelConfig[]; - mergeStrategy: 'prepend-and-remove-owned' | 'replace-owned' | 'append'; - ownsModel?: (model: ProviderModelConfig) => boolean; -} - -/** - * Arbitrary key-value metadata to persist alongside a provider install. - * Each top-level key becomes a settings path prefix (e.g. `codingPlan.version`). - */ -export type ProviderInstallState = Record>; - -export interface ApplyProviderInstallPlanOptions { - settings: LoadedSettings; - config: { - reloadModelProvidersConfig: (mp: ModelProvidersConfig) => void; - getModelsConfig: () => { - syncAfterAuthRefresh: (authType: AuthType, modelId: string) => void; - }; - refreshAuth: (authType: AuthType) => Promise; - }; - scope?: SettingScope; - refreshAuth?: boolean; -} - -export interface ApplyProviderInstallPlanResult { - persistScope: SettingScope; - updatedModelProviders: ModelProvidersConfig; -} diff --git a/packages/cli/src/config/loadedSettingsAdapter.test.ts b/packages/cli/src/config/loadedSettingsAdapter.test.ts new file mode 100644 index 0000000000..b4445cab84 --- /dev/null +++ b/packages/cli/src/config/loadedSettingsAdapter.test.ts @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createLoadedSettingsAdapter } from './loadedSettingsAdapter.js'; +import { SettingScope } from './settings.js'; + +// settingsUtils makes real fs calls in backup/restore — stub them out so the +// tests can focus on adapter behavior without touching disk. +vi.mock('../utils/settingsUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), + }; +}); + +// Named shape so dot-access on the known keys (`env`, `modelProviders`) is not +// treated as access through an index signature — keeps the strict TS option +// `noPropertyAccessFromIndexSignature` happy while still allowing arbitrary +// extra keys via the index signature. +interface SettingsShape { + env?: Record; + modelProviders?: Record; + [key: string]: unknown; +} + +interface MutableSettingsFile { + settings: SettingsShape; + originalSettings: SettingsShape; + path: string; +} + +function makeSettings(initial: SettingsShape = {}) { + const file: MutableSettingsFile = { + settings: structuredClone(initial), + originalSettings: structuredClone(initial), + path: '/tmp/qwen-test-settings.json', + }; + const setValue = vi.fn( + (_scope: SettingScope, key: string, value: unknown) => { + const parts = key.split('.'); + let current: Record = file.settings as Record< + string, + unknown + >; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + // Mirror setNestedPropertySafe's reserved-segment rejection. Inline + // literal === comparisons (rather than e.g. Set.has) are what + // CodeQL's prototype-pollution sanitiser recognises, so we use them + // at the only step that actually writes to `current`. + if ( + part === '__proto__' || + part === 'constructor' || + part === 'prototype' + ) { + throw new Error(`mock setValue refused reserved segment in: ${key}`); + } + if (i === parts.length - 1) { + current[part] = value; + } else { + if (!current[part] || typeof current[part] !== 'object') { + current[part] = {}; + } + current = current[part] as Record; + } + } + file.originalSettings = structuredClone(file.settings); + }, + ); + const recomputeMerged = vi.fn(() => { + /* merged() is computed lazily via the getter below */ + }); + const settings = { + get merged() { + return file.settings; + }, + forScope: vi.fn(() => file), + setValue, + recomputeMerged, + }; + return { settings, file, setValue, recomputeMerged }; +} + +describe('createLoadedSettingsAdapter', () => { + it('forwards setValue to LoadedSettings.setValue with the resolved scope', () => { + const { settings, setValue } = makeSettings(); + const adapter = createLoadedSettingsAdapter( + settings as never, + SettingScope.User, + ); + adapter.setValue('env.MY_KEY', 'val'); + expect(setValue).toHaveBeenCalledWith( + SettingScope.User, + 'env.MY_KEY', + 'val', + ); + }); + + it('rejects prototype-pollution keys before reaching LoadedSettings', () => { + const { settings, setValue } = makeSettings(); + const adapter = createLoadedSettingsAdapter( + settings as never, + SettingScope.User, + ); + expect(() => adapter.setValue('__proto__.polluted', 'x')).toThrow( + /reserved segment/, + ); + expect(() => adapter.setValue('foo.constructor.bar', 'x')).toThrow( + /reserved segment/, + ); + expect(() => adapter.setValue('prototype.x', 'x')).toThrow( + /reserved segment/, + ); + // The guard short-circuits before delegating to LoadedSettings — that's the + // contract this test exists to lock in. + expect(setValue).not.toHaveBeenCalled(); + }); + + it('getValue reads from settings.merged via dotted key', () => { + const { settings } = makeSettings({ + env: { MY_KEY: 'from-merged' }, + modelProviders: { openai: [{ id: 'gpt' }] }, + }); + const adapter = createLoadedSettingsAdapter( + settings as never, + SettingScope.User, + ); + expect(adapter.getValue('env.MY_KEY')).toBe('from-merged'); + expect(adapter.getValue('modelProviders.openai')).toEqual([{ id: 'gpt' }]); + expect(adapter.getValue('missing.path')).toBeUndefined(); + }); + + it('backup() snapshots in-memory state; restore() reverts and recomputes merged', () => { + const { settings, file, recomputeMerged } = makeSettings({ + env: { ORIGINAL: '1' }, + }); + const adapter = createLoadedSettingsAdapter( + settings as never, + SettingScope.User, + ); + + // backup/restore/cleanupBackup are optional in the contract, but + // createLoadedSettingsAdapter always installs them — assert + use !. + expect(adapter.backup).toBeTypeOf('function'); + adapter.backup!(); + + // Simulate mutations that would happen during an install plan apply. + adapter.setValue('env.NEW_KEY', 'new-value'); + expect(file.settings.env).toEqual({ + ORIGINAL: '1', + NEW_KEY: 'new-value', + }); + + expect(adapter.restore).toBeTypeOf('function'); + adapter.restore!(); + + expect(file.settings).toEqual({ env: { ORIGINAL: '1' } }); + expect(file.originalSettings).toEqual({ env: { ORIGINAL: '1' } }); + expect(recomputeMerged).toHaveBeenCalled(); + }); + + it('cleanupBackup() clears the in-memory snapshot so a later restore is a no-op', () => { + const { settings, file } = makeSettings({ env: { K: 'v1' } }); + const adapter = createLoadedSettingsAdapter( + settings as never, + SettingScope.User, + ); + expect(adapter.backup).toBeTypeOf('function'); + adapter.backup!(); + adapter.setValue('env.K', 'v2'); + expect(adapter.cleanupBackup).toBeTypeOf('function'); + adapter.cleanupBackup!(); + // restore after cleanup should not bring v1 back + expect(adapter.restore).toBeTypeOf('function'); + adapter.restore!(); + expect(file.settings.env).toEqual({ K: 'v2' }); + }); +}); diff --git a/packages/cli/src/config/loadedSettingsAdapter.ts b/packages/cli/src/config/loadedSettingsAdapter.ts new file mode 100644 index 0000000000..49f93fa5a1 --- /dev/null +++ b/packages/cli/src/config/loadedSettingsAdapter.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Adapter that lets core's `applyProviderInstallPlan` write through + * `LoadedSettings` while preserving CLI-specific guarantees: + * - scope resolution via `getPersistScopeForModelSelection` + * - on-disk `.orig` backup of the target settings file + * - in-memory snapshot of `settings` / `originalSettings` for rollback + * - merged-settings recomputation after restore + */ + +import type { + ModelProvidersConfig, + ProviderSettingsAdapter, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings, SettingScope } from './settings.js'; +import { getPersistScopeForModelSelection } from './modelProvidersScope.js'; +import { + backupSettingsFile, + cleanupSettingsBackup, + restoreSettingsFromBackup, + getNestedProperty, +} from '../utils/settingsUtils.js'; + +export function createLoadedSettingsAdapter( + settings: LoadedSettings, + scope?: SettingScope, +): ProviderSettingsAdapter { + const persistScope = scope ?? getPersistScopeForModelSelection(settings); + const settingsFile = settings.forScope(persistScope); + + let settingsSnapshot: object | null = null; + let originalSnapshot: object | null = null; + + return { + getValue(key: string): unknown { + return getNestedProperty(settings.merged as Record, key); + }, + + setValue(key: string, value: unknown): void { + // Defense in depth: refuse prototype-chain segments before delegating to + // LoadedSettings.setValue, which goes through setNestedPropertySafe and + // doesn't enforce this itself. Inline literal === comparisons (rather + // than Set.has) are what CodeQL's prototype-pollution sanitiser + // recognises — keep this list in sync with the matching guard in + // `packages/vscode-ide-companion/src/services/settingsWriter.ts`. + for (const part of key.split('.')) { + if ( + part === '__proto__' || + part === 'constructor' || + part === 'prototype' + ) { + throw new Error( + `Refusing to write settings key with reserved segment: ${key}`, + ); + } + } + settings.setValue(persistScope, key, value); + }, + + getModelProviders(): ModelProvidersConfig { + return (settings.merged.modelProviders ?? {}) as ModelProvidersConfig; + }, + + persist(): void { + // LoadedSettings.setValue already persists on each write. + }, + + backup(): void { + backupSettingsFile(settingsFile.path); + settingsSnapshot = structuredClone(settingsFile.settings); + originalSnapshot = structuredClone(settingsFile.originalSettings); + }, + + restore(): void { + // restoreSettingsFromBackup returns false (rather than throwing) when + // the .orig copy can't be restored (EACCES, disk full, missing .orig). + // Log loudly so a user staring at the next CLI session knows the + // on-disk file may be inconsistent with the recovered in-memory state. + const restored = restoreSettingsFromBackup(settingsFile.path); + if (!restored) { + // eslint-disable-next-line no-console -- best-effort rollback path + console.error( + `[loadedSettingsAdapter] On-disk rollback of ${settingsFile.path} failed; ` + + `in-memory state was restored but the file may be inconsistent. ` + + `Re-run /auth or inspect the file directly to recover.`, + ); + } + if (settingsSnapshot !== null) { + settingsFile.settings = + settingsSnapshot as typeof settingsFile.settings; + } + if (originalSnapshot !== null) { + settingsFile.originalSettings = + originalSnapshot as typeof settingsFile.originalSettings; + } + settings.recomputeMerged(); + }, + + cleanupBackup(): void { + cleanupSettingsBackup(settingsFile.path); + settingsSnapshot = null; + originalSnapshot = null; + }, + }; +} diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 7070b3d593..b8da5421a4 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -190,8 +190,7 @@ export default { 'open full Qwen Code documentation in your browser': 'obrir la documentació completa de Qwen Code al navegador', 'Configuration not available.': 'Configuració no disponible.', - 'Configure authentication information for login': - "Configurar la informació d'autenticació per a iniciar sessió", + 'Connect an LLM provider': 'Connectar un proveïdor LLM', 'Copy the last result or code snippet to clipboard': "Copiar l'últim resultat o fragment de codi al porta-retalls", @@ -1114,9 +1113,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 Benvingut de nou! (Darrera actualització: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Objectiu general:', - 'Select Authentication Method': "Seleccioneu el mètode d'autenticació", - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - "Cal seleccionar un mètode d'autenticació per continuar. Premeu Ctrl+C de nou per sortir.", + 'Connect a Provider': 'Connectar un proveïdor', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'Cal connectar un proveïdor per continuar. Premeu Ctrl+C de nou per sortir.', 'Terms of Services and Privacy Notice': 'Termes de servei i avís de privacitat', 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 6d855a1ef5..fe0fd14304 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -169,8 +169,7 @@ export default { 'open full Qwen Code documentation in your browser': 'Vollständige Qwen Code Dokumentation im Browser öffnen', 'Configuration not available.': 'Konfiguration nicht verfügbar.', - 'Configure authentication information for login': - 'Authentifizierungsinformationen für die Anmeldung konfigurieren', + 'Connect an LLM provider': 'LLM-Anbieter verbinden', 'Copy the last result or code snippet to clipboard': 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', @@ -1078,9 +1077,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 Willkommen zurück! (Zuletzt aktualisiert: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Gesamtziel:', - 'Select Authentication Method': 'Authentifizierungsmethode auswählen', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - 'Sie müssen eine Authentifizierungsmethode wählen, um fortzufahren. Drücken Sie erneut Ctrl+C zum Beenden.', + 'Connect a Provider': 'Anbieter verbinden', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'Sie müssen einen Anbieter verbinden, um fortzufahren. Drücken Sie erneut Ctrl+C zum Beenden.', 'Terms of Services and Privacy Notice': 'Nutzungsbedingungen und Datenschutzhinweis', 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index fb4ff837ba..b756049b2b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -190,8 +190,7 @@ export default { 'open full Qwen Code documentation in your browser': 'open full Qwen Code documentation in your browser', 'Configuration not available.': 'Configuration not available.', - 'Configure authentication information for login': - 'Configure authentication information for login', + 'Connect an LLM provider': 'Connect an LLM provider', 'Copy the last result or code snippet to clipboard': 'Copy the last result or code snippet to clipboard', 'Show working-tree change stats versus HEAD': @@ -1155,9 +1154,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 Welcome back! (Last updated: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Overall Goal:', - 'Select Authentication Method': 'Select Authentication Method', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + 'Connect a Provider': 'Connect a Provider', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'You must connect a provider to proceed. Press Ctrl+C again to exit.', 'Terms of Services and Privacy Notice': 'Terms of Services and Privacy Notice', 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index cfce04e60e..5e6d2891df 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -190,8 +190,7 @@ export default { 'open full Qwen Code documentation in your browser': 'ouvrir la documentation complète de Qwen Code dans votre navigateur', 'Configuration not available.': 'Configuration non disponible.', - 'Configure authentication information for login': - "Configurer les informations d'authentification pour la connexion", + 'Connect an LLM provider': 'Se connecter à un fournisseur LLM', 'Copy the last result or code snippet to clipboard': 'Copier le dernier résultat ou extrait de code dans le presse-papiers', @@ -1122,9 +1121,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 Bon retour ! (Dernière mise à jour : {{timeAgo}})', '🎯 Overall Goal:': '🎯 Objectif global :', - 'Select Authentication Method': "Sélectionner la méthode d'authentification", - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - "Vous devez sélectionner une méthode d'authentification pour continuer. Appuyez à nouveau sur Ctrl+C pour quitter.", + 'Connect a Provider': 'Connecter un fournisseur', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'Vous devez connecter un fournisseur pour continuer. Appuyez à nouveau sur Ctrl+C pour quitter.', 'Terms of Services and Privacy Notice': "Conditions d'utilisation et avis de confidentialité", 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 9b7e9a4931..0c94843e3f 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -147,8 +147,7 @@ export default { 'open full Qwen Code documentation in your browser': 'ブラウザで Qwen Code のドキュメントを開く', 'Configuration not available.': '設定が利用できません', - 'Configure authentication information for login': - 'ログイン用の認証情報を設定', + 'Connect an LLM provider': 'LLM プロバイダーに接続', 'Copy the last result or code snippet to clipboard': '最後の結果またはコードスニペットをクリップボードにコピー', @@ -830,9 +829,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 おかえりなさい!(最終更新: {{timeAgo}})', '🎯 Overall Goal:': '🎯 全体目標:', - 'Select Authentication Method': '認証方法を選択', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - '続行するには認証方法を選択してください。Ctrl+C をもう一度押すと終了します', + 'Connect a Provider': 'プロバイダーに接続', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + '続行するにはプロバイダーに接続してください。Ctrl+C をもう一度押すと終了します', 'Terms of Services and Privacy Notice': '利用規約とプライバシー通知', 'Qwen OAuth': 'Qwen OAuth', 'Discontinued — switch to Coding Plan or API Key': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index b09eabc4fc..3e72bc3a35 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -183,8 +183,7 @@ export default { 'open full Qwen Code documentation in your browser': 'abrir documentação completa do Qwen Code no seu navegador', 'Configuration not available.': 'Configuração não disponível.', - 'Configure authentication information for login': - 'Configurar informações de autenticação para login', + 'Connect an LLM provider': 'Conectar a um provedor LLM', 'Copy the last result or code snippet to clipboard': 'Copiar o último resultado ou trecho de código para a área de transferência', @@ -1080,9 +1079,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 Bem-vindo de volta! (Última atualização: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Objetivo Geral:', - 'Select Authentication Method': 'Selecionar Método de Autenticação', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - 'Você deve selecionar um método de autenticação para prosseguir. Pressione Ctrl+C novamente para sair.', + 'Connect a Provider': 'Conectar um provedor', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'Você deve conectar um provedor para prosseguir. Pressione Ctrl+C novamente para sair.', 'Terms of Services and Privacy Notice': 'Termos de Serviço e Aviso de Privacidade', 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2d5296501f..105239e06e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -192,8 +192,7 @@ export default { 'open full Qwen Code documentation in your browser': 'Открытие полной документации Qwen Code в браузере', 'Configuration not available.': 'Конфигурация недоступна.', - 'Configure authentication information for login': - 'Настройка аутентификационной информации для входа', + 'Connect an LLM provider': 'Подключить провайдера LLM', 'Copy the last result or code snippet to clipboard': 'Копирование последнего результата или фрагмента кода в буфер обмена', @@ -1029,9 +1028,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 С возвращением! (Последнее обновление: {{timeAgo}})', '🎯 Overall Goal:': '🎯 Общая цель:', - 'Select Authentication Method': 'Выберите метод авторизации', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - 'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.', + 'Connect a Provider': 'Подключить провайдера', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + 'Необходимо подключить провайдера для продолжения. Нажмите Ctrl+C снова для выхода.', 'Terms of Services and Privacy Notice': 'Условия обслуживания и уведомление о конфиденциальности', 'Qwen OAuth': 'Qwen OAuth', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 17af2229a4..c4da4e404d 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -171,7 +171,7 @@ export default { 'open full Qwen Code documentation in your browser': '在瀏覽器中打開完整的 Qwen Code 文檔', 'Configuration not available.': '配置不可用', - 'Configure authentication information for login': '配置登錄認證信息', + 'Connect an LLM provider': '連接 LLM 提供商', 'Copy the last result or code snippet to clipboard': '將最後的結果或代碼片段複製到剪貼板', 'Show working-tree change stats versus HEAD': @@ -964,9 +964,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 歡迎回來!(最後更新:{{timeAgo}})', '🎯 Overall Goal:': '🎯 總體目標:', - 'Select Authentication Method': '選擇認證方式', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - '您必須選擇認證方法才能繼續。再次按 Ctrl+C 退出', + 'Connect a Provider': '連接服務商', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + '必須連接一個服務商才能繼續。再次按 Ctrl+C 退出', 'Terms of Services and Privacy Notice': '服務條款和隱私聲明', 'Qwen OAuth': 'Qwen OAuth (免費)', 'Discontinued — switch to Coding Plan or API Key': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ce91586739..022dcf81b6 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -182,7 +182,7 @@ export default { 'open full Qwen Code documentation in your browser': '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', - 'Configure authentication information for login': '配置登录认证信息', + 'Connect an LLM provider': '连接 LLM 提供商', 'Copy the last result or code snippet to clipboard': '将最后的结果或代码片段复制到剪贴板', 'Show working-tree change stats versus HEAD': @@ -1096,9 +1096,9 @@ export default { '👋 Welcome back! (Last updated: {{timeAgo}})': '👋 欢迎回来!(最后更新:{{timeAgo}})', '🎯 Overall Goal:': '🎯 总体目标:', - 'Select Authentication Method': '选择认证方式', - 'You must select an auth method to proceed. Press Ctrl+C again to exit.': - '您必须选择认证方法才能继续。再次按 Ctrl+C 退出', + 'Connect a Provider': '连接服务商', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.': + '必须连接一个服务商才能继续。再次按 Ctrl+C 退出', 'Terms of Services and Privacy Notice': '服务条款和隐私声明', 'Qwen OAuth': 'Qwen OAuth (免费)', 'Discontinued — switch to Coding Plan or API Key': diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 8d5593fd9d..c7fc5592ab 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,7 +40,6 @@ import { dreamCommand } from '../ui/commands/dreamCommand.js'; import { forgetCommand } from '../ui/commands/forgetCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; -import { manageModelsCommand } from '../ui/commands/manageModelsCommand.js'; import { rememberCommand } from '../ui/commands/rememberCommand.js'; import { planCommand } from '../ui/commands/planCommand.js'; import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; @@ -128,7 +127,6 @@ export class BuiltinCommandLoader implements ICommandLoader { goalCommand, memoryCommand, modelCommand, - manageModelsCommand, rememberCommand, planCommand, permissionsCommand, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5003edb8b1..5a2654846b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -236,21 +236,15 @@ describe('AppContainer State Management', () => { authMessage: null, }, }, - handleAuthSelect: vi.fn(), - handleSubscriptionPlanSubmit: vi.fn(), - handleCodingPlanSubmit: vi.fn(), - handleTokenPlanSubmit: vi.fn(), - handleApiKeyProviderSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), - handleCustomApiKeySubmit: vi.fn(), + closeAuthDialog: vi.fn(), + handleProviderSubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), actions: { setAuthState: vi.fn(), onAuthError: vi.fn(), - handleAuthSelect: vi.fn(), + closeAuthDialog: vi.fn(), handleProviderSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), }, @@ -2408,21 +2402,15 @@ describe('AppContainer State Management', () => { authMessage: null, }, }, - handleAuthSelect: vi.fn(), - handleSubscriptionPlanSubmit: vi.fn(), - handleCodingPlanSubmit: vi.fn(), - handleTokenPlanSubmit: vi.fn(), - handleApiKeyProviderSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), - handleCustomApiKeySubmit: vi.fn(), + closeAuthDialog: vi.fn(), + handleProviderSubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), actions: { setAuthState: vi.fn(), onAuthError: vi.fn(), - handleAuthSelect: vi.fn(), + closeAuthDialog: vi.fn(), handleProviderSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b970093f15..c0250d2886 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -104,7 +104,6 @@ import { useAuthCommand } from './auth/useAuth.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; -import { useManageModelsCommand } from './hooks/useManageModelsCommand.js'; import { useArenaCommand } from './hooks/useArenaCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useBranchCommand } from './hooks/useBranchCommand.js'; @@ -835,7 +834,7 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic, ); const { state: authState, actions: authActions } = auth; - const { onAuthError, openAuthDialog, handleAuthSelect } = authActions; + const { onAuthError, openAuthDialog, closeAuthDialog } = authActions; const { isAuthDialogOpen, isAuthenticating, pendingAuthType } = authState; useInitializationAuthError(initializationResult.authError, onAuthError); @@ -909,11 +908,6 @@ export const AppContainer = (props: AppContainerProps) => { openModelDialog, closeModelDialog, } = useModelCommand(); - const { - isManageModelsDialogOpen, - openManageModelsDialog, - closeManageModelsDialog, - } = useManageModelsCommand(); const { activeArenaDialog, openArenaDialog, closeArenaDialog } = useArenaCommand(); @@ -996,7 +990,6 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openStatusLineDialog, openModelDialog, - openManageModelsDialog, openTrustDialog, openArenaDialog, openPermissionsDialog, @@ -1031,7 +1024,6 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openStatusLineDialog, openModelDialog, - openManageModelsDialog, openArenaDialog, setDebugMessage, dispatchExtensionStateUpdate, @@ -2076,7 +2068,6 @@ export const AppContainer = (props: AppContainerProps) => { isStatusLineDialogOpen || isMemoryDialogOpen || isModelDialogOpen || - isManageModelsDialogOpen || isTrustDialogOpen || activeArenaDialog !== null || isPermissionsDialogOpen || @@ -2543,7 +2534,7 @@ export const AppContainer = (props: AppContainerProps) => { isApprovalModeDialogOpen, handleApprovalModeSelect, isAuthDialogOpen, - handleAuthSelect, + closeAuthDialog, pendingAuthType, isEditorDialogOpen, exitEditorDialog, @@ -2989,7 +2980,6 @@ export const AppContainer = (props: AppContainerProps) => { isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, - isManageModelsDialogOpen, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, @@ -3110,7 +3100,6 @@ export const AppContainer = (props: AppContainerProps) => { isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, - isManageModelsDialogOpen, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, @@ -3235,8 +3224,6 @@ export const AppContainer = (props: AppContainerProps) => { closeMemoryDialog, closeModelDialog, openModelDialog, - openManageModelsDialog, - closeManageModelsDialog, openArenaDialog, closeArenaDialog, handleArenaModelsSelected, @@ -3311,8 +3298,6 @@ export const AppContainer = (props: AppContainerProps) => { closeMemoryDialog, closeModelDialog, openModelDialog, - openManageModelsDialog, - closeManageModelsDialog, openArenaDialog, closeArenaDialog, handleArenaModelsSelected, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 966fab0d26..86dfa08605 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -51,9 +51,8 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => { const createMockUIActions = (overrides: UIActionsOverrides = {}): UIActions => { const { auth, ...topLevelOverrides } = overrides; const authActions = { - handleAuthSelect: vi.fn(), + closeAuthDialog: vi.fn(), handleProviderSubmit: vi.fn(), - handleOpenRouterSubmit: vi.fn(), setAuthState: vi.fn(), onAuthError: vi.fn(), openAuthDialog: vi.fn(), @@ -173,15 +172,7 @@ const navigateToCustomProtocolSelect = async ( ) => { await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); await moveDownAndWaitForSelection(stdin, lastFrame, 'Third-party Providers'); - await moveDownAndWaitForSelection(stdin, lastFrame, 'OAuth'); - await vi.waitFor( - () => { - expect(lastFrame()).toContain('Custom Provider'); - }, - { timeout: WAIT_FOR_TIMEOUT }, - ); - stdin.write('\u001b[B'); - await waitForSelectedOption(lastFrame, 'Custom Provider'); + await moveDownAndWaitForSelection(stdin, lastFrame, 'Custom Provider'); await pressEnterAndWaitFor( stdin, lastFrame, @@ -488,8 +479,9 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame } = renderAuthDialog(settings); - // QWEN_OAUTH maps to the OAuth entry in the four-flow main menu - expect(lastFrame()).toContain('OAuth'); + // QWEN OAuth no longer has a UI entry; the dialog falls back to the + // default Alibaba ModelStudio option. + expect(lastFrame()).toContain('Alibaba ModelStudio'); }); it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => { @@ -528,7 +520,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame } = renderAuthDialog(settings); - // Default is Alibaba ModelStudio (first option); Qwen OAuth is under OAuth. + // Default is Alibaba ModelStudio (first option). expect(lastFrame()).toContain('Alibaba ModelStudio'); }); @@ -587,7 +579,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { itWhenTuiInputReliable( 'should prevent exiting when no auth method is selected and show error message', async () => { - const handleAuthSelect = vi.fn(); + const closeAuthDialog = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -624,7 +616,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { lastFrame, stdin, unmount } = renderAuthDialog( settings, {}, - { handleAuthSelect }, + { closeAuthDialog }, undefined, // config.getAuthType() returns undefined ); await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); @@ -632,16 +624,16 @@ describe('AuthDialog', { timeout: 15000 }, () => { // Simulate pressing escape key stdin.write('\u001b'); // ESC key - // Should show error message instead of calling handleAuthSelect + // Should show error message instead of calling closeAuthDialog await vi.waitFor( () => { const frame = lastFrame(); - expect(frame).toContain('You must select an auth method'); + expect(frame).toContain('You must connect a provider to proceed'); expect(frame).toContain('Press Ctrl+C again to exit'); }, { timeout: WAIT_FOR_TIMEOUT }, ); - expect(handleAuthSelect).not.toHaveBeenCalled(); + expect(closeAuthDialog).not.toHaveBeenCalled(); unmount(); }, ); @@ -649,7 +641,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { itWhenTuiInputReliable( 'should not exit if there is already an error message', async () => { - const handleAuthSelect = vi.fn(); + const closeAuthDialog = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -691,7 +683,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { authError: 'Initial error', }, }, - { handleAuthSelect }, + { closeAuthDialog }, undefined, // config.getAuthType() returns undefined ); await vi.waitFor( @@ -705,8 +697,8 @@ describe('AuthDialog', { timeout: 15000 }, () => { stdin.write('\u001b'); // ESC key await wait(); - // Should not call handleAuthSelect - expect(handleAuthSelect).not.toHaveBeenCalled(); + // Should not call closeAuthDialog + expect(closeAuthDialog).not.toHaveBeenCalled(); unmount(); }, ); @@ -714,7 +706,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { itWhenTuiInputReliable( 'should allow exiting when auth method is already selected', async () => { - const handleAuthSelect = vi.fn(); + const closeAuthDialog = vi.fn(); const settings: LoadedSettings = new LoadedSettings( { settings: { ui: { customThemes: {} }, mcpServers: {} }, @@ -751,7 +743,7 @@ describe('AuthDialog', { timeout: 15000 }, () => { const { stdin, lastFrame, unmount } = renderAuthDialog( settings, {}, - { handleAuthSelect }, + { closeAuthDialog }, AuthType.USE_OPENAI, // config.getAuthType() returns USE_OPENAI ); await vi.waitFor( @@ -765,8 +757,8 @@ describe('AuthDialog', { timeout: 15000 }, () => { stdin.write('\u001b'); // ESC key await wait(); - // Should call handleAuthSelect with undefined to exit - expect(handleAuthSelect).toHaveBeenCalledWith(undefined); + // Should call closeAuthDialog to exit + expect(closeAuthDialog).toHaveBeenCalled(); unmount(); }, ); @@ -817,10 +809,6 @@ describe('AuthDialog', { timeout: 15000 }, () => { label: 'Third-party Providers', childTitle: 'Third-party Providers · Provider', }, - { - label: 'OAuth', - childTitle: 'Select OAuth Provider', - }, { label: 'Custom Provider', childTitle: 'Custom Provider · Step 1/6 · Protocol', @@ -1327,71 +1315,6 @@ describe('AuthDialog', { timeout: 15000 }, () => { unmount(); }, ); - - itWhenTuiInputReliable( - 'should trigger OpenRouter OAuth from OAuth provider options', - async () => { - const handleOpenRouterSubmit = vi.fn().mockResolvedValue(undefined); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - originalSettings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - originalSettings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { stdin, lastFrame, unmount } = renderAuthDialog( - settings, - {}, - { handleOpenRouterSubmit }, - ); - - await waitForSelectedOption(lastFrame, 'Alibaba ModelStudio'); - await moveDownAndWaitForSelection( - stdin, - lastFrame, - 'Third-party Providers', - ); - await moveDownAndWaitForSelection(stdin, lastFrame, 'OAuth'); - await pressEnterAndWaitFor(stdin, lastFrame, 'Select OAuth Provider'); - await waitForSelectedOption(lastFrame, 'OpenRouter'); - stdin.write('\r'); - - await vi.waitFor( - () => { - expect(handleOpenRouterSubmit).toHaveBeenCalledTimes(1); - }, - { timeout: WAIT_FOR_TIMEOUT }, - ); - - unmount(); - }, - ); }); describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => { @@ -1714,14 +1637,14 @@ describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => { stdin.write('\r'); await wait(); - // Verify review includes generationConfig + // Verify review includes generationConfig (audio is off by default) await vi.waitFor(() => { const frame = lastFrame(); expect(frame).toContain('"generationConfig"'); expect(frame).toContain('"enable_thinking"'); expect(frame).toContain('"image": true'); expect(frame).toContain('"video": true'); - expect(frame).toContain('"audio": true'); + expect(frame).not.toContain('"audio"'); }); // Press Enter to save @@ -1738,7 +1661,6 @@ describe('AuthDialog Custom API Key Wizard', { timeout: 15000 }, () => { multimodal: { image: true, video: true, - audio: true, }, }, }), diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 63716f110b..6961689199 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -6,7 +6,6 @@ import type React from 'react'; import { useState, useMemo } from 'react'; -import { AuthType } from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; @@ -23,11 +22,8 @@ import { customProvider, ALIBABA_PROVIDERS, THIRD_PARTY_PROVIDERS, -} from '../../auth/allProviders.js'; -import { - resolveMetadataKey, type ProviderConfig, -} from '../../auth/providerConfig.js'; +} from '@qwen-code/qwen-code-core'; import { useProviderSetupFlow } from './useProviderSetupFlow.js'; import { ProviderSetupSteps } from './ProviderSetupSteps.js'; @@ -39,13 +35,11 @@ type ViewLevel = | 'main' | 'alibaba-select' | 'thirdparty-select' - | 'oauth-select' | 'provider-setup'; type MainOption = | 'ALIBABA_MODELSTUDIO' | 'THIRD_PARTY_PROVIDERS' - | 'OAUTH' | 'CUSTOM_PROVIDER'; // --------------------------------------------------------------------------- @@ -69,15 +63,6 @@ const MAIN_ITEMS = [ description: t('Choose a built-in provider and connect with an API key'), value: 'THIRD_PARTY_PROVIDERS' as MainOption, }, - { - key: 'OAUTH', - title: t('OAuth'), - label: t('OAuth'), - description: t( - 'Open a browser, sign in, and let the CLI finish provider setup', - ), - value: 'OAUTH' as MainOption, - }, { key: 'CUSTOM_PROVIDER', title: t('Custom Provider'), @@ -89,25 +74,6 @@ const MAIN_ITEMS = [ }, ]; -const OAUTH_ITEMS = [ - { - key: 'openrouter', - title: t('OpenRouter'), - label: t('OpenRouter'), - description: t( - 'Browser OAuth · Auto-configure API key and OpenRouter models', - ), - value: 'openrouter', - }, - { - key: 'qwen-oauth-discontinued', - title: t('Qwen'), - label: t('Qwen'), - description: t('Discontinued — switch to Coding Plan or API Key'), - value: 'qwen-oauth-discontinued', - }, -]; - function providerToItem(config: ProviderConfig) { return { key: config.id, @@ -140,10 +106,9 @@ function getStepLabel(step: string | null, p: ProviderConfig): string { // --------------------------------------------------------------------------- const VIEW_TITLES: Record = { - main: t('Select Authentication Method'), + main: t('Connect a Provider'), 'alibaba-select': t('Alibaba ModelStudio · Access Method'), 'thirdparty-select': t('Third-party Providers · Provider'), - 'oauth-select': t('Select OAuth Provider'), }; // --------------------------------------------------------------------------- @@ -152,15 +117,10 @@ const VIEW_TITLES: Record = { export function AuthDialog(): React.JSX.Element { const { - auth: { pendingAuthType, authError }, + auth: { authError }, } = useUIState(); const { - auth: { - handleAuthSelect: onAuthSelect, - handleProviderSubmit, - handleOpenRouterSubmit, - onAuthError, - }, + auth: { closeAuthDialog, handleProviderSubmit, onAuthError }, } = useUIActions(); const config = useConfig(); const settings = useSettings(); @@ -219,22 +179,12 @@ export function AuthDialog(): React.JSX.Element { pushView('provider-setup'); }; - const handleOAuthSelect = (value: string) => { - clearErrors(); - if (value === 'openrouter') { - void handleOpenRouterSubmit(); - return; - } - setErrorMessage( - t( - 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.', - ), - ); - }; - const subMenus: Record< string, - { items: typeof OAUTH_ITEMS; onSelect: (v: string) => void } + { + items: Array>; + onSelect: (v: string) => void; + } > = { 'alibaba-select': { items: alibabaItems, @@ -244,7 +194,6 @@ export function AuthDialog(): React.JSX.Element { items: thirdPartyItems, onSelect: handleProviderSelect, }, - 'oauth-select': { items: OAUTH_ITEMS, onSelect: handleOAuthSelect }, }; const activeSubMenu = subMenus[viewLevel]; @@ -256,18 +205,16 @@ export function AuthDialog(): React.JSX.Element { contentGenConfig?.baseUrl, contentGenConfig?.apiKeyEnvKey, ); - const isCurrentlyCodingPlan = !!( - matchedProvider && resolveMetadataKey(matchedProvider) - ); + // Land on the tab that matches the active provider's uiGroup so a DeepSeek + // / MiniMax / OpenRouter user opens Third-party Providers, not Alibaba. + // (resolveMetadataKey returns config.id for *any* provider with a static + // models[], so it can't be used to detect "Alibaba" specifically.) const defaultMainIndex = useMemo(() => { - const currentAuth = pendingAuthType ?? config.getAuthType(); - if (!currentAuth) return 0; - if (currentAuth === AuthType.QWEN_OAUTH) return 2; - if (currentAuth === AuthType.USE_OPENAI && isCurrentlyCodingPlan) return 0; - return 1; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pendingAuthType, isCurrentlyCodingPlan]); + if (matchedProvider?.uiGroup === 'third-party') return 1; + if (matchedProvider?.uiGroup === 'custom') return 2; + return 0; + }, [matchedProvider]); // -- Handlers ------------------------------------------------------------- @@ -280,9 +227,6 @@ export function AuthDialog(): React.JSX.Element { case 'THIRD_PARTY_PROVIDERS': pushView('thirdparty-select'); break; - case 'OAUTH': - pushView('oauth-select'); - break; case 'CUSTOM_PROVIDER': setupFlow.start(customProvider, undefined, existingEnv); pushView('provider-setup'); @@ -305,12 +249,12 @@ export function AuthDialog(): React.JSX.Element { if (config.getAuthType() === undefined) { setErrorMessage( t( - 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + 'You must connect a provider to proceed. Press Ctrl+C again to exit.', ), ); return; } - onAuthSelect(undefined); + closeAuthDialog(); } }, { isActive: true }, @@ -401,7 +345,7 @@ export function AuthDialog(): React.JSX.Element { {viewLevel === 'main' && ( <> - {'\u2500'.repeat(80)} + {'─'.repeat(80)} diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx index b5f756a25c..f05f9998c6 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx @@ -64,6 +64,7 @@ describe('ProviderSetupSteps', () => { totalSteps: 1, protocol: AuthType.USE_OPENAI, baseUrl: '', + baseUrlPlaceholder: '', baseUrlOptionIndex: 0, baseUrlError: null, apiKey: '', diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx index b736b0dece..10d2e8215e 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx @@ -13,10 +13,7 @@ import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; import { AuthType } from '@qwen-code/qwen-code-core'; -import type { - ProviderConfig, - BaseUrlOption, -} from '../../auth/providerConfig.js'; +import type { ProviderConfig, BaseUrlOption } from '@qwen-code/qwen-code-core'; import type { ProviderSetupFlow } from './useProviderSetupFlow.js'; // --------------------------------------------------------------------------- @@ -109,7 +106,9 @@ function BaseUrlInputStep({ value={flow.state.baseUrl} onChange={flow.changeBaseUrl} onSubmit={flow.submitBaseUrl} - placeholder="https://api.openai.com/v1" + placeholder={ + flow.state.baseUrlPlaceholder || 'https://api.openai.com/v1' + } /> {flow.state.baseUrlError && ( diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index f51f53694c..328f49e5dc 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -6,18 +6,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { + AuthType, + deepseekProvider, + openRouterProvider, + tokenPlanProvider, + customProvider, + generateCustomEnvKey as generateCustomApiKeyEnvKey, + getDefaultModelIds, + resolveBaseUrl, + type ProviderSetupInputs, +} from '@qwen-code/qwen-code-core'; import { useAuthCommand, normalizeCustomModelIds, maskApiKey, } from './useAuth.js'; -import { generateCustomEnvKey as generateCustomApiKeyEnvKey } from '../../auth/allProviders.js'; -import { - OPENROUTER_OAUTH_CALLBACK_URL, - createOpenRouterOAuthSession, - runOpenRouterOAuthLogin, -} from '../../auth/providers/oauth/openrouterOAuth.js'; vi.mock('../hooks/useQwenAuth.js', () => ({ useQwenAuth: vi.fn(() => ({ @@ -36,48 +40,16 @@ vi.mock('../../config/modelProvidersScope.js', () => ({ getPersistScopeForModelSelection: vi.fn(() => 'user'), })); -vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ - OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback', - createOpenRouterOAuthSession: vi.fn(() => ({ - callbackUrl: 'http://localhost:3000/openrouter/callback', - codeVerifier: 'test-verifier', - state: 'test-state', - authorizationUrl: - 'https://openrouter.ai/auth?callback_url=http%3A%2F%2Flocalhost%3A3000%2Fopenrouter%2Fcallback&code_challenge=test-challenge&state=test-state', - })), - getOpenRouterModelsWithFallback: vi.fn(async () => [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter · GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ]), - getPreferredOpenRouterModelId: vi.fn((models) => models[0]?.id), - isOpenRouterConfig: vi.fn((model) => - Boolean(model.baseUrl?.includes('openrouter.ai')), - ), - OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', - OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1', - selectRecommendedOpenRouterModels: vi.fn((models) => models), - runOpenRouterOAuthLogin: vi.fn( - () => new Promise(() => undefined) as Promise<{ apiKey: string }>, - ), -})); - const createSettings = () => ({ merged: { modelProviders: {}, }, setValue: vi.fn(), + recomputeMerged: vi.fn(), forScope: vi.fn(() => ({ path: '/tmp/settings.json', + settings: {}, + originalSettings: {}, })), }); @@ -99,7 +71,7 @@ describe('useAuthCommand', () => { vi.clearAllMocks(); }); - it('closes auth dialog immediately when starting OpenRouter OAuth', async () => { + it('exposes closeAuthDialog that flips isAuthDialogOpen to false', () => { const settings = createSettings(); const config = createConfig(); const addItem = vi.fn(); @@ -111,27 +83,16 @@ describe('useAuthCommand', () => { act(() => { result.current.openAuthDialog(); }); - expect(result.current.isAuthDialogOpen).toBe(true); - await act(async () => { - void result.current.handleOpenRouterSubmit(); - await Promise.resolve(); - }); - - expect(result.current.pendingAuthType).toBe(AuthType.USE_OPENAI); - expect(result.current.isAuthenticating).toBe(true); - expect(result.current.externalAuthState).toEqual({ - title: 'OpenRouter Authentication', - message: - 'Open the authorization page if your browser does not launch automatically.', - detail: expect.stringContaining('https://openrouter.ai/auth'), + act(() => { + result.current.closeAuthDialog(); }); expect(result.current.isAuthDialogOpen).toBe(false); - expect(addItem).not.toHaveBeenCalled(); + expect(result.current.authError).toBe(null); }); - it('cancels OpenRouter OAuth wait and reopens the auth dialog', async () => { + it('configures DeepSeek via the unified provider submit', async () => { const settings = createSettings(); const config = createConfig(); const addItem = vi.fn(); @@ -140,135 +101,42 @@ describe('useAuthCommand', () => { useAuthCommand(settings as never, config as never, addItem), ); - act(() => { - result.current.openAuthDialog(); - }); - - await act(async () => { - void result.current.handleOpenRouterSubmit(); - await Promise.resolve(); - }); - - expect(result.current.isAuthenticating).toBe(true); - expect(createOpenRouterOAuthSession).toHaveBeenCalledWith( - OPENROUTER_OAUTH_CALLBACK_URL, - ); - expect(runOpenRouterOAuthLogin).toHaveBeenCalledWith( - OPENROUTER_OAUTH_CALLBACK_URL, - expect.objectContaining({ - abortSignal: expect.any(AbortSignal), - session: expect.objectContaining({ - authorizationUrl: expect.stringContaining( - 'https://openrouter.ai/auth', - ), - }), - }), - ); - - act(() => { - result.current.cancelAuthentication(); - }); - - const abortSignal = vi.mocked(runOpenRouterOAuthLogin).mock.calls[0]?.[1] - ?.abortSignal; - expect(abortSignal?.aborted).toBe(true); - expect(result.current.isAuthenticating).toBe(false); - expect(result.current.externalAuthState).toBe(null); - expect(result.current.pendingAuthType).toBe(AuthType.USE_OPENAI); - expect(result.current.isAuthDialogOpen).toBe(true); - }); - - it('cleans up UI state when OpenRouter OAuth rejects with AbortError', async () => { - const settings = createSettings(); - const config = createConfig(); - const addItem = vi.fn(); - vi.mocked(runOpenRouterOAuthLogin).mockRejectedValueOnce( - new DOMException('OpenRouter OAuth cancelled.', 'AbortError'), - ); - - const { result } = renderHook(() => - useAuthCommand(settings as never, config as never, addItem), - ); + const inputs: ProviderSetupInputs = { + baseUrl: resolveBaseUrl(deepseekProvider), + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], + }; await act(async () => { - await result.current.handleOpenRouterSubmit(); - }); - - expect(result.current.isAuthenticating).toBe(false); - expect(result.current.externalAuthState).toBe(null); - expect(result.current.pendingAuthType).toBeUndefined(); - expect(result.current.isAuthDialogOpen).toBe(true); - expect(addItem).not.toHaveBeenCalled(); - }); - - it('adds /model and /manage-models guidance after OpenRouter auth succeeds', async () => { - const settings = createSettings(); - const config = createConfig(); - const addItem = vi.fn(); - vi.mocked(runOpenRouterOAuthLogin).mockResolvedValueOnce({ - apiKey: 'oauth-key-123', - userId: 'user-1', + await result.current.handleProviderSubmit(deepseekProvider, inputs); }); - const { result } = renderHook(() => - useAuthCommand(settings as never, config as never, addItem), + expect(settings.setValue).toHaveBeenCalledWith( + 'user', + 'env.DEEPSEEK_API_KEY', + 'sk-deepseek', ); - - await act(async () => { - await result.current.handleOpenRouterSubmit(); - }); - expect(settings.setValue).toHaveBeenCalledWith( 'user', - 'env.OPENROUTER_API_KEY', - 'oauth-key-123', + 'security.auth.selectedType', + 'openai', ); expect(settings.setValue).toHaveBeenCalledWith( 'user', - 'modelProviders.openai', - [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter · GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], + 'model.name', + 'deepseek-v4-flash', ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ - [AuthType.USE_OPENAI]: [ - { - id: 'z-ai/glm-4.5-air:free', - name: 'OpenRouter · GLM 4.5 Air', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - { - id: 'openai/gpt-oss-120b:free', - name: 'OpenRouter · GPT OSS 120B', - baseUrl: 'https://openrouter.ai/api/v1', - envKey: 'OPENROUTER_API_KEY', - }, - ], - }); - expect(config.refreshAuth).not.toHaveBeenCalled(); - expect(result.current.authError).toBe(null); + expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); expect(result.current.isAuthDialogOpen).toBe(false); expect(addItem).toHaveBeenCalledWith( expect.objectContaining({ - text: 'Successfully configured OpenRouter. Use /model to switch models.', + text: expect.stringContaining('Successfully configured DeepSeek'), }), expect.any(Number), ); }); - it('configures DeepSeek via the shared API key provider flow', async () => { + it('configures OpenRouter via the unified provider submit', async () => { const settings = createSettings(); const config = createConfig(); const addItem = vi.fn(); @@ -278,41 +146,17 @@ describe('useAuthCommand', () => { ); await act(async () => { - await result.current.handleApiKeyProviderSubmit( - 'deepseek', - ' sk-deepseek ', - 'deepseek-v4-flash, deepseek-v4-pro, deepseek-v4-flash', - ); + await result.current.handleProviderSubmit(openRouterProvider, { + baseUrl: resolveBaseUrl(openRouterProvider), + apiKey: 'sk-or-v1-key', + modelIds: ['z-ai/glm-4.5-air:free'], + }); }); expect(settings.setValue).toHaveBeenCalledWith( 'user', - 'env.DEEPSEEK_API_KEY', - 'sk-deepseek', - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'modelProviders.openai', - [ - { - id: 'deepseek-v4-flash', - name: '[DeepSeek] deepseek-v4-flash', - baseUrl: 'https://api.deepseek.com', - envKey: 'DEEPSEEK_API_KEY', - generationConfig: { contextWindowSize: 1000000 }, - }, - { - id: 'deepseek-v4-pro', - name: '[DeepSeek] deepseek-v4-pro', - baseUrl: 'https://api.deepseek.com', - envKey: 'DEEPSEEK_API_KEY', - generationConfig: { - contextWindowSize: 1000000, - extra_body: { enable_thinking: true }, - modalities: { image: true, video: true }, - }, - }, - ], + 'env.OPENROUTER_API_KEY', + 'sk-or-v1-key', ); expect(settings.setValue).toHaveBeenCalledWith( 'user', @@ -322,21 +166,8 @@ describe('useAuthCommand', () => { expect(settings.setValue).toHaveBeenCalledWith( 'user', 'model.name', - 'deepseek-v4-flash', - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'providerMetadata.deepseek.version', - expect.any(String), - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'providerMetadata.deepseek.baseUrl', - 'https://api.deepseek.com', + 'z-ai/glm-4.5-air:free', ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ - [AuthType.USE_OPENAI]: expect.any(Array), - }); expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); @@ -350,7 +181,11 @@ describe('useAuthCommand', () => { ); await act(async () => { - await result.current.handleTokenPlanSubmit('sk-token-plan'); + await result.current.handleProviderSubmit(tokenPlanProvider, { + baseUrl: resolveBaseUrl(tokenPlanProvider), + apiKey: 'sk-token-plan', + modelIds: getDefaultModelIds(tokenPlanProvider), + }); }); expect(settings.setValue).toHaveBeenCalledWith( @@ -358,40 +193,6 @@ describe('useAuthCommand', () => { 'env.BAILIAN_TOKEN_PLAN_API_KEY', 'sk-token-plan', ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'modelProviders.openai', - expect.arrayContaining([ - expect.objectContaining({ - id: 'qwen3.6-plus', - name: '[ModelStudio Token Plan] qwen3.6-plus', - baseUrl: - 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', - envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', - }), - expect.objectContaining({ - id: 'deepseek-v3.2', - name: '[ModelStudio Token Plan] deepseek-v3.2', - baseUrl: - 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', - envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', - }), - expect.objectContaining({ - id: 'glm-5', - name: '[ModelStudio Token Plan] glm-5', - baseUrl: - 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', - envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', - }), - expect.objectContaining({ - id: 'MiniMax-M2.5', - name: '[ModelStudio Token Plan] MiniMax-M2.5', - baseUrl: - 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', - envKey: 'BAILIAN_TOKEN_PLAN_API_KEY', - }), - ]), - ); expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); @@ -401,23 +202,6 @@ describe('useAuthCommand', () => { 'https://api.example.com/v1', ); const settings = createSettings(); - settings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'old-custom', - name: 'old-custom', - baseUrl: 'https://api.example.com/v1', - envKey, - }, - { - id: 'preserved-model', - name: 'preserved-model', - baseUrl: 'https://api.other.com/v1', - envKey: 'OTHER_API_KEY', - generationConfig: { contextWindowSize: 999 }, - }, - ], - }; const config = createConfig(); const addItem = vi.fn(); @@ -426,17 +210,15 @@ describe('useAuthCommand', () => { ); await act(async () => { - await result.current.handleCustomApiKeySubmit( - AuthType.USE_OPENAI, - ' https://api.example.com/v1 ', - ' sk-custom ', - 'custom-model, custom-model-2, custom-model', - { + await result.current.handleProviderSubmit(customProvider, { + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'sk-custom', + modelIds: ['custom-model'], + advancedConfig: { enableThinking: true, - multimodal: { image: true, video: false, audio: true }, - maxTokens: 4096, }, - ); + }); }); expect(settings.setValue).toHaveBeenCalledWith( @@ -444,47 +226,6 @@ describe('useAuthCommand', () => { `env.${envKey}`, 'sk-custom', ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'modelProviders.openai', - [ - { - id: 'custom-model', - name: 'custom-model', - baseUrl: 'https://api.example.com/v1', - envKey, - generationConfig: { - modalities: { image: true, video: false, audio: true }, - extra_body: { enable_thinking: true }, - samplingParams: { max_tokens: 4096 }, - }, - }, - { - id: 'custom-model-2', - name: 'custom-model-2', - baseUrl: 'https://api.example.com/v1', - envKey, - generationConfig: { - modalities: { image: true, video: false, audio: true }, - extra_body: { enable_thinking: true }, - samplingParams: { max_tokens: 4096 }, - }, - }, - { - id: 'old-custom', - name: 'old-custom', - baseUrl: 'https://api.example.com/v1', - envKey, - }, - { - id: 'preserved-model', - name: 'preserved-model', - baseUrl: 'https://api.other.com/v1', - envKey: 'OTHER_API_KEY', - generationConfig: { contextWindowSize: 999 }, - }, - ], - ); expect(settings.setValue).toHaveBeenCalledWith( 'user', 'security.auth.selectedType', @@ -495,40 +236,41 @@ describe('useAuthCommand', () => { 'model.name', 'custom-model', ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalledWith({ - [AuthType.USE_OPENAI]: expect.arrayContaining([ - expect.objectContaining({ id: 'custom-model' }), - expect.objectContaining({ id: 'preserved-model' }), - ]), - }); expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); }); - it('configures Alibaba standard regional endpoints via the shared API key provider flow', async () => { + it('cancelAuthentication resets dialog + flags + clears authError', async () => { + const settings = createSettings(); + const config = createConfig(); + const addItem = vi.fn(); + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + // Put the hook into the middle of an in-flight auth + an error to make + // sure cancel resets *all* the visible state, not just isAuthenticating. + act(() => { + result.current.onAuthError('boom'); + }); + expect(result.current.authError).toBe('boom'); + expect(result.current.isAuthDialogOpen).toBe(true); + + act(() => { + result.current.cancelAuthentication(); + }); + + expect(result.current.isAuthenticating).toBe(false); + expect(result.current.externalAuthState).toBeNull(); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.authError).toBeNull(); + }); + + it('surfaces install-plan rejection as an auth error and records telemetry', async () => { const settings = createSettings(); - settings.merged.modelProviders = { - [AuthType.USE_OPENAI]: [ - { - id: 'deepseek-v4-flash', - name: '[DeepSeek] deepseek-v4-flash', - baseUrl: 'https://api.deepseek.com', - envKey: 'DEEPSEEK_API_KEY', - }, - { - id: 'old-qwen', - name: '[ModelStudio Standard] old-qwen', - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - envKey: 'DASHSCOPE_API_KEY', - }, - { - id: 'custom-dashscope-compatible', - name: '[Custom] custom-dashscope-compatible', - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - envKey: 'DASHSCOPE_API_KEY', - }, - ], - }; const config = createConfig(); + config.refreshAuth = vi.fn(async () => { + throw new Error('refreshAuth rejected: bad endpoint'); + }); const addItem = vi.fn(); const { result } = renderHook(() => @@ -536,63 +278,27 @@ describe('useAuthCommand', () => { ); await act(async () => { - await result.current.handleApiKeyProviderSubmit( - 'alibabaStandard', - 'sk-dashscope', - 'qwen3.5-plus', - 'sg-singapore', - ); + await result.current.handleProviderSubmit(deepseekProvider, { + baseUrl: resolveBaseUrl(deepseekProvider), + apiKey: 'sk-bad', + modelIds: ['deepseek-v4-flash'], + }); }); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'env.DASHSCOPE_API_KEY', - 'sk-dashscope', - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'modelProviders.openai', - [ - { - id: 'qwen3.5-plus', - name: '[ModelStudio Standard] qwen3.5-plus', - baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', - envKey: 'DASHSCOPE_API_KEY', - }, - { - id: 'deepseek-v4-flash', - name: '[DeepSeek] deepseek-v4-flash', - baseUrl: 'https://api.deepseek.com', - envKey: 'DEEPSEEK_API_KEY', - }, - { - id: 'custom-dashscope-compatible', - name: '[Custom] custom-dashscope-compatible', - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - envKey: 'DASHSCOPE_API_KEY', - }, - ], - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'security.auth.selectedType', - 'openai', - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'model.name', - 'qwen3.5-plus', - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'providerMetadata.alibabaStandard.version', - expect.any(String), - ); - expect(settings.setValue).toHaveBeenCalledWith( - 'user', - 'providerMetadata.alibabaStandard.baseUrl', - 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + // handleAuthFailure should have set the error, reopened the dialog, and + // cleared the in-flight flag. The success toast must NOT have fired. + expect(result.current.authError).toEqual( + expect.stringContaining('refreshAuth rejected'), ); + expect(result.current.isAuthDialogOpen).toBe(true); + expect(result.current.isAuthenticating).toBe(false); + expect(addItem).not.toHaveBeenCalled(); + // pendingAuthType was set before applyProviderInstallPlan ran, so + // handleAuthFailure had it available — the AuthEvent path is no longer + // silently dropped on failure. (We can't assert the telemetry sink + // directly here, but the visible side effects above all depend on + // handleAuthFailure having seen pendingAuthType.) + expect(result.current.pendingAuthType).toBe(AuthType.USE_OPENAI); }); }); @@ -635,7 +341,6 @@ describe('generateCustomApiKeyEnvKey', () => { }); it('produces equal keys for URLs that differ only in trailing slash', () => { - // Trailing slashes are normalized away, so these should be equal. const key1 = generateCustomApiKeyEnvKey( AuthType.USE_OPENAI, 'https://openrouter.ai/api/v1/', diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 255a3d2202..ac068c0826 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -10,46 +10,19 @@ import { getErrorMessage, logAuth, type Config, - type ModelProvidersConfig, + buildInstallPlan, + applyProviderInstallPlan, + type ProviderConfig, + type ProviderSetupInputs, } from '@qwen-code/qwen-code-core'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; -import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; -import { - buildInstallPlan, - getDefaultModelIds, - resolveBaseUrl, - type ProviderConfig, - type ProviderSetupInputs, -} from '../../auth/providerConfig.js'; -import { - codingPlanProvider, - tokenPlanProvider, - openRouterProvider, - findProviderById, -} from '../../auth/allProviders.js'; -import { - createOpenRouterOAuthSession, - OPENROUTER_OAUTH_CALLBACK_URL, - runOpenRouterOAuthLogin, - getOpenRouterModelsWithFallback, - selectRecommendedOpenRouterModels, - getPreferredOpenRouterModelId, -} from '../../auth/providers/oauth/openrouterOAuth.js'; - -// Re-export types used by other modules -export interface OpenAICredentials { - apiKey: string; - baseUrl?: string; - model?: string; -} - /** * Normalize model IDs: split by comma, trim, deduplicate, remove empty. */ @@ -93,15 +66,13 @@ export type AuthController = { actions: { setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; - handleAuthSelect: ( - authType: AuthType | undefined, - credentials?: OpenAICredentials, - ) => Promise; + /** Close the /auth dialog without changing the active provider. */ + closeAuthDialog: () => void; + /** Persist a provider's install plan and switch to it. */ handleProviderSubmit: ( providerConfig: ProviderConfig, inputs: ProviderSetupInputs, ) => Promise; - handleOpenRouterSubmit: () => Promise; openAuthDialog: () => void; cancelAuthentication: () => void; }; @@ -129,8 +100,6 @@ export const useAuthCommand = ( message: string; detail?: string; } | null>(null); - const [openRouterAbortCtrl, setOpenRouterAbortCtrl] = - useState(null); const { qwenAuthState, cancelQwenAuth } = useQwenAuth( pendingAuthType, @@ -151,15 +120,25 @@ export const useAuthCommand = ( ); const handleAuthFailure = useCallback( - (error: unknown) => { + (error: unknown, protocolForTelemetry?: AuthType) => { setIsAuthenticating(false); setExternalAuthState(null); const msg = t('Failed to authenticate. Message: {{message}}', { message: getErrorMessage(error), }); onAuthError(msg); - if (pendingAuthType) { - logAuth(config, new AuthEvent(pendingAuthType, 'manual', 'error', msg)); + // Prefer the explicit argument over the closed-over pendingAuthType: + // setPendingAuthType(protocol) queues an async React update, but a + // synchronous throw in handleProviderSubmit reaches the catch before + // the next render, so the closure may still see `undefined` here. + // Callers from the new unified flow pass `protocol` explicitly to + // sidestep that staleness; legacy callers fall back to the closure. + const effectiveProtocol = protocolForTelemetry ?? pendingAuthType; + if (effectiveProtocol) { + logAuth( + config, + new AuthEvent(effectiveProtocol, 'manual', 'error', msg), + ); } }, [onAuthError, pendingAuthType, config], @@ -174,16 +153,29 @@ export const useAuthCommand = ( onAuthChange?.(); }, [onAuthChange]); - // -- Unified provider submit ---------------------------------------------- + // -- Provider connect ----------------------------------------------------- const handleProviderSubmit = useCallback( async (providerConfig: ProviderConfig, inputs: ProviderSetupInputs) => { + // Resolve the protocol once and store it as pendingAuthType so that if + // applyProviderInstallPlan rejects, handleAuthFailure (which gates the + // AuthEvent telemetry on pendingAuthType being defined) can record the + // failure under the right AuthType bucket instead of silently dropping + // it. + const protocol = inputs.protocol ?? providerConfig.protocol; try { + setPendingAuthType(protocol); setIsAuthenticating(true); setAuthError(null); const plan = buildInstallPlan(providerConfig, inputs); - await applyProviderInstallPlan(plan, { settings, config }); + await applyProviderInstallPlan(plan, { + settings: createLoadedSettingsAdapter(settings), + reloadModelProviders: (mp) => config.reloadModelProvidersConfig(mp), + syncAuthState: (authType, modelId) => + config.getModelsConfig().syncAfterAuthRefresh(authType, modelId), + refreshAuth: (authType) => config.refreshAuth(authType), + }); completeAuthentication(); @@ -198,205 +190,31 @@ export const useAuthCommand = ( Date.now(), ); - const protocol = inputs.protocol ?? providerConfig.protocol; logAuth(config, new AuthEvent(protocol, 'manual', 'success')); } catch (error) { - handleAuthFailure(error); + // Pass protocol explicitly so error telemetry is recorded even when + // a synchronous throw beats the setPendingAuthType state update. + handleAuthFailure(error, protocol); } }, [settings, config, completeAuthentication, addItem, handleAuthFailure], ); - // -- OpenRouter OAuth (the only genuinely different flow) ------------------ - - const handleOpenRouterSubmit = useCallback(async () => { - try { - setPendingAuthType(AuthType.USE_OPENAI); - setIsAuthenticating(true); - setAuthError(null); - setIsAuthDialogOpen(false); - - const oauthSession = createOpenRouterOAuthSession( - OPENROUTER_OAUTH_CALLBACK_URL, - ); - setExternalAuthState({ - title: t('OpenRouter Authentication'), - message: t( - 'Open the authorization page if your browser does not launch automatically.', - ), - detail: oauthSession.authorizationUrl, - }); - - const abortController = new AbortController(); - setOpenRouterAbortCtrl(abortController); - const oauthResult = await runOpenRouterOAuthLogin( - OPENROUTER_OAUTH_CALLBACK_URL, - { abortSignal: abortController.signal, session: oauthSession }, - ); - setOpenRouterAbortCtrl(null); - - const selectedKey = oauthResult.apiKey; - if (!selectedKey) { - throw new Error( - t('OpenRouter authentication completed without an API key.'), - ); - } - - setExternalAuthState({ - title: t('OpenRouter Authentication'), - message: t('Finalizing OpenRouter setup...'), - }); - - // Fetch models and build install plan using unified path - const allModels = await getOpenRouterModelsWithFallback(); - const recommendedModels = selectRecommendedOpenRouterModels(allModels); - const preferredModelId = getPreferredOpenRouterModelId(recommendedModels); - - const plan = buildInstallPlan(openRouterProvider, { - baseUrl: resolveBaseUrl(openRouterProvider), - apiKey: selectedKey, - modelIds: preferredModelId ? [preferredModelId] : [], - prebuiltModels: recommendedModels, - }); - - await applyProviderInstallPlan(plan, { - settings, - config, - refreshAuth: false, - }); - - setExternalAuthState(null); - completeAuthentication(); - - addItem( - { - type: MessageType.INFO, - text: t( - 'Successfully configured OpenRouter. Use /model to switch models.', - ), - }, - Date.now(), - ); - - logAuth(config, new AuthEvent(AuthType.USE_OPENAI, 'manual', 'success')); - } catch (error) { - setOpenRouterAbortCtrl(null); - if (error instanceof DOMException && error.name === 'AbortError') { - setExternalAuthState(null); - setPendingAuthType(undefined); - setIsAuthenticating(false); - setIsAuthDialogOpen(true); - return; - } - handleAuthFailure(error); - } - }, [settings, config, completeAuthentication, addItem, handleAuthFailure]); - - // -- Legacy auth select (Qwen OAuth / direct) ---------------------------- - - const isProviderManagedModel = useCallback( - (authType: AuthType, modelId: string | undefined) => { - if (!modelId) return false; - const modelProviders = settings.merged.modelProviders as - | ModelProvidersConfig - | undefined; - if (!modelProviders) return false; - const providerModels = modelProviders[authType]; - return ( - Array.isArray(providerModels) && - providerModels.some((m) => m.id === modelId) - ); - }, - [settings], - ); - - const handleAuthSelect = useCallback( - async (authType: AuthType | undefined, credentials?: OpenAICredentials) => { - if (!authType) { - setIsAuthDialogOpen(false); - setAuthError(null); - return; - } - - if ( - authType === AuthType.USE_OPENAI && - credentials?.model && - isProviderManagedModel(authType, credentials.model) - ) { - onAuthError( - t( - 'Model "{{modelName}}" is managed via settings.modelProviders. Please complete the fields in settings, or use another model id.', - { modelName: credentials.model }, - ), - ); - return; - } - - setPendingAuthType(authType); - setAuthError(null); - setIsAuthDialogOpen(false); - setIsAuthenticating(true); - - if (authType === AuthType.USE_OPENAI) { - onAuthError( - t( - 'Manual OpenAI-compatible setup has moved to provider setup. Choose a provider or use Custom API Key.', - ), - ); - setIsAuthenticating(false); - setPendingAuthType(undefined); - setIsAuthDialogOpen(true); - return; - } - - // Qwen OAuth or other direct auth - try { - await config.refreshAuth(authType); - - if (authType === AuthType.QWEN_OAUTH) { - const scope = getPersistScopeForModelSelection(settings); - settings.setValue(scope, 'security.auth.selectedType', authType); - } - completeAuthentication(); - addItem( - { - type: MessageType.INFO, - text: t('Authenticated successfully with {{authType}}.', { - authType, - }), - }, - Date.now(), - ); - logAuth(config, new AuthEvent(authType, 'manual', 'success')); - } catch (e) { - handleAuthFailure(e); - } - }, - [ - config, - settings, - completeAuthentication, - addItem, - handleAuthFailure, - isProviderManagedModel, - onAuthError, - ], - ); - // -- Dialog open / close / cancel ---------------------------------------- const openAuthDialog = useCallback(() => { setIsAuthDialogOpen(true); }, []); + const closeAuthDialog = useCallback(() => { + setIsAuthDialogOpen(false); + setAuthError(null); + }, []); + const cancelAuthentication = useCallback(() => { if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) { cancelQwenAuth(); } - if (isAuthenticating && pendingAuthType === AuthType.USE_OPENAI) { - openRouterAbortCtrl?.abort(); - setOpenRouterAbortCtrl(null); - } if (isAuthenticating && pendingAuthType) { logAuth(config, new AuthEvent(pendingAuthType, 'manual', 'cancelled')); } @@ -404,79 +222,7 @@ export const useAuthCommand = ( setExternalAuthState(null); setIsAuthDialogOpen(true); setAuthError(null); - }, [ - isAuthenticating, - pendingAuthType, - cancelQwenAuth, - config, - openRouterAbortCtrl, - ]); - - // -- Legacy wrappers (delegate to handleProviderSubmit) ------------------- - - const handleSubscriptionPlanSubmit = useCallback( - async (planId: 'coding' | 'token', apiKey: string, baseUrl?: string) => { - const providerConfig = - planId === 'token' ? tokenPlanProvider : codingPlanProvider; - const resolvedBaseUrl = resolveBaseUrl(providerConfig, baseUrl); - await handleProviderSubmit(providerConfig, { - baseUrl: resolvedBaseUrl, - apiKey, - modelIds: getDefaultModelIds(providerConfig), - }); - }, - [handleProviderSubmit], - ); - - const handleApiKeyProviderSubmit = useCallback( - async ( - providerId: string, - apiKey: string, - modelIdsInput: string, - endpointOption?: string, - ) => { - const providerConfig = findProviderById(providerId); - if (!providerConfig) { - onAuthError(t('Unknown provider: {{id}}', { id: providerId })); - return; - } - const resolvedBaseUrl = resolveBaseUrl( - providerConfig, - endpointOption - ? Array.isArray(providerConfig.baseUrl) - ? providerConfig.baseUrl.find((o) => o.id === endpointOption)?.url - : undefined - : undefined, - ); - await handleProviderSubmit(providerConfig, { - baseUrl: resolvedBaseUrl, - apiKey: apiKey.trim(), - modelIds: normalizeModelIds(modelIdsInput), - }); - }, - [handleProviderSubmit, onAuthError], - ); - - const handleCustomApiKeySubmit = useCallback( - async ( - protocol: AuthType, - baseUrl: string, - apiKey: string, - modelIdsInput: string, - generationConfig?: ProviderSetupInputs['advancedConfig'], - ) => { - const providerConfig = findProviderById('custom-openai-compatible'); - if (!providerConfig) return; - await handleProviderSubmit(providerConfig, { - protocol, - baseUrl: baseUrl.trim(), - apiKey: apiKey.trim(), - modelIds: normalizeModelIds(modelIdsInput), - advancedConfig: generationConfig, - }); - }, - [handleProviderSubmit], - ); + }, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]); // -- Validate QWEN_DEFAULT_AUTH_TYPE env var on mount -------------------- @@ -524,18 +270,16 @@ export const useAuthCommand = ( () => ({ setAuthState, onAuthError, - handleAuthSelect, + closeAuthDialog, handleProviderSubmit, - handleOpenRouterSubmit, openAuthDialog, cancelAuthentication, }), [ setAuthState, onAuthError, - handleAuthSelect, + closeAuthDialog, handleProviderSubmit, - handleOpenRouterSubmit, openAuthDialog, cancelAuthentication, ], @@ -551,21 +295,8 @@ export const useAuthCommand = ( pendingAuthType, externalAuthState, qwenAuthState, - handleAuthSelect, + closeAuthDialog, handleProviderSubmit, - handleOpenRouterSubmit, - handleSubscriptionPlanSubmit, - handleCodingPlanSubmit: useCallback( - (apiKey: string, baseUrl?: string) => - handleSubscriptionPlanSubmit('coding', apiKey, baseUrl), - [handleSubscriptionPlanSubmit], - ), - handleTokenPlanSubmit: useCallback( - (apiKey: string) => handleSubscriptionPlanSubmit('token', apiKey), - [handleSubscriptionPlanSubmit], - ), - handleApiKeyProviderSubmit, - handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, state, diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index 2d399ba50c..b28f063256 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -5,22 +5,19 @@ */ import { useState, useCallback } from 'react'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { InputModalities } from '@qwen-code/qwen-code-core'; -import { t } from '../../i18n/index.js'; - -const DEFAULT_BASE_URLS: Partial> = { - [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', - [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', - [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', -}; import { + AuthType, shouldShowStep, resolveBaseUrl, + getDefaultBaseUrlForProtocol, getDefaultModelIds, - type ProviderConfig, - type ProviderSetupInputs, -} from '../../auth/providerConfig.js'; +} from '@qwen-code/qwen-code-core'; +import type { + InputModalities, + ProviderConfig, + ProviderSetupInputs, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; import { normalizeModelIds, maskApiKey } from './useAuth.js'; // --------------------------------------------------------------------------- @@ -66,6 +63,7 @@ export interface ProviderSetupState { // BaseUrl baseUrl: string; + baseUrlPlaceholder: string; baseUrlOptionIndex: number; baseUrlError: string | null; @@ -107,6 +105,7 @@ export function useProviderSetupFlow( const [protocol, setProtocol] = useState(AuthType.USE_OPENAI); const [baseUrl, setBaseUrl] = useState(''); + const [baseUrlPlaceholder, setBaseUrlPlaceholder] = useState(''); const [baseUrlOptionIndex, setBaseUrlOptionIndex] = useState(0); const [baseUrlError, setBaseUrlError] = useState(null); const [apiKey, setApiKey] = useState(''); @@ -117,7 +116,7 @@ export function useProviderSetupFlow( const [modalityEnabled, setModalityEnabled] = useState(false); const [modalityImage, setModalityImage] = useState(true); const [modalityVideo, setModalityVideo] = useState(true); - const [modalityAudio, setModalityAudio] = useState(true); + const [modalityAudio, setModalityAudio] = useState(false); const [modalityPdf, setModalityPdf] = useState(false); const [contextWindowSize, setContextWindowSize] = useState(''); const [focusedConfigIndex, setFocusedConfigIndex] = useState(0); @@ -139,9 +138,14 @@ export function useProviderSetupFlow( const proto = initialProtocol ?? config.protocol; setProtocol(proto); - const defaultUrl = - resolveBaseUrl(config) || DEFAULT_BASE_URLS[proto] || ''; - setBaseUrl(defaultUrl); + // For presets the baseUrl is fixed (string) or selected from options; + // for the custom provider it's empty and the placeholder hints at the + // default endpoint for the chosen protocol. + const resolved = resolveBaseUrl(config); + setBaseUrl(resolved); + setBaseUrlPlaceholder( + resolved ? '' : getDefaultBaseUrlForProtocol(proto), + ); setBaseUrlOptionIndex(0); setBaseUrlError(null); @@ -149,7 +153,7 @@ export function useProviderSetupFlow( if (existingEnv) { const envKeyName = typeof config.envKey === 'function' - ? config.envKey(proto, defaultUrl) + ? config.envKey(proto, resolved) : config.envKey; prefillKey = existingEnv[envKeyName] ?? ''; } @@ -162,7 +166,7 @@ export function useProviderSetupFlow( setModalityEnabled(false); setModalityImage(true); setModalityVideo(true); - setModalityAudio(true); + setModalityAudio(false); setModalityPdf(false); setContextWindowSize(''); setFocusedConfigIndex(0); @@ -194,8 +198,10 @@ export function useProviderSetupFlow( const selectProtocol = useCallback( (selectedProtocol: AuthType) => { setProtocol(selectedProtocol); - const nextBaseUrl = DEFAULT_BASE_URLS[selectedProtocol] ?? ''; - setBaseUrl(nextBaseUrl); + // Clear baseUrl so the user types fresh; show the protocol's default + // endpoint as a placeholder (used if they submit blank). + setBaseUrl(''); + setBaseUrlPlaceholder(getDefaultBaseUrlForProtocol(selectedProtocol)); setApiKey(''); setApiKeyError(null); goNext(); @@ -213,19 +219,24 @@ export function useProviderSetupFlow( ); const submitBaseUrl = useCallback((): boolean => { - const trimmed = baseUrl.trim(); - if (!trimmed) { + // Empty input falls back to the placeholder default so the visible hint + // matches what gets written. + const effective = baseUrl.trim() || baseUrlPlaceholder.trim(); + if (!effective) { setBaseUrlError(t('Base URL cannot be empty.')); return false; } - if (!/^https?:\/\//i.test(trimmed)) { + if (!/^https?:\/\//i.test(effective)) { setBaseUrlError(t('Base URL must start with http:// or https://.')); return false; } + if (!baseUrl.trim()) { + setBaseUrl(effective); + } setBaseUrlError(null); goNext(); return true; - }, [baseUrl, goNext]); + }, [baseUrl, baseUrlPlaceholder, goNext]); const changeBaseUrl = useCallback((value: string) => { setBaseUrl(value); @@ -460,6 +471,7 @@ export function useProviderSetupFlow( totalSteps: visibleSteps.length, protocol, baseUrl, + baseUrlPlaceholder, baseUrlOptionIndex, baseUrlError, apiKey, diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index 751a4c97ec..2330be8a9b 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -72,8 +72,7 @@ describe('authCommand', () => { it('should have the correct name and description', () => { expect(authCommand.name).toBe('auth'); - expect(authCommand.description).toBe( - 'Configure authentication information for login', - ); + expect(authCommand.altNames).toEqual(['connect', 'login']); + expect(authCommand.description).toBe('Connect an LLM provider'); }); }); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 4328a3c0e3..e63add0845 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -14,9 +14,9 @@ import { t } from '../../i18n/index.js'; export const authCommand: SlashCommand = { name: 'auth', - altNames: ['login'], + altNames: ['connect', 'login'], get description() { - return t('Configure authentication information for login'); + return t('Connect an LLM provider'); }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'], diff --git a/packages/cli/src/ui/commands/manageModelsCommand.test.ts b/packages/cli/src/ui/commands/manageModelsCommand.test.ts deleted file mode 100644 index 2666d4f8b7..0000000000 --- a/packages/cli/src/ui/commands/manageModelsCommand.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { manageModelsCommand } from './manageModelsCommand.js'; -import type { CommandContext } from './types.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -describe('manageModelsCommand', () => { - let mockContext: CommandContext; - - beforeEach(() => { - mockContext = createMockCommandContext(); - }); - - it('should return a dialog action to open the manage-models dialog', () => { - if (!manageModelsCommand.action) { - throw new Error('The manage-models command must have an action.'); - } - - const result = manageModelsCommand.action(mockContext, ''); - - expect(result).toEqual({ - type: 'dialog', - dialog: 'manage-models', - }); - }); - - it('should have the correct name and description', () => { - expect(manageModelsCommand.name).toBe('manage-models'); - expect(manageModelsCommand.description).toBe( - 'Browse dynamic model catalogs and choose which models stay enabled locally', - ); - }); -}); diff --git a/packages/cli/src/ui/commands/manageModelsCommand.ts b/packages/cli/src/ui/commands/manageModelsCommand.ts deleted file mode 100644 index e16b18016e..0000000000 --- a/packages/cli/src/ui/commands/manageModelsCommand.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { OpenDialogActionReturn, SlashCommand } from './types.js'; -import { CommandKind } from './types.js'; -import { t } from '../../i18n/index.js'; - -export const manageModelsCommand: SlashCommand = { - name: 'manage-models', - get description() { - return t( - 'Browse dynamic model catalogs and choose which models stay enabled locally', - ); - }, - kind: CommandKind.BUILT_IN, - supportedModes: ['interactive'] as const, - action: (): OpenDialogActionReturn => ({ - type: 'dialog', - dialog: 'manage-models', - }), -}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a7454ea975..debcf0bd26 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -177,7 +177,6 @@ export interface OpenDialogActionReturn { | 'memory' | 'model' | 'fast-model' - | 'manage-models' | 'subagent_create' | 'subagent_list' | 'trust' diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index fd3e7a0f22..2bff0766b1 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -6,9 +6,11 @@ import { useMemo } from 'react'; import { Box } from 'ink'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { findProviderByCredentials } from '../../auth/allProviders.js'; -import { resolveMetadataKey } from '../../auth/providerConfig.js'; +import { + AuthType, + findProviderByCredentials, + resolveMetadataKey, +} from '@qwen-code/qwen-code-core'; import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 610a4dc387..5f6ba3c0a8 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -24,7 +24,6 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { TrustDialog } from './TrustDialog.js'; import { PermissionsDialog } from './PermissionsDialog.js'; import { ModelDialog } from './ModelDialog.js'; -import { ManageModelsDialog } from './ManageModelsDialog.js'; import { ArenaStartDialog } from './arena/ArenaStartDialog.js'; import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js'; import { ArenaStopDialog } from './arena/ArenaStopDialog.js'; @@ -220,14 +219,6 @@ export const DialogManager = ({ /> ); } - if (uiState.isManageModelsDialogOpen) { - return ( - - ); - } if (uiState.isSettingsDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx b/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx index 528d202378..44fb0e818b 100644 --- a/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx +++ b/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx @@ -17,9 +17,9 @@ describe('ExternalAuthProgress', () => { const onCancel = vi.fn(); const { lastFrame } = render( , ); diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 5c4a805678..34858837e6 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -94,7 +94,6 @@ const createUIState = (overrides: Partial = {}): UIState => isMemoryDialogOpen: false, isModelDialogOpen: false, isFastModelMode: false, - isManageModelsDialogOpen: false, isTrustDialogOpen: false, activeArenaDialog: null, isPermissionsDialogOpen: false, diff --git a/packages/cli/src/ui/components/ManageModelsDialog.test.tsx b/packages/cli/src/ui/components/ManageModelsDialog.test.tsx deleted file mode 100644 index ac39d8c8ce..0000000000 --- a/packages/cli/src/ui/components/ManageModelsDialog.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { act, waitFor } from '@testing-library/react'; -import { AuthType, type Config } from '@qwen-code/qwen-code-core'; -import { - applyCatalogFilters, - buildModelLabel, - getNextEnabledTabSource, - getNextFocusMode, - ManageModelsDialog, - type FilterMode, -} from './ManageModelsDialog.js'; -import type { ManageModelsCatalogEntry } from '../manageModels/manageModels.js'; -import { - fetchManageModelsCatalog, - getEnabledModelIdsForSource, - saveManageModelsSelection, -} from '../manageModels/manageModels.js'; -import { renderWithProviders } from '../../test-utils/render.js'; -import type { Key, KeypressHandler } from '../contexts/KeypressContext.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -vi.mock('../manageModels/manageModels.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - fetchManageModelsCatalog: vi.fn(), - getEnabledModelIdsForSource: vi.fn(), - saveManageModelsSelection: vi.fn(), - }; -}); - -vi.mock('./shared/TextInput.js', async () => { - const { Text } = await import('ink'); - return { - TextInput: ({ - value, - placeholder, - }: { - value?: string; - placeholder?: string; - }) => {value ? `> ${value}` : `> ${placeholder}`}, - }; -}); - -vi.mock('../hooks/useKeypress.js'); - -function makeEntry( - id: string, - options: { - badges?: string[]; - supportsVision?: boolean; - contextWindowSize?: number; - } = {}, -): ManageModelsCatalogEntry { - return { - id, - label: id, - searchText: `${id} ${(options.badges || []).join(' ')}`, - supportsVision: options.supportsVision ?? false, - contextWindowSize: options.contextWindowSize, - badges: options.badges || [], - model: { - id, - name: id, - baseUrl: 'https://openrouter.ai/api/v1', - }, - }; -} - -const catalogEntries = [ - makeEntry('qwen/qwen3-coder:free', { - badges: ['free'], - }), - makeEntry('openai/gpt-4o-mini'), - makeEntry('anthropic/claude-haiku'), -]; - -let dialogKeypressHandler: KeypressHandler | null = null; - -const createKey = (overrides: Partial): Key => ({ - name: '', - sequence: '', - ctrl: false, - meta: false, - shift: false, - paste: false, - ...overrides, -}); - -const pressDialogKey = async (overrides: Partial) => { - if (!dialogKeypressHandler) { - throw new Error('ManageModelsDialog keypress handler was not registered.'); - } - const handler = dialogKeypressHandler; - - await act(async () => { - handler(createKey(overrides)); - }); -}; - -describe('ManageModelsDialog helpers', () => { - it('buildModelLabel uses the short display label only', () => { - expect( - buildModelLabel( - makeEntry('qwen/qwen3-coder:free', { - badges: ['free', 'vision'], - contextWindowSize: 1_000_000, - }), - ), - ).toBe('qwen/qwen3-coder:free'); - }); - - it.each<[FilterMode, string[]]>([ - ['all', ['qwen/qwen3-coder:free', 'openai/gpt-4o-mini']], - ['enabled', ['openai/gpt-4o-mini']], - ['free', ['qwen/qwen3-coder:free']], - ['vision', ['qwen/qwen3-coder:free']], - ])('applyCatalogFilters supports %s filter', (filterMode, expectedIds) => { - const entries = [ - makeEntry('qwen/qwen3-coder:free', { - badges: ['free', 'vision'], - supportsVision: true, - }), - makeEntry('openai/gpt-4o-mini'), - ]; - - expect( - applyCatalogFilters({ - entries, - query: '', - selectedIds: ['openai/gpt-4o-mini'], - filterMode, - }).map((entry) => entry.id), - ).toEqual(expectedIds); - }); - - it('applyCatalogFilters combines query and filter mode', () => { - const entries = [ - makeEntry('qwen/qwen3-coder:free', { - badges: ['free'], - }), - makeEntry('glm/glm-4.5-air:free', { - badges: ['free'], - }), - ]; - - expect( - applyCatalogFilters({ - entries, - query: 'qwen', - selectedIds: [], - filterMode: 'free', - }).map((entry) => entry.id), - ).toEqual(['qwen/qwen3-coder:free']); - }); - - it('applyCatalogFilters supports enabled quick filter in search', () => { - const entries = [ - makeEntry('qwen/qwen3-coder:free'), - makeEntry('openai/gpt-4o-mini'), - ]; - - expect( - applyCatalogFilters({ - entries, - query: 'enabled', - selectedIds: ['openai/gpt-4o-mini'], - filterMode: 'all', - }).map((entry) => entry.id), - ).toEqual(['openai/gpt-4o-mini']); - - expect( - applyCatalogFilters({ - entries, - query: 'is:enabled gpt', - selectedIds: ['openai/gpt-4o-mini'], - filterMode: 'all', - }).map((entry) => entry.id), - ).toEqual(['openai/gpt-4o-mini']); - }); - - it('cycles focus across tabs, search, and list', () => { - expect(getNextFocusMode('tabs', 'forward', true)).toBe('search'); - expect(getNextFocusMode('search', 'forward', true)).toBe('list'); - expect(getNextFocusMode('list', 'forward', true)).toBe('tabs'); - expect(getNextFocusMode('search', 'backward', false)).toBe('tabs'); - }); - - it('keeps provider tab on the only enabled source', () => { - expect(getNextEnabledTabSource('openrouter', 'left')).toBe('openrouter'); - expect(getNextEnabledTabSource('openrouter', 'right')).toBe('openrouter'); - }); -}); - -describe('ManageModelsDialog keyboard navigation', () => { - beforeEach(() => { - dialogKeypressHandler = null; - vi.mocked(useKeypress).mockImplementation((handler, { isActive }) => { - if (isActive) { - dialogKeypressHandler = handler; - } - }); - vi.mocked(fetchManageModelsCatalog).mockResolvedValue({ - source: 'openrouter', - title: 'OpenRouter', - description: - 'Browse the latest OpenRouter model catalog and choose which models are enabled locally.', - authType: AuthType.USE_OPENAI, - entries: catalogEntries, - }); - vi.mocked(getEnabledModelIdsForSource).mockReturnValue([]); - vi.mocked(saveManageModelsSelection).mockResolvedValue({ - updatedConfigs: [], - selectedIds: [], - activeModelId: undefined, - }); - }); - - const renderDialog = () => - renderWithProviders( - , - ); - - it('keeps bare j in search mode instead of entering the list', async () => { - const { lastFrame, unmount } = renderDialog(); - - await waitFor(() => { - expect(lastFrame()).toContain('qwen/qwen3-coder:free'); - }); - - await pressDialogKey({ name: 'n', sequence: '\u000E', ctrl: true }); - await pressDialogKey({ name: 'j', sequence: 'j' }); - - expect(lastFrame()).not.toContain('› [ ] qwen/qwen3-coder:free'); - unmount(); - }); - - it('uses selection shortcuts across tabs, search, and list', async () => { - const { lastFrame, unmount } = renderDialog(); - - await waitFor(() => { - expect(lastFrame()).toContain('qwen/qwen3-coder:free'); - }); - - await pressDialogKey({ name: 'j', sequence: 'j' }); // tabs -> search - await pressDialogKey({ name: 'n', sequence: '\u000E', ctrl: true }); // search -> list - expect(lastFrame()).toContain('› [ ] qwen/qwen3-coder:free'); - - await pressDialogKey({ name: 'n', sequence: '\u000E', ctrl: true }); // list highlight down - expect(lastFrame()).toContain('› [ ] openai/gpt-4o-mini'); - - await pressDialogKey({ name: 'p', sequence: '\u0010', ctrl: true }); // list highlight up - expect(lastFrame()).toContain('› [ ] qwen/qwen3-coder:free'); - unmount(); - }); -}); diff --git a/packages/cli/src/ui/components/ManageModelsDialog.tsx b/packages/cli/src/ui/components/ManageModelsDialog.tsx deleted file mode 100644 index b6a89a9294..0000000000 --- a/packages/cli/src/ui/components/ManageModelsDialog.tsx +++ /dev/null @@ -1,656 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import process from 'node:process'; -import { - type Config, - type ProviderModelConfig as ModelConfig, -} from '@qwen-code/qwen-code-core'; -import { useSettings } from '../contexts/SettingsContext.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../keyMatchers.js'; -import { theme } from '../semantic-colors.js'; -import { TextInput } from './shared/TextInput.js'; -import type { LoadedSettings } from '../../config/settings.js'; -import { - type ManageModelsCatalog, - type ManageModelsCatalogEntry, - type ManageModelsSource, - fetchManageModelsCatalog, - getEnabledModelIdsForSource, - saveManageModelsSelection, -} from '../manageModels/manageModels.js'; - -interface ManageModelsDialogProps { - config: Config; - onClose: () => void; -} - -type DialogStatus = 'loading' | 'ready' | 'saving' | 'error'; -type FocusMode = 'tabs' | 'search' | 'list'; -export type FilterMode = 'all' | 'enabled' | 'free' | 'vision'; - -const MAX_VISIBLE_MODELS = 12; -const MANAGE_MODELS_TABS = [ - { source: 'openrouter', label: 'OpenRouter', enabled: true }, - { source: 'modelstudio', label: 'ModelStudio', enabled: false }, -] as const; - -type ManageModelsTabSource = (typeof MANAGE_MODELS_TABS)[number]['source']; - -export function buildModelLabel(entry: ManageModelsCatalogEntry): string { - return entry.label; -} - -export function applyCatalogFilters(params: { - entries: ManageModelsCatalogEntry[]; - query: string; - selectedIds: string[]; - filterMode: FilterMode; -}): ManageModelsCatalogEntry[] { - const { entries, query, selectedIds, filterMode } = params; - const normalized = query.trim().toLowerCase(); - const rawTokens = normalized ? normalized.split(/\s+/).filter(Boolean) : []; - const quickFilterEnabled = rawTokens.some( - (token) => token === 'enabled' || token === 'is:enabled', - ); - const tokens = rawTokens.filter( - (token) => token !== 'enabled' && token !== 'is:enabled', - ); - const selectedSet = new Set(selectedIds); - - return entries.filter((entry) => { - if ( - (filterMode === 'enabled' || quickFilterEnabled) && - !selectedSet.has(entry.id) - ) { - return false; - } - if (filterMode === 'free' && !entry.badges.includes('free')) { - return false; - } - if (filterMode === 'vision' && !entry.supportsVision) { - return false; - } - - if (tokens.length === 0) { - return true; - } - - const haystack = `${entry.searchText} ${entry.id}`.toLowerCase(); - return tokens.every((token) => haystack.includes(token)); - }); -} - -function getFilterLabel(filterMode: FilterMode): string { - switch (filterMode) { - case 'enabled': - return 'Enabled'; - case 'free': - return 'Free'; - case 'vision': - return 'Vision'; - case 'all': - default: - return 'All'; - } -} - -function cycleFilter( - current: FilterMode, - direction: 'left' | 'right', -): FilterMode { - const modes: FilterMode[] = ['all', 'enabled', 'free', 'vision']; - const currentIndex = modes.indexOf(current); - const nextIndex = - direction === 'right' - ? (currentIndex + 1) % modes.length - : (currentIndex - 1 + modes.length) % modes.length; - return modes[nextIndex] || 'all'; -} - -function formatContextWindowSize(value?: number): string { - return typeof value === 'number' ? value.toLocaleString('en-US') : 'unknown'; -} - -export function getNextFocusMode( - current: FocusMode, - direction: 'forward' | 'backward', - hasList: boolean, -): FocusMode { - const order: FocusMode[] = hasList - ? ['tabs', 'search', 'list'] - : ['tabs', 'search']; - const currentIndex = order.indexOf(current); - const safeIndex = currentIndex >= 0 ? currentIndex : 0; - const nextIndex = - direction === 'forward' - ? (safeIndex + 1) % order.length - : (safeIndex - 1 + order.length) % order.length; - return order[nextIndex] || 'tabs'; -} - -export function getNextEnabledTabSource( - current: ManageModelsTabSource, - direction: 'left' | 'right', -): ManageModelsTabSource { - const currentIndex = MANAGE_MODELS_TABS.findIndex( - (tab) => tab.source === current, - ); - const safeIndex = currentIndex >= 0 ? currentIndex : 0; - - for (let offset = 1; offset <= MANAGE_MODELS_TABS.length; offset += 1) { - const candidateIndex = - direction === 'right' - ? (safeIndex + offset) % MANAGE_MODELS_TABS.length - : (safeIndex - offset + MANAGE_MODELS_TABS.length) % - MANAGE_MODELS_TABS.length; - const candidate = MANAGE_MODELS_TABS[candidateIndex]; - if (candidate?.enabled) { - return candidate.source; - } - } - - return current; -} - -export function ManageModelsDialog({ - config, - onClose, -}: ManageModelsDialogProps): React.JSX.Element { - const settings = useSettings(); - const [activeTabSource, setActiveTabSource] = - useState('openrouter'); - const source: ManageModelsSource = 'openrouter'; - - const [status, setStatus] = useState('loading'); - const [error, setError] = useState(null); - const [catalog, setCatalog] = useState(null); - const [query, setQuery] = useState(''); - const [focusMode, setFocusMode] = useState('tabs'); - const [filterMode, setFilterMode] = useState('all'); - const [selectedIds, setSelectedIds] = useState([]); - const [highlightedId, setHighlightedId] = useState(null); - const [statusMessage, setStatusMessage] = useState(null); - - const loadCatalog = useCallback(async () => { - setStatus('loading'); - setError(null); - setStatusMessage(null); - - try { - const nextCatalog = await fetchManageModelsCatalog(source); - const enabledIds = getEnabledModelIdsForSource(source, settings); - setCatalog(nextCatalog); - setSelectedIds(enabledIds); - setHighlightedId(nextCatalog.entries[0]?.id || null); - setStatus('ready'); - } catch (loadError) { - setError( - loadError instanceof Error ? loadError.message : String(loadError), - ); - setStatus('error'); - } - }, [settings, source]); - - useEffect(() => { - void loadCatalog(); - }, [loadCatalog]); - - const filteredEntries = useMemo( - () => - applyCatalogFilters({ - entries: catalog?.entries || [], - query, - selectedIds, - filterMode, - }), - [catalog?.entries, query, selectedIds, filterMode], - ); - - useEffect(() => { - if (filteredEntries.length === 0) { - setHighlightedId(null); - if (focusMode === 'list') { - setFocusMode('search'); - } - return; - } - - if ( - highlightedId && - filteredEntries.some((entry) => entry.id === highlightedId) - ) { - return; - } - - setHighlightedId(filteredEntries[0]?.id || null); - }, [filteredEntries, focusMode, highlightedId]); - - const highlightedIndex = useMemo(() => { - if (!highlightedId) { - return 0; - } - const index = filteredEntries.findIndex( - (entry) => entry.id === highlightedId, - ); - return index >= 0 ? index : 0; - }, [filteredEntries, highlightedId]); - - const highlightedEntry = useMemo(() => { - if (!highlightedId) { - return null; - } - return catalog?.entries.find((entry) => entry.id === highlightedId) || null; - }, [catalog?.entries, highlightedId]); - - const visibleWindow = useMemo(() => { - if (filteredEntries.length <= MAX_VISIBLE_MODELS) { - return { - start: 0, - entries: filteredEntries, - }; - } - - const centeredStart = Math.max( - 0, - Math.min( - highlightedIndex - Math.floor(MAX_VISIBLE_MODELS / 2), - filteredEntries.length - MAX_VISIBLE_MODELS, - ), - ); - - return { - start: centeredStart, - entries: filteredEntries.slice( - centeredStart, - centeredStart + MAX_VISIBLE_MODELS, - ), - }; - }, [filteredEntries, highlightedIndex]); - - const moveHighlight = useCallback( - (direction: 'up' | 'down') => { - if (filteredEntries.length === 0) { - return; - } - - if (direction === 'up') { - if (highlightedIndex <= 0) { - setFocusMode('search'); - return; - } - setHighlightedId(filteredEntries[highlightedIndex - 1]?.id || null); - return; - } - - const nextIndex = Math.min( - highlightedIndex + 1, - filteredEntries.length - 1, - ); - setHighlightedId(filteredEntries[nextIndex]?.id || null); - }, - [filteredEntries, highlightedIndex], - ); - - const toggleHighlightedSelection = useCallback(() => { - const currentEntry = filteredEntries[highlightedIndex]; - if (!currentEntry) { - return; - } - - setSelectedIds((current) => { - const next = new Set(current); - if (next.has(currentEntry.id)) { - next.delete(currentEntry.id); - } else { - next.add(currentEntry.id); - } - return Array.from(next); - }); - }, [filteredEntries, highlightedIndex]); - - const handleSave = useCallback(async () => { - if (!catalog) { - return; - } - - const selectedEntries = catalog.entries.filter((entry) => - selectedIds.includes(entry.id), - ); - - if (selectedEntries.length === 0) { - setError('Select at least one model to keep enabled.'); - return; - } - - setStatus('saving'); - setError(null); - setStatusMessage(null); - - try { - const selectedModels: ModelConfig[] = selectedEntries.map( - (entry) => entry.model, - ); - const result = await saveManageModelsSelection({ - source, - selectedModels, - settings: settings as LoadedSettings, - config, - }); - setSelectedIds(result.selectedIds); - setStatus('ready'); - setStatusMessage( - result.activeModelId - ? `Saved ${result.selectedIds.length} enabled models · active model: ${result.activeModelId} · use /model to switch models` - : `Saved ${result.selectedIds.length} enabled models · use /model to switch models`, - ); - } catch (saveError) { - setError( - saveError instanceof Error ? saveError.message : String(saveError), - ); - setStatus('error'); - } - }, [catalog, config, selectedIds, settings, source]); - - useKeypress( - (key) => { - if (key.name === 'escape') { - onClose(); - return; - } - - if (key.ctrl && key.name === 'r' && status !== 'saving') { - void loadCatalog(); - return; - } - - if (status === 'saving') { - return; - } - - if (key.name === 'tab') { - setFocusMode((current) => - getNextFocusMode( - current, - key.shift ? 'backward' : 'forward', - filteredEntries.length > 0, - ), - ); - return; - } - - if (focusMode === 'tabs') { - if (key.name === 'left') { - setActiveTabSource((current) => - getNextEnabledTabSource(current, 'left'), - ); - return; - } - if (key.name === 'right') { - setActiveTabSource((current) => - getNextEnabledTabSource(current, 'right'), - ); - return; - } - // tabs row has no active TextInput — accept ↓/j/Ctrl+N uniformly - if (keyMatchers[Command.SELECTION_DOWN](key)) { - setFocusMode('search'); - } - return; - } - - if (focusMode === 'search') { - // The search TextInput is active in this mode, so only accept - // unambiguous non-letter shortcuts (arrows + Ctrl+P/Ctrl+N) for - // transitions — bare k/j must reach the TextInput as typed characters. - const isSearchUp = key.name === 'up' || (key.ctrl && key.name === 'p'); - const isSearchDown = - key.name === 'down' || (key.ctrl && key.name === 'n'); - if (key.name === 'left') { - setFilterMode((current) => cycleFilter(current, 'left')); - return; - } - if (key.name === 'right') { - setFilterMode((current) => cycleFilter(current, 'right')); - return; - } - if (isSearchUp) { - setFocusMode('tabs'); - return; - } - if (isSearchDown && filteredEntries.length > 0) { - setFocusMode('list'); - } - return; - } - - if (focusMode === 'list') { - if (keyMatchers[Command.SELECTION_UP](key)) { - moveHighlight('up'); - return; - } - if (keyMatchers[Command.SELECTION_DOWN](key)) { - moveHighlight('down'); - return; - } - if (key.name === 'space' || key.sequence === ' ') { - toggleHighlightedSelection(); - return; - } - if (key.name === 'return') { - void handleSave(); - } - } - }, - { isActive: true }, - ); - - const terminalWidth = process.stdout.columns || 120; - const searchInputWidth = Math.max(40, Math.min(100, terminalWidth - 16)); - - const enabledSet = useMemo(() => new Set(selectedIds), [selectedIds]); - const hiddenAboveCount = visibleWindow.start; - const hiddenBelowCount = Math.max( - 0, - filteredEntries.length - - (visibleWindow.start + visibleWindow.entries.length), - ); - - return ( - - - - {'─'.repeat(200)} - - - - - - Manage Models:{' '} - - {MANAGE_MODELS_TABS.map((tab) => { - const isActive = activeTabSource === tab.source; - const isFocused = focusMode === 'tabs' && isActive; - - return ( - - {isActive ? ( - - {` ${tab.label} `} - - ) : ( - - {` ${tab.label}${tab.enabled ? '' : ' (soon)'} `} - - )} - - ); - })} - - - - {(status === 'loading' || status === 'saving') && ( - - - {status === 'loading' - ? 'Loading OpenRouter catalog…' - : 'Saving enabled models…'} - - - )} - - {error && ( - - {error} - - )} - - {statusMessage && ( - - {statusMessage} - - )} - - - { - if (filteredEntries.length > 0) { - setFocusMode('list'); - } - }} - onDown={() => { - if (filteredEntries.length > 0) { - setFocusMode('list'); - } - }} - placeholder="Search models… (type enabled to filter)" - height={1} - isActive={status !== 'saving' && focusMode === 'search'} - inputWidth={searchInputWidth} - /> - - - - - - {getFilterLabel(filterMode)} · {catalog?.entries.length || 0} total - · {filteredEntries.length} shown · {selectedIds.length} enabled - - - {filteredEntries.length === 0 ? ( - - No models match the current search and filter. - - ) : ( - - {hiddenAboveCount > 0 && ( - - ↑ {hiddenAboveCount} more above - - )} - - {visibleWindow.entries.map((entry, index) => { - const absoluteIndex = visibleWindow.start + index; - const isActive = - focusMode === 'list' && absoluteIndex === highlightedIndex; - const isEnabled = enabledSet.has(entry.id); - const prefix = isActive ? '›' : ' '; - const checkbox = isEnabled ? '[✓]' : '[ ]'; - const rowColor = isActive - ? theme.status.success - : isEnabled - ? theme.text.accent - : theme.text.primary; - - return ( - - {prefix} {checkbox} {buildModelLabel(entry)} - - ); - })} - - {hiddenBelowCount > 0 && ( - - ↓ {hiddenBelowCount} more below - - )} - - )} - - - - Details - {highlightedEntry ? ( - - {highlightedEntry.label} - - Model ID: {highlightedEntry.id} - - - Enabled: {enabledSet.has(highlightedEntry.id) ? 'yes' : 'no'} - - - Vision: {highlightedEntry.supportsVision ? 'yes' : 'no'} - - - Context:{' '} - {formatContextWindowSize(highlightedEntry.contextWindowSize)} - - - Tags:{' '} - {highlightedEntry.badges.length > 0 - ? highlightedEntry.badges.join(', ') - : 'none'} - - - ) : ( - - Move to the model list to inspect a model. - - )} - - - - - - ←/→ tab switch · ↓ enter list · Space toggle · Enter save · Esc cancel - - - - ); -} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7ce19a9f7b..9a4b5c3662 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -44,8 +44,6 @@ export interface UIActions { closeMemoryDialog: () => void; closeModelDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; - openManageModelsDialog: () => void; - closeManageModelsDialog: () => void; openArenaDialog: (type: Exclude) => void; closeArenaDialog: () => void; handleArenaModelsSelected?: (models: string[]) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 25569a64e5..cbfa9a7ac9 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -58,7 +58,6 @@ export interface UIState { isMemoryDialogOpen: boolean; isModelDialogOpen: boolean; isFastModelMode: boolean; - isManageModelsDialogOpen: boolean; isTrustDialogOpen: boolean; activeArenaDialog: ArenaDialogType; isPermissionsDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 82e2beecd5..760a2a9565 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -133,7 +133,6 @@ describe('useSlashCommandProcessor', () => { openSettingsDialog: vi.fn(), openStatusLineDialog: vi.fn(), openModelDialog: mockOpenModelDialog, - openManageModelsDialog: vi.fn(), openTrustDialog: vi.fn(), openPermissionsDialog: vi.fn(), openApprovalModeDialog: vi.fn(), diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 0607778cdd..f038829e3a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -92,7 +92,6 @@ export interface SlashCommandProcessorActions { openSettingsDialog: () => void; openStatusLineDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; - openManageModelsDialog: () => void; openTrustDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; @@ -692,9 +691,6 @@ export const useSlashCommandProcessor = ( case 'fast-model': actions.openModelDialog({ fastModelMode: true }); return { type: 'handled' }; - case 'manage-models': - actions.openManageModelsDialog(); - return { type: 'handled' }; case 'trust': actions.openTrustDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 0be40def8e..8c0bb60da5 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -8,12 +8,6 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; import type { ArenaDialogType } from './useArenaCommand.js'; -// OpenAICredentials type (previously imported from OpenAIKeyPrompt) -interface OpenAICredentials { - apiKey: string; - baseUrl?: string; - model?: string; -} export interface DialogCloseOptions { // Theme dialog @@ -29,10 +23,7 @@ export interface DialogCloseOptions { // Auth dialog isAuthDialogOpen: boolean; - handleAuthSelect: ( - authType: AuthType | undefined, - credentials?: OpenAICredentials, - ) => Promise; + closeAuthDialog: () => void; pendingAuthType: AuthType | undefined; // Editor dialog diff --git a/packages/cli/src/ui/hooks/useManageModelsCommand.test.ts b/packages/cli/src/ui/hooks/useManageModelsCommand.test.ts deleted file mode 100644 index f9d46218f5..0000000000 --- a/packages/cli/src/ui/hooks/useManageModelsCommand.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useManageModelsCommand } from './useManageModelsCommand.js'; - -describe('useManageModelsCommand', () => { - it('should initialize with the dialog closed', () => { - const { result } = renderHook(() => useManageModelsCommand()); - expect(result.current.isManageModelsDialogOpen).toBe(false); - }); - - it('should open the dialog when openManageModelsDialog is called', () => { - const { result } = renderHook(() => useManageModelsCommand()); - - act(() => { - result.current.openManageModelsDialog(); - }); - - expect(result.current.isManageModelsDialogOpen).toBe(true); - }); - - it('should close the dialog when closeManageModelsDialog is called', () => { - const { result } = renderHook(() => useManageModelsCommand()); - - act(() => { - result.current.openManageModelsDialog(); - }); - expect(result.current.isManageModelsDialogOpen).toBe(true); - - act(() => { - result.current.closeManageModelsDialog(); - }); - expect(result.current.isManageModelsDialogOpen).toBe(false); - }); -}); diff --git a/packages/cli/src/ui/hooks/useManageModelsCommand.ts b/packages/cli/src/ui/hooks/useManageModelsCommand.ts deleted file mode 100644 index ed1f2965e0..0000000000 --- a/packages/cli/src/ui/hooks/useManageModelsCommand.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback, useState } from 'react'; - -interface UseManageModelsCommandReturn { - isManageModelsDialogOpen: boolean; - openManageModelsDialog: () => void; - closeManageModelsDialog: () => void; -} - -export function useManageModelsCommand(): UseManageModelsCommandReturn { - const [isManageModelsDialogOpen, setIsManageModelsDialogOpen] = - useState(false); - - const openManageModelsDialog = useCallback(() => { - setIsManageModelsDialogOpen(true); - }, []); - - const closeManageModelsDialog = useCallback(() => { - setIsManageModelsDialogOpen(false); - }, []); - - return { - isManageModelsDialogOpen, - openManageModelsDialog, - closeManageModelsDialog, - }; -} diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index 148e1a576e..0da3161ba3 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -6,22 +6,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { useProviderUpdates } from './useProviderUpdates.js'; import { + AuthType, CODING_PLAN_CHINA_BASE_URL, CODING_PLAN_ENV_KEY, codingPlanProvider, -} from '../../auth/providers/alibaba/codingPlan.js'; -import { TOKEN_PLAN_BASE_URL, tokenPlanProvider, -} from '../../auth/providers/alibaba/tokenPlan.js'; -import { buildProviderTemplate, computeModelListVersion, PROVIDER_METADATA_NS, -} from '../../auth/providerConfig.js'; +} from '@qwen-code/qwen-code-core'; +import { useProviderUpdates } from './useProviderUpdates.js'; vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.ts b/packages/cli/src/ui/hooks/useProviderUpdates.ts index b401daee75..fb56dc12e8 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.ts @@ -5,11 +5,14 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ProviderModelConfig, Config } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { t } from '../../i18n/index.js'; -import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js'; +import type { + ProviderModelConfig, + Config, + ProviderConfig, +} from '@qwen-code/qwen-code-core'; import { + ALL_PROVIDERS, + applyProviderInstallPlan, buildInstallPlan, buildProviderTemplate, computeModelListVersion, @@ -18,9 +21,10 @@ import { resolveBaseUrl, resolveMetadataKey, resolveOwnsModel, - type ProviderConfig, -} from '../../auth/providerConfig.js'; -import { ALL_PROVIDERS } from '../../auth/allProviders.js'; +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { t } from '../../i18n/index.js'; +import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; // --------------------------------------------------------------------------- @@ -234,9 +238,12 @@ export function useProviderUpdates( } await applyProviderInstallPlan(installPlan, { - settings, - config, - refreshAuth: false, + settings: createLoadedSettingsAdapter(settings), + reloadModelProviders: (mp) => config.reloadModelProvidersConfig(mp), + syncAuthState: (authType, modelId) => + config.getModelsConfig().syncAfterAuthRefresh(authType, modelId), + refreshAuth: (authType) => config.refreshAuth(authType), + doRefreshAuth: false, }); const activeModel = config.getModel(); diff --git a/packages/cli/src/ui/manageModels/manageModels.test.ts b/packages/cli/src/ui/manageModels/manageModels.test.ts deleted file mode 100644 index b98a67ff7d..0000000000 --- a/packages/cli/src/ui/manageModels/manageModels.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - AuthType, - type Config, - type ModelProvidersConfig, -} from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { - fetchManageModelsCatalog, - getEnabledModelIdsForSource, - saveManageModelsSelection, -} from './manageModels.js'; - -const { - mockFetchOpenRouterModels, - mockMergeOpenRouterConfigs, - mockIsOpenRouterConfig, -} = vi.hoisted(() => ({ - mockFetchOpenRouterModels: vi.fn(), - mockMergeOpenRouterConfigs: vi.fn(), - mockIsOpenRouterConfig: vi.fn(), -})); - -vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({ - OPENROUTER_DEFAULT_MODEL: 'z-ai/glm-4.5-air:free', - fetchOpenRouterModels: mockFetchOpenRouterModels, - mergeOpenRouterConfigs: mockMergeOpenRouterConfigs, - isOpenRouterConfig: mockIsOpenRouterConfig, -})); - -describe('manageModels', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('fetchManageModelsCatalog maps OpenRouter models into catalog entries', async () => { - mockFetchOpenRouterModels.mockResolvedValue([ - { - id: 'qwen/qwen3-coder:free', - name: 'OpenRouter · Qwen3 Coder', - capabilities: { vision: true }, - generationConfig: { contextWindowSize: 1_000_000 }, - }, - ]); - - const catalog = await fetchManageModelsCatalog('openrouter'); - - expect(catalog.source).toBe('openrouter'); - expect(catalog.entries).toHaveLength(1); - expect(catalog.entries[0]?.label).toBe('Qwen3 Coder'); - expect(catalog.entries[0]?.badges).toEqual( - expect.arrayContaining(['free', 'vision', 'long-context']), - ); - }); - - it('getEnabledModelIdsForSource only returns OpenRouter-enabled ids', () => { - mockIsOpenRouterConfig.mockImplementation( - (config: { baseUrl?: string }) => - config.baseUrl?.includes('openrouter') ?? false, - ); - - const settings = { - merged: { - modelProviders: { - [AuthType.USE_OPENAI]: [ - { - id: 'openai/gpt-4o-mini', - baseUrl: 'https://openrouter.ai/api/v1', - }, - { id: 'custom/model', baseUrl: 'https://example.com/v1' }, - ], - }, - }, - } as unknown as LoadedSettings; - - expect(getEnabledModelIdsForSource('openrouter', settings)).toEqual([ - 'openai/gpt-4o-mini', - ]); - }); - - it('saveManageModelsSelection merges selected OpenRouter models and reloads config', async () => { - const settings = { - isTrusted: false, - user: { settings: { modelProviders: {} } }, - workspace: { settings: {} }, - merged: { - modelProviders: { - [AuthType.USE_OPENAI]: [ - { id: 'old-openrouter', baseUrl: 'https://openrouter.ai/api/v1' }, - { id: 'custom/model', baseUrl: 'https://example.com/v1' }, - ], - } satisfies ModelProvidersConfig, - }, - setValue: vi.fn(), - } as unknown as LoadedSettings; - - const config = { - getContentGeneratorConfig: vi - .fn() - .mockReturnValue({ authType: AuthType.USE_OPENAI }), - getModel: vi.fn().mockReturnValue('old-openrouter'), - reloadModelProvidersConfig: vi.fn(), - refreshAuth: vi.fn().mockResolvedValue(undefined), - } as unknown as Config; - - mockMergeOpenRouterConfigs.mockReturnValue([ - { id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' }, - { id: 'custom/model', baseUrl: 'https://example.com/v1' }, - ]); - - const result = await saveManageModelsSelection({ - source: 'openrouter', - selectedModels: [ - { id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' }, - ], - settings, - config, - }); - - expect(mockMergeOpenRouterConfigs).toHaveBeenCalled(); - expect(settings.setValue).toHaveBeenCalledWith( - expect.anything(), - `modelProviders.${AuthType.USE_OPENAI}`, - [ - { id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' }, - { id: 'custom/model', baseUrl: 'https://example.com/v1' }, - ], - ); - expect(config.reloadModelProvidersConfig).toHaveBeenCalled(); - expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); - expect(result.selectedIds).toEqual(['openai/gpt-4o-mini']); - expect(result.activeModelId).toBe('openai/gpt-4o-mini'); - }); -}); diff --git a/packages/cli/src/ui/manageModels/manageModels.ts b/packages/cli/src/ui/manageModels/manageModels.ts deleted file mode 100644 index 2d8c8474cb..0000000000 --- a/packages/cli/src/ui/manageModels/manageModels.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - AuthType, - type Config, - type ProviderModelConfig as ModelConfig, - type ModelProvidersConfig, -} from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; -import { - OPENROUTER_DEFAULT_MODEL, - fetchOpenRouterModels, - isOpenRouterConfig, - mergeOpenRouterConfigs, -} from '../../auth/providers/oauth/openrouterOAuth.js'; - -export const MANAGE_MODELS_SOURCES = ['openrouter'] as const; - -export type ManageModelsSource = (typeof MANAGE_MODELS_SOURCES)[number]; - -export interface ManageModelsCatalogEntry { - id: string; - label: string; - searchText: string; - supportsVision: boolean; - contextWindowSize?: number; - badges: string[]; - model: ModelConfig; -} - -export interface ManageModelsCatalog { - source: ManageModelsSource; - title: string; - description: string; - authType: AuthType; - entries: ManageModelsCatalogEntry[]; -} - -export interface ManageModelsSaveResult { - updatedConfigs: ModelConfig[]; - selectedIds: string[]; - activeModelId?: string; -} - -function isFreeOpenRouterModel(modelId: string): boolean { - const normalizedId = modelId.toLowerCase(); - return normalizedId.includes(':free') || normalizedId === 'openrouter/free'; -} - -function getManageModelsDisplayLabel( - source: ManageModelsSource, - model: ModelConfig, -): string { - const rawLabel = model.name || model.id; - - switch (source) { - case 'openrouter': - return rawLabel.replace(/^OpenRouter\s*·\s*/i, '').trim() || model.id; - default: - return rawLabel; - } -} - -function createEntry( - source: ManageModelsSource, - model: ModelConfig, -): ManageModelsCatalogEntry { - const contextWindowSize = model.generationConfig?.contextWindowSize; - const supportsVision = model.capabilities?.vision === true; - const badges: string[] = []; - - if (isFreeOpenRouterModel(model.id)) { - badges.push('free'); - } - if (supportsVision) { - badges.push('vision'); - } - if (typeof contextWindowSize === 'number' && contextWindowSize >= 1_000_000) { - badges.push('long-context'); - } - - const displayLabel = getManageModelsDisplayLabel(source, model); - - return { - id: model.id, - label: displayLabel, - searchText: [model.id, model.name, displayLabel, ...badges] - .filter(Boolean) - .join(' '), - supportsVision, - contextWindowSize, - badges, - model, - }; -} - -export async function fetchManageModelsCatalog( - source: ManageModelsSource, -): Promise { - switch (source) { - case 'openrouter': { - const models = await fetchOpenRouterModels(); - return { - source, - title: 'OpenRouter', - description: - 'Browse the latest OpenRouter model catalog and choose which models are enabled locally.', - authType: AuthType.USE_OPENAI, - entries: models.map((model) => createEntry(source, model)), - }; - } - default: - throw new Error(`Unsupported manage models source: ${source}`); - } -} - -export function getEnabledModelIdsForSource( - source: ManageModelsSource, - settings: LoadedSettings, -): string[] { - const modelProviders = settings.merged.modelProviders as - | ModelProvidersConfig - | undefined; - const openaiConfigs = modelProviders?.[AuthType.USE_OPENAI] || []; - - switch (source) { - case 'openrouter': - return openaiConfigs - .filter((config) => isOpenRouterConfig(config)) - .map((config) => config.id); - default: - return []; - } -} - -export async function saveManageModelsSelection(params: { - source: ManageModelsSource; - selectedModels: ModelConfig[]; - settings: LoadedSettings; - config: Config; -}): Promise { - const { source, selectedModels, settings, config } = params; - const persistScope = getPersistScopeForModelSelection(settings); - const mergedModelProviders = settings.merged.modelProviders as - | ModelProvidersConfig - | undefined; - const existingOpenAIConfigs = - mergedModelProviders?.[AuthType.USE_OPENAI] || []; - - switch (source) { - case 'openrouter': { - const updatedConfigs = mergeOpenRouterConfigs( - existingOpenAIConfigs, - selectedModels, - ); - - if (updatedConfigs.length === 0) { - throw new Error( - 'At least one OpenAI-compatible model must remain enabled.', - ); - } - - settings.setValue( - persistScope, - `modelProviders.${AuthType.USE_OPENAI}`, - updatedConfigs, - ); - - const selectedIds = selectedModels.map((model) => model.id); - const currentAuthType = config.getContentGeneratorConfig()?.authType; - const currentModelId = config.getModel(); - const currentModelStillAvailable = currentModelId - ? updatedConfigs.some((model) => model.id === currentModelId) - : false; - - let activeModelId = currentModelId; - if (!currentModelStillAvailable) { - const preferredDefault = updatedConfigs.find( - (model) => model.id === OPENROUTER_DEFAULT_MODEL, - ); - activeModelId = preferredDefault?.id || updatedConfigs[0]?.id; - if (activeModelId) { - settings.setValue(persistScope, 'model.name', activeModelId); - } - } - - const updatedModelProviders: ModelProvidersConfig = { - ...(mergedModelProviders || {}), - [AuthType.USE_OPENAI]: updatedConfigs, - }; - config.reloadModelProvidersConfig(updatedModelProviders); - - if (currentAuthType === AuthType.USE_OPENAI) { - await config.refreshAuth(AuthType.USE_OPENAI); - } - - return { - updatedConfigs, - selectedIds, - activeModelId, - }; - } - default: - throw new Error(`Unsupported manage models source: ${source}`); - } -} diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 7661ba69a9..323eb1989e 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -18,12 +18,11 @@ import { createDebugLogger, detectRuntime, + getAllProviderBaseUrls, getOrCreateSharedDispatcher, redactProxyCredentials, } from '@qwen-code/qwen-code-core'; -import { getAllProviderBaseUrls } from '../auth/allProviders.js'; - const debugLogger = createDebugLogger('PRECONNECT'); let preconnectFired = false; diff --git a/packages/cli/src/utils/doctorChecks.test.ts b/packages/cli/src/utils/doctorChecks.test.ts index a4fd7a63b6..6201e7e4a4 100644 --- a/packages/cli/src/utils/doctorChecks.test.ts +++ b/packages/cli/src/utils/doctorChecks.test.ts @@ -10,23 +10,16 @@ import { type CommandContext } from '../ui/commands/types.js'; import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; import * as systemInfoUtils from './systemInfo.js'; import * as authModule from '../config/auth.js'; -import * as allProviders from '../auth/allProviders.js'; +import * as allProviders from '@qwen-code/qwen-code-core'; vi.mock('./systemInfo.js'); vi.mock('../config/auth.js'); -vi.mock('../auth/allProviders.js', async (importOriginal) => { - const actual = - (await importOriginal()) as typeof import('../auth/allProviders.js'); - return { - ...actual, - findProviderByCredentials: vi.fn(actual.findProviderByCredentials), - }; -}); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = (await importOriginal()) as typeof import('@qwen-code/qwen-code-core'); return { ...actual, + findProviderByCredentials: vi.fn(actual.findProviderByCredentials), canUseRipgrep: vi.fn().mockResolvedValue(true), getMCPServerStatus: vi.fn().mockReturnValue('connected'), MCPServerStatus: { diff --git a/packages/cli/src/utils/doctorChecks.ts b/packages/cli/src/utils/doctorChecks.ts index efd6dea3d2..77690fe325 100644 --- a/packages/cli/src/utils/doctorChecks.ts +++ b/packages/cli/src/utils/doctorChecks.ts @@ -8,14 +8,14 @@ import process from 'node:process'; import os from 'node:os'; import { getNpmVersion, getGitVersion } from './systemInfo.js'; import { validateAuthMethod } from '../config/auth.js'; -import { findProviderByCredentials } from '../auth/allProviders.js'; -import type { CommandContext } from '../ui/commands/types.js'; -import type { DoctorCheckResult } from '../ui/types.js'; import { + findProviderByCredentials, canUseRipgrep, getMCPServerStatus, MCPServerStatus, } from '@qwen-code/qwen-code-core'; +import type { CommandContext } from '../ui/commands/types.js'; +import type { DoctorCheckResult } from '../ui/types.js'; import { t } from '../i18n/index.js'; const MIN_NODE_MAJOR = 22; diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index 223e83f0b7..03c769d8e1 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -34,6 +34,8 @@ import { isDefaultValue, isValueInherited, getEffectiveDisplayValue, + setNestedPropertySafe, + setNestedPropertyForce, } from './settingsUtils.js'; import { getSettingsSchema, @@ -1152,3 +1154,51 @@ describe('SettingsUtils', () => { }); }); }); + +describe('setNestedProperty prototype-pollution guards', () => { + // After each test, assert global Object.prototype was not polluted. + const assertNoPollution = () => { + expect(({} as Record)['polluted']).toBeUndefined(); + expect( + (Object.prototype as Record)['polluted'], + ).toBeUndefined(); + }; + + describe('setNestedPropertySafe', () => { + it('writes a normal dotted path', () => { + const obj: Record = {}; + setNestedPropertySafe(obj, 'a.b.c', 1); + const a = obj['a'] as Record>; + expect(a['b']['c']).toBe(1); + }); + + it('refuses a __proto__ segment (no pollution, no write)', () => { + const obj: Record = {}; + setNestedPropertySafe(obj, '__proto__.polluted', 'yes'); + assertNoPollution(); + expect(Object.keys(obj)).toEqual([]); + }); + + it('refuses constructor / prototype segments', () => { + const obj: Record = {}; + setNestedPropertySafe(obj, 'constructor.prototype.polluted', 'yes'); + setNestedPropertySafe(obj, 'foo.prototype.polluted', 'yes'); + assertNoPollution(); + }); + }); + + describe('setNestedPropertyForce', () => { + it('writes a normal dotted path', () => { + const obj: Record = {}; + setNestedPropertyForce(obj, 'x.y', 2); + expect((obj['x'] as Record)['y']).toBe(2); + }); + + it('refuses a __proto__ segment (no pollution, no write)', () => { + const obj: Record = {}; + setNestedPropertyForce(obj, '__proto__.polluted', 'yes'); + assertNoPollution(); + expect(Object.keys(obj)).toEqual([]); + }); + }); +}); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index e92be62fb4..c85d83379d 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -390,12 +390,34 @@ export function settingExistsInScope( return value !== undefined; } +/** + * True if any dotted-path segment would let a write climb into the prototype + * chain. Defense in depth at the utility level: callers like + * migrateProviderMetadata feed `field` names straight from Object.entries on + * user-editable settings.json, and JSON.parse preserves `__proto__` as an own + * property — a crafted file could otherwise pollute Object.prototype here. + * Inline literal === comparisons (not Set.has) so CodeQL recognises this as a + * prototype-pollution sanitiser. + */ +function pathHasUnsafeSegment(keys: string[]): boolean { + for (const key of keys) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + return true; + } + } + return false; +} + export function setNestedPropertyForce( obj: Record, path: string, value: unknown, ): void { const keys = path.split('.'); + // Refuse prototype-chain segments (see pathHasUnsafeSegment). Silent skip + // rather than throw: callers iterate user data and a poisoned key should + // be ignored, not crash the operation. + if (pathHasUnsafeSegment(keys)) return; const lastKey = keys.pop(); if (!lastKey) return; @@ -416,6 +438,8 @@ export function setNestedPropertySafe( value: unknown, ): void { const keys = path.split('.'); + // Refuse prototype-chain segments (see pathHasUnsafeSegment). + if (pathHasUnsafeSegment(keys)) return; const lastKey = keys.pop(); if (!lastKey) return; @@ -656,8 +680,15 @@ export function restoreSettingsFromBackup(filePath: string): boolean { fs.unlinkSync(backupPath); return true; } - } catch (_e) { - // Ignore restore errors — caller should handle the failure + } catch (err) { + // Caller handles the boolean failure, but log the underlying cause so + // EACCES / disk full / file-locked don't all look identical from + // upstream — the adapter's own warning then has something to point at. + // eslint-disable-next-line no-console -- best-effort rollback path + console.error( + `[settingsUtils] restoreSettingsFromBackup(${filePath}) failed:`, + err, + ); } return false; } diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index 7d0978954d..c53defff6b 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,8 +6,10 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; -import { findProviderByCredentials } from '../auth/allProviders.js'; -import { resolveMetadataKey } from '../auth/providerConfig.js'; +import { + findProviderByCredentials, + resolveMetadataKey, +} from '@qwen-code/qwen-code-core'; /** * Field configuration for system information display diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55be58312d..2db8dbae55 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -127,6 +127,12 @@ export type { CronListTool, CronListParams } from './tools/cron-list.js'; export type { CronDeleteTool, CronDeleteParams } from './tools/cron-delete.js'; export type { ToolSearchTool, ToolSearchParams } from './tools/tool-search.js'; +// ============================================================================ +// Providers +// ============================================================================ + +export * from './providers/index.js'; + // ============================================================================ // Services // ============================================================================ diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index f2e4bc1f13..1159f63923 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -236,7 +236,7 @@ describe('ModelsConfig', () => { }); // User manually updates credentials via updateCredentials. - // Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model, + // Note: In practice, the /auth provider-setup flow prevents using a modelId that matches a provider model, // but if syncAfterAuthRefresh is called with a modelId that exists in registry, // we should use provider config. modelsConfig.updateCredentials({ apiKey: 'manual-key' }); diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index f82ae8a72d..0c90931905 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -877,7 +877,7 @@ export class ModelsConfig { this.currentAuthType = authType; // Step 1: If modelId exists in registry, always use config from modelRegistry - // Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it), + // Manual credentials won't have a modelId that matches a provider model (the /auth provider-setup flow prevents it), // so if modelId exists in registry, we should always use provider config. // This handles provider switching even within the same authType. // Prefer exact match (id+baseUrl) when the current baseUrl was set by a diff --git a/packages/core/src/providers/__tests__/install.test.ts b/packages/core/src/providers/__tests__/install.test.ts new file mode 100644 index 0000000000..96e9557281 --- /dev/null +++ b/packages/core/src/providers/__tests__/install.test.ts @@ -0,0 +1,551 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ModelProvidersConfig } from '../../models/types.js'; +import { + applyProviderInstallPlan, + ProviderInstallError, + type ProviderInstallPlan, + type ProviderSettingsAdapter, +} from '../index.js'; + +function createAdapter(modelProviders: ModelProvidersConfig = {}) { + const adapter: ProviderSettingsAdapter & { + setValue: ReturnType; + persist: ReturnType; + backup: ReturnType; + restore: ReturnType; + cleanupBackup: ReturnType; + } = { + getValue: vi.fn(), + setValue: vi.fn(), + getModelProviders: vi.fn(() => modelProviders), + persist: vi.fn(), + backup: vi.fn(), + restore: vi.fn(), + cleanupBackup: vi.fn(), + }; + return adapter; +} + +describe('applyProviderInstallPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env['TEST_API_KEY']; + delete process.env['BRAND_NEW_KEY']; + }); + + it('refuses an install plan that sets a reserved env var (NODE_OPTIONS)', async () => { + const adapter = createAdapter(); + // CI sets NODE_OPTIONS (e.g. --max-old-space-size); snapshot whatever it + // is so we can assert the rejected plan left it UNCHANGED rather than + // assuming it's unset. + const originalNodeOptions = process.env['NODE_OPTIONS']; + const plan: ProviderInstallPlan = { + providerId: 'evil', + authType: AuthType.USE_OPENAI, + env: { NODE_OPTIONS: '--require /tmp/evil.js' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter }), + ).rejects.toThrow(/reserved environment variable: NODE_OPTIONS/); + // The evil value must not have leaked into the live process; the + // pre-existing value (if any) is untouched. + expect(process.env['NODE_OPTIONS']).toBe(originalNodeOptions); + expect(process.env['NODE_OPTIONS']).not.toBe('--require /tmp/evil.js'); + expect(adapter.setValue).not.toHaveBeenCalledWith( + 'env.NODE_OPTIONS', + expect.anything(), + ); + }); + + it('matches the env denylist case-insensitively (Path)', async () => { + const adapter = createAdapter(); + const plan: ProviderInstallPlan = { + providerId: 'evil', + authType: AuthType.USE_OPENAI, + env: { Path: 'C:\\evil' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter }), + ).rejects.toThrow(/reserved environment variable: Path/); + }); + + it.each(['TMP', 'TEMP', 'tmp'])( + 'rejects the Windows temp-redirect env var %s', + async (key) => { + const adapter = createAdapter(); + const plan: ProviderInstallPlan = { + providerId: 'evil', + authType: AuthType.USE_OPENAI, + env: { [key]: 'C:\\evil-temp' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter }), + ).rejects.toThrow(/reserved environment variable/); + }, + ); + + it('persists env, auth selection, selected model, and merged model providers', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { + id: 'old-owned', + envKey: 'TEST_API_KEY', + generationConfig: { contextWindowSize: 123 }, + }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ], + }); + const reloadModelProviders = vi.fn(); + const syncAuthState = vi.fn(); + const refreshAuth = vi.fn(async () => undefined); + + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'sk-test' }, + modelSelection: { modelId: 'new-model' }, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-model', envKey: 'TEST_API_KEY' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model) => model.envKey === 'TEST_API_KEY', + }, + ], + }; + + await applyProviderInstallPlan(plan, { + settings: adapter, + reloadModelProviders, + syncAuthState, + refreshAuth, + }); + + expect(adapter.setValue).toHaveBeenCalledWith( + 'env.TEST_API_KEY', + 'sk-test', + ); + expect(process.env['TEST_API_KEY']).toBe('sk-test'); + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'new-model', envKey: 'TEST_API_KEY' }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ]); + expect(adapter.setValue).toHaveBeenCalledWith( + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + expect(adapter.setValue).toHaveBeenCalledWith('model.name', 'new-model'); + expect(adapter.persist).toHaveBeenCalled(); + expect(reloadModelProviders).toHaveBeenCalledWith({ + [AuthType.USE_OPENAI]: [ + { id: 'new-model', envKey: 'TEST_API_KEY' }, + { + id: 'preserved', + envKey: 'OTHER_API_KEY', + generationConfig: { contextWindowSize: 456 }, + }, + ], + }); + expect(syncAuthState).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'new-model', + ); + expect(refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + expect(adapter.cleanupBackup).toHaveBeenCalled(); + }); + + it('can skip immediate auth refresh', async () => { + const adapter = createAdapter(); + const refreshAuth = vi.fn(async () => undefined); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'sk-test' }, + }; + + await applyProviderInstallPlan(plan, { + settings: adapter, + refreshAuth, + doRefreshAuth: false, + }); + + expect(adapter.setValue).toHaveBeenCalledWith( + 'env.TEST_API_KEY', + 'sk-test', + ); + expect(refreshAuth).not.toHaveBeenCalled(); + }); + + it('uses patch ownsModel for merge filtering', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'old-a', envKey: 'A' }, + { id: 'old-b', envKey: 'B' }, + ], + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-a', envKey: 'A' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model) => model.envKey === 'A', + }, + ], + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'new-a', envKey: 'A' }, + { id: 'old-b', envKey: 'B' }, + ]); + }); + + it('falls back to id+baseUrl identity when ownsModel is omitted', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + // Same id, different baseUrl → should be preserved (different identity) + { id: 'gpt-4o', baseUrl: 'https://proxy-a.example/v1' }, + // Same id+baseUrl as incoming → should be removed + { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }, + // Different id, same baseUrl as incoming → should be preserved + { id: 'gpt-3.5', baseUrl: 'https://api.openai.com/v1' }, + ], + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }], + mergeStrategy: 'prepend-and-remove-owned', + // ownsModel intentionally omitted — exercises isSameModelIdentity path + }, + ], + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' }, + { id: 'gpt-4o', baseUrl: 'https://proxy-a.example/v1' }, + { id: 'gpt-3.5', baseUrl: 'https://api.openai.com/v1' }, + ]); + }); + + it('writes provider state and legacy credentials', async () => { + const adapter = createAdapter(); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + legacyCredentials: { + apiKey: 'legacy-key', + baseUrl: 'https://example.com/v1', + }, + providerState: { + codingPlan: { + baseUrl: 'https://coding.example.com/v1', + version: 'v1', + }, + }, + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith( + 'security.auth.apiKey', + 'legacy-key', + ); + expect(adapter.setValue).toHaveBeenCalledWith( + 'security.auth.baseUrl', + 'https://example.com/v1', + ); + expect(adapter.setValue).toHaveBeenCalledWith( + 'codingPlan.baseUrl', + 'https://coding.example.com/v1', + ); + expect(adapter.setValue).toHaveBeenCalledWith('codingPlan.version', 'v1'); + }); + + it('appends models with append merge strategy', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'existing-1', envKey: 'A' }, + { id: 'existing-2', envKey: 'B' }, + ], + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-model', envKey: 'C' }], + mergeStrategy: 'append', + }, + ], + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'existing-1', envKey: 'A' }, + { id: 'existing-2', envKey: 'B' }, + { id: 'new-model', envKey: 'C' }, + ]); + }); + + it('replaces owned models with replace-owned strategy (appends new at end)', async () => { + const adapter = createAdapter({ + [AuthType.USE_OPENAI]: [ + { id: 'owned-1', envKey: 'A' }, + { id: 'unrelated', envKey: 'B' }, + { id: 'owned-2', envKey: 'A' }, + ], + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-a', envKey: 'A' }], + mergeStrategy: 'replace-owned', + ownsModel: (model) => model.envKey === 'A', + }, + ], + }; + + await applyProviderInstallPlan(plan, { settings: adapter }); + + expect(adapter.setValue).toHaveBeenCalledWith('modelProviders.openai', [ + { id: 'unrelated', envKey: 'B' }, + { id: 'new-a', envKey: 'A' }, + ]); + }); + + it('rolls back process.env on error', async () => { + process.env['TEST_API_KEY'] = 'old-value'; + const adapter = createAdapter(); + const refreshAuth = vi.fn(async () => { + throw new Error('network error'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'new-value' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter, refreshAuth }), + ).rejects.toThrow('network error'); + + expect(process.env['TEST_API_KEY']).toBe('old-value'); + expect(adapter.restore).toHaveBeenCalled(); + }); + + it('deletes env var on rollback if it did not exist before', async () => { + const adapter = createAdapter(); + const refreshAuth = vi.fn(async () => { + throw new Error('fail'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { BRAND_NEW_KEY: 'value' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter, refreshAuth }), + ).rejects.toThrow('fail'); + + expect(process.env['BRAND_NEW_KEY']).toBeUndefined(); + }); + + // -- Rollback safety nets ------------------------------------------------- + // The catch path in applyProviderInstallPlan has three deliberate + // safety nets that were previously untested. These tests pin them down so + // a future refactor that "simplifies" the catch can't silently regress. + + it('restores runtime model providers when refreshAuth rejects after reloadModelProviders ran', async () => { + const previousProviders = { + [AuthType.USE_OPENAI]: [{ id: 'previous', envKey: 'OLD_KEY' }], + }; + const adapter = createAdapter(previousProviders); + const reloadModelProviders = vi.fn(); + const refreshAuth = vi.fn(async () => { + throw new Error('refreshAuth rejected'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'sk-new' }, + modelProviders: [ + { + authType: AuthType.USE_OPENAI, + models: [{ id: 'new-model', envKey: 'TEST_API_KEY' }], + mergeStrategy: 'prepend-and-remove-owned', + ownsModel: (model) => model.envKey === 'TEST_API_KEY', + }, + ], + }; + + await expect( + applyProviderInstallPlan(plan, { + settings: adapter, + reloadModelProviders, + refreshAuth, + }), + ).rejects.toThrow('refreshAuth rejected'); + + // Two reload calls: the success-path one with the patched providers, + // then a rollback one that hands back the snapshot we took *before* + // applying any patches. + expect(reloadModelProviders).toHaveBeenCalledTimes(2); + expect(reloadModelProviders).toHaveBeenLastCalledWith(previousProviders); + }); + + it('still rolls back env vars when backup() throws before persist', async () => { + process.env['TEST_API_KEY'] = 'old-value'; + const adapter = createAdapter(); + adapter.backup.mockImplementation(() => { + throw new Error('backup failed'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'new-value' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter }), + ).rejects.toThrow('backup failed'); + + // backup() throwing inside the try must still hand control to the + // catch path so env vars are restored. (Before this commit's + // "backup inside try" fix the throw escaped uncaught and env vars + // leaked.) + expect(process.env['TEST_API_KEY']).toBe('old-value'); + }); + + it('continues env rollback even when settings.restore itself throws', async () => { + process.env['TEST_API_KEY'] = 'before-install'; + const adapter = createAdapter(); + adapter.restore.mockImplementation(() => { + throw new Error('restore failed'); + }); + const refreshAuth = vi.fn(async () => { + throw new Error('original error'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'during-install' }, + }; + + await expect( + applyProviderInstallPlan(plan, { settings: adapter, refreshAuth }), + ).rejects.toThrow('original error'); + + // restore() throwing must not mask the original error and must not skip + // the env-var rollback loop that runs after it. + expect(adapter.restore).toHaveBeenCalled(); + expect(process.env['TEST_API_KEY']).toBe('before-install'); + }); + + it('annotates the rethrown error with the failing step and preserves the original cause', async () => { + process.env['TEST_API_KEY'] = 'old'; + const adapter = createAdapter(); + const refreshAuth = vi.fn(async () => { + throw new Error('endpoint unreachable'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'new' }, + }; + + let caught: unknown; + try { + await applyProviderInstallPlan(plan, { + settings: adapter, + refreshAuth, + }); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(Error); + // ProviderInstallError is a class, so instanceof works at runtime. + expect(caught).toBeInstanceOf(ProviderInstallError); + const err = caught as ProviderInstallError & { cause?: Error }; + // Step + authType are structured properties (not baked into the + // user-facing message, which stays the underlying error text). + expect(err.step).toBe('refreshAuth'); + expect(err.authType).toBe('openai'); + expect(err.message).toBe('endpoint unreachable'); + // Original error preserved via cause so callers matching on err.code + // (NodeJS.ErrnoException) still work. + expect(err.cause).toBeInstanceOf(Error); + expect((err.cause as Error).message).toBe('endpoint unreachable'); + }); + + it('continues throw + env rollback when reloadModelProviders rollback itself throws', async () => { + process.env['TEST_API_KEY'] = 'before'; + const previousProviders = { + [AuthType.USE_OPENAI]: [{ id: 'previous', envKey: 'OLD' }], + }; + const adapter = createAdapter(previousProviders); + let reloadCalls = 0; + const reloadModelProviders = vi.fn(() => { + reloadCalls += 1; + if (reloadCalls === 2) { + // The rollback-time reload (the second call) explodes. + throw new Error('reload restore failed'); + } + }); + const refreshAuth = vi.fn(async () => { + throw new Error('original error'); + }); + const plan: ProviderInstallPlan = { + providerId: 'test-provider', + authType: AuthType.USE_OPENAI, + env: { TEST_API_KEY: 'during' }, + }; + + await expect( + applyProviderInstallPlan(plan, { + settings: adapter, + reloadModelProviders, + refreshAuth, + }), + ).rejects.toThrow('original error'); + + // The rethrow must still carry the original error, env vars must still + // be rolled back, and the broken rollback reload must not mask anything. + expect(reloadModelProviders).toHaveBeenCalledTimes(2); + expect(process.env['TEST_API_KEY']).toBe('before'); + }); +}); diff --git a/packages/cli/src/auth/providers/alibaba/codingPlan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts similarity index 94% rename from packages/cli/src/auth/providers/alibaba/codingPlan.test.ts rename to packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts index a47e0cc4b9..876cb1b522 100644 --- a/packages/cli/src/auth/providers/alibaba/codingPlan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts @@ -5,19 +5,17 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; import { + AuthType, CODING_PLAN_CHINA_BASE_URL, CODING_PLAN_ENV_KEY, codingPlanProvider, -} from './codingPlan.js'; -import { buildInstallPlan, buildProviderTemplate, computeModelListVersion, getDefaultModelIds, resolveBaseUrl, -} from '../../providerConfig.js'; +} from '@qwen-code/qwen-code-core'; describe('coding plan provider', () => { it('creates a Coding Plan install plan', () => { diff --git a/packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-standard.test.ts similarity index 95% rename from packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts rename to packages/core/src/providers/__tests__/presets/alibaba-standard.test.ts index 3be19ffa59..1e59f9f559 100644 --- a/packages/cli/src/auth/providers/alibaba/alibabaStandard.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-standard.test.ts @@ -5,13 +5,13 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { alibabaStandardProvider } from './alibabaStandard.js'; import { + AuthType, + alibabaStandardProvider, buildInstallPlan, resolveBaseUrl, providerMatchesCredentials, -} from '../../providerConfig.js'; +} from '@qwen-code/qwen-code-core'; describe('alibabaStandardProvider', () => { it('has correct provider config', () => { diff --git a/packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts similarity index 94% rename from packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts rename to packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts index cc09acf993..1b9d6b19f0 100644 --- a/packages/cli/src/auth/providers/alibaba/tokenPlan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts @@ -5,20 +5,18 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; import { + AuthType, TOKEN_PLAN_ENV_KEY, TOKEN_PLAN_BASE_URL, tokenPlanProvider, -} from './tokenPlan.js'; -import { buildInstallPlan, buildProviderTemplate, computeModelListVersion, getDefaultModelIds, resolveBaseUrl, providerMatchesCredentials, -} from '../../providerConfig.js'; +} from '@qwen-code/qwen-code-core'; describe('token plan provider', () => { it('creates a Token Plan install plan', () => { diff --git a/packages/cli/src/auth/providers/custom/customProvider.test.ts b/packages/core/src/providers/__tests__/presets/custom-provider.test.ts similarity index 56% rename from packages/cli/src/auth/providers/custom/customProvider.test.ts rename to packages/core/src/providers/__tests__/presets/custom-provider.test.ts index c79e6829bd..bf8a8d3f92 100644 --- a/packages/cli/src/auth/providers/custom/customProvider.test.ts +++ b/packages/core/src/providers/__tests__/presets/custom-provider.test.ts @@ -5,16 +5,19 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; import { + AuthType, customProvider, - generateCustomEnvKey, CUSTOM_API_KEY_ENV_PREFIX, -} from './customProvider.js'; -import { buildInstallPlan, shouldShowStep } from '../../providerConfig.js'; + buildInstallPlan, + shouldShowStep, +} from '@qwen-code/qwen-code-core'; +// Re-import generateCustomEnvKey from the relative source path so the new +// hash-suffix format is exercised even before dist/ is rebuilt. +import { generateCustomEnvKey } from '../../presets/custom-provider.js'; describe('generateCustomEnvKey', () => { - it('produces a deterministic URL-based key', () => { + it('produces a deterministic URL-based key with a stable hash suffix', () => { const key1 = generateCustomEnvKey( AuthType.USE_OPENAI, 'https://api.example.com/v1', @@ -24,8 +27,11 @@ describe('generateCustomEnvKey', () => { 'https://api.example.com/v1', ); expect(key1).toBe(key2); - expect(key1).toBe( - `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTPS_API_EXAMPLE_COM_V1`, + // Readable prefix + 6-hex-char SHA-256 suffix. + expect(key1).toMatch( + new RegExp( + `^${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTPS_API_EXAMPLE_COM_V1_[0-9A-F]{12}$`, + ), ); }); @@ -47,9 +53,35 @@ describe('generateCustomEnvKey', () => { expect(k1).not.toBe(k2); }); - it('normalizes special characters to underscores', () => { + it('disambiguates structurally distinct URLs that normalize identically', () => { + // Pre-fix bug: `api.example.com`, `api-example.com`, `api_example.com` + // all collapsed to `API_EXAMPLE_COM`, so two different custom providers + // would overwrite each other's API key. The hash suffix prevents that. + const dotted = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api.example.com', + ); + const dashed = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api-example.com', + ); + const underscored = generateCustomEnvKey( + AuthType.USE_OPENAI, + 'https://api_example.com', + ); + expect(dotted).not.toBe(dashed); + expect(dotted).not.toBe(underscored); + expect(dashed).not.toBe(underscored); + }); + + it('normalizes special characters to underscores in the readable part', () => { const k1 = generateCustomEnvKey(AuthType.USE_OPENAI, 'http://api.a-b.com'); - expect(k1).toBe(`${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTP_API_A_B_COM`); + // Readable prefix matches; trailing 6-hex suffix is separate. + expect(k1).toMatch( + new RegExp( + `^${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTP_API_A_B_COM_[0-9A-F]{12}$`, + ), + ); }); it('handles empty strings', () => { @@ -65,7 +97,6 @@ describe('customProvider', () => { protocol: AuthType.USE_OPENAI, baseUrl: undefined, models: undefined, - authMethod: 'input', showAdvancedConfig: true, uiGroup: 'custom', }); @@ -79,8 +110,29 @@ describe('customProvider', () => { ]); }); - it('does not define ownsModel (falls back to id-based filtering)', () => { - expect(customProvider.ownsModel).toBeUndefined(); + it('owns any model whose envKey starts with the QWEN_CUSTOM_API_KEY_ prefix', () => { + // Without ownsModel, applyModelProvidersPatch falls back to id+baseUrl + // identity matching, so reinstalling a custom provider under a different + // baseUrl would leave the old entries behind. Prefix-match cleanly + // identifies "ours" without false positives against presets. + expect(customProvider.ownsModel).toBeTypeOf('function'); + expect( + customProvider.ownsModel?.({ + id: 'whatever', + envKey: `${CUSTOM_API_KEY_ENV_PREFIX}OPENAI_HTTPS_API_FOO_COM_ABCDEF`, + }), + ).toBe(true); + expect( + customProvider.ownsModel?.({ + id: 'preset-model', + envKey: 'DEEPSEEK_API_KEY', + }), + ).toBe(false); + expect( + customProvider.ownsModel?.({ + id: 'no-env-key', + }), + ).toBe(false); }); it('shows protocol, baseUrl, models, and advancedConfig steps', () => { diff --git a/packages/cli/src/auth/providers/thirdParty/deepseek.test.ts b/packages/core/src/providers/__tests__/presets/deepseek.test.ts similarity index 92% rename from packages/cli/src/auth/providers/thirdParty/deepseek.test.ts rename to packages/core/src/providers/__tests__/presets/deepseek.test.ts index c5ab28d385..c39aa5bbf7 100644 --- a/packages/cli/src/auth/providers/thirdParty/deepseek.test.ts +++ b/packages/core/src/providers/__tests__/presets/deepseek.test.ts @@ -5,8 +5,11 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { deepseekProvider, buildInstallPlan } from '../../allProviders.js'; +import { + AuthType, + deepseekProvider, + buildInstallPlan, +} from '@qwen-code/qwen-code-core'; describe('deepseekProvider', () => { it('has correct provider config', () => { diff --git a/packages/cli/src/auth/providers/thirdParty/idealab.test.ts b/packages/core/src/providers/__tests__/presets/idealab.test.ts similarity index 94% rename from packages/cli/src/auth/providers/thirdParty/idealab.test.ts rename to packages/core/src/providers/__tests__/presets/idealab.test.ts index 69c557ec12..d445f13085 100644 --- a/packages/cli/src/auth/providers/thirdParty/idealab.test.ts +++ b/packages/core/src/providers/__tests__/presets/idealab.test.ts @@ -5,8 +5,11 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { idealabProvider, buildInstallPlan } from '../../allProviders.js'; +import { + AuthType, + idealabProvider, + buildInstallPlan, +} from '@qwen-code/qwen-code-core'; describe('idealabProvider', () => { it('has correct provider config', () => { diff --git a/packages/cli/src/auth/providers/thirdParty/minimax.test.ts b/packages/core/src/providers/__tests__/presets/minimax.test.ts similarity index 90% rename from packages/cli/src/auth/providers/thirdParty/minimax.test.ts rename to packages/core/src/providers/__tests__/presets/minimax.test.ts index 79ed027237..0087af5fbe 100644 --- a/packages/cli/src/auth/providers/thirdParty/minimax.test.ts +++ b/packages/core/src/providers/__tests__/presets/minimax.test.ts @@ -5,8 +5,11 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { minimaxProvider, buildInstallPlan } from '../../allProviders.js'; +import { + AuthType, + minimaxProvider, + buildInstallPlan, +} from '@qwen-code/qwen-code-core'; describe('minimaxProvider', () => { it('offers international and China endpoints', () => { diff --git a/packages/cli/src/auth/providers/thirdParty/modelscope.test.ts b/packages/core/src/providers/__tests__/presets/modelscope.test.ts similarity index 73% rename from packages/cli/src/auth/providers/thirdParty/modelscope.test.ts rename to packages/core/src/providers/__tests__/presets/modelscope.test.ts index 90a6bf3971..d574bb8176 100644 --- a/packages/cli/src/auth/providers/thirdParty/modelscope.test.ts +++ b/packages/core/src/providers/__tests__/presets/modelscope.test.ts @@ -5,13 +5,20 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { modelscopeProvider, buildInstallPlan } from '../../allProviders.js'; +// Re-import via the relative source path so this test exercises the +// in-tree implementation even before dist/ is rebuilt (the +// @qwen-code/qwen-code-core package main points at dist/ on a fresh +// branch). The provider was deleted from the CLI side in this PR and not +// rebuilt in core's test folder until now. +import { AuthType } from '../../../core/contentGenerator.js'; +import { modelscopeProvider } from '../../presets/modelscope.js'; +import { buildInstallPlan } from '../../provider-config.js'; describe('modelscopeProvider', () => { it('has correct provider config', () => { expect(modelscopeProvider).toMatchObject({ id: 'modelscope', + label: 'ModelScope API Key', protocol: AuthType.USE_OPENAI, baseUrl: 'https://api-inference.modelscope.cn/v1', envKey: 'MODELSCOPE_API_KEY', @@ -48,6 +55,8 @@ describe('modelscopeProvider', () => { const models = plan.modelProviders?.[0]?.models; expect(models).toHaveLength(2); + // Known model: contextWindowSize is preserved, plus modelscope's + // enableThinking=true adds extra_body.enable_thinking. expect(models?.[0]?.generationConfig).toMatchObject({ contextWindowSize: 1000000, }); diff --git a/packages/core/src/providers/__tests__/presets/openrouter.test.ts b/packages/core/src/providers/__tests__/presets/openrouter.test.ts new file mode 100644 index 0000000000..a0cc7c515b --- /dev/null +++ b/packages/core/src/providers/__tests__/presets/openrouter.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +// Re-import via the relative source path so the new ownsModel envKey gate +// is exercised even before dist/ is rebuilt (the @qwen-code/qwen-code-core +// package resolves to dist/ on a fresh branch). +import { + openRouterProvider, + OPENROUTER_ENV_KEY, +} from '../../presets/openrouter.js'; + +describe('openRouterProvider', () => { + it('owns models that match BOTH our envKey and an openrouter.ai host', () => { + expect( + openRouterProvider.ownsModel?.({ + id: 'openrouter-model', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(true); + }); + + it('refuses ownership over a different envKey on the same host (user-added entry)', () => { + // A user wired their own gateway through openrouter.ai with a custom env + // var — re-install must not silently delete their model entry. + expect( + openRouterProvider.ownsModel?.({ + id: 'user-added', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'MY_PRIVATE_GATEWAY_KEY', + }), + ).toBe(false); + }); + + it('refuses ownership over an unrelated host even with our envKey', () => { + expect( + openRouterProvider.ownsModel?.({ + id: 'other-model', + baseUrl: 'https://api.example.com/v1', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(false); + }); + + it('refuses ownership when baseUrl is missing or malformed', () => { + expect( + openRouterProvider.ownsModel?.({ + id: 'no-url', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(false); + expect( + openRouterProvider.ownsModel?.({ + id: 'bad-url', + baseUrl: 'not a url', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/auth/providers/thirdParty/zai.test.ts b/packages/core/src/providers/__tests__/presets/zai.test.ts similarity index 93% rename from packages/cli/src/auth/providers/thirdParty/zai.test.ts rename to packages/core/src/providers/__tests__/presets/zai.test.ts index ab33a2397e..19c990a6bd 100644 --- a/packages/cli/src/auth/providers/thirdParty/zai.test.ts +++ b/packages/core/src/providers/__tests__/presets/zai.test.ts @@ -5,8 +5,11 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import { zaiProvider, buildInstallPlan } from '../../allProviders.js'; +import { + AuthType, + zaiProvider, + buildInstallPlan, +} from '@qwen-code/qwen-code-core'; describe('zaiProvider', () => { it('offers standard API key and Coding Plan endpoints', () => { diff --git a/packages/cli/src/auth/providerConfig.test.ts b/packages/core/src/providers/__tests__/provider-config.test.ts similarity index 65% rename from packages/cli/src/auth/providerConfig.test.ts rename to packages/core/src/providers/__tests__/provider-config.test.ts index d948cb7a1e..d8e507ae46 100644 --- a/packages/cli/src/auth/providerConfig.test.ts +++ b/packages/core/src/providers/__tests__/provider-config.test.ts @@ -5,17 +5,19 @@ */ import { describe, expect, it } from 'vitest'; -import { AuthType } from '@qwen-code/qwen-code-core'; import { + AuthType, buildInstallPlan, buildProviderTemplate, computeModelListVersion, + findProviderByCredentials, + getAllProviderBaseUrls, getDefaultModelIds, resolveBaseUrl, shouldShowStep, providerMatchesCredentials, type ProviderConfig, -} from './providerConfig.js'; +} from '@qwen-code/qwen-code-core'; function makeConfig(overrides: Partial = {}): ProviderConfig { return { @@ -25,7 +27,6 @@ function makeConfig(overrides: Partial = {}): ProviderConfig { protocol: AuthType.USE_OPENAI, baseUrl: 'https://api.test.com/v1', envKey: 'TEST_API_KEY', - authMethod: 'input', models: [{ id: 'model-a', contextWindowSize: 8192, enableThinking: true }], modelNamePrefix: 'Test', ...overrides, @@ -367,13 +368,8 @@ describe('shouldShowStep', () => { ).toBe(false); }); - it('hides apiKey step for oauth providers', () => { - expect(shouldShowStep(makeConfig({ authMethod: 'input' }), 'apiKey')).toBe( - true, - ); - expect(shouldShowStep(makeConfig({ authMethod: 'oauth' }), 'apiKey')).toBe( - false, - ); + it('always shows the apiKey step', () => { + expect(shouldShowStep(makeConfig(), 'apiKey')).toBe(true); }); it('shows models step only when editable or undefined', () => { @@ -440,11 +436,22 @@ describe('providerMatchesCredentials', () => { ).toBe(false); }); - it('returns false for function-typed envKey', () => { + it('matches when function-typed envKey derives a matching key', () => { + // Previously asserted toBe(false) when envKey was non-string. The + // provider matcher now resolves function-typed envKey so custom + // providers stay visible to /doctor and AppHeader. Uses the relative + // source import (declared with the other dist-bypass aliases below) + // so the new behaviour is exercised before dist/ is rebuilt; see the + // 'providerMatchesCredentials with function envKey' suite below for + // protocol-iteration coverage. const config = makeConfig({ envKey: () => 'DYNAMIC' }); expect( - providerMatchesCredentials(config, 'https://api.test.com/v1', 'DYNAMIC'), - ).toBe(false); + providerMatchesCredentialsSrc( + config, + 'https://api.test.com/v1', + 'DYNAMIC', + ), + ).toBe(true); }); }); @@ -487,3 +494,183 @@ describe('buildProviderTemplate', () => { expect(template[0]?.name).toBe('[Intl] m'); }); }); + +describe('findProviderByCredentials', () => { + it('finds a preset by its env key + base URL', () => { + const found = findProviderByCredentials( + 'https://api.deepseek.com', + 'DEEPSEEK_API_KEY', + ); + expect(found?.id).toBe('deepseek'); + }); + + it('returns undefined for an unknown env key', () => { + expect( + findProviderByCredentials('https://api.deepseek.com', 'NOT_A_REAL_KEY'), + ).toBeUndefined(); + }); + + it('returns undefined for a known env key but mismatched base URL', () => { + expect( + findProviderByCredentials( + 'https://wrong.example.com/v1', + 'DEEPSEEK_API_KEY', + ), + ).toBeUndefined(); + }); + + it('matches a multi-baseUrl preset against any of its registered URLs', () => { + // coding-plan ships both China and Singapore endpoints under the same env key. + const china = findProviderByCredentials( + 'https://coding.dashscope.aliyuncs.com/v1', + 'BAILIAN_CODING_PLAN_API_KEY', + ); + const intl = findProviderByCredentials( + 'https://coding-intl.dashscope.aliyuncs.com/v1', + 'BAILIAN_CODING_PLAN_API_KEY', + ); + expect(china?.id).toBe('coding-plan'); + expect(intl?.id).toBe('coding-plan'); + }); +}); + +describe('getAllProviderBaseUrls', () => { + it('returns a non-empty list including known preset URLs', () => { + const urls = getAllProviderBaseUrls(); + expect(urls.length).toBeGreaterThan(0); + expect(urls).toContain('https://api.deepseek.com'); + expect(urls).toContain('https://openrouter.ai/api/v1'); + }); + + it('expands BaseUrlOption[] presets into each option URL', () => { + const urls = getAllProviderBaseUrls(); + // coding-plan has China + Singapore options + expect(urls).toContain('https://coding.dashscope.aliyuncs.com/v1'); + expect(urls).toContain('https://coding-intl.dashscope.aliyuncs.com/v1'); + }); +}); + +// The package-name imports above resolve to dist/, which lags the source on a +// branch that hasn't been built yet. Re-import via the relative source path so +// these new edge-case tests exercise the in-tree implementation. +import { + resolveBaseUrl as resolveBaseUrlSrc, + providerMatchesCredentials as providerMatchesCredentialsSrc, +} from '../provider-config.js'; + +describe('resolveBaseUrl edge cases', () => { + it('does not crash on an empty baseUrl array — falls back to selected or ""', () => { + const config = makeConfig({ baseUrl: [] }); + // Without selectedBaseUrl, return '' instead of throwing on [0].url + expect(resolveBaseUrlSrc(config)).toBe(''); + // With selectedBaseUrl, return that instead + expect(resolveBaseUrlSrc(config, 'https://api.user.com/v1')).toBe( + 'https://api.user.com/v1', + ); + }); +}); + +describe('providerMatchesCredentials with function envKey (custom provider)', () => { + // Custom provider derives envKey from (protocol, baseUrl) via a function. + // Treating non-string envKey as "no match" made custom providers invisible + // to findProviderByCredentials → /doctor and system-info diagnostics. + it('matches a custom-style provider whose envKey is a function deriving from baseUrl', () => { + const derivedFor = (_protocol: AuthType, baseUrl: string) => + `QWEN_CUSTOM_${Buffer.from(baseUrl).toString('hex').slice(0, 8)}`; + const config = makeConfig({ + id: 'custom-like', + envKey: derivedFor, + baseUrl: undefined, // user-picked + protocol: AuthType.USE_OPENAI, + }); + + const url = 'https://api.example.com/v1'; + const expectedKey = derivedFor(AuthType.USE_OPENAI, url); + expect(providerMatchesCredentialsSrc(config, url, expectedKey)).toBe(true); + }); + + it('does not match when the derived key differs from the supplied envKey', () => { + const derivedFor = (_protocol: AuthType, baseUrl: string) => + `QWEN_CUSTOM_${baseUrl.length}`; + const config = makeConfig({ + id: 'custom-like', + envKey: derivedFor, + baseUrl: undefined, + protocol: AuthType.USE_OPENAI, + }); + expect( + providerMatchesCredentialsSrc( + config, + 'https://api.example.com/v1', + 'WRONG_ENV_KEY', + ), + ).toBe(false); + }); + + it('returns false (not crash) when the function envKey itself throws', () => { + const config = makeConfig({ + id: 'custom-like', + envKey: () => { + throw new Error('boom'); + }, + baseUrl: undefined, + }); + expect( + providerMatchesCredentialsSrc( + config, + 'https://api.example.com/v1', + 'ANY', + ), + ).toBe(false); + }); + + it('iterates protocolOptions and matches when any one derives the env key', () => { + // buildInstallPlan derives the persisted env key from inputs.protocol + // (which may be USE_ANTHROPIC or USE_GEMINI for a custom provider), not + // from config.protocol. The matcher must try every protocolOption so a + // custom provider configured under Anthropic/Gemini still gets matched + // back from the on-disk envKey. + const derivedFor = (protocol: AuthType, baseUrl: string) => + `QWEN_CUSTOM_${protocol.toUpperCase()}_${baseUrl.length}`; + const config = makeConfig({ + id: 'custom-like', + envKey: derivedFor, + baseUrl: undefined, + protocol: AuthType.USE_OPENAI, // default + protocolOptions: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ], + }); + + const url = 'https://api.example.com/v1'; + // User picked Anthropic at install time, so the persisted key derives + // from USE_ANTHROPIC, not the default USE_OPENAI. + const anthropicKey = derivedFor(AuthType.USE_ANTHROPIC, url); + expect(providerMatchesCredentialsSrc(config, url, anthropicKey)).toBe(true); + + // Gemini path also matches. + const geminiKey = derivedFor(AuthType.USE_GEMINI, url); + expect(providerMatchesCredentialsSrc(config, url, geminiKey)).toBe(true); + }); +}); + +import { resolveMetadataKey as resolveMetadataKeySrc } from '../provider-config.js'; + +describe('resolveMetadataKey dotted-id guard', () => { + it('returns the id unchanged for normal providers with static models', () => { + const config = makeConfig({ id: 'deepseek', models: [{ id: 'm1' }] }); + expect(resolveMetadataKeySrc(config)).toBe('deepseek'); + }); + + it('returns undefined for providers without static models', () => { + const config = makeConfig({ id: 'custom-like', models: undefined }); + expect(resolveMetadataKeySrc(config)).toBeUndefined(); + }); + + it("throws when the id contains '.' (would corrupt dotted setValue writes)", () => { + const config = makeConfig({ id: 'company.ai', models: [{ id: 'm1' }] }); + expect(() => resolveMetadataKeySrc(config)).toThrow(/must not contain/); + }); +}); diff --git a/packages/cli/src/auth/allProviders.ts b/packages/core/src/providers/all-providers.ts similarity index 61% rename from packages/cli/src/auth/allProviders.ts rename to packages/core/src/providers/all-providers.ts index 2b0bfceef3..606bd6a37c 100644 --- a/packages/cli/src/auth/allProviders.ts +++ b/packages/core/src/providers/all-providers.ts @@ -7,20 +7,18 @@ * lookup tables used by the UI and CLI commands. */ -import { - providerMatchesCredentials, - type ProviderConfig, -} from './providerConfig.js'; -import { codingPlanProvider } from './providers/alibaba/codingPlan.js'; -import { tokenPlanProvider } from './providers/alibaba/tokenPlan.js'; -import { alibabaStandardProvider } from './providers/alibaba/alibabaStandard.js'; -import { openRouterProvider } from './providers/oauth/openrouter.js'; -import { deepseekProvider } from './providers/thirdParty/deepseek.js'; -import { minimaxProvider } from './providers/thirdParty/minimax.js'; -import { zaiProvider } from './providers/thirdParty/zai.js'; -import { idealabProvider } from './providers/thirdParty/idealab.js'; -import { modelscopeProvider } from './providers/thirdParty/modelscope.js'; -import { customProvider } from './providers/custom/customProvider.js'; +import { providerMatchesCredentials } from './provider-config.js'; +import type { ProviderConfig } from './types.js'; +import { codingPlanProvider } from './presets/alibaba-coding-plan.js'; +import { tokenPlanProvider } from './presets/alibaba-token-plan.js'; +import { alibabaStandardProvider } from './presets/alibaba-standard.js'; +import { openRouterProvider } from './presets/openrouter.js'; +import { deepseekProvider } from './presets/deepseek.js'; +import { minimaxProvider } from './presets/minimax.js'; +import { zaiProvider } from './presets/zai.js'; +import { idealabProvider } from './presets/idealab.js'; +import { modelscopeProvider } from './presets/modelscope.js'; +import { customProvider } from './presets/custom-provider.js'; // Re-export all providers export { @@ -38,7 +36,7 @@ export { export { CUSTOM_API_KEY_ENV_PREFIX, generateCustomEnvKey, -} from './providers/custom/customProvider.js'; +} from './presets/custom-provider.js'; // --------------------------------------------------------------------------- // Provider Registry @@ -49,12 +47,12 @@ export const ALL_PROVIDERS: readonly ProviderConfig[] = [ codingPlanProvider, tokenPlanProvider, alibabaStandardProvider, - openRouterProvider, deepseekProvider, minimaxProvider, zaiProvider, idealabProvider, modelscopeProvider, + openRouterProvider, customProvider, ]; @@ -65,9 +63,6 @@ export const ALIBABA_PROVIDERS = ALL_PROVIDERS.filter( export const THIRD_PARTY_PROVIDERS = ALL_PROVIDERS.filter( (p) => p.uiGroup === 'third-party', ); -export const OAUTH_PROVIDERS = ALL_PROVIDERS.filter( - (p) => p.uiGroup === 'oauth', -); export function findProviderById(id: string): ProviderConfig | undefined { return ALL_PROVIDERS.find((p) => p.id === id); @@ -87,16 +82,8 @@ export function findProviderByCredentials( export function getAllProviderBaseUrls(): string[] { return ALL_PROVIDERS.flatMap((p) => { if (typeof p.baseUrl === 'string') return [p.baseUrl]; - if (Array.isArray(p.baseUrl)) return p.baseUrl.map((o) => o.url); + if (Array.isArray(p.baseUrl)) + return p.baseUrl.map((o: { url: string }) => o.url); return []; }); } - -// Re-export providerConfig utilities for convenience -export { - buildInstallPlan, - resolveBaseUrl, - getDefaultModelIds, - shouldShowStep, - computeModelListVersion, -} from './providerConfig.js'; diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts new file mode 100644 index 0000000000..2421d3e940 --- /dev/null +++ b/packages/core/src/providers/index.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Types +export type { + BaseUrlOption, + ModelSpec, + ProviderId, + ProviderConfig, + ProviderInstallPlan, + ProviderInstallState, + ProviderModelConfig, + ProviderModelProvidersPatch, + ProviderSettingsAdapter, + ProviderSetupInputs, +} from './types.js'; + +// Provider config utilities +export { + buildInstallPlan, + buildProviderTemplate, + computeModelListVersion, + getDefaultBaseUrlForProtocol, + getDefaultModelIds, + providerMatchesCredentials, + PROVIDER_METADATA_NS, + resolveBaseUrl, + resolveMetadataKey, + resolveOwnsModel, + shouldShowStep, +} from './provider-config.js'; + +// Provider registry +export { + ALL_PROVIDERS, + ALIBABA_PROVIDERS, + alibabaStandardProvider, + codingPlanProvider, + CUSTOM_API_KEY_ENV_PREFIX, + customProvider, + deepseekProvider, + findProviderByCredentials, + findProviderById, + generateCustomEnvKey, + getAllProviderBaseUrls, + idealabProvider, + minimaxProvider, + modelscopeProvider, + openRouterProvider, + THIRD_PARTY_PROVIDERS, + tokenPlanProvider, + zaiProvider, +} from './all-providers.js'; + +// Preset constants +export { + CODING_PLAN_CHINA_BASE_URL, + CODING_PLAN_ENV_KEY, + CODING_PLAN_GLOBAL_BASE_URL, +} from './presets/alibaba-coding-plan.js'; +export { + TOKEN_PLAN_BASE_URL, + TOKEN_PLAN_ENV_KEY, +} from './presets/alibaba-token-plan.js'; +export { + OPENROUTER_BASE_URL, + OPENROUTER_ENV_KEY, +} from './presets/openrouter.js'; + +// Install logic +export { + applyProviderInstallPlan, + ProviderInstallError, + type ApplyProviderInstallPlanOptions, + type ApplyProviderInstallPlanResult, +} from './install.js'; diff --git a/packages/core/src/providers/install.ts b/packages/core/src/providers/install.ts new file mode 100644 index 0000000000..f1e86da348 --- /dev/null +++ b/packages/core/src/providers/install.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AuthType } from '../core/contentGenerator.js'; +import type { ModelProvidersConfig } from '../models/types.js'; +import type { + ProviderInstallPlan, + ProviderModelProvidersPatch, + ProviderSettingsAdapter, +} from './types.js'; + +/** + * Environment variable names an install plan must never set — they alter + * process/loader behavior (code injection, PATH hijack, home redirection). + * Compared case-insensitively. Provider API-key envs never collide with + * these. + */ +const DENY_ENV_KEYS = new Set([ + 'NODE_OPTIONS', + 'NODE_PATH', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'PATH', + 'HOME', + 'TMPDIR', + 'TMP', // Windows temp redirect + 'TEMP', // Windows temp redirect +]); + +// --------------------------------------------------------------------------- +// Model providers merge logic +// --------------------------------------------------------------------------- + +function isSameModelIdentity( + a: { id: string; baseUrl?: string }, + b: { id: string; baseUrl?: string }, +): boolean { + return a.id === b.id && (a.baseUrl ?? '') === (b.baseUrl ?? ''); +} + +function applyModelProvidersPatch( + existingModelProviders: ModelProvidersConfig, + patch: ProviderModelProvidersPatch, +): ModelProvidersConfig { + const existingModels = existingModelProviders[patch.authType] ?? []; + + let updatedModels = patch.models; + if (patch.mergeStrategy === 'append') { + updatedModels = [...existingModels, ...patch.models]; + } else { + const ownsModel = patch.ownsModel; + const preservedModels = existingModels.filter((model) => { + if (ownsModel) { + return !ownsModel(model); + } + return !patch.models.some((newModel) => + isSameModelIdentity(newModel, model), + ); + }); + + updatedModels = + patch.mergeStrategy === 'replace-owned' + ? [...preservedModels, ...patch.models] + : [...patch.models, ...preservedModels]; + } + + return { + ...existingModelProviders, + [patch.authType]: updatedModels, + }; +} + +// --------------------------------------------------------------------------- +// Apply install plan +// --------------------------------------------------------------------------- + +export interface ApplyProviderInstallPlanOptions { + settings: ProviderSettingsAdapter; + /** Callback to reload model providers config in the runtime. */ + reloadModelProviders?: (mp: ModelProvidersConfig) => void; + /** Callback to sync auth state after install. */ + syncAuthState?: (authType: AuthType, modelId: string) => void; + /** Callback to refresh auth after install. */ + refreshAuth?: (authType: AuthType) => Promise; + /** Whether to call refreshAuth after install. Defaults to true. */ + doRefreshAuth?: boolean; +} + +export interface ApplyProviderInstallPlanResult { + updatedModelProviders: ModelProvidersConfig; +} + +/** + * Error thrown by {@link applyProviderInstallPlan} when a step fails. The + * message is the underlying error's message (safe to surface to users); the + * `step` and `authType` properties carry diagnostic context, and `cause` + * preserves the original error (so callers matching on `err.code` still work + * via the chain). + * + * A class (not an interface) so `err instanceof ProviderInstallError` works + * at runtime — an interface would erase at compile time and silently always + * be false. + */ +export class ProviderInstallError extends Error { + readonly step: string; + readonly authType: AuthType; + + constructor( + message: string, + step: string, + authType: AuthType, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'ProviderInstallError'; + this.step = step; + this.authType = authType; + } +} + +export async function applyProviderInstallPlan( + plan: ProviderInstallPlan, + options: ApplyProviderInstallPlanOptions, +): Promise { + const { + settings, + reloadModelProviders, + syncAuthState, + refreshAuth, + doRefreshAuth = true, + } = options; + + const previousEnvValues = new Map(); + // Snapshot the runtime providers map *before* any setValue/reload so we can + // restore in-memory state if a callback later in the flow rejects (e.g. + // refreshAuth() against a bad endpoint). Without this the live session + // could be left holding providers that the plan failed to install. + const previousRuntimeProviders: ModelProvidersConfig = { + ...settings.getModelProviders(), + }; + + // Track which step is in flight so a rethrow at the bottom can name it + // (an EACCES from persist vs a refreshAuth rejection look identical + // otherwise — eight steps, one anonymous error). + let currentStep = 'init'; + + try { + // backup() inside the try so a failure here (e.g. structuredClone on a + // non-serializable adapter) still triggers the catch + env rollback. + currentStep = 'backup'; + settings.backup?.(); + + // Set environment variables (snapshot previous values for rollback). + // Defense in depth: refuse process-altering env names. Today every + // caller routes through buildInstallPlan with hardcoded provider keys, + // but ProviderInstallPlan is exported, so a future provider config or a + // hand-built plan could otherwise inject NODE_OPTIONS / LD_PRELOAD / + // PATH etc. into both settings.json and the live process.env. + currentStep = 'env'; + for (const [key, value] of Object.entries(plan.env ?? {})) { + if (DENY_ENV_KEYS.has(key.toUpperCase())) { + throw new Error( + `Install plan must not set reserved environment variable: ${key}`, + ); + } + previousEnvValues.set(key, process.env[key]); + settings.setValue(`env.${key}`, value); + process.env[key] = value; + } + + // Apply model providers patches + currentStep = 'modelProviders'; + let updatedModelProviders: ModelProvidersConfig = { + ...previousRuntimeProviders, + }; + + for (const patch of plan.modelProviders ?? []) { + updatedModelProviders = applyModelProvidersPatch( + updatedModelProviders, + patch, + ); + settings.setValue( + `modelProviders.${patch.authType}`, + updatedModelProviders[patch.authType] ?? [], + ); + } + + // Set auth type + currentStep = 'authType'; + settings.setValue('security.auth.selectedType', plan.authType); + + // Legacy credentials + currentStep = 'legacyCredentials'; + if (plan.legacyCredentials?.apiKey != null) { + settings.setValue('security.auth.apiKey', plan.legacyCredentials.apiKey); + } + if (plan.legacyCredentials?.baseUrl != null) { + settings.setValue( + 'security.auth.baseUrl', + plan.legacyCredentials.baseUrl, + ); + } + + // Model selection + currentStep = 'modelSelection'; + if (plan.modelSelection?.modelId) { + settings.setValue('model.name', plan.modelSelection.modelId); + } + + // Provider state metadata + currentStep = 'providerState'; + for (const [key, entries] of Object.entries(plan.providerState ?? {})) { + for (const [field, value] of Object.entries(entries)) { + settings.setValue(`${key}.${field}`, value); + } + } + + // Persist to disk + currentStep = 'persist'; + settings.persist(); + + // Reload runtime config + currentStep = 'reloadModelProviders'; + reloadModelProviders?.(updatedModelProviders); + if (plan.modelSelection?.modelId) { + currentStep = 'syncAuthState'; + syncAuthState?.(plan.authType, plan.modelSelection.modelId); + } + if (doRefreshAuth && refreshAuth) { + currentStep = 'refreshAuth'; + await refreshAuth(plan.authType); + } + + currentStep = 'cleanupBackup'; + settings.cleanupBackup?.(); + + return { updatedModelProviders }; + } catch (error) { + // Best-effort rollback. Each step is wrapped so a failure in one + // doesn't mask the original error or skip the later steps. + try { + settings.restore?.(); + } catch (restoreErr) { + // eslint-disable-next-line no-console -- best-effort rollback path + console.error( + '[applyProviderInstallPlan] settings.restore failed during rollback:', + restoreErr, + ); + } + try { + for (const [key, prev] of previousEnvValues) { + if (prev === undefined) { + delete process.env[key]; + } else { + process.env[key] = prev; + } + } + } catch (envErr) { + // process.env writes can throw if a custom property descriptor on + // process.env has been installed (rare, but observed in some test + // harnesses). Don't let it skip the runtime-providers rollback below. + // eslint-disable-next-line no-console -- best-effort rollback path + console.error('[applyProviderInstallPlan] env rollback failed:', envErr); + } + // Restore in-memory runtime providers — reloadModelProviders may have run + // before the failure (e.g. before a refreshAuth rejection). + try { + reloadModelProviders?.(previousRuntimeProviders); + } catch (reloadErr) { + // eslint-disable-next-line no-console -- best-effort rollback path + console.error( + '[applyProviderInstallPlan] reloadModelProviders failed during rollback:', + reloadErr, + ); + } + // Attach the failing step + authType as *structured properties* rather + // than baking them into the message. Keeps the user-facing message clean + // (callers show error.message verbatim) while letting devs read + // error.step / error.authType and the original error.cause off the chain. + const errMsg = error instanceof Error ? error.message : String(error); + throw new ProviderInstallError(errMsg, currentStep, plan.authType, { + cause: error instanceof Error ? error : undefined, + }); + } +} diff --git a/packages/cli/src/auth/providers/alibaba/codingPlan.ts b/packages/core/src/providers/presets/alibaba-coding-plan.ts similarity index 94% rename from packages/cli/src/auth/providers/alibaba/codingPlan.ts rename to packages/core/src/providers/presets/alibaba-coding-plan.ts index dde31c5e93..0cde6dce98 100644 --- a/packages/cli/src/auth/providers/alibaba/codingPlan.ts +++ b/packages/core/src/providers/presets/alibaba-coding-plan.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig, ModelSpec } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig, ModelSpec } from '../types.js'; // --------------------------------------------------------------------------- // Constants @@ -51,7 +51,7 @@ const MODELSTUDIO_MODELS: ModelSpec[] = [ ]; // --------------------------------------------------------------------------- -// Provider config (unified ProviderConfig) +// Provider config // --------------------------------------------------------------------------- export const codingPlanProvider: ProviderConfig = { @@ -75,7 +75,6 @@ export const codingPlanProvider: ProviderConfig = { }, ], envKey: CODING_PLAN_ENV_KEY, - authMethod: 'input', models: MODELSTUDIO_MODELS, modelsEditable: true, modelNamePrefix: (baseUrl) => diff --git a/packages/cli/src/auth/providers/alibaba/alibabaStandard.ts b/packages/core/src/providers/presets/alibaba-standard.ts similarity index 93% rename from packages/cli/src/auth/providers/alibaba/alibabaStandard.ts rename to packages/core/src/providers/presets/alibaba-standard.ts index 8e10d98205..36d4b4c489 100644 --- a/packages/cli/src/auth/providers/alibaba/alibabaStandard.ts +++ b/packages/core/src/providers/presets/alibaba-standard.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const alibabaStandardProvider: ProviderConfig = { id: 'alibabaStandard', @@ -43,7 +43,6 @@ export const alibabaStandardProvider: ProviderConfig = { }, ], envKey: 'DASHSCOPE_API_KEY', - authMethod: 'input', models: [ { id: 'qwen3.6-plus', contextWindowSize: 1000000, enableThinking: true }, { id: 'glm-5.1', contextWindowSize: 202752, enableThinking: true }, diff --git a/packages/cli/src/auth/providers/alibaba/tokenPlan.ts b/packages/core/src/providers/presets/alibaba-token-plan.ts similarity index 88% rename from packages/cli/src/auth/providers/alibaba/tokenPlan.ts rename to packages/core/src/providers/presets/alibaba-token-plan.ts index 87b4b50e78..c62ad2dbb8 100644 --- a/packages/cli/src/auth/providers/alibaba/tokenPlan.ts +++ b/packages/core/src/providers/presets/alibaba-token-plan.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig, ModelSpec } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig, ModelSpec } from '../types.js'; // --------------------------------------------------------------------------- // Constants @@ -28,7 +28,7 @@ const TOKEN_PLAN_MODELS: ModelSpec[] = [ ]; // --------------------------------------------------------------------------- -// Provider config (unified ProviderConfig) +// Provider config // --------------------------------------------------------------------------- export const tokenPlanProvider: ProviderConfig = { @@ -39,7 +39,6 @@ export const tokenPlanProvider: ProviderConfig = { protocol: AuthType.USE_OPENAI, baseUrl: TOKEN_PLAN_BASE_URL, envKey: TOKEN_PLAN_ENV_KEY, - authMethod: 'input', models: TOKEN_PLAN_MODELS, modelsEditable: true, modelNamePrefix: 'ModelStudio Token Plan', diff --git a/packages/core/src/providers/presets/custom-provider.ts b/packages/core/src/providers/presets/custom-provider.ts new file mode 100644 index 0000000000..7a21dc9e9f --- /dev/null +++ b/packages/core/src/providers/presets/custom-provider.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; + +export const CUSTOM_API_KEY_ENV_PREFIX = 'QWEN_CUSTOM_API_KEY_'; + +/** + * Derive the env-var key that holds the API token for a custom provider. + * + * The readable part (`PROTOCOL_NORMALIZED_URL`) is kept for human eyeballing + * of settings.json, but URL normalization is lossy — `api.example.com`, + * `api-example.com`, and `api_example.com` all collapse to + * `API_EXAMPLE_COM`. A 12-hex-char (48-bit) suffix derived from a SHA-256 + * of the canonicalized (protocol, baseUrl) pair disambiguates structurally + * distinct endpoints so configuring one custom provider can't silently + * overwrite another's API key. 48 bits gives ~280 trillion values — well + * past the point where an attacker controlling a user-typed URL could + * realistically collide an existing entry to redirect an API key write, + * while still keeping the env var name pasteable into a dashboard. + * + * Migration note: this suffix changed from 6 → 12 chars in a recent commit. + * Old 6-char keys persist in settings.json (and ~/.qwen/env-equivalent + * stores) until either the user reconnects under the same URL (which writes + * the new 12-char key but leaves the old one as orphan disk state — harmless, + * never read) or runs the "clear auth" flow. The old key is never read by + * applyProviderInstallPlan because the new model provider entries point at + * the new key. + */ +/** + * Normalize a string to a `[A-Z0-9_]+` env-var-safe segment without using any + * `+`-quantified regex. CodeQL flags polynomial regex on user-controlled + * input even though V8 handles these patterns linearly; a single-pass + * character scan side-steps both the warning and the (theoretical) worst + * case. Collapses runs of non-alphanumeric characters to a single `_` and + * strips leading/trailing underscores. + */ +function normalizeEnvSegment(value: string): string { + const upper = value.trim().toUpperCase(); + let result = ''; + let prevWasUnderscore = false; + for (let i = 0; i < upper.length; i++) { + const code = upper.charCodeAt(i); + const isAlphaNum = + (code >= 65 /* A */ && code <= 90) /* Z */ || + (code >= 48 /* 0 */ && code <= 57); /* 9 */ + if (isAlphaNum) { + result += upper[i]; + prevWasUnderscore = false; + } else if (!prevWasUnderscore) { + result += '_'; + prevWasUnderscore = true; + } + } + // Strip leading/trailing underscores. + let start = 0; + let end = result.length; + while (start < end && result.charCodeAt(start) === 95 /* _ */) start++; + while (end > start && result.charCodeAt(end - 1) === 95 /* _ */) end--; + return result.slice(start, end); +} + +/** + * Strip trailing `/` characters from a URL without a `+`-quantified regex + * (CodeQL flags `/\/+$/` as polynomial on uncontrolled input). Linear. + */ +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47 /* / */) end--; + return value.slice(0, end); +} + +export function generateCustomEnvKey( + protocol: AuthType, + baseUrl: string, +): string { + // Strip trailing slashes before hashing so callers that differ only in + // that (e.g. .../v1 vs .../v1/) still resolve to the same env-var bucket, + // preserving the prior implementation's invariant. + const canonicalBaseUrl = stripTrailingSlashes(baseUrl.trim()); + const suffix = createHash('sha256') + .update(`${protocol}\0${canonicalBaseUrl}`) + .digest('hex') + .slice(0, 12) + .toUpperCase(); + + return `${CUSTOM_API_KEY_ENV_PREFIX}${normalizeEnvSegment( + protocol, + )}_${normalizeEnvSegment(baseUrl)}_${suffix}`; +} + +export const customProvider: ProviderConfig = { + id: 'custom-openai-compatible', + label: 'Custom Provider', + description: + 'Manually connect a local server, proxy, or unsupported provider', + protocol: AuthType.USE_OPENAI, + protocolOptions: [ + AuthType.USE_OPENAI, + AuthType.USE_ANTHROPIC, + AuthType.USE_GEMINI, + ], + baseUrl: undefined, + envKey: generateCustomEnvKey, + models: undefined, + modelNamePrefix: '', + showAdvancedConfig: true, + // Without this, applyModelProvidersPatch falls back to id+baseUrl identity + // matching, so reinstalling a custom provider under a different baseUrl + // leaves the old model entries behind — they accumulate over time. + // Every key we mint via generateCustomEnvKey starts with the well-known + // prefix, so a prefix match cleanly identifies "ours" without false + // positives against preset entries. + ownsModel: (model) => + typeof model.envKey === 'string' && + model.envKey.startsWith(CUSTOM_API_KEY_ENV_PREFIX), + uiGroup: 'custom', +}; diff --git a/packages/cli/src/auth/providers/thirdParty/deepseek.ts b/packages/core/src/providers/presets/deepseek.ts similarity index 84% rename from packages/cli/src/auth/providers/thirdParty/deepseek.ts rename to packages/core/src/providers/presets/deepseek.ts index 3e3b88cb05..66f2f3a36a 100644 --- a/packages/cli/src/auth/providers/thirdParty/deepseek.ts +++ b/packages/core/src/providers/presets/deepseek.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const deepseekProvider: ProviderConfig = { id: 'deepseek', @@ -14,7 +14,6 @@ export const deepseekProvider: ProviderConfig = { protocol: AuthType.USE_OPENAI, baseUrl: 'https://api.deepseek.com', envKey: 'DEEPSEEK_API_KEY', - authMethod: 'input', models: [ { id: 'deepseek-v4-pro', diff --git a/packages/cli/src/auth/providers/thirdParty/idealab.ts b/packages/core/src/providers/presets/idealab.ts similarity index 89% rename from packages/cli/src/auth/providers/thirdParty/idealab.ts rename to packages/core/src/providers/presets/idealab.ts index a4a9e2ce61..c69afa3f71 100644 --- a/packages/cli/src/auth/providers/thirdParty/idealab.ts +++ b/packages/core/src/providers/presets/idealab.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const idealabProvider: ProviderConfig = { id: 'idealab', @@ -15,7 +15,6 @@ export const idealabProvider: ProviderConfig = { protocol: AuthType.USE_OPENAI, baseUrl: 'https://idealab.alibaba-inc.com/api/openai/v1', envKey: 'IDEALAB_API_KEY', - authMethod: 'input', models: [ { id: 'Qwen3.6-Plus-DogFooding', diff --git a/packages/cli/src/auth/providers/thirdParty/minimax.ts b/packages/core/src/providers/presets/minimax.ts similarity index 87% rename from packages/cli/src/auth/providers/thirdParty/minimax.ts rename to packages/core/src/providers/presets/minimax.ts index 0d7740653f..e187c02bfe 100644 --- a/packages/cli/src/auth/providers/thirdParty/minimax.ts +++ b/packages/core/src/providers/presets/minimax.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const minimaxProvider: ProviderConfig = { id: 'minimax', @@ -27,7 +27,6 @@ export const minimaxProvider: ProviderConfig = { }, ], envKey: 'MINIMAX_API_KEY', - authMethod: 'input', models: [ { id: 'MiniMax-M2.7', contextWindowSize: 204800 }, { id: 'MiniMax-M2.7-highspeed', contextWindowSize: 204800 }, diff --git a/packages/cli/src/auth/providers/thirdParty/modelscope.ts b/packages/core/src/providers/presets/modelscope.ts similarity index 86% rename from packages/cli/src/auth/providers/thirdParty/modelscope.ts rename to packages/core/src/providers/presets/modelscope.ts index f7e7ad365d..8471aa1555 100644 --- a/packages/cli/src/auth/providers/thirdParty/modelscope.ts +++ b/packages/core/src/providers/presets/modelscope.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const modelscopeProvider: ProviderConfig = { id: 'modelscope', @@ -14,7 +14,6 @@ export const modelscopeProvider: ProviderConfig = { protocol: AuthType.USE_OPENAI, baseUrl: 'https://api-inference.modelscope.cn/v1', envKey: 'MODELSCOPE_API_KEY', - authMethod: 'input', models: [ { id: 'deepseek-ai/DeepSeek-V4-Flash', diff --git a/packages/core/src/providers/presets/openrouter.ts b/packages/core/src/providers/presets/openrouter.ts new file mode 100644 index 0000000000..b0d738b3f0 --- /dev/null +++ b/packages/core/src/providers/presets/openrouter.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; + +export const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export const openRouterProvider: ProviderConfig = { + id: 'openrouter', + label: 'OpenRouter', + description: + 'Connect with an OpenRouter API key (get one from openrouter.ai/keys)', + protocol: AuthType.USE_OPENAI, + baseUrl: OPENROUTER_BASE_URL, + envKey: OPENROUTER_ENV_KEY, + models: [ + { id: 'z-ai/glm-4.5-air:free', contextWindowSize: 128000 }, + { id: 'openai/gpt-oss-120b:free', contextWindowSize: 131072 }, + ], + modelsEditable: true, + modelNamePrefix: 'OpenRouter', + ownsModel: (model) => { + // A user who manually added an OpenRouter-routed model under a custom + // envKey (e.g. their own gateway) shouldn't have their entry silently + // removed on re-install — require BOTH the hostname *and* our envKey to + // claim ownership. + if (model.envKey !== OPENROUTER_ENV_KEY) return false; + try { + const host = new URL(model.baseUrl ?? '').hostname; + return host === 'openrouter.ai' || host.endsWith('.openrouter.ai'); + } catch { + return false; + } + }, + documentationUrl: 'https://openrouter.ai/docs', + uiGroup: 'third-party', +}; diff --git a/packages/cli/src/auth/providers/thirdParty/zai.ts b/packages/core/src/providers/presets/zai.ts similarity index 86% rename from packages/cli/src/auth/providers/thirdParty/zai.ts rename to packages/core/src/providers/presets/zai.ts index c3861bf303..dd2ed1e9e4 100644 --- a/packages/cli/src/auth/providers/thirdParty/zai.ts +++ b/packages/core/src/providers/presets/zai.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { ProviderConfig } from '../../providerConfig.js'; +import { AuthType } from '../../core/contentGenerator.js'; +import type { ProviderConfig } from '../types.js'; export const zaiProvider: ProviderConfig = { id: 'zai', @@ -27,7 +27,6 @@ export const zaiProvider: ProviderConfig = { }, ], envKey: 'ZAI_API_KEY', - authMethod: 'input', models: [ { id: 'GLM-5.1', contextWindowSize: 204800, enableThinking: true }, { id: 'GLM-5', contextWindowSize: 204800 }, diff --git a/packages/cli/src/auth/providerConfig.ts b/packages/core/src/providers/provider-config.ts similarity index 69% rename from packages/cli/src/auth/providerConfig.ts rename to packages/core/src/providers/provider-config.ts index 0ece848cf7..3ca3719568 100644 --- a/packages/cli/src/auth/providerConfig.ts +++ b/packages/core/src/providers/provider-config.ts @@ -5,128 +5,15 @@ */ import { createHash } from 'node:crypto'; +import { AuthType } from '../core/contentGenerator.js'; import type { - AuthType, - InputModalities, + ModelSpec, + ProviderConfig, + ProviderInstallPlan, + ProviderInstallState, ProviderModelConfig, -} from '@qwen-code/qwen-code-core'; -import type { ProviderInstallPlan, ProviderInstallState } from './types.js'; - -// --------------------------------------------------------------------------- -// Declarative provider config — every built-in provider is an instance of this -// --------------------------------------------------------------------------- - -export interface ModelSpec { - id: string; - contextWindowSize?: number; - enableThinking?: boolean; - modalities?: InputModalities; - description?: string; -} - -export interface BaseUrlOption { - id: string; - label: string; - url: string; - documentationUrl?: string; - apiKeyUrl?: string; -} - -export interface ProviderConfig { - id: string; - label: string; - description: string; - - /** Always fixed for current providers. */ - protocol: AuthType; - - /** - * - `string` → fixed, skip UI step - * - `BaseUrlOption[]` → show option selector - * - `undefined` → user types freely (custom provider) - */ - baseUrl?: string | BaseUrlOption[]; - - /** Environment variable key, or a function to generate one. */ - envKey: string | ((protocol: AuthType, baseUrl: string) => string); - - /** API key acquisition method. */ - authMethod: 'input' | 'oauth'; - - /** - * - `ModelSpec[]` → model definitions with optional per-model metadata - * - `undefined` → user must type all model IDs (custom provider) - */ - models?: ModelSpec[]; - - /** - * Whether the user can add/remove models in the setup UI. - * - `true` → show model editing step; known IDs inherit their ModelSpec metadata - * - `false` → skip model step; use models as-is (e.g. Coding Plan) - * Defaults to `false` when `models` is set, ignored when `models` is `undefined`. - */ - modelsEditable?: boolean; - - /** Display name prefix for model entries, or a function of baseUrl. */ - modelNamePrefix: string | ((baseUrl: string) => string); - - /** - * Protocol options for manual selection (custom provider only). - * If provided with >1 entry, shows a protocol selection step. - */ - protocolOptions?: AuthType[]; - - /** Show advanced config step (thinking, modalities). */ - showAdvancedConfig?: boolean; - - /** Validate the API key before submission. */ - validateApiKey?: (key: string, baseUrl: string) => string | null; - - /** API key input placeholder. */ - apiKeyPlaceholder?: string; - - /** Documentation URL for the provider. */ - documentationUrl?: string | ((baseUrl: string) => string); - - /** - * Custom ownership check — identifies models belonging to this provider. - * Auto-derived from `envKey` (string) + `modelNamePrefix` (string) when omitted. - * Only needed for providers with function-typed envKey/prefix or non-standard logic. - */ - ownsModel?: (model: ProviderModelConfig) => boolean; - - /** - * UI grouping hint — used by AuthDialog to organize providers into sections. - * Providers with the same `uiGroup` appear together under a shared heading. - */ - uiGroup?: string; - - /** Step label overrides for the UI. */ - uiLabels?: { - flowTitle?: string; - baseUrlStepTitle?: string; - }; -} - -// --------------------------------------------------------------------------- -// Collected user inputs from the setup wizard -// --------------------------------------------------------------------------- - -export interface ProviderSetupInputs { - /** Override protocol (only for custom provider). Defaults to config.protocol. */ - protocol?: AuthType; - baseUrl: string; - apiKey: string; - modelIds: string[]; - /** Pre-built model configs (e.g. OpenRouter fetches models from API). Overrides modelIds. */ - prebuiltModels?: ProviderModelConfig[]; - advancedConfig?: { - enableThinking?: boolean; - multimodal?: InputModalities; - contextWindowSize?: number; - maxTokens?: number; - }; -} + ProviderSetupInputs, +} from './types.js'; // --------------------------------------------------------------------------- // Build model configs from a ProviderConfig + user inputs @@ -289,8 +176,18 @@ function buildModelConfigs( * Only defined for providers with a static `models` list. */ export function resolveMetadataKey(config: ProviderConfig): string | undefined { - if (config.models) return config.id; - return undefined; + if (!config.models) return undefined; + // setValue uses dotted-path traversal — a provider id containing '.' would + // be split into multiple nested objects (`providerMetadata.foo.bar` → + // `providerMetadata.foo.bar = ...` vs `providerMetadata['foo.bar'] = ...`). + // Reject early so the bug is loud at registration time rather than + // silently corrupting the settings tree at install time. + if (config.id.includes('.')) { + throw new Error( + `Provider id must not contain '.' (would corrupt providerMetadata.${config.id} dotted writes): ${config.id}`, + ); + } + return config.id; } /** @@ -359,6 +256,26 @@ export function computeModelListVersion(models: ProviderModelConfig[]): string { return createHash('sha256').update(JSON.stringify(models)).digest('hex'); } +/** + * Default base URLs per protocol, used as placeholder/fallback when the user + * doesn't supply one for a custom provider. Kept in core so the CLI flow + * (useProviderSetupFlow) and the VS Code flow (AuthMessageHandler) agree on + * the same value — if Anthropic ships a new endpoint we only update it here. + */ +const DEFAULT_BASE_URLS: Partial> = { + [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', + [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', + [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', +}; + +/** Resolve the placeholder/default base URL for a chosen protocol. */ +export function getDefaultBaseUrlForProtocol( + protocol: AuthType | undefined, +): string { + if (protocol === undefined) return ''; + return DEFAULT_BASE_URLS[protocol] ?? ''; +} + // --------------------------------------------------------------------------- // Resolve base URL from config + user selection // --------------------------------------------------------------------------- @@ -372,7 +289,11 @@ export function resolveBaseUrl( } if (Array.isArray(config.baseUrl)) { const match = config.baseUrl.find((opt) => opt.url === selectedBaseUrl); - return match?.url ?? config.baseUrl[0].url; + if (match) return match.url; + // Defensive: an empty baseUrl array would crash `config.baseUrl[0].url` + // and bring down the install flow. Fall back to the caller-supplied + // value (or empty string) instead. + return config.baseUrl[0]?.url ?? selectedBaseUrl ?? ''; } return selectedBaseUrl ?? ''; } @@ -402,7 +323,7 @@ export function shouldShowStep( case 'baseUrl': return config.baseUrl === undefined || Array.isArray(config.baseUrl); case 'apiKey': - return config.authMethod !== 'oauth'; + return true; case 'models': return !config.models || config.modelsEditable === true; case 'advancedConfig': @@ -421,7 +342,55 @@ export function providerMatchesCredentials( baseUrl: string | undefined, envKey: string | undefined, ): boolean { - if (typeof config.envKey !== 'string' || config.envKey !== envKey) { + // Resolve envKey first: presets carry a string literal, but the custom + // provider carries a function that derives the key from (protocol, baseUrl). + // Treating "non-string" as no-match made custom providers invisible to + // findProviderByCredentials → /doctor and system-info diagnostics. + let configEnvKey: string | undefined; + if (typeof config.envKey === 'string') { + configEnvKey = config.envKey; + } else if (typeof config.envKey === 'function' && baseUrl) { + // buildInstallPlan derives the persisted env key from `inputs.protocol` + // (which may be USE_ANTHROPIC / USE_GEMINI for a custom provider), not + // the config's default `config.protocol`. So when we don't know which + // protocol the user originally chose, try every option the provider + // offers and match if any of them derives `envKey`. + const protocols = config.protocolOptions?.length + ? config.protocolOptions + : [config.protocol]; + for (const proto of protocols) { + try { + const derived = config.envKey(proto, baseUrl); + if (derived === envKey) { + configEnvKey = derived; + break; + } + } catch (err) { + // A throw here is a programming error in the provider's envKey fn, + // not an expected "no match" — surface it so a custom provider + // silently vanishing from /doctor / system-info has a trace. Log + // only the host (a custom baseUrl can embed credentials like + // https://user:sk-secret@host) and the error message, not the raw + // error object / full URL. + let safeHost: string; + try { + safeHost = new URL(baseUrl).hostname; + } catch { + safeHost = '[invalid]'; + } + // Log only the error's class name, not its message: a user-defined + // envKey fn could throw `new Error(\`bad config: ${apiKey}\`)` and the + // message would leak the key into extension-host logs. + // eslint-disable-next-line no-console -- diagnostic for a misconfigured provider + console.warn( + `[providerMatchesCredentials] envKey(${proto}, ${safeHost}) threw (${ + err instanceof Error ? err.constructor.name : typeof err + }); skipping this protocol`, + ); + } + } + } + if (configEnvKey !== envKey) { return false; } if (typeof config.baseUrl === 'string') { @@ -430,6 +399,12 @@ export function providerMatchesCredentials( if (Array.isArray(config.baseUrl)) { return config.baseUrl.some((opt) => opt.url === baseUrl); } + // Custom providers leave baseUrl `undefined` because every user picks + // their own — accept any non-empty baseUrl whose derived envKey already + // matched above. + if (config.baseUrl === undefined && configEnvKey !== undefined) { + return Boolean(baseUrl); + } return false; } diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts new file mode 100644 index 0000000000..b5cb91878e --- /dev/null +++ b/packages/core/src/providers/types.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AuthType, InputModalities } from '../core/contentGenerator.js'; +import type { ModelConfig, ModelProvidersConfig } from '../models/types.js'; + +// Re-export for convenience +export type ProviderModelConfig = ModelConfig; + +// --------------------------------------------------------------------------- +// Provider Config — declarative provider definition +// --------------------------------------------------------------------------- + +export type ProviderId = string; + +export interface ModelSpec { + id: string; + contextWindowSize?: number; + enableThinking?: boolean; + modalities?: InputModalities; + description?: string; +} + +export interface BaseUrlOption { + id: string; + label: string; + url: string; + documentationUrl?: string; + apiKeyUrl?: string; +} + +export interface ProviderConfig { + id: string; + label: string; + description: string; + + /** Always fixed for current providers. */ + protocol: AuthType; + + /** + * - `string` → fixed, skip UI step + * - `BaseUrlOption[]` → show option selector + * - `undefined` → user types freely (custom provider) + */ + baseUrl?: string | BaseUrlOption[]; + + /** Environment variable key, or a function to generate one. */ + envKey: string | ((protocol: AuthType, baseUrl: string) => string); + + /** + * - `ModelSpec[]` → model definitions with optional per-model metadata + * - `undefined` → user must type all model IDs (custom provider) + */ + models?: ModelSpec[]; + + /** + * Whether the user can add/remove models in the setup UI. + * - `true` → show model editing step; known IDs inherit their ModelSpec metadata + * - `false` → skip model step; use models as-is + * Defaults to `false` when `models` is set, ignored when `models` is `undefined`. + */ + modelsEditable?: boolean; + + /** Display name prefix for model entries, or a function of baseUrl. */ + modelNamePrefix: string | ((baseUrl: string) => string); + + /** + * Protocol options for manual selection (custom provider only). + * If provided with >1 entry, shows a protocol selection step. + */ + protocolOptions?: AuthType[]; + + /** Show advanced config step (thinking, modalities). */ + showAdvancedConfig?: boolean; + + /** Validate the API key before submission. */ + validateApiKey?: (key: string, baseUrl: string) => string | null; + + /** API key input placeholder. */ + apiKeyPlaceholder?: string; + + /** Documentation URL for the provider. */ + documentationUrl?: string | ((baseUrl: string) => string); + + /** + * Custom ownership check — identifies models belonging to this provider. + * Auto-derived from `envKey` (string) + `modelNamePrefix` (string) when omitted. + * Only needed for providers with function-typed envKey/prefix or non-standard logic. + */ + ownsModel?: (model: ProviderModelConfig) => boolean; + + /** + * UI grouping hint — used by AuthDialog to organize providers into sections. + * Providers with the same `uiGroup` appear together under a shared heading. + */ + uiGroup?: string; + + /** Step label overrides for the UI. */ + uiLabels?: { + flowTitle?: string; + baseUrlStepTitle?: string; + }; +} + +// --------------------------------------------------------------------------- +// Provider Setup Inputs — collected from user during setup wizard +// --------------------------------------------------------------------------- + +export interface ProviderSetupInputs { + /** Override protocol (only for custom provider). Defaults to config.protocol. */ + protocol?: AuthType; + baseUrl: string; + apiKey: string; + modelIds: string[]; + /** Pre-built model configs (e.g. OpenRouter fetches models from API). Overrides modelIds. */ + prebuiltModels?: ProviderModelConfig[]; + advancedConfig?: { + enableThinking?: boolean; + multimodal?: InputModalities; + contextWindowSize?: number; + maxTokens?: number; + }; +} + +// --------------------------------------------------------------------------- +// Provider Install Plan — output of buildInstallPlan +// --------------------------------------------------------------------------- + +export interface ProviderModelProvidersPatch { + authType: AuthType; + models: ProviderModelConfig[]; + mergeStrategy: 'prepend-and-remove-owned' | 'replace-owned' | 'append'; + ownsModel?: (model: ProviderModelConfig) => boolean; +} + +/** + * Arbitrary key-value metadata to persist alongside a provider install. + * Each top-level key becomes a settings path prefix (e.g. `codingPlan.version`). + */ +export type ProviderInstallState = Record>; + +export interface ProviderInstallPlan { + providerId: ProviderId; + authType: AuthType; + env?: Record; + legacyCredentials?: { + apiKey?: string; + baseUrl?: string; + }; + modelSelection?: { + modelId: string; + }; + modelProviders?: ProviderModelProvidersPatch[]; + providerState?: ProviderInstallState; + display?: { + successMessage?: string; + nextSteps?: string[]; + }; +} + +// --------------------------------------------------------------------------- +// Provider Settings Adapter — abstraction for settings read/write +// --------------------------------------------------------------------------- + +export interface ProviderSettingsAdapter { + /** Get a value by dotted key path (e.g. 'security.auth.selectedType'). */ + getValue(key: string): unknown; + /** + * Set a value by dotted key path. + * + * IMPORTANT: implementations MAY flush to disk on every call (the CLI's + * LoadedSettings-backed adapter does — each setValue triggers a + * saveSettings). Callers must therefore NOT assume the on-disk file is + * untouched until `persist()`; if the process crashes mid-sequence, disk + * can hold a partial write. `backup()`/`restore()` are the rollback path + * for that, not deferred persistence. Don't insert new pre-persist steps + * assuming atomicity. + */ + setValue(key: string, value: unknown): void; + /** Get the current model providers config. */ + getModelProviders(): ModelProvidersConfig; + /** + * Flush changes to disk. NOTE: this may be a no-op for adapters whose + * `setValue` already persists eagerly (see the warning on `setValue`). + * It remains in the contract as the explicit commit point for adapters + * that *do* buffer (e.g. the VS Code file adapter writes here). + */ + persist(): void; + /** Create a backup before making changes (for rollback on error). */ + backup?(): void; + /** Restore from backup (on error). */ + restore?(): void; + /** Clean up backup after successful operation. */ + cleanupBackup?(): void; +} diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts index 8eb00a3c2d..04380d4ec5 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -25,10 +25,14 @@ 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 } from './subscriptionPlanDefinitions.js'; import { + applyProviderInstallPlanToFile, + clearPersistedAuth, readQwenSettingsForVSCode, + restoreSettingsSnapshot, + snapshotSettingsForRollback, writeCodingPlanConfig, writeModelProvidersConfig, } from './settingsWriter.js'; @@ -103,4 +107,261 @@ describe('settingsWriter', () => { codingPlanRegion: 'china', }); }); + + 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(); + + 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(); + }); + }); + + 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(); + + // 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); + }); + + 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(); + + // No-op restore must not throw and must not clobber the file. + expect(() => restoreSettingsSnapshot(snapshot)).not.toThrow(); + expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ "broken": [1, 2'); + }); + + it('snapshot returns {} (not null) when no settings file exists', () => { + // ENOENT → readSettings returns {}, so we get a valid empty snapshot + // that restore can write (creating the file). + const snapshot = snapshotSettingsForRollback(); + expect(snapshot).toEqual({}); + }); + }); }); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index 9c88198b23..e3a4f5c2a0 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -9,7 +9,17 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { AuthType, Storage } from '@qwen-code/qwen-code-core'; +import { + ALL_PROVIDERS, + AuthType, + CUSTOM_API_KEY_ENV_PREFIX, + Storage, + applyProviderInstallPlan, + resolveMetadataKey, + type ProviderInstallPlan, + type ProviderSettingsAdapter, + type ModelProvidersConfig, +} from '@qwen-code/qwen-code-core'; import { CODING_PLAN_ENV_KEY, CodingPlanRegion, @@ -19,6 +29,20 @@ import { isSubscriptionPlanConfig, } from './subscriptionPlanDefinitions.js'; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Mode for ~/.qwen/settings.json — owner-only read/write. The unified install + * plan now persists API keys into `env.*` inside this file, so on multi-user + * systems the default 0644 (process umask) would expose those secrets to any + * local user. Keep this in sync with `loadedSettingsAdapter`'s expectations. + */ +const SETTINGS_FILE_MODE = 0o600; +/** Directory mode for ~/.qwen — owner-only. */ +const SETTINGS_DIR_MODE = 0o700; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -43,27 +67,185 @@ export interface QwenSettingsForVSCode { // --------------------------------------------------------------------------- /** - * Read ~/.qwen/settings.json. Returns {} if missing or invalid. + * Length of a JSON escape sequence starting with `\\`. Handles the six-char + * `\\uXXXX` form — otherwise a value like `"sk-abc\\u0022,..."` would let the + * embedded `\\u0022` close the string early and the rest of the buffer would + * parse as additional keys, enabling settings.json key injection. + */ +function jsonEscapeLength(text: string, backslashIdx: number): number { + return text[backslashIdx + 1] === 'u' ? 6 : 2; +} + +/** + * Strip single-line (`//`) and multi-line comments from JSONC content + * so that `JSON.parse` can handle ~/.qwen/settings.json files that + * contain comments (common when hand-edited or generated by CLI). + */ +function stripJsonComments(text: string): string { + let result = ''; + let i = 0; + while (i < text.length) { + // String literal — copy verbatim (handles escaped quotes) + if (text[i] === '"') { + let j = i + 1; + while (j < text.length) { + if (text[j] === '\\') { + j += jsonEscapeLength(text, j); + } else if (text[j] === '"') { + j++; + break; + } else { + j++; + } + } + result += text.slice(i, j); + i = j; + } else if (text[i] === '/' && text[i + 1] === '/') { + // Single-line comment — skip until newline + const nl = text.indexOf('\n', i); + i = nl === -1 ? text.length : nl; + } else if (text[i] === '/' && text[i + 1] === '*') { + // Multi-line comment — skip until */ + const end = text.indexOf('*/', i + 2); + i = end === -1 ? text.length : end + 2; + } else { + result += text[i]; + i++; + } + } + return result; +} + +/** + * Strip trailing commas inside arrays/objects (`,]` / `,}`) — VSCode's own + * settings.json allows them and they crash strict JSON.parse. Uses a + * character-by-character scanner so that the `,]` substring inside a string + * literal (e.g. `"MY_VAR": ",]"`) is preserved unchanged — a regex would + * silently rewrite it and corrupt the value. + */ +function stripTrailingCommas(text: string): string { + let result = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + // Copy the entire string literal (including escapes) verbatim. Must + // use the same jsonEscapeLength helper as stripJsonComments so that + // `"` is not mis-parsed as `\u` + early-terminating `"`. + let j = i + 1; + while (j < text.length) { + if (text[j] === '\\') { + j += jsonEscapeLength(text, j); + continue; + } + if (text[j] === '"') { + j++; + break; + } + j++; + } + result += text.slice(i, j); + i = j; + continue; + } + if (ch === ',') { + // Drop comma only if the next non-whitespace char is } or ]. + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (j < text.length && (text[j] === ']' || text[j] === '}')) { + i++; + continue; + } + } + result += ch; + i++; + } + return result; +} + +/** + * Read ~/.qwen/settings.json. Returns {} if the file is missing. + * Parse errors are logged and re-thrown so callers don't silently destroy a + * malformed file by treating it as empty and then overwriting it. + * Handles JSONC (JSON with comments + trailing commas) for hand-edited files. */ function readSettings(): Record { + const settingsPath = Storage.getGlobalSettingsPath(); + let content: string; try { - const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8'); - return JSON.parse(content) as Record; - } catch { - return {}; + content = fs.readFileSync(settingsPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw err; + } + try { + return JSON.parse( + stripTrailingCommas(stripJsonComments(content)), + ) as Record; + } catch (err) { + // Surface an actionable message rather than the raw SyntaxError. The + // caller's catch will see this; refusing to overwrite a malformed file + // is the whole point — better than silently destroying user state by + // treating it as `{}`. + console.error( + `[settingsWriter] Failed to parse ${settingsPath}; refusing to overwrite a malformed file.`, + err, + ); + const message = + err instanceof Error ? `${err.name}: ${err.message}` : String(err); + throw new Error( + `Cannot parse ~/.qwen/settings.json (${message}). ` + + `Standard JSONC (// comments, /* */, trailing commas) is supported, ` + + `but non-standard JSON features (unquoted keys, single quotes) are not. ` + + `Please fix or delete ${settingsPath} and try again.`, + { cause: err instanceof Error ? err : undefined }, + ); } } /** - * Write ~/.qwen/settings.json (creates dir if needed). + * Write ~/.qwen/settings.json atomically (temp file + rename), creating the + * directory if needed. Atomic rename prevents leaving a half-written file + * behind on EACCES / disk-full / process crash mid-write. */ function writeSettings(settings: Record): void { const settingsPath = Storage.getGlobalSettingsPath(); const dir = path.dirname(settingsPath); if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true, mode: SETTINGS_DIR_MODE }); + } + const tmpPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + // Create the temp file with owner-only permissions so secrets in `env.*` + // are never observable to other local users even briefly. + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), { + encoding: 'utf-8', + mode: SETTINGS_FILE_MODE, + }); + try { + fs.renameSync(tmpPath, settingsPath); + } catch (renameErr) { + // renameSync can fail on Windows when a watcher / antivirus holds the + // target (EPERM/EBUSY). The temp file otherwise lingers in ~/.qwen + // containing API keys — clean it up so secrets don't accumulate on + // disk across repeated failed writes. + try { + fs.unlinkSync(tmpPath); + } catch { + /* best-effort cleanup; surface the original rename error below */ + } + throw renameErr; + } + // Tighten any pre-existing settings file inherited from older writes that + // used the default umask. chmodSync is a no-op on the just-written file but + // covers the case where the file was created earlier with looser bits. + try { + fs.chmodSync(settingsPath, SETTINGS_FILE_MODE); + } catch { + // Best effort — surface nothing to the user if the FS rejects chmod + // (e.g. Windows, mounted FS without POSIX permissions). } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); } /** @@ -229,16 +411,188 @@ export function writeModelProvidersConfig(params: { writeSettings(settings); } +// --------------------------------------------------------------------------- +// Unified install plan — bridges core's ProviderInstallPlan to file I/O +// --------------------------------------------------------------------------- + +/** + * Create a ProviderSettingsAdapter backed by ~/.qwen/settings.json. + * Reads/writes use the low-level helpers already in this module. + */ +function createFileSettingsAdapter(): ProviderSettingsAdapter { + let data = readSettings(); + let backupData: Record | null = null; + + return { + getValue(key: string): unknown { + const parts = key.split('.'); + let current: unknown = data; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; + }, + + setValue(key: string, value: unknown): void { + const parts = key.split('.'); + let current = data; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + // Refuse to walk through prototype-pollution keys. ProviderInstallPlan + // contents come from in-process callers, but install plans accept + // arbitrary string keys (env vars, providerState namespaces) so the + // input is untrusted enough to warrant the guard. Literal === checks + // (not Set.has) are what CodeQL's prototype-pollution sanitiser + // recognises — keep them at the only step that actually writes to + // `current`. + if ( + part === '__proto__' || + part === 'constructor' || + part === 'prototype' + ) { + throw new Error( + `Refusing to write settings key with reserved segment: ${key}`, + ); + } + if (i === parts.length - 1) { + current[part] = value; + break; + } + const existing = Object.prototype.hasOwnProperty.call(current, part) + ? current[part] + : undefined; + if (existing == null) { + current[part] = {}; + } else if (Array.isArray(existing) || typeof existing !== 'object') { + // Refuse to silently overwrite a scalar (or treat an array as an + // object) at an intermediate segment — would either destroy user + // data (e.g. {"env": "legacy-string"} losing the string when + // env.NEW_KEY is written) or set string keys on an array. + throw new Error( + `Cannot write settings key "${key}": segment "${part}" is ${ + Array.isArray(existing) ? 'an array' : `a ${typeof existing}` + }, not an object`, + ); + } + current = current[part] as Record; + } + }, + + getModelProviders(): ModelProvidersConfig { + return (data.modelProviders ?? {}) as ModelProvidersConfig; + }, + + persist(): void { + writeSettings(data); + }, + + backup(): void { + backupData = JSON.parse(JSON.stringify(data)) as Record; + }, + + restore(): void { + if (!backupData) return; + // Write to disk FIRST. If writeSettings throws (EACCES / disk full / + // EPERM on Windows), the in-memory update is skipped on purpose: + // callers never observe a clean snapshot while the file on disk lies. + // + // Note: the CLI adapter (loadedSettingsAdapter) takes a different + // trade-off — restoreSettingsFromBackup() returns a boolean, so it + // logs on failure and *unconditionally* restores in-memory state. + // VS Code can be stricter because its writeSettings is the only path + // and a throw here is recoverable; the CLI lacks that escape hatch. + writeSettings(backupData); + data = backupData; + backupData = null; + }, + + cleanupBackup(): void { + backupData = null; + }, + }; +} + +/** + * Apply a ProviderInstallPlan to ~/.qwen/settings.json. + * This is the primary entry point for the VSCode interactive auth flow. + * + * `applyProviderInstallPlan` is async, so a returned (or thrown) Promise must + * be awaited — otherwise an `EACCES` from `persist()` or the prototype-pollution + * guard in `setValue()` would be swallowed and the caller would carry on + * reconnecting the agent as if the settings write had succeeded. + */ +export async function applyProviderInstallPlanToFile( + plan: ProviderInstallPlan, +): Promise { + const settings = createFileSettingsAdapter(); + await applyProviderInstallPlan(plan, { settings }); +} + +/** + * Capture a deep-cloned snapshot of the current on-disk settings for rollback, + * or `null` if the file is missing/unreadable. + * + * Unlike `readSettings`, this never throws — callers use it to checkpoint + * before `applyProviderInstallPlanToFile` so that a *later* step the install + * plan can't see (e.g. the agent reconnect rejecting a bad API key) can be + * undone via {@link restoreSettingsSnapshot}. `applyProviderInstallPlan`'s own + * backup/restore only covers failures *inside* the plan; the + * disconnect/reconnect in WebViewProvider runs after `cleanupBackup`, so the + * caller owns that rollback window. + */ +export function snapshotSettingsForRollback(): Record | null { + try { + return structuredClone(readSettings()); + } catch (err) { + // Leave a breadcrumb: returning null disables credential rollback, so if + // settings are transiently unreadable (AV lock, disk hiccup) the oncall + // engineer can tie repeated cross-restart auth failures back to here. + // Log only the error's class name (not its message) — consistent with the + // providerMatchesCredentials guard, so the security stance holds even + // though this catch is filesystem errors rather than user-defined fns. + console.warn( + '[settingsWriter] snapshotSettingsForRollback failed; credential rollback disabled:', + err instanceof Error ? err.constructor.name : typeof err, + ); + return null; + } +} + +/** + * Write a snapshot captured by {@link snapshotSettingsForRollback} back to + * disk. No-op when the snapshot is `null` (nothing safe to restore). + */ +export function restoreSettingsSnapshot( + snapshot: Record | null, +): void { + if (snapshot === null) return; + writeSettings(snapshot); +} + // --------------------------------------------------------------------------- // Read: ~/.qwen/settings.json → VSCode Settings // --------------------------------------------------------------------------- /** * Read ~/.qwen/settings.json and extract values for VSCode Settings UI. - * Returns null if no valid configuration found. + * Returns null if no valid configuration found, or if the file is + * malformed — the panel falls back to the empty/default state instead of + * crashing the extension on activation. `readSettings` itself now throws + * on parse failure (so we never silently overwrite a corrupt file in the + * write paths), so this caller has to catch. */ export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { - const settings = readSettings(); + let settings: Record; + try { + settings = readSettings(); + } catch (error) { + console.error( + '[settingsWriter] readQwenSettingsForVSCode failed; returning null:', + error, + ); + return null; + } const security = settings.security as Record | undefined; const auth = security?.auth as Record | undefined; @@ -299,13 +653,33 @@ export function clearPersistedAuth(): void { delete (security.auth as Record).selectedType; } - // Remove API keys + // Remove API keys: every preset's string envKey + any custom-provider + // env var (prefix-matched). Without this, third-party (DeepSeek / + // MiniMax / Z.AI / IdeaLab / ModelScope / OpenRouter) and custom + // (QWEN_CUSTOM_API_KEY_*) keys would linger on disk after the user + // clears authentication. const env = settings.env as Record | undefined; if (env) { + // Subscription plan keys (kept explicit in case the provider list + // ever drifts from SUBSCRIPTION_PLAN_OPTIONS). 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]; + } + } } // Remove subscription plan metadata (legacy + new namespace) @@ -314,8 +688,20 @@ export function clearPersistedAuth(): void { } const pm = settings.providerMetadata as Record | undefined; if (pm) { - delete pm['coding-plan']; - delete pm['token-plan']; + // 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 */ + } + } } 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 85a7512a6c..57672289e4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -16,6 +16,10 @@ vi.mock('vscode', () => ({ showQuickPick: mockShowQuickPick, showInputBox: mockShowInputBox, }, + QuickPickItemKind: { + Separator: -1, + Default: 0, + }, })); import { AuthMessageHandler } from './AuthMessageHandler.js'; @@ -41,9 +45,14 @@ describe('AuthMessageHandler', () => { }); it('sends authCancelled when the api key input is dismissed mid-flow', async () => { + // First pick: select provider (coding-plan) + // Second pick: select base URL region mockShowQuickPick .mockResolvedValueOnce({ value: 'coding-plan' }) - .mockResolvedValueOnce({ value: 'china' }); + .mockResolvedValueOnce({ + value: 'https://coding.dashscope.aliyuncs.com/v1', + }); + // API key input: user cancels mockShowInputBox.mockResolvedValue(undefined); const sendToWebView = vi.fn(); @@ -58,4 +67,256 @@ 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'); + + 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' }); + + // 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({ + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-deepseek', + modelIds: ['deepseek-v4-flash', 'deepseek-v4-pro'], + }), + ); + 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, + ); + 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'); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 6748aecc71..aa99749bae 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -7,21 +7,30 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; +import { + ALL_PROVIDERS, + ALIBABA_PROVIDERS, + AuthType, + THIRD_PARTY_PROVIDERS, + shouldShowStep, + resolveBaseUrl, + getDefaultBaseUrlForProtocol, + getDefaultModelIds, + type ProviderConfig, + type ProviderSetupInputs, + type BaseUrlOption, +} from '@qwen-code/qwen-code-core'; /** * Auth message handler - * Handles all authentication-related messages + * Handles all authentication-related messages. + * + * Uses the shared ProviderConfig registry from core to dynamically + * generate setup flows instead of hardcoding provider-specific logic. */ export class AuthMessageHandler extends BaseMessageHandler { private authInteractiveHandler: - | (( - provider: string, - region?: string, - apiKey?: string, - baseUrl?: string, - model?: string, - modelIds?: string, - ) => Promise) + | ((config: ProviderConfig, inputs: ProviderSetupInputs) => Promise) | null = null; canHandle(messageType: string): boolean { @@ -48,16 +57,12 @@ export class AuthMessageHandler extends BaseMessageHandler { } /** - * Set auth interactive handler — interactive auth flow. + * Set auth interactive handler — called with provider config and user inputs. */ setAuthInteractiveHandler( handler: ( - provider: string, - region?: string, - apiKey?: string, - baseUrl?: string, - model?: string, - modelIds?: string, + config: ProviderConfig, + inputs: ProviderSetupInputs, ) => Promise, ): void { this.authInteractiveHandler = handler; @@ -88,19 +93,6 @@ export class AuthMessageHandler extends BaseMessageHandler { } } - // --------------------------------------------------------------------------- - // auth: Interactive auth flow (mirrors CLI's /auth) - // --------------------------------------------------------------------------- - - // Alibaba Standard API Key region endpoints - private static readonly ALIBABA_STANDARD_ENDPOINTS: Record = { - 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', - 'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', - 'cn-hongkong': - 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', - }; - /** * Notify the webview that the interactive auth flow was dismissed. */ @@ -111,9 +103,17 @@ export class AuthMessageHandler extends BaseMessageHandler { /** * Helper: show a QuickPick and return the selected item's `value`. * Returns undefined if the user cancels. + * + * Items with `kind: Separator` are rendered by VSCode as non-selectable + * group headers; they should be left in `items` to preserve grouping. */ private async pick( - items: Array<{ label: string; description?: string; value: T }>, + items: Array<{ + label: string; + description?: string; + value: T; + kind?: vscode.QuickPickItemKind; + }>, title: string, placeHolder: string, ): Promise { @@ -121,7 +121,7 @@ export class AuthMessageHandler extends BaseMessageHandler { title, placeHolder, }); - if (!choice) { + if (!choice || choice.kind === vscode.QuickPickItemKind.Separator) { this.notifyAuthCancelled(); return undefined; } @@ -156,44 +156,71 @@ export class AuthMessageHandler extends BaseMessageHandler { return value; } + // --------------------------------------------------------------------------- + // Main entry: dynamic provider selection from ALL_PROVIDERS + // --------------------------------------------------------------------------- + /** * Handle auth — full interactive auth flow. - * - * Tree (mirrors CLI AuthDialog): - * |- Coding Plan -> Region (China/Global) -> 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: '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); + } + + // Pass items including separators; VSCode QuickPick renders separator + // entries as non-selectable group headers (mirrors the CLI grouping). + const selectedId = await this.pick( + items, + 'Qwen Code: Select Provider', + 'Choose how to connect', ); + if (!selectedId) return; + + const provider = ALL_PROVIDERS.find((p) => p.id === selectedId); if (!provider) { + console.error('[AuthMessageHandler] Provider not found:', selectedId); return; } - if (provider === 'coding-plan') { - await this.authCodingPlan(); - } else { - await this.authApiKey(); - } + // Run generic setup flow + await this.runProviderSetupFlow(provider); } catch (error) { const errorMsg = getErrorMessage(error); console.error('[AuthMessageHandler] auth failed:', error); @@ -204,184 +231,201 @@ export class AuthMessageHandler extends BaseMessageHandler { } } - /** - * Coding Plan: region -> API key -> connect. - */ - private async authCodingPlan(): Promise { - const region = await this.pick( - [ - { - label: '中国 (China)', - description: '阿里云百炼 — aliyun.com', - value: 'china' as const, - }, - { - label: 'Global', - description: 'Alibaba Cloud — alibabacloud.com', - value: 'global' as const, - }, - ], - 'Qwen Code: Coding Plan Region', - 'Select region', - ); - if (!region) { - 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; - } + // --------------------------------------------------------------------------- + // Generic provider setup flow — driven by ProviderConfig + // --------------------------------------------------------------------------- - if (this.authInteractiveHandler) { - await this.authInteractiveHandler('coding-plan', region, apiKey); - } - } + private async runProviderSetupFlow(provider: ProviderConfig): Promise { + const flowTitle = + provider.uiLabels?.flowTitle ?? `Qwen Code: ${provider.label}`; - /** - * 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; + // Step 0: Protocol (only for providers offering multiple, e.g. custom) + let protocol: AuthType | undefined; + if ( + shouldShowStep(provider, 'protocol') && + provider.protocolOptions && + provider.protocolOptions.length > 1 + ) { + // AuthType's raw string values ('openai' / 'anthropic' / 'gemini') are + // implementation detail; QuickPick should show human-readable labels. + const protocolLabels: Record = { + [AuthType.USE_OPENAI]: 'OpenAI Compatible', + [AuthType.USE_ANTHROPIC]: 'Anthropic', + [AuthType.USE_GEMINI]: 'Gemini', + }; + const selected = await this.pick( + provider.protocolOptions.map((p) => ({ + label: protocolLabels[String(p)] ?? String(p), + value: String(p), + })), + `${flowTitle}: Protocol`, + 'Select API protocol', + ); + if (!selected) return; + protocol = selected as AuthType; } - 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 { - await this.authCustom(); + baseUrl = resolveBaseUrl(provider); } - } - /** - * Alibaba Standard: region -> API key -> model IDs -> connect. - */ - private async authAlibabaStandard(): Promise { - const endpoints = AuthMessageHandler.ALIBABA_STANDARD_ENDPOINTS; - - const region = await this.pick( - Object.entries(endpoints).map(([key, endpoint]) => ({ - label: - key === 'cn-beijing' - ? 'China (Beijing)' - : key === 'sg-singapore' - ? 'Singapore' - : key === 'us-virginia' - ? 'US (Virginia)' - : 'China (Hong Kong)', - description: `Endpoint: ${endpoint}`, - value: key, - })), - 'Qwen Code: Select Region', - 'Select region for Alibaba Cloud ModelStudio', - ); - if (!region) { - return; - } - - const apiKey = await this.input({ - title: 'Qwen Code: API Key', - prompt: 'Enter your Alibaba Cloud ModelStudio API key', - placeHolder: 'sk-...', + // Step 2: API Key + const apiKeyInput = await this.input({ + title: `${flowTitle}: API Key`, + prompt: 'Enter your API key', + placeHolder: provider.apiKeyPlaceholder ?? 'sk-...', password: true, required: true, }); - if (!apiKey) { - return; - } + if (!apiKeyInput) return; + // Trim before validation and persistence — a key pasted with trailing + // whitespace would otherwise be stored as-is and cause silent auth + // failures, and validateApiKey could reject in VS Code what the CLI + // (which trims) accepts. + const apiKey = apiKeyInput.trim(); + if (!apiKey) return; - const modelIds = await this.input({ - title: 'Qwen Code: Model IDs', - prompt: 'Enter model IDs (comma-separated)', - placeHolder: 'qwen3.5-plus,glm-5,kimi-k2.5', - value: 'qwen3.5-plus', - required: true, - }); - if (!modelIds) { - return; + // Validate API key if provider has validation + if (provider.validateApiKey) { + const validationError = provider.validateApiKey(apiKey, baseUrl); + if (validationError) { + // No authCancelled here — see the base-URL validation note above. + this.sendToWebView({ + type: 'authError', + data: { message: validationError }, + }); + return; + } } - const baseUrl = endpoints[region] || endpoints['cn-beijing']; - const firstModel = modelIds.split(',')[0]?.trim() || 'qwen3.5-plus'; - - if (this.authInteractiveHandler) { - await this.authInteractiveHandler( - 'alibaba-standard', - region, - apiKey, - baseUrl, - firstModel, - modelIds, - ); + // Step 3: Model selection (if needed) + let modelIds: string[]; + if (shouldShowStep(provider, 'models')) { + const defaults = getDefaultModelIds(provider); + const modelInput = await this.input({ + title: `${flowTitle}: Models`, + prompt: 'Enter model IDs (comma-separated)', + placeHolder: defaults.join(',') || 'model-name', + value: defaults.join(','), + required: true, + }); + if (!modelInput) return; + modelIds = modelInput + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + if (modelIds.length === 0) { + // E.g. user typed only whitespace/commas like ", , ,". No + // authCancelled — see the base-URL validation note above. + this.sendToWebView({ + type: 'authError', + data: { message: 'Model IDs cannot be empty.' }, + }); + return; + } + } else { + modelIds = getDefaultModelIds(provider); } - } - /** - * Custom: base URL -> API key -> model -> connect. - */ - private async authCustom(): Promise { - const baseUrl = await this.input({ - title: 'Qwen Code: Base URL', - prompt: 'Enter API base URL', - placeHolder: 'https://api.openai.com/v1', - value: 'https://api.openai.com/v1', - }); - if (baseUrl === undefined) { - return; + // Step 4: Advanced config (if needed) + let advancedConfig: ProviderSetupInputs['advancedConfig']; + if (shouldShowStep(provider, 'advancedConfig')) { + // Simplified: just ask about thinking mode + const enableThinking = await this.pick( + [ + { + label: 'Yes', + description: 'Enable extended thinking mode', + value: 'yes' as const, + }, + { + label: 'No', + description: 'Standard mode', + value: 'no' as const, + }, + ], + `${flowTitle}: Advanced Config`, + 'Enable thinking mode?', + ); + if (!enableThinking) return; + advancedConfig = { + enableThinking: enableThinking === 'yes', + }; } - const apiKey = await this.input({ - title: 'Qwen Code: API Key', - prompt: 'Enter your API key', - placeHolder: 'sk-...', - password: true, - required: true, - }); - if (!apiKey) { + // Submit + if (!this.authInteractiveHandler) { + console.error( + '[AuthMessageHandler] authInteractiveHandler not set; cannot apply provider config.', + ); + // No authCancelled — see the base-URL validation note above. + this.sendToWebView({ + type: 'authError', + data: { + message: + 'Auth handler not initialized. Please reopen the panel and try again.', + }, + }); return; } - - const model = await this.input({ - title: 'Qwen Code: Model', - prompt: 'Enter model name', - placeHolder: 'gpt-4o', - required: true, + await this.authInteractiveHandler(provider, { + protocol, + baseUrl, + apiKey, + modelIds, + advancedConfig, }); - if (!model) { - return; - } - - if (this.authInteractiveHandler) { - await this.authInteractiveHandler( - 'api-key', - undefined, - apiKey, - baseUrl, - model, - ); - } } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index 0b307847f0..0025592c3e 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -172,12 +172,8 @@ export class MessageRouter { */ setAuthInteractiveHandler( handler: ( - provider: string, - region?: string, - apiKey?: string, - baseUrl?: string, - model?: string, - modelIds?: string, + config: import('@qwen-code/qwen-code-core').ProviderConfig, + inputs: import('@qwen-code/qwen-code-core').ProviderSetupInputs, ) => Promise, ): void { this.authHandler.setAuthInteractiveHandler(handler); diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 6c5460cfcb..7e62f80293 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -79,12 +79,8 @@ export class MessageHandler { */ setAuthInteractiveHandler( handler: ( - provider: string, - region?: string, - apiKey?: string, - baseUrl?: string, - model?: string, - modelIds?: string, + config: import('@qwen-code/qwen-code-core').ProviderConfig, + inputs: import('@qwen-code/qwen-code-core').ProviderSetupInputs, ) => Promise, ): void { this.router.setAuthInteractiveHandler(handler); 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 f87bd35cf4..403659133a 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -23,6 +23,9 @@ const { mockWriteCodingPlanConfig, mockWriteModelProvidersConfig, mockClearPersistedAuth, + mockApplyProviderInstallPlanToFile, + mockSnapshotSettingsForRollback, + mockRestoreSettingsSnapshot, slashCommandNotificationCallbackRef, endTurnCallbackRef, streamChunkCallbackRef, @@ -77,6 +80,11 @@ const { mockWriteCodingPlanConfig: vi.fn(() => ({})), mockWriteModelProvidersConfig: vi.fn(), mockClearPersistedAuth: vi.fn(), + mockApplyProviderInstallPlanToFile: vi.fn().mockResolvedValue(undefined), + mockSnapshotSettingsForRollback: vi.fn<() => Record | null>( + () => null, + ), + mockRestoreSettingsSnapshot: vi.fn(), slashCommandNotificationCallbackRef: { current: undefined as | ((event: { @@ -166,6 +174,9 @@ vi.mock('../../services/settingsWriter.js', () => ({ writeModelProvidersConfig: mockWriteModelProvidersConfig, readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode, clearPersistedAuth: mockClearPersistedAuth, + applyProviderInstallPlanToFile: mockApplyProviderInstallPlanToFile, + snapshotSettingsForRollback: mockSnapshotSettingsForRollback, + restoreSettingsSnapshot: mockRestoreSettingsSnapshot, })); vi.mock('../../services/qwenAgentManager.js', () => ({ @@ -1682,3 +1693,217 @@ describe('Notification & dot indicator', () => { ); }); }); + +describe('WebViewProvider.handleAuthInteractive credential rollback', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSnapshotSettingsForRollback.mockReturnValue(null); + }); + + // Minimal real-ish provider config + inputs so the real buildInstallPlan + // (core is not mocked beyond Storage) produces a valid plan. + const providerConfig = { + id: 'deepseek', + label: 'DeepSeek', + protocol: 'openai', + baseUrl: 'https://api.deepseek.com', + envKey: 'DEEPSEEK_API_KEY', + models: [{ id: 'deepseek-v4-flash' }], + modelNamePrefix: 'DeepSeek', + } as unknown as Parameters[0]; + const inputs = { + baseUrl: 'https://api.deepseek.com', + apiKey: 'sk-bad-key', + modelIds: ['deepseek-v4-flash'], + } as unknown as Parameters[1]; + + function makeProvider() { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + // Avoid touching the real webview pipe. + ( + provider as unknown as { sendMessageToWebView: () => void } + ).sendMessageToWebView = vi.fn(); + return provider; + } + + it('restores the snapshot when the reconnect leaves authState !== true', async () => { + const snapshot = { env: { OPENAI_API_KEY: 'sk-old' } }; + mockSnapshotSettingsForRollback.mockReturnValue(snapshot); + + const provider = makeProvider(); + // doInitializeAgentConnection runs but the backend rejects the key, so + // authState stays false. + ( + provider as unknown as { + doInitializeAgentConnection: () => Promise; + authState: boolean; + } + ).doInitializeAgentConnection = vi.fn(async () => { + (provider as unknown as { authState: boolean }).authState = false; + }); + + await ( + provider as unknown as { + handleAuthInteractive: (c: unknown, i: unknown) => Promise; + } + ).handleAuthInteractive(providerConfig, inputs); + + expect(mockApplyProviderInstallPlanToFile).toHaveBeenCalledTimes(1); + expect(mockRestoreSettingsSnapshot).toHaveBeenCalledWith(snapshot); + }); + + it('disconnects the agent after rolling back rejected credentials', async () => { + mockSnapshotSettingsForRollback.mockReturnValue({ + env: { OPENAI_API_KEY: 'sk-old' }, + }); + + const provider = makeProvider(); + const disconnect = vi.fn(); + ( + provider as unknown as { + agentManager: { disconnect: () => void }; + agentInitialized: boolean; + } + ).agentManager = { disconnect } as never; + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + ( + provider as unknown as { + doInitializeAgentConnection: () => Promise; + authState: boolean; + } + ).doInitializeAgentConnection = vi.fn(async () => { + // Reconnect happened (sets agentInitialized true) but auth rejected. + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + (provider as unknown as { authState: boolean }).authState = false; + }); + + await ( + provider as unknown as { + handleAuthInteractive: (c: unknown, i: unknown) => Promise; + } + ).handleAuthInteractive(providerConfig, inputs); + + // Bad-key agent must be torn down so later actions don't hit it. + expect(disconnect).toHaveBeenCalled(); + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(false); + }); + + it('does NOT restore when the reconnect authenticates (authState === true)', async () => { + mockSnapshotSettingsForRollback.mockReturnValue({ + env: { OPENAI_API_KEY: 'sk-old' }, + }); + + const provider = makeProvider(); + ( + provider as unknown as { + doInitializeAgentConnection: () => Promise; + authState: boolean; + } + ).doInitializeAgentConnection = vi.fn(async () => { + (provider as unknown as { authState: boolean }).authState = true; + }); + + await ( + provider as unknown as { + handleAuthInteractive: (c: unknown, i: unknown) => Promise; + } + ).handleAuthInteractive(providerConfig, inputs); + + expect(mockRestoreSettingsSnapshot).not.toHaveBeenCalled(); + }); + + it('swallows a rollback write failure so the authError message still sends', async () => { + mockSnapshotSettingsForRollback.mockReturnValue({ + env: { OPENAI_API_KEY: 'sk-old' }, + }); + // restore itself throws (e.g. EPERM on Windows renameSync). + mockRestoreSettingsSnapshot.mockImplementation(() => { + throw new Error('EPERM: rename failed'); + }); + + const provider = makeProvider(); + const sendToWebView = ( + provider as unknown as { sendMessageToWebView: ReturnType } + ).sendMessageToWebView; + ( + provider as unknown as { + doInitializeAgentConnection: () => Promise; + authState: boolean; + } + ).doInitializeAgentConnection = vi.fn(async () => { + (provider as unknown as { authState: boolean }).authState = false; + }); + + await expect( + ( + provider as unknown as { + handleAuthInteractive: (c: unknown, i: unknown) => Promise; + } + ).handleAuthInteractive(providerConfig, inputs), + ).resolves.toBeUndefined(); + + // The rollback throw must not prevent the user-facing authError. + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ type: 'authError' }), + ); + }); + + it('rolls back + disconnects + reports authError when doInitializeAgentConnection throws (outer catch)', async () => { + // The outer catch handles unexpected exceptions (disk errors, partial + // writes) — the path where rollback is most likely to also be needed. + const snapshot = { env: { OPENAI_API_KEY: 'sk-old' } }; + mockSnapshotSettingsForRollback.mockReturnValue(snapshot); + + const provider = makeProvider(); + const sendToWebView = ( + provider as unknown as { sendMessageToWebView: ReturnType } + ).sendMessageToWebView; + const disconnect = vi.fn(); + ( + provider as unknown as { agentManager: { disconnect: () => void } } + ).agentManager = { disconnect } as never; + ( + provider as unknown as { + doInitializeAgentConnection: () => Promise; + } + ).doInitializeAgentConnection = vi.fn(async () => { + // Partial init: agent process spawned (agentInitialized=true) then a + // post-connect step throws. + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + throw new Error('disk exploded mid-reconnect'); + }); + + await expect( + ( + provider as unknown as { + handleAuthInteractive: (c: unknown, i: unknown) => Promise; + } + ).handleAuthInteractive(providerConfig, inputs), + ).resolves.toBeUndefined(); + + // (1) snapshot restored, (2) the half-connected stale-credential agent is + // torn down, (3) authError with "Configuration failed", (4) resolved + // without throwing (asserted above). + expect(mockRestoreSettingsSnapshot).toHaveBeenCalledWith(snapshot); + expect(disconnect).toHaveBeenCalled(); + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(false); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'authError', + data: expect.objectContaining({ + message: expect.stringContaining('Configuration failed'), + }), + }), + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index d6ada5b8b1..d0c3622a6e 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -28,12 +28,17 @@ import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; import { + applyProviderInstallPlanToFile, + snapshotSettingsForRollback, + restoreSettingsSnapshot, writeCodingPlanConfig, - writeModelProvidersConfig, readQwenSettingsForVSCode, clearPersistedAuth, } from '../../services/settingsWriter.js'; -import { parseInsightMessage } from '@qwen-code/qwen-code-core'; +import { + buildInstallPlan, + parseInsightMessage, +} from '@qwen-code/qwen-code-core'; /** Threshold (ms) before a completed task triggers a notification. */ const LONG_TASK_THRESHOLD_MS = 20_000; @@ -151,15 +156,8 @@ export class WebViewProvider { // Set auth interactive handler — interactive auth flow (QuickPick → InputBox → write settings → reconnect) this.messageHandler.setAuthInteractiveHandler( - async (provider, region, apiKey, baseUrl, model, modelIds) => { - await this.handleAuthInteractive( - provider, - region, - apiKey, - baseUrl, - model, - modelIds, - ); + async (providerConfig, inputs) => { + await this.handleAuthInteractive(providerConfig, inputs); }, ); @@ -1305,14 +1303,10 @@ export class WebViewProvider { * Mirrors the CLI's `qwen auth coding-plan` / `qwen auth` flow. */ private async handleAuthInteractive( - provider: string, - region?: string, - apiKey?: string, - baseUrl?: string, - model?: string, - modelIds?: string, + providerConfig: import('@qwen-code/qwen-code-core').ProviderConfig, + inputs: import('@qwen-code/qwen-code-core').ProviderSetupInputs, ): Promise { - if (!apiKey) { + if (!inputs.apiKey) { this.sendMessageToWebView({ type: 'authError', data: { message: 'API key is required.' }, @@ -1320,40 +1314,57 @@ export class WebViewProvider { return; } + // Log only the host so we don't leak credentials embedded in user-info + // (`https://user:sk-secret@host/v1`) or query strings into extension-host + // logs / diagnostic bundles. + const baseUrlHost = (() => { + try { + return new URL(inputs.baseUrl).hostname; + } catch { + return '[invalid]'; + } + })(); console.log( - `[WebViewProvider] authInteractive: provider=${provider}, region=${region}, model=${model}`, + `[WebViewProvider] authInteractive: provider=${providerConfig.id}, host=${baseUrlHost}`, ); - try { - if (provider === 'coding-plan') { - writeCodingPlanConfig(region === 'global' ? 'global' : 'china', 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 + // so a subsequent chat message doesn't hit a stale-credential error that + // looks unrelated to this auth failure; the next /auth reconnects clean. + const disconnectStaleAgent = () => { + if (!this.agentInitialized) return; + try { + this.agentManager.disconnect(); + } catch (e) { + console.log('[WebViewProvider] Error disconnecting after rollback:', e); } + this.agentInitialized = false; + }; + try { + // Use core's buildInstallPlan to create a standardized install plan, + // then apply it via the VSCode settings adapter. + const plan = buildInstallPlan(providerConfig, inputs); + await applyProviderInstallPlanToFile(plan); // Disconnect + reconnect if (this.agentInitialized) { @@ -1369,15 +1380,20 @@ export class WebViewProvider { await this.doInitializeAgentConnection({ autoAuthenticate: false }); // Only emit authSuccess when the reconnection actually authenticated. - // doInitializeAgentConnection updates this.authState via sendMessageToWebView; - // if credentials were rejected, authState will be false and we should not - // claim success (which would briefly show a success toast then re-open auth). + // doInitializeAgentConnection sets this.authState via sendMessageToWebView + // — when credentials are rejected (wrong key / bad endpoint) it stays + // false, and showing a success toast then would mislead the user. if (this.authState === true) { this.sendMessageToWebView({ type: 'authSuccess', data: { message: 'Provider configured successfully!' }, }); } else { + // Auth failed against the live backend — roll the bad credentials + // back off disk so a restart doesn't keep retrying them, and tear + // down the agent still holding the rejected key in memory. + safeRollback(); + disconnectStaleAgent(); this.sendMessageToWebView({ type: 'authError', data: { @@ -1389,6 +1405,17 @@ export class WebViewProvider { } catch (error) { const errorMsg = getErrorMessage(error); console.error('[WebViewProvider] authInteractive failed:', error); + // A throw can land here after the plan committed but before/while + // reconnecting — restore the snapshot so partial/bad state doesn't + // linger. (Redundant but harmless if the plan's own rollback already + // ran: it just rewrites the same pre-state.) safeRollback swallows a + // rollback throw so it can't pre-empt the authError message below. + // doInitializeAgentConnection may have partially initialized the agent + // (agentInitialized=true) before throwing, so disconnect it too — + // mirrors the else-branch so a half-connected stale-credential agent + // doesn't linger. + safeRollback(); + disconnectStaleAgent(); this.sendMessageToWebView({ type: 'authError', data: { message: `Configuration failed: ${errorMsg}` }, diff --git a/scripts/tests/vitest.config.ts b/scripts/tests/vitest.config.ts index 9eb42595cf..7880537299 100644 --- a/scripts/tests/vitest.config.ts +++ b/scripts/tests/vitest.config.ts @@ -12,6 +12,13 @@ export default defineConfig({ environment: 'node', include: ['scripts/tests/**/*.test.{js,ts}'], setupFiles: ['scripts/tests/test-setup.ts'], + // Several tests in install-script.test.js shell out to `node` to run + // create-standalone-package.js, which on Windows runs a full + // tar+gzip pass under antivirus inspection. Real runtimes observed on + // Windows CI: 4780ms / 1666ms / 1079ms — the 4.8s one is right at + // vitest's 5s default and flakes. Bump the suite timeout so a single + // slow subprocess startup doesn't fail an otherwise-healthy test run. + testTimeout: 30_000, coverage: { provider: 'v8', reporter: ['text', 'lcov'],