diff --git a/.gitignore b/.gitignore index d4b35d0e7..2dae5710a 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ storybook-static # Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js) packages/core/src/skills/bundled/qc-helper/docs +tmp/ \ No newline at end of file diff --git a/docs/design/openrouter-auth-and-models.md b/docs/design/openrouter-auth-and-models.md new file mode 100644 index 000000000..7c82476a6 --- /dev/null +++ b/docs/design/openrouter-auth-and-models.md @@ -0,0 +1,89 @@ +# OpenRouter Auth and Model Management Design + +This document captures the design intent behind the OpenRouter auth flow and the +model management changes introduced with it. It intentionally focuses on the +product and architectural choices, not implementation history. + +## Goals + +- Let users authenticate with OpenRouter from both CLI and `/auth`. +- Reuse the existing OpenAI-compatible provider path instead of adding a new auth + type for OpenRouter. +- Make the first-run experience usable without asking users to manage hundreds of + models immediately. +- Keep a clear path toward richer model management via `/manage-models`. + +## OpenRouter Auth + +OpenRouter is integrated as an OpenAI-compatible provider: + +- auth type: `AuthType.USE_OPENAI` +- provider settings: `modelProviders.openai` +- API key env var: `OPENROUTER_API_KEY` +- base URL: `https://openrouter.ai/api/v1` + +This avoids introducing an OpenRouter-specific `AuthType` when the runtime model +provider path is already OpenAI-compatible. It keeps auth status, model +resolution, provider selection, and settings schema aligned with the existing +provider abstraction. + +The user-facing flows are: + +- `qwen auth openrouter --key ` for automation or direct API-key setup. +- `qwen auth openrouter` for browser-based OAuth. +- `/auth` → API Key → OpenRouter for the TUI flow. + +Browser OAuth uses OpenRouter's PKCE flow and writes the exchanged API key into +settings before refreshing auth as `AuthType.USE_OPENAI`. + +## Model Management + +OpenRouter exposes a large dynamic model catalog. Writing every discovered model +into `modelProviders.openai` would make `/model` noisy and would turn a long-term +settings field into a cache of a remote catalog. + +The key design split is: + +- **Catalog**: the full set of models discovered from a source such as + OpenRouter. +- **Enabled set**: the smaller set of models that should appear in `/model` and + be persisted in user settings. + +For the initial OpenRouter flow, auth should finish with a useful default enabled +set instead of interrupting the user with a large picker. The recommended set +should be small, stable, and biased toward models that let users try the product +successfully, including free models when available. + +`/model` remains a fast model switcher. It should not become the place where +users browse and curate a full provider catalog. + +## `/manage-models` + +Richer model management belongs in a separate `/manage-models` entry point. That +flow should let users: + +- browse discovered models; +- search by id, display name, provider prefix, and derived tags such as `free` or + `vision`; +- see which models are currently enabled; +- enable or disable models in batches. + +The source dimension must remain part of this design. OpenRouter is only the +first dynamic catalog source; future sources such as ModelScope and ModelStudio +should fit the same shape. UI complexity can be reduced, but the underlying +source abstraction should stay available as the extension point. + +## Current Boundary + +This change should do the minimum needed to make OpenRouter auth and model setup +pleasant: + +- OAuth or key-based auth configures OpenRouter through the existing + OpenAI-compatible provider path. +- The initial enabled model set is curated instead of dumping the full catalog + into settings. +- Full catalog storage, browsing, filtering, and batch management are deferred to + `/manage-models`. + +The design principle is simple: authentication should get users to a working +state quickly, while model curation should live in a dedicated management flow. diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index b90795bc7..c99ce7e8d 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -50,6 +50,21 @@ const codePlanCommand = { }, }; +const openRouterCommand = { + command: 'openrouter', + describe: t('Authenticate using OpenRouter API key setup'), + builder: (yargs: Argv) => + yargs.option('key', { + alias: 'k', + describe: t('API key for OpenRouter'), + type: 'string', + }), + handler: async (argv: { key?: string }) => { + const key = argv['key'] as string | undefined; + await handleQwenAuth('openrouter', { key }); + }, +}; + const statusCommand = { command: 'status', describe: t('Show current authentication status'), @@ -61,12 +76,13 @@ const statusCommand = { export const authCommand: CommandModule = { command: 'auth', describe: t( - 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan', + 'Configure Qwen authentication information with Qwen-OAuth, OpenRouter, or Alibaba Cloud Coding Plan', ), builder: (yargs: Argv) => yargs .command(qwenOauthCommand) .command(codePlanCommand) + .command(openRouterCommand) .command(statusCommand) .demandCommand(0) // Don't require a subcommand .version(false), diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 25a7d44fa..099f5e639 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -12,18 +12,29 @@ import { } from '@qwen-code/qwen-code-core'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { t } from '../../i18n/index.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { getCodingPlanConfig, isCodingPlanConfig, CodingPlanRegion, CODING_PLAN_ENV_KEY, -} from '@qwen-code/qwen-code-core'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +} from '../../constants/codingPlan.js'; import { backupSettingsFile } from '../../utils/settingsUtils.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { loadCliConfig } from '../../config/config.js'; import type { CliArgs } from '../../config/config.js'; import { InteractiveSelector } from './interactiveSelector.js'; +import { + applyOpenRouterModelsConfiguration, + createOpenRouterOAuthSession, + isOpenRouterConfig, + OPENROUTER_ENV_KEY, + runOpenRouterOAuthLogin, +} from './openrouterOAuth.js'; + +function formatElapsedTime(startMs: number): string { + return `${((Date.now() - startMs) / 1000).toFixed(2)}s`; +} interface QwenAuthOptions { region?: string; @@ -53,7 +64,7 @@ interface MergedSettingsWithCodingPlan { * Handles the authentication process based on the specified command and options */ export async function handleQwenAuth( - command: 'qwen-oauth' | 'coding-plan', + command: 'qwen-oauth' | 'coding-plan' | 'openrouter', options: QwenAuthOptions, ) { try { @@ -126,6 +137,8 @@ export async function handleQwenAuth( await handleQwenOAuth(config, settings); } else if (command === 'coding-plan') { await handleCodePlanAuth(config, settings, options); + } else if (command === 'openrouter') { + await handleOpenRouterAuth(config, settings, options); } // Exit after authentication is complete @@ -192,7 +205,7 @@ async function handleCodePlanAuth( } else { // Otherwise, prompt interactively selectedRegion = await promptForRegion(); - selectedKey = await promptForKey(); + selectedKey = await promptForAuthKey(t('Enter your Coding Plan API key: ')); } writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); @@ -279,6 +292,105 @@ async function handleCodePlanAuth( } } +/** + * Handles OpenRouter API key setup. + */ +async function handleOpenRouterAuth( + config: Config, + settings: LoadedSettings, + options: QwenAuthOptions, +): Promise { + writeStdoutLine(t('Processing OpenRouter authentication...')); + + try { + const authStartMs = Date.now(); + let selectedKey = options.key; + + if (!selectedKey) { + const oauthStartMs = Date.now(); + const oauthSession = createOpenRouterOAuthSession(); + writeStdoutLine( + t( + 'Starting OpenRouter OAuth in your browser. If needed, open this link manually: {{authorizationUrl}}', + { + authorizationUrl: oauthSession.authorizationUrl, + }, + ), + ); + const oauthResult = await runOpenRouterOAuthLogin(undefined, { + session: oauthSession, + }); + writeStdoutLine( + t('Waited for OpenRouter browser authorization in {{elapsed}}.', { + elapsed: + typeof oauthResult.authorizationCodeWaitMs === 'number' + ? `${(oauthResult.authorizationCodeWaitMs / 1000).toFixed(2)}s` + : formatElapsedTime(oauthStartMs), + }), + ); + writeStdoutLine( + t('Exchanged OpenRouter auth code for API key in {{elapsed}}.', { + elapsed: + typeof oauthResult.apiKeyExchangeMs === 'number' + ? `${(oauthResult.apiKeyExchangeMs / 1000).toFixed(2)}s` + : formatElapsedTime(oauthStartMs), + }), + ); + writeStdoutLine( + t('OpenRouter OAuth callback completed in {{elapsed}}.', { + elapsed: formatElapsedTime(oauthStartMs), + }), + ); + selectedKey = oauthResult.apiKey; + } + + if (!selectedKey) { + throw new Error( + 'OpenRouter authentication completed without an API key.', + ); + } + + const authTypeScope = getPersistScopeForModelSelection(settings); + const settingsFile = settings.forScope(authTypeScope); + backupSettingsFile(settingsFile.path); + + const modelsStartMs = Date.now(); + await applyOpenRouterModelsConfiguration({ + settings, + config, + apiKey: selectedKey, + reloadConfig: true, + }); + writeStdoutLine( + t('Fetched OpenRouter models in {{elapsed}}.', { + elapsed: formatElapsedTime(modelsStartMs), + }), + ); + + const refreshStartMs = Date.now(); + await config.refreshAuth(AuthType.USE_OPENAI); + writeStdoutLine( + t('Refreshed OpenRouter auth in {{elapsed}}.', { + elapsed: formatElapsedTime(refreshStartMs), + }), + ); + writeStdoutLine( + t('Total OpenRouter setup time: {{elapsed}}.', { + elapsed: formatElapsedTime(authStartMs), + }), + ); + + writeStdoutLine(t('Successfully configured OpenRouter.')); + } catch (error) { + writeStderrLine( + t('Failed to configure OpenRouter: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} + /** * Prompts the user to select a region using an interactive selector */ @@ -305,12 +417,12 @@ async function promptForRegion(): Promise { /** * Prompts the user to enter an API key */ -async function promptForKey(): Promise { +async function promptForAuthKey(prompt: string): Promise { // Create a simple password-style input (without echoing characters) const stdin = process.stdin; const stdout = process.stdout; - stdout.write(t('Enter your Coding Plan API key: ')); + stdout.write(prompt); // Set raw mode to capture keystrokes const wasRaw = stdin.isRaw; @@ -370,6 +482,13 @@ async function promptForKey(): Promise { export async function runInteractiveAuth() { const selector = new InteractiveSelector( [ + { + value: 'openrouter' as const, + label: t('OpenRouter'), + description: t( + 'API key setup · OpenAI-compatible provider via OpenRouter', + ), + }, { value: 'coding-plan' as const, label: t('Alibaba Cloud Coding Plan'), @@ -400,6 +519,8 @@ export async function runInteractiveAuth() { if (choice === 'coding-plan') { await handleQwenAuth('coding-plan', {}); + } else if (choice === 'openrouter') { + await handleQwenAuth('openrouter', {}); } } @@ -419,6 +540,9 @@ export async function showAuthStatus(): Promise { if (!selectedType) { writeStdoutLine(t('⚠️ No authentication method configured.\n')); writeStdoutLine(t('Run one of the following commands to get started:\n')); + writeStdoutLine( + t(' qwen auth openrouter - Configure OpenRouter API key'), + ); writeStdoutLine( t( ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)', @@ -446,54 +570,83 @@ export async function showAuthStatus(): Promise { t('\n ⚠ Run /auth to switch to Coding Plan or another provider.\n'), ); } else if (selectedType === AuthType.USE_OPENAI) { - // Check for Coding Plan configuration const codingPlanRegion = mergedSettings.codingPlan?.region; const codingPlanVersion = mergedSettings.codingPlan?.version; const modelName = mergedSettings.model?.name; + const openAiProviders = + mergedSettings.modelProviders?.[AuthType.USE_OPENAI] || []; + const hasOpenRouterConfig = openAiProviders.some(isOpenRouterConfig); + const hasOpenRouterApiKey = + !!process.env[OPENROUTER_ENV_KEY] || + !!mergedSettings.env?.[OPENROUTER_ENV_KEY]; - // Check if API key is set in environment - const hasApiKey = - !!process.env[CODING_PLAN_ENV_KEY] || - !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; - - if (hasApiKey) { - writeStdoutLine( - t('✓ Authentication Method: Alibaba Cloud Coding Plan'), - ); - - if (codingPlanRegion) { - const regionDisplay = - codingPlanRegion === CodingPlanRegion.CHINA - ? t('中国 (China) - 阿里云百炼') - : t('Global - Alibaba Cloud'); - writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay })); - } - - if (modelName) { + if (hasOpenRouterConfig) { + if (hasOpenRouterApiKey) { + writeStdoutLine(t('✓ Authentication Method: OpenRouter')); + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + writeStdoutLine(t(' Status: API key configured\n')); + } else { writeStdoutLine( - t(' Current Model: {{model}}', { model: modelName }), + t('⚠️ Authentication Method: OpenRouter (Incomplete)'), ); - } - - if (codingPlanVersion) { writeStdoutLine( - t(' Config Version: {{version}}', { - version: codingPlanVersion.substring(0, 8) + '...', - }), + t(' Issue: API key not found in environment or settings\n'), ); + writeStdoutLine(t(' Run `qwen auth openrouter` to re-configure.\n')); } - - writeStdoutLine(t(' Status: API key configured\n')); } else { - writeStdoutLine( - t( - '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', - ), - ); - writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), - ); - writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n')); + // Check for Coding Plan configuration + const hasApiKey = + !!process.env[CODING_PLAN_ENV_KEY] || + !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + + if (hasApiKey) { + writeStdoutLine( + t('✓ Authentication Method: Alibaba Cloud Coding Plan'), + ); + + if (codingPlanRegion) { + const regionDisplay = + codingPlanRegion === CodingPlanRegion.CHINA + ? t('中国 (China) - 阿里云百炼') + : t('Global - Alibaba Cloud'); + writeStdoutLine( + t(' Region: {{region}}', { region: regionDisplay }), + ); + } + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + if (codingPlanVersion) { + writeStdoutLine( + t(' Config Version: {{version}}', { + version: codingPlanVersion.substring(0, 8) + '...', + }), + ); + } + + writeStdoutLine(t(' Status: API key configured\n')); + } else { + writeStdoutLine( + t( + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ), + ); + writeStdoutLine( + t(' Issue: API key not found in environment or settings\n'), + ); + writeStdoutLine( + t(' Run `qwen auth coding-plan` to re-configure.\n'), + ); + } } } else { writeStdoutLine( diff --git a/packages/cli/src/commands/auth/openrouter.test.ts b/packages/cli/src/commands/auth/openrouter.test.ts new file mode 100644 index 000000000..d4aedf05f --- /dev/null +++ b/packages/cli/src/commands/auth/openrouter.test.ts @@ -0,0 +1,304 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { handleQwenAuth } from './handler.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; + +const { + mockRefreshAuth, + mockSetValue, + mockForScope, + mockBackupSettingsFile, + mockLoadCliConfig, +} = vi.hoisted(() => { + const mockRefreshAuth = vi.fn(); + return { + mockRefreshAuth, + mockSetValue: vi.fn(), + mockForScope: vi.fn(() => ({ path: '/user.json' })), + mockBackupSettingsFile: vi.fn(), + mockLoadCliConfig: vi.fn(async () => ({ + refreshAuth: mockRefreshAuth, + })), + }; +}); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: vi.fn(), +})); + +vi.mock('../../config/config.js', () => ({ + loadCliConfig: mockLoadCliConfig, +})); + +vi.mock('../../utils/settingsUtils.js', () => ({ + backupSettingsFile: mockBackupSettingsFile, +})); + +vi.mock('../../config/modelProvidersScope.js', () => ({ + getPersistScopeForModelSelection: vi.fn(() => 'user'), +})); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: vi.fn(), + writeStderrLine: vi.fn(), +})); + +vi.mock('./openrouterOAuth.js', () => ({ + OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY', + OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback', + createOpenRouterOAuthSession: vi.fn(() => ({ + callbackUrl: 'http://localhost:3000/openrouter/callback', + codeVerifier: 'test-verifier', + authorizationUrl: 'https://openrouter.ai/auth?manual=1', + })), + applyOpenRouterModelsConfiguration: vi.fn(async ({ settings, apiKey }) => { + process.env['OPENROUTER_API_KEY'] = apiKey; + settings.setValue('user', 'env.OPENROUTER_API_KEY', apiKey); + settings.setValue( + 'user', + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + settings.setValue('user', 'model.name', 'openai/gpt-4o-mini:free'); + settings.setValue('user', `modelProviders.${AuthType.USE_OPENAI}`, [ + { + id: 'openai/gpt-4o-mini:free', + name: 'OpenRouter · GPT-4o mini', + 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: 'gpt-4.1', + name: 'OpenAI GPT-4.1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ]); + return { + updatedConfigs: [ + { + id: 'openai/gpt-4o-mini:free', + name: 'OpenRouter · GPT-4o mini', + 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: 'gpt-4.1', + name: 'OpenAI GPT-4.1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + activeModelId: 'openai/gpt-4o-mini:free', + persistScope: 'user', + }; + }), + runOpenRouterOAuthLogin: vi.fn(), +})); + +import { loadSettings } from '../../config/settings.js'; +import { + applyOpenRouterModelsConfiguration, + runOpenRouterOAuthLogin, +} from './openrouterOAuth.js'; + +describe('handleQwenAuth openrouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + delete process.env['OPENROUTER_API_KEY']; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env['OPENROUTER_API_KEY']; + }); + + const createMockSettings = ( + merged: Record, + ): LoadedSettings => + ({ + merged, + system: { settings: {}, path: '/system.json' }, + systemDefaults: { settings: {}, path: '/system-defaults.json' }, + user: { settings: {}, path: '/user.json' }, + workspace: { settings: {}, path: '/workspace.json' }, + forScope: mockForScope, + setValue: mockSetValue, + getUserHooks: vi.fn(() => []), + getProjectHooks: vi.fn(() => []), + isTrusted: true, + }) as unknown as LoadedSettings; + + it('stores OpenRouter key and model provider config', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + modelProviders: { + [AuthType.USE_OPENAI]: [ + { + id: 'gpt-4.1', + name: 'OpenAI GPT-4.1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ], + }, + }), + ); + + await handleQwenAuth('openrouter', { key: 'or-key-123' }); + + expect(mockBackupSettingsFile).toHaveBeenCalledWith('/user.json'); + expect(mockSetValue).toHaveBeenCalledWith( + 'user', + 'env.OPENROUTER_API_KEY', + 'or-key-123', + ); + expect(mockSetValue).toHaveBeenCalledWith( + 'user', + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + expect(mockSetValue).toHaveBeenCalledWith( + 'user', + 'model.name', + 'openai/gpt-4o-mini:free', + ); + + const modelProvidersCall = mockSetValue.mock.calls.find( + (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, + ); + expect(modelProvidersCall).toBeDefined(); + expect(modelProvidersCall?.[2]).toEqual([ + { + id: 'openai/gpt-4o-mini:free', + name: 'OpenRouter · GPT-4o mini', + 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: 'gpt-4.1', + name: 'OpenAI GPT-4.1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ]); + expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.anything(), + config: expect.anything(), + apiKey: 'or-key-123', + reloadConfig: true, + }), + ); + expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI); + expect(process.env['OPENROUTER_API_KEY']).toBe('or-key-123'); + }); + + it('replaces existing OpenRouter configs instead of duplicating them', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + modelProviders: { + [AuthType.USE_OPENAI]: [ + { + 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', + }, + ], + }, + }), + ); + + await handleQwenAuth('openrouter', { key: 'or-key-456' }); + + const modelProvidersCall = mockSetValue.mock.calls.find( + (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, + ); + expect(modelProvidersCall?.[2]).toEqual([ + { + id: 'openai/gpt-4o-mini:free', + name: 'OpenRouter · GPT-4o mini', + 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: 'gpt-4.1', + name: 'OpenAI GPT-4.1', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }, + ]); + }); + + it('uses OAuth flow when key is not provided', async () => { + vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); + vi.mocked(runOpenRouterOAuthLogin).mockResolvedValue({ + apiKey: 'oauth-key-123', + userId: 'user-1', + authorizationUrl: 'https://openrouter.ai/auth?manual=1', + }); + + await handleQwenAuth('openrouter', {}); + + expect(runOpenRouterOAuthLogin).toHaveBeenCalledTimes(1); + expect(mockSetValue).toHaveBeenCalledWith( + 'user', + 'env.OPENROUTER_API_KEY', + 'oauth-key-123', + ); + expect(process.env['OPENROUTER_API_KEY']).toBe('oauth-key-123'); + }); + + it('delegates OpenRouter provider updates to the shared configuration helper', async () => { + vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); + + await handleQwenAuth('openrouter', { key: 'or-key-dynamic' }); + + expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.anything(), + config: expect.anything(), + apiKey: 'or-key-dynamic', + reloadConfig: true, + }), + ); + }); +}); diff --git a/packages/cli/src/commands/auth/openrouterOAuth.test.ts b/packages/cli/src/commands/auth/openrouterOAuth.test.ts new file mode 100644 index 000000000..81fe89d77 --- /dev/null +++ b/packages/cli/src/commands/auth/openrouterOAuth.test.ts @@ -0,0 +1,730 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthType, type Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { + buildOpenRouterAuthorizationUrl, + createOpenRouterOAuthSession, + createOAuthState, + createPkcePair, + exchangeAuthCodeForApiKey, + fetchOpenRouterModels, + getOpenRouterModelsWithFallback, + getPreferredOpenRouterModelId, + mergeOpenRouterConfigs, + OPENROUTER_DEFAULT_MODELS, + OPENROUTER_MODELS_URL, + OPENROUTER_OAUTH_AUTHORIZE_URL, + OPENROUTER_OAUTH_EXCHANGE_URL, + runOpenRouterOAuthLogin, + selectRecommendedOpenRouterModels, + startOAuthCallbackListener, + applyOpenRouterModelsConfiguration, +} 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 = { + ready: Promise.resolve(), + waitForCode: Promise.resolve('auth-code-123'), + close: vi.fn( + () => + new Promise((resolve) => { + resolveClose = resolve; + }), + ), + }; + 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(() => 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 = { + ready: Promise.resolve(), + waitForCode: Promise.resolve('auth-code-123'), + close: vi.fn(async () => undefined), + }; + const openBrowser = vi.fn(async () => ({}) as never); + const startListener = vi.fn(() => 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', + }, + }); + + 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 = { + ready: Promise.resolve(), + waitForCode: Promise.resolve('auth-code-123'), + close: vi.fn(async () => undefined), + }; + 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: () => 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 = { + ready: Promise.resolve(), + waitForCode: new Promise(() => undefined), + close: vi.fn(async () => undefined), + }; + const openBrowser = vi.fn(async () => ({}) as never); + const exchangeApiKey = vi.fn(); + + const resultPromise = runOpenRouterOAuthLogin( + 'http://localhost:3000/openrouter/callback', + { + openBrowser, + startListener: () => 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 = { + ready: Promise.resolve(), + waitForCode: new Promise(() => undefined), + close: vi.fn(async () => undefined), + }; + const openBrowser = vi.fn(async () => ({}) as never); + const exchangeApiKey = vi.fn(); + + const resultPromise = runOpenRouterOAuthLogin( + 'http://localhost:3000/openrouter/callback', + { + openBrowser, + startListener: () => 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 a recommended OpenRouter subset instead of returning the full catalog', () => { + const recommended = selectRecommendedOpenRouterModels( + [ + { + id: 'qwen/qwen3-coder:free', + name: 'OpenRouter · Qwen3 Coder', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'qwen/qwen3-max', + name: 'OpenRouter · Qwen3 Max', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'glm/glm-4.5-air:free', + name: 'OpenRouter · GLM 4.5 Air', + 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: 'anthropic/claude-3.7-sonnet', + name: 'OpenRouter · Claude 3.7 Sonnet', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + { + id: 'google/gemini-2.5-flash', + name: 'OpenRouter · Gemini 2.5 Flash', + 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 }, + }, + { + id: 'deepseek/deepseek-r1', + name: 'OpenRouter · DeepSeek R1', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + generationConfig: { contextWindowSize: 1048576 }, + }, + { + id: 'meta/llama-3.3-70b', + name: 'OpenRouter · Llama 3.3 70B', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + 6, + ); + + expect(recommended.map((model) => model.id)).toEqual([ + 'qwen/qwen3-coder:free', + 'glm/glm-4.5-air:free', + 'qwen/qwen3-max', + 'minimax/minimax-m1', + 'anthropic/claude-3.7-sonnet', + 'google/gemini-2.5-flash', + ]); + }); + + it('applies OpenRouter configuration to settings and reloads providers', async () => { + const settings = { + merged: { + modelProviders: { + [AuthType.USE_OPENAI]: [ + { id: 'custom/model', baseUrl: 'https://example.com/v1' }, + ], + }, + }, + user: { settings: { modelProviders: {} }, path: '/user.json' }, + workspace: { settings: {}, path: '/workspace.json' }, + system: { settings: {}, path: '/system.json' }, + systemDefaults: { settings: {}, path: '/system-defaults.json' }, + setValue: vi.fn(), + forScope: vi.fn(), + } as unknown as LoadedSettings; + const config = { + reloadModelProvidersConfig: vi.fn(), + } as unknown as Config; + const fetchSpy = vi + .spyOn( + await import('./openrouterOAuth.js'), + 'getOpenRouterModelsWithFallback', + ) + .mockResolvedValue([ + { + id: 'openai/gpt-4o-mini', + name: 'OpenRouter · GPT-4o mini', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ]); + + const result = await applyOpenRouterModelsConfiguration({ + settings, + config, + apiKey: 'or-key-123', + reloadConfig: true, + }); + + expect(settings.setValue).toHaveBeenCalledWith( + expect.anything(), + 'env.OPENROUTER_API_KEY', + 'or-key-123', + ); + + const modelProvidersCall = vi + .mocked(settings.setValue) + .mock.calls.find( + (call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`, + ); + expect(modelProvidersCall).toBeDefined(); + expect(modelProvidersCall?.[2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }), + expect.objectContaining({ + id: 'custom/model', + baseUrl: 'https://example.com/v1', + }), + ]), + ); + + expect(config.reloadModelProvidersConfig).toHaveBeenCalled(); + expect(result.activeModelId).toBeDefined(); + fetchSpy.mockRestore(); + }); + + it('prefers the default OpenRouter model when it remains enabled', () => { + expect( + getPreferredOpenRouterModelId([ + { id: 'anthropic/claude-3.7-sonnet' }, + { id: 'openai/gpt-4o-mini' }, + ] as never), + ).toBe('openai/gpt-4o-mini'); + }); + + it('falls back to the first enabled OpenRouter model when the default is unavailable', () => { + expect( + getPreferredOpenRouterModelId([ + { id: 'anthropic/claude-3.7-sonnet' }, + ] as never), + ).toBe('anthropic/claude-3.7-sonnet'); + }); + + 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', + }, + ]); + }); +}); diff --git a/packages/cli/src/commands/auth/openrouterOAuth.ts b/packages/cli/src/commands/auth/openrouterOAuth.ts new file mode 100644 index 000000000..5d36da75b --- /dev/null +++ b/packages/cli/src/commands/auth/openrouterOAuth.ts @@ -0,0 +1,751 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createServer, type Server } from 'node:http'; +import { createHash, randomBytes } from 'node:crypto'; +import open from 'open'; + +import { + AuthType, + type Config, + type ModelProvidersConfig, + type ProviderModelConfig as ModelConfig, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; + +export const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; +export const OPENROUTER_DEFAULT_MODEL = 'openai/gpt-4o-mini'; +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_URL = + 'http://localhost:3000/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: 'openai/gpt-4o-mini', + name: 'OpenRouter · GPT-4o mini', + baseUrl: OPENROUTER_BASE_URL, + envKey: OPENROUTER_ENV_KEY, + }, + { + id: 'anthropic/claude-3.7-sonnet', + name: 'OpenRouter · Claude 3.7 Sonnet', + baseUrl: OPENROUTER_BASE_URL, + envKey: OPENROUTER_ENV_KEY, + }, + { + id: 'google/gemini-2.5-flash', + name: 'OpenRouter · Gemini 2.5 Flash', + baseUrl: OPENROUTER_BASE_URL, + envKey: OPENROUTER_ENV_KEY, + }, +]; + +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 function startOAuthCallbackListener( + callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, + timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS, + expectedState?: string, +): OAuthCallbackListener { + const parsedUrl = new URL(callbackUrl); + if (parsedUrl.protocol !== 'http:') { + throw new Error( + 'Only http localhost callback URLs are currently supported.', + ); + } + + 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 (expectedState && 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) => { + rejectReady(error instanceof Error ? error : new Error(String(error))); + void finish( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); + }); + + const port = parsedUrl.port ? Number(parsedUrl.port) : 80; + 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, + }; +} + +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_MODEL_PRIORITY_PREFIXES = ['qwen/', 'glm/', 'minimax/']; +const OPENROUTER_RECOMMENDED_MODEL_LIMIT = 16; +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 getOpenRouterModelPriority(modelId: string): number { + const normalizedId = modelId.toLowerCase(); + const matchedIndex = OPENROUTER_MODEL_PRIORITY_PREFIXES.findIndex((prefix) => + normalizedId.startsWith(prefix), + ); + return matchedIndex === -1 + ? OPENROUTER_MODEL_PRIORITY_PREFIXES.length + : matchedIndex; +} + +function isOpenRouterFreeConfig(model: ModelConfig): boolean { + return isOpenRouterFreeModelId(model.id); +} + +function compareOpenRouterModels(a: ModelConfig, b: ModelConfig): number { + const freeDiff = + Number(isOpenRouterFreeConfig(b)) - Number(isOpenRouterFreeConfig(a)); + if (freeDiff !== 0) { + return freeDiff; + } + + const priorityDiff = + getOpenRouterModelPriority(a.id) - getOpenRouterModelPriority(b.id); + if (priorityDiff !== 0) { + return priorityDiff; + } + + 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 chooseRepresentativeModel( + models: ModelConfig[], + predicate: (model: ModelConfig) => boolean, + selectedIds: Set, +): ModelConfig | undefined { + return models.find((model) => predicate(model) && !selectedIds.has(model.id)); +} + +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[] { + if (models.length <= limit) { + return models; + } + + const sorted = [...models].sort(compareOpenRouterModels); + const recommended: ModelConfig[] = []; + const selectedIds = new Set(); + + const freeModels = sorted.filter((model) => isOpenRouterFreeConfig(model)); + for (const model of freeModels.slice(0, Math.min(limit, 6))) { + addRecommendedModel(recommended, model, selectedIds, limit); + } + + for (const prefix of OPENROUTER_MODEL_PRIORITY_PREFIXES) { + addRecommendedModel( + recommended, + chooseRepresentativeModel( + sorted, + (model) => model.id.toLowerCase().startsWith(prefix), + selectedIds, + ), + selectedIds, + limit, + ); + } + + for (const family of ['anthropic/', 'google/', 'openai/']) { + addRecommendedModel( + recommended, + chooseRepresentativeModel( + sorted, + (model) => model.id.toLowerCase().startsWith(family), + selectedIds, + ), + selectedIds, + limit, + ); + } + + addRecommendedModel( + recommended, + chooseRepresentativeModel( + sorted, + (model) => model.capabilities?.vision === true, + selectedIds, + ), + selectedIds, + limit, + ); + + addRecommendedModel( + recommended, + chooseRepresentativeModel( + sorted, + (model) => (model.generationConfig?.contextWindowSize || 0) >= 1000000, + selectedIds, + ), + selectedIds, + limit, + ); + + 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 interface ApplyOpenRouterModelsResult { + updatedConfigs: ModelConfig[]; + activeModelId?: string; + persistScope: ReturnType; +} + +export async function applyOpenRouterModelsConfiguration(params: { + settings: LoadedSettings; + config: Config; + apiKey: string; + reloadConfig: boolean; +}): Promise { + const { settings, config, apiKey, reloadConfig } = params; + const persistScope = getPersistScopeForModelSelection(settings); + + settings.setValue(persistScope, `env.${OPENROUTER_ENV_KEY}`, apiKey); + process.env[OPENROUTER_ENV_KEY] = apiKey; + + const existingConfigs = + (settings.merged.modelProviders as ModelProvidersConfig | undefined)?.[ + AuthType.USE_OPENAI + ] || []; + const openRouterCatalog = await getOpenRouterModelsWithFallback(); + const openRouterModels = selectRecommendedOpenRouterModels(openRouterCatalog); + const updatedConfigs = mergeOpenRouterConfigs( + existingConfigs, + openRouterModels, + ); + + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + settings.setValue( + persistScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + const activeModelId = getPreferredOpenRouterModelId(updatedConfigs); + if (activeModelId) { + settings.setValue(persistScope, 'model.name', activeModelId); + } + + if (reloadConfig) { + const updatedModelProviders: ModelProvidersConfig = { + ...(settings.merged.modelProviders as ModelProvidersConfig | undefined), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + } + + return { + updatedConfigs, + activeModelId, + persistScope, + }; +} + +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; +} + +interface OpenRouterOAuthLoginDeps { + openBrowser?: typeof open; + startListener?: typeof startOAuthCallbackListener; + exchangeApiKey?: typeof exchangeAuthCodeForApiKey; + now?: () => number; + signalTarget?: OAuthSignalTarget; + abortSignal?: AbortSignal; + session?: OpenRouterOAuthSession; +} + +export async function runOpenRouterOAuthLogin( + callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL, + deps: OpenRouterOAuthLoginDeps = {}, +): Promise { + const session = deps.session || createOpenRouterOAuthSession(callbackUrl); + const { + callbackUrl: effectiveCallbackUrl, + codeVerifier, + state, + authorizationUrl: authUrl, + } = session; + + const openBrowser = deps.openBrowser || open; + const startListener = deps.startListener || startOAuthCallbackListener; + const exchangeApiKey = deps.exchangeApiKey || exchangeAuthCodeForApiKey; + const now = deps.now || Date.now; + const signalTarget = deps.signalTarget || process; + const abortSignal = deps.abortSignal; + + const listener = startListener( + effectiveCallbackUrl, + OPENROUTER_OAUTH_TIMEOUT_MS, + state, + ); + let cleanupSignalHandlers = () => {}; + let cleanupAbortListener = () => {}; + try { + await listener.ready; + 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/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index ca5a52718..b7b649a82 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -6,7 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { showAuthStatus } from './handler.js'; -import { AuthType, CODING_PLAN_ENV_KEY } from '@qwen-code/qwen-code-core'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../../config/settings.js', () => ({ @@ -26,11 +27,13 @@ describe('showAuthStatus', () => { vi.clearAllMocks(); vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); delete process.env[CODING_PLAN_ENV_KEY]; + delete process.env['OPENROUTER_API_KEY']; }); afterEach(() => { vi.restoreAllMocks(); delete process.env[CODING_PLAN_ENV_KEY]; + delete process.env['OPENROUTER_API_KEY']; }); const createMockSettings = ( @@ -55,6 +58,9 @@ describe('showAuthStatus', () => { expect(writeStdoutLine).toHaveBeenCalledWith( expect.stringContaining('No authentication method configured'), ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth openrouter'), + ); expect(writeStdoutLine).toHaveBeenCalledWith( expect.stringContaining('qwen auth qwen-oauth'), ); @@ -120,6 +126,77 @@ describe('showAuthStatus', () => { expect(process.exit).toHaveBeenCalledWith(0); }); + it('should show OpenRouter status when configured with API key', async () => { + process.env['OPENROUTER_API_KEY'] = 'test-openrouter-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + model: { + name: 'openai/gpt-4o-mini', + }, + modelProviders: { + [AuthType.USE_OPENAI]: [ + { + id: 'openai/gpt-4o-mini', + name: 'OpenRouter · GPT-4o mini', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('OpenRouter'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('openai/gpt-4o-mini'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('API key configured'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should show OpenRouter as incomplete when API key is missing', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + modelProviders: { + [AuthType.USE_OPENAI]: [ + { + id: 'openai/gpt-4o-mini', + name: 'OpenRouter · GPT-4o mini', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('OpenRouter (Incomplete)'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth openrouter'), + ); + }); + it('should show Coding Plan as incomplete when API key is missing', async () => { vi.mocked(loadSettings).mockReturnValue( createMockSettings({ diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d4b6e94bf..4cc0d50e5 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1301,6 +1301,8 @@ export default { 'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', 'Bring your own API key': 'Eigenen API-Schlüssel verwenden', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'Browserbasierte Authentifizierung mit externen Anbietern (z. B. OpenRouter, ModelScope)', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2a427bdb..268aa5048 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1361,6 +1361,8 @@ export default { 'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', 'Bring your own API key': 'Bring your own API key', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Use coding plan credentials or your own api-keys/providers.', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 138f7bf8d..433b61f4a 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1345,6 +1345,8 @@ export default { "Payant · Jusqu'à 6 000 requêtes/5h · Tous les modèles Alibaba Cloud Coding Plan", 'Alibaba Cloud Coding Plan': 'Plan de codage Alibaba Cloud', 'Bring your own API key': 'Apportez votre propre clé API', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'Authentification basée sur le navigateur avec des fournisseurs tiers (par exemple OpenRouter, ModelScope)', 'API-KEY': 'CLÉ-API', 'Use coding plan credentials or your own api-keys/providers.': 'Utilisez les identifiants du plan de codage ou vos propres clés API/fournisseurs.', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 2c7225639..94a4e5ffc 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1022,6 +1022,8 @@ export default { '有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', 'Bring your own API key': '自分のAPIキーを使用', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'サードパーティプロバイダーによるブラウザベースの認証(例:OpenRouter、ModelScope)', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index db681a19a..74e9630dc 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1309,6 +1309,8 @@ export default { 'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', 'Bring your own API key': 'Traga sua própria chave API', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'Autenticação baseada em navegador com provedores terceiros (por exemplo, OpenRouter, ModelScope)', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ed2af31b3..f2071ffed 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1231,6 +1231,8 @@ export default { 'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan', 'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan', 'Bring your own API key': 'Используйте свой API-ключ', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + 'Браузерная аутентификация с использованием сторонних провайдеров (например, OpenRouter, ModelScope)', 'API-KEY': 'API-KEY', 'Use coding plan credentials or your own api-keys/providers.': 'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 9460ba71d..5ab279b51 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1141,6 +1141,8 @@ export default { 'Alibaba Cloud Coding Plan': '阿里雲百鍊 Coding Plan', 'Bring your own API key': '使用自己的 API 密鑰', 'API-KEY': 'API-KEY', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + '基於瀏覽器的第三方提供商認證(例如 OpenRouter、ModelScope)', 'Use coding plan credentials or your own api-keys/providers.': '使用 Coding Plan 憑證或您自己的 API 密鑰/提供商。', OpenAI: 'OpenAI', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 4c3c98ba6..701732e87 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1291,6 +1291,8 @@ export default { '付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型', 'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan', 'Bring your own API key': '使用自己的 API 密钥', + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)': + '基于浏览器的第三方提供商认证(例如 OpenRouter、ModelScope)', 'Use coding plan credentials or your own api-keys/providers.': '使用 Coding Plan 凭证或您自己的 API 密钥/提供商。', OpenAI: 'OpenAI', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 9d7fd4722..086542695 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -36,6 +36,7 @@ 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'; @@ -118,6 +119,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : []), 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 0ecd6dd6c..9b15d302b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -191,9 +191,17 @@ describe('AppContainer State Management', () => { onAuthError: vi.fn(), isAuthDialogOpen: false, isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, handleAuthSelect: vi.fn(), handleCodingPlanSubmit: vi.fn(), handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), openAuthDialog: vi.fn(), cancelAuthentication: vi.fn(), }); @@ -1397,7 +1405,17 @@ describe('AppContainer State Management', () => { onAuthError: vi.fn(), isAuthDialogOpen: false, isAuthenticating: true, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: 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 c16e5305d..0e88f0c02 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -70,6 +70,7 @@ 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 { useResumeCommand } from './hooks/useResumeCommand.js'; @@ -518,10 +519,13 @@ export const AppContainer = (props: AppContainerProps) => { isAuthDialogOpen, isAuthenticating, pendingAuthType, + externalAuthState, qwenAuthState, handleAuthSelect, handleCodingPlanSubmit, handleAlibabaStandardSubmit, + handleOpenRouterSubmit, + handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, } = useAuthCommand(settings, config, historyManager.addItem, refreshStatic); @@ -591,6 +595,11 @@ export const AppContainer = (props: AppContainerProps) => { openModelDialog, closeModelDialog, } = useModelCommand(); + const { + isManageModelsDialogOpen, + openManageModelsDialog, + closeManageModelsDialog, + } = useManageModelsCommand(); const { activeArenaDialog, openArenaDialog, closeArenaDialog } = useArenaCommand(); @@ -656,6 +665,7 @@ export const AppContainer = (props: AppContainerProps) => { openMemoryDialog, openSettingsDialog, openModelDialog, + openManageModelsDialog, openTrustDialog, openArenaDialog, openPermissionsDialog, @@ -687,6 +697,7 @@ export const AppContainer = (props: AppContainerProps) => { openMemoryDialog, openSettingsDialog, openModelDialog, + openManageModelsDialog, openArenaDialog, setDebugMessage, dispatchExtensionStateUpdate, @@ -1551,6 +1562,7 @@ export const AppContainer = (props: AppContainerProps) => { isSettingsDialogOpen || isMemoryDialogOpen || isModelDialogOpen || + isManageModelsDialogOpen || isTrustDialogOpen || activeArenaDialog !== null || isPermissionsDialogOpen || @@ -2237,6 +2249,7 @@ export const AppContainer = (props: AppContainerProps) => { authError, isAuthDialogOpen, pendingAuthType, + externalAuthState, // Qwen OAuth state qwenAuthState, editorError, @@ -2247,6 +2260,7 @@ export const AppContainer = (props: AppContainerProps) => { isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, + isManageModelsDialogOpen, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, @@ -2356,6 +2370,7 @@ export const AppContainer = (props: AppContainerProps) => { authError, isAuthDialogOpen, pendingAuthType, + externalAuthState, // Qwen OAuth state qwenAuthState, editorError, @@ -2366,6 +2381,7 @@ export const AppContainer = (props: AppContainerProps) => { isMemoryDialogOpen, isModelDialogOpen, isFastModelMode, + isManageModelsDialogOpen, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, @@ -2484,12 +2500,16 @@ export const AppContainer = (props: AppContainerProps) => { cancelAuthentication, handleCodingPlanSubmit, handleAlibabaStandardSubmit, + handleOpenRouterSubmit, + handleCustomApiKeySubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, closeMemoryDialog, closeModelDialog, openModelDialog, + openManageModelsDialog, + closeManageModelsDialog, openArenaDialog, closeArenaDialog, handleArenaModelsSelected, @@ -2554,12 +2574,16 @@ export const AppContainer = (props: AppContainerProps) => { cancelAuthentication, handleCodingPlanSubmit, handleAlibabaStandardSubmit, + handleOpenRouterSubmit, + handleCustomApiKeySubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, 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 b540ae56e..ad62f46ff 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -34,6 +34,7 @@ const createMockUIActions = (overrides: Partial = {}): UIActions => { handleAuthSelect: vi.fn(), handleCodingPlanSubmit: vi.fn(), handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), onAuthError: vi.fn(), handleRetryLastPrompt: vi.fn(), } as Partial; @@ -69,6 +70,118 @@ const renderAuthDialog = ( ); }; +/** + * Type text into the terminal one character at a time. + * Works around a Node 24.x + ink compatibility issue on Windows + * where bulk stdin.write() may not propagate to TextInput correctly. + */ +const typeText = async ( + stdin: { write: (s: string) => void }, + text: string, +) => { + const delay = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + for (const char of text) { + stdin.write(char); + await delay(5); + } + await delay(30); +}; + +const escapeRegExp = (text: string) => + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const expectSelectedOption = (frame: string | undefined, label: string) => { + expect(frame).toMatch( + new RegExp(`›\\s*(?:\\d+\\.\\s*)?${escapeRegExp(label)}`), + ); +}; + +const waitForSelectedOption = async ( + lastFrame: () => string | undefined, + label: string, +) => { + await vi.waitFor(() => { + expectSelectedOption(lastFrame(), label); + }); +}; + +const pressEnterAndWaitFor = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, + expectedText: string, +) => { + stdin.write('\r'); + await vi.waitFor(() => { + expect(lastFrame()).toContain(expectedText); + }); +}; + +const moveDownAndWaitForSelection = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, + label: string, +) => { + stdin.write('\u001b[B'); + await waitForSelectedOption(lastFrame, label); +}; + +const navigateToCustomProtocolSelect = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, +) => { + await waitForSelectedOption(lastFrame, 'OAuth'); + await moveDownAndWaitForSelection( + stdin, + lastFrame, + 'Alibaba Cloud Coding Plan', + ); + await moveDownAndWaitForSelection(stdin, lastFrame, 'API Key'); + await pressEnterAndWaitFor(stdin, lastFrame, 'Select API Key Type'); + await waitForSelectedOption( + lastFrame, + 'Alibaba Cloud ModelStudio Standard API Key', + ); + await moveDownAndWaitForSelection(stdin, lastFrame, 'Custom API Key'); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 1/6 · Protocol'); +}; + +const navigateToCustomBaseUrlInput = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, +) => { + await navigateToCustomProtocolSelect(stdin, lastFrame); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 2/6 · Base URL'); +}; + +const navigateToCustomApiKeyInput = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, +) => { + await navigateToCustomBaseUrlInput(stdin, lastFrame); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 3/6 · API Key'); +}; + +const navigateToCustomModelIdInput = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, + apiKey = 'sk-test', +) => { + await navigateToCustomApiKeyInput(stdin, lastFrame); + await typeText(stdin, apiKey); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 4/6 · Model IDs'); +}; + +const navigateToCustomAdvancedConfig = async ( + stdin: { write: (s: string) => void }, + lastFrame: () => string | undefined, + apiKey = 'sk-test', + modelIds = 'model-1,model-2', +) => { + await navigateToCustomModelIdInput(stdin, lastFrame, apiKey); + await typeText(stdin, modelIds); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 5/6 · Advanced Config'); +}; + describe('AuthDialog', () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -308,8 +421,8 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); - // QWEN_OAUTH is the first option, so it should be selected - expect(lastFrame()).toContain('Qwen OAuth'); + // QWEN_OAUTH maps to 'OAUTH' in the new three-option main menu + expect(lastFrame()).toContain('OAuth'); }); it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => { @@ -391,8 +504,8 @@ describe('AuthDialog', () => { const { lastFrame } = renderAuthDialog(settings); // Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore, - // it will just show the default Qwen OAuth option - expect(lastFrame()).toContain('Qwen OAuth'); + // it will just show the default OAuth option + expect(lastFrame()).toContain('OAuth'); }); }); @@ -558,4 +671,524 @@ describe('AuthDialog', () => { expect(handleAuthSelect).toHaveBeenCalledWith(undefined); unmount(); }); + + it('should show OpenRouter in API key options', async () => { + 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); + await wait(); + + // OAuth is selected by default, press Enter to enter OAuth provider list + stdin.write('\r'); + await wait(); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('OpenRouter'); + expect(frame).toContain('Browser OAuth'); + }); + + unmount(); + }); + + it('should trigger OpenRouter OAuth from API key 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, unmount } = renderAuthDialog( + settings, + {}, + { handleOpenRouterSubmit }, + ); + await wait(); + + // OAuth is selected by default, press Enter to enter OAuth provider list + stdin.write('\r'); + await wait(); + // OpenRouter is the first option, press Enter to trigger OAuth + stdin.write('\r'); + await wait(); + + await vi.waitFor(() => { + expect(handleOpenRouterSubmit).toHaveBeenCalledTimes(1); + }); + + unmount(); + }); +}); + +const isUnreliableTuiInputEnvironment = + process.platform === 'win32' || + (process.env['CI'] === 'true' && process.version.startsWith('v20.')); +const itWhenTuiInputReliable = isUnreliableTuiInputEnvironment ? it.skip : it; + +describe('AuthDialog Custom API Key Wizard', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + const createStandardSettings = (): 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(), + ); + + itWhenTuiInputReliable( + 'navigates to protocol selection when Custom API Key is selected', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomProtocolSelect(stdin, lastFrame); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 1/6 · Protocol'); + expect(frame).toContain('OpenAI-compatible'); + expect(frame).toContain('Anthropic-compatible'); + expect(frame).toContain('Gemini-compatible'); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'navigates to base URL input after selecting a protocol', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomBaseUrlInput(stdin, lastFrame); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 2/6 · Base URL'); + expect(frame).toContain('Enter the API endpoint'); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'shows review screen with JSON after entering model IDs', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test-key-12345', + 'qwen/qwen3-coder,gpt-4.1', + ); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 6/6 · Review'); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 6/6 · Review'); + expect(frame).toContain('The following JSON will be saved'); + expect(frame).toContain('QWEN_CUSTOM_API_KEY_OPENAI'); + expect(frame).toContain('qwen/qwen3-coder'); + expect(frame).toContain('gpt-4.1'); + expect(frame).toContain('Enter to save'); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'calls handleCustomApiKeySubmit on Enter in review view', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1,model-2', + ); + await pressEnterAndWaitFor(stdin, lastFrame, 'Step 6/6 · Review'); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Enter to save'); + }); + + stdin.write('\r'); // Enter to save + await wait(); + + await vi.waitFor(() => { + expect(handleCustomApiKeySubmit).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + 'sk-test', + 'model-1,model-2', + undefined, + ); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'shows advanced config screen after entering model IDs', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1,model-2', + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 5/6 · Advanced Config'); + expect(frame).toContain( + 'Optional: configure advanced generation settings', + ); + expect(frame).toContain('Enable thinking'); + expect(frame).toContain('Enable modality'); + expect(frame).toContain('Enter to continue'); + }); + + unmount(); + }, + ); + + itWhenTuiInputReliable( + 'passes generationConfig when advanced options are toggled', + async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + + await navigateToCustomAdvancedConfig( + stdin, + lastFrame, + 'sk-test', + 'model-1', + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 5/6 · Advanced Config'); + }); + + // Toggle thinking (press Space — thinking is initially focused) + stdin.write(' '); + await wait(); + + // Navigate down to modality, toggle (press ↓ then Space) + stdin.write('\u001b[B'); + await wait(); + stdin.write(' '); + await wait(); + + // Press Enter to continue to review + stdin.write('\r'); + await wait(); + + // Verify review includes generationConfig + 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'); + }); + + // Press Enter to save + stdin.write('\r'); + await wait(); + + await vi.waitFor(() => { + expect(handleCustomApiKeySubmit).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + 'sk-test', + 'model-1', + { + enableThinking: true, + multimodal: { + image: true, + video: true, + audio: true, + }, + }, + ); + }); + + unmount(); + }, + ); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index f7fb402b2..4d32b003f 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -26,6 +26,11 @@ import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, type AlibabaStandardRegion, } from '../../constants/alibabaStandardApiKey.js'; +import { + generateCustomApiKeyEnvKey, + normalizeCustomModelIds, + maskApiKey, +} from './useAuth.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/'; @@ -43,8 +48,15 @@ function parseDefaultAuthType( } // Main menu option type -type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY'; -type ApiKeyOption = 'ALIBABA_STANDARD_API_KEY' | 'CUSTOM_API_KEY'; +type MainOption = 'OAUTH' | 'CODING_PLAN' | 'API_KEY'; +type ApiKeyOption = + | 'OPENROUTER_OAUTH' + | 'ALIBABA_STANDARD_API_KEY' + | 'CUSTOM_API_KEY'; +type OAuthOption = + | 'OPENROUTER_OAUTH' + | 'MODELSCOPE_OAUTH' + | 'QWEN_OAUTH_DISCONTINUED'; // View level for navigation type ViewLevel = @@ -55,7 +67,13 @@ type ViewLevel = | 'alibaba-standard-region-select' | 'alibaba-standard-api-key-input' | 'alibaba-standard-model-id-input' - | 'custom-info'; + | 'custom-protocol-select' + | 'custom-base-url-input' + | 'custom-api-key-input' + | 'custom-model-id-input' + | 'custom-advanced-config' + | 'custom-review-json' + | 'oauth-provider-select'; const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5'; const ALIBABA_STANDARD_API_DOCUMENTATION_URLS: Record< @@ -77,6 +95,8 @@ export function AuthDialog(): React.JSX.Element { handleAuthSelect: onAuthSelect, handleCodingPlanSubmit, handleAlibabaStandardSubmit, + handleOpenRouterSubmit, + handleCustomApiKeySubmit, onAuthError, } = useUIActions(); const config = useConfig(); @@ -90,6 +110,7 @@ export function AuthDialog(): React.JSX.Element { const [alibabaStandardRegionIndex, setAlibabaStandardRegionIndex] = useState(0); const [apiKeyTypeIndex, setApiKeyTypeIndex] = useState(0); + const [oauthProviderIndex, setOAuthProviderIndex] = useState(0); const [alibabaStandardRegion, setAlibabaStandardRegion] = useState('cn-beijing'); const [alibabaStandardApiKey, setAlibabaStandardApiKey] = useState(''); @@ -100,6 +121,30 @@ export function AuthDialog(): React.JSX.Element { const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] = useState(null); + // Custom API Key wizard state + const [customProtocolIndex, setCustomProtocolIndex] = useState(0); + const [customProtocol, setCustomProtocol] = useState( + AuthType.USE_OPENAI, + ); + const [customBaseUrl, setCustomBaseUrl] = useState(''); + const [customBaseUrlError, setCustomBaseUrlError] = useState( + null, + ); + const [customApiKey, setCustomApiKey] = useState(''); + const [customApiKeyError, setCustomApiKeyError] = useState( + null, + ); + const [customModelIds, setCustomModelIds] = useState(''); + const [customModelIdsError, setCustomModelIdsError] = useState( + null, + ); + + // Advanced generation config state + const [advancedThinkingEnabled, setAdvancedThinkingEnabled] = useState(false); + const [advancedModalityEnabled, setAdvancedModalityEnabled] = useState(false); + const [focusedConfigIndex, setFocusedConfigIndex] = useState(0); + // 0 = thinking, 1 = modality + // Main authentication entries (flat three-option layout) const mainItems = [ { @@ -119,11 +164,13 @@ export function AuthDialog(): React.JSX.Element { value: 'API_KEY' as MainOption, }, { - key: AuthType.QWEN_OAUTH, - title: t('Qwen OAuth'), - label: t('Qwen OAuth'), - description: t('Discontinued — switch to Coding Plan or API Key'), - value: AuthType.QWEN_OAUTH as MainOption, + key: 'OAUTH', + title: t('OAuth'), + label: t('OAuth'), + description: t( + 'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)', + ), + value: 'OAUTH' as MainOption, }, ]; @@ -210,6 +257,38 @@ export function AuthDialog(): React.JSX.Element { }, ]; + const protocolItems = [ + { + key: AuthType.USE_OPENAI, + title: t('OpenAI-compatible'), + label: t('OpenAI-compatible'), + description: t( + 'OpenAI Chat Completions API (OpenRouter, vLLM, Ollama, LM Studio, Fireworks, etc.)', + ), + value: AuthType.USE_OPENAI as AuthType, + }, + { + key: AuthType.USE_ANTHROPIC, + title: t('Anthropic-compatible'), + label: t('Anthropic-compatible'), + description: t('Anthropic Messages API'), + value: AuthType.USE_ANTHROPIC as AuthType, + }, + { + key: AuthType.USE_GEMINI, + title: t('Gemini-compatible'), + label: t('Gemini-compatible'), + description: t('Google Gemini API'), + value: AuthType.USE_GEMINI as AuthType, + }, + ]; + + const DEFAULT_CUSTOM_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', + }; + const apiKeyTypeItems = [ { key: 'ALIBABA_STANDARD_API_KEY', @@ -229,8 +308,36 @@ export function AuthDialog(): React.JSX.Element { }, ]; + const oauthProviderItems = [ + { + key: 'OPENROUTER_OAUTH', + title: t('OpenRouter'), + label: t('OpenRouter'), + description: t( + 'Browser OAuth · Auto-configure API key and OpenRouter models', + ), + value: 'OPENROUTER_OAUTH' as OAuthOption, + }, + { + key: 'MODELSCOPE_OAUTH', + title: t('ModelScope'), + label: t('ModelScope'), + description: t( + 'Browser OAuth · Auto-configure API key and ModelScope models', + ), + value: 'MODELSCOPE_OAUTH' as OAuthOption, + }, + { + key: 'QWEN_OAUTH_DISCONTINUED', + title: t('Qwen'), + label: t('Qwen'), + description: t('Discontinued — switch to Coding Plan or API Key'), + value: 'QWEN_OAUTH_DISCONTINUED' as OAuthOption, + }, + ]; + // Map an AuthType to the corresponding main menu option. - // QWEN_OAUTH maps directly; USE_OPENAI maps to: + // QWEN_OAUTH maps to 'OAUTH'; USE_OPENAI maps to: // - CODING_PLAN when current config matches coding plan // - API_KEY for other OpenAI / Anthropic / Gemini-compatible configs const contentGenConfig = config.getContentGeneratorConfig(); @@ -240,7 +347,7 @@ export function AuthDialog(): React.JSX.Element { contentGenConfig?.apiKeyEnvKey, ) !== false; const authTypeToMainOption = (authType: AuthType): MainOption => { - if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH; + if (authType === AuthType.QWEN_OAUTH) return 'OAUTH'; if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) { return 'CODING_PLAN'; } @@ -269,8 +376,8 @@ export function AuthDialog(): React.JSX.Element { return item.value === authTypeToMainOption(defaultAuthType); } - // Priority 4: default to QWEN_OAUTH - return item.value === AuthType.QWEN_OAUTH; + // Priority 4: default to OAUTH + return item.value === 'OAUTH'; }), ); @@ -289,13 +396,8 @@ export function AuthDialog(): React.JSX.Element { return; } - // Qwen OAuth free tier discontinued — show warning instead of proceeding - if (value === AuthType.QWEN_OAUTH) { - setErrorMessage( - t( - 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.', - ), - ); + if (value === 'OAUTH') { + setViewLevel('oauth-provider-select'); return; } @@ -313,7 +415,53 @@ export function AuthDialog(): React.JSX.Element { return; } - setViewLevel('custom-info'); + // Reset custom wizard state and go to protocol selection + setCustomProtocolIndex(0); + setCustomProtocol(AuthType.USE_OPENAI); + setCustomBaseUrl(''); + setCustomBaseUrlError(null); + setCustomApiKey(''); + setCustomApiKeyError(null); + setCustomModelIds(''); + setCustomModelIdsError(null); + setAdvancedThinkingEnabled(false); + setAdvancedModalityEnabled(false); + setFocusedConfigIndex(0); + setViewLevel('custom-protocol-select'); + }; + + const handleOAuthProviderSelect = async (value: OAuthOption) => { + setErrorMessage(null); + onAuthError(null); + + if (value === 'OPENROUTER_OAUTH') { + await handleOpenRouterSubmit(); + return; + } + + // Qwen OAuth free tier discontinued — show warning instead of proceeding + if (value === 'QWEN_OAUTH_DISCONTINUED') { + setErrorMessage( + t( + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.', + ), + ); + return; + } + + // Future: Add support for ModelScope OAuth when implemented + if (value === 'MODELSCOPE_OAUTH') { + // Currently not implemented, show message + setErrorMessage( + t( + 'ModelScope OAuth is not yet implemented. Please select another option.', + ), + ); + return; + } + + // For other OAuth providers, you can extend the functionality here + await onAuthSelect(AuthType.USE_OPENAI); }; const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => { @@ -381,6 +529,89 @@ export function AuthDialog(): React.JSX.Element { ); }; + const handleCustomProtocolSelect = (protocol: AuthType) => { + setErrorMessage(null); + onAuthError(null); + setCustomProtocol(protocol); + const defaultUrl = DEFAULT_CUSTOM_BASE_URLS[protocol] ?? ''; + setCustomBaseUrl(defaultUrl); + setCustomBaseUrlError(null); + setViewLevel('custom-base-url-input'); + }; + + const handleCustomBaseUrlSubmit = () => { + const trimmedUrl = customBaseUrl.trim(); + if (!trimmedUrl) { + setCustomBaseUrlError(t('Base URL cannot be empty.')); + return; + } + if (!/^https?:\/\//i.test(trimmedUrl)) { + setCustomBaseUrlError(t('Base URL must start with http:// or https://.')); + return; + } + setCustomBaseUrlError(null); + setCustomApiKey(''); + setCustomApiKeyError(null); + setViewLevel('custom-api-key-input'); + }; + + const handleCustomApiKeySubmitLocal = () => { + const trimmedKey = customApiKey.trim(); + if (!trimmedKey) { + setCustomApiKeyError(t('API key cannot be empty.')); + return; + } + setCustomApiKeyError(null); + setCustomModelIds(''); + setCustomModelIdsError(null); + setViewLevel('custom-model-id-input'); + }; + + const handleCustomModelIdSubmit = () => { + const normalized = normalizeCustomModelIds(customModelIds); + if (normalized.length === 0) { + setCustomModelIdsError(t('Model IDs cannot be empty.')); + return; + } + setCustomModelIdsError(null); + setViewLevel('custom-advanced-config'); + }; + + const handleAdvancedConfigSubmit = () => { + setViewLevel('custom-review-json'); + }; + + const handleCustomReviewSubmit = () => { + const trimmedBaseUrl = customBaseUrl.trim(); + const trimmedApiKey = customApiKey.trim(); + const trimmedModelIds = customModelIds; + + // Build generationConfig only if any advanced option is set + const hasThinking = advancedThinkingEnabled; + const hasModality = advancedModalityEnabled; + + const generationConfig = + hasThinking || hasModality + ? { + enableThinking: hasThinking ? true : undefined, + multimodal: hasModality + ? { image: true, video: true, audio: true } + : undefined, + } + : undefined; + + void handleCustomApiKeySubmit( + customProtocol as + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + trimmedBaseUrl, + trimmedApiKey, + trimmedModelIds, + generationConfig, + ); + }; + const handleGoBack = () => { setErrorMessage(null); onAuthError(null); @@ -391,14 +622,26 @@ export function AuthDialog(): React.JSX.Element { setViewLevel('region-select'); } else if (viewLevel === 'api-key-type-select') { setViewLevel('main'); - } else if (viewLevel === 'custom-info') { + } else if (viewLevel === 'custom-protocol-select') { setViewLevel('api-key-type-select'); + } else if (viewLevel === 'custom-base-url-input') { + setViewLevel('custom-protocol-select'); + } else if (viewLevel === 'custom-api-key-input') { + setViewLevel('custom-base-url-input'); + } else if (viewLevel === 'custom-model-id-input') { + setViewLevel('custom-api-key-input'); + } else if (viewLevel === 'custom-advanced-config') { + setViewLevel('custom-model-id-input'); + } else if (viewLevel === 'custom-review-json') { + setViewLevel('custom-advanced-config'); } else if (viewLevel === 'alibaba-standard-region-select') { setViewLevel('api-key-type-select'); } else if (viewLevel === 'alibaba-standard-api-key-input') { setViewLevel('alibaba-standard-region-select'); } else if (viewLevel === 'alibaba-standard-model-id-input') { setViewLevel('alibaba-standard-api-key-input'); + } else if (viewLevel === 'oauth-provider-select') { + setViewLevel('main'); } }; @@ -411,7 +654,18 @@ export function AuthDialog(): React.JSX.Element { return; } - if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + if (viewLevel === 'api-key-input') { + handleGoBack(); + return; + } + if ( + viewLevel === 'custom-protocol-select' || + viewLevel === 'custom-base-url-input' || + viewLevel === 'custom-api-key-input' || + viewLevel === 'custom-model-id-input' || + viewLevel === 'custom-advanced-config' || + viewLevel === 'custom-review-json' + ) { handleGoBack(); return; } @@ -419,7 +673,8 @@ export function AuthDialog(): React.JSX.Element { viewLevel === 'api-key-type-select' || viewLevel === 'alibaba-standard-region-select' || viewLevel === 'alibaba-standard-api-key-input' || - viewLevel === 'alibaba-standard-model-id-input' + viewLevel === 'alibaba-standard-model-id-input' || + viewLevel === 'oauth-provider-select' ) { handleGoBack(); return; @@ -443,6 +698,50 @@ export function AuthDialog(): React.JSX.Element { { isActive: true }, ); + // Handle Enter key for review view to save + useKeypress( + (key) => { + if (key.name === 'return' && viewLevel === 'custom-review-json') { + handleCustomReviewSubmit(); + } + }, + { isActive: true }, + ); + + // Advanced config keypress: ↑↓ to navigate, Space to toggle, Enter to submit + useKeypress( + (key) => { + if (viewLevel !== 'custom-advanced-config') return; + + const { name } = key; + + if (name === 'up') { + setFocusedConfigIndex((v) => (v <= 0 ? 1 : v - 1)); + return; + } + + if (name === 'down') { + setFocusedConfigIndex((v) => (v >= 1 ? 0 : v + 1)); + return; + } + + if (name === 'space') { + if (focusedConfigIndex === 0) { + setAdvancedThinkingEnabled((v) => !v); + } else { + setAdvancedModalityEnabled((v) => !v); + } + return; + } + + if (name === 'return') { + handleAdvancedConfigSubmit(); + return; + } + }, + { isActive: true }, + ); + // Render main auth selection const renderMainView = () => ( <> @@ -625,26 +924,294 @@ export function AuthDialog(): React.JSX.Element { ); - // Render custom mode info - const renderCustomInfoView = () => ( + // Render custom protocol selection + const renderCustomProtocolSelectView = () => ( <> + + { + const index = protocolItems.findIndex( + (item) => item.value === value, + ); + setCustomProtocolIndex(index); + }} + itemGap={1} + /> + + + + {t('Enter to select, ↑↓ to navigate, Esc to go back')} + + + + ); + + // Render custom base URL input + const renderCustomBaseUrlInputView = () => ( + - {t('You can configure your API key and models in settings.json')} + {t('Enter the API endpoint for this protocol.')} - {t('Refer to the documentation for setup instructions')} + { + setCustomBaseUrl(value); + if (customBaseUrlError) { + setCustomBaseUrlError(null); + } + }} + onSubmit={handleCustomBaseUrlSubmit} + placeholder="https://api.openai.com/v1" + /> - + {customBaseUrlError && ( + + {customBaseUrlError} + + )} + - {MODEL_PROVIDERS_DOCUMENTATION_URL} + {t( + 'Need advanced generationConfig or capabilities? See documentation', + )} - {t('Esc to go back')} + + {t('Enter to submit, Esc to go back')} + + + + ); + + // Render custom API key input + const renderCustomApiKeyInputView = () => ( + + + + {t('Enter the API key for this endpoint.')} + + + + { + setCustomApiKey(value); + if (customApiKeyError) { + setCustomApiKeyError(null); + } + }} + onSubmit={handleCustomApiKeySubmitLocal} + placeholder="sk-..." + /> + + {customApiKeyError && ( + + {customApiKeyError} + + )} + + + {t('Enter to submit, Esc to go back')} + + + + ); + + // Render custom model ID input + const renderCustomModelIdInputView = () => ( + + + + {t('Enter one or more model IDs, separated by commas.')} + + + + { + setCustomModelIds(value); + if (customModelIdsError) { + setCustomModelIdsError(null); + } + }} + onSubmit={handleCustomModelIdSubmit} + placeholder="qwen/qwen3-coder,openai/gpt-4.1" + /> + + {customModelIdsError && ( + + {customModelIdsError} + + )} + + + {t('Enter to submit, Esc to go back')} + + + + ); + + // Render custom advanced config + const renderCustomAdvancedConfigView = () => { + const checkmark = (v: boolean) => (v ? '◉' : '○'); + const cursor = (index: number) => + focusedConfigIndex === index ? '›' : ' '; + + return ( + + + + {t('Optional: configure advanced generation settings.')} + + + + + {cursor(0)} {checkmark(advancedThinkingEnabled)}{' '} + {t('Enable thinking')} + + + + + {t( + 'Allows the model to perform extended reasoning before responding.', + )} + + + + + {cursor(1)} {checkmark(advancedModalityEnabled)}{' '} + {t('Enable modality')} + + + + + {t('Enables image, video, and audio input/output capabilities.')} + + + + + {t( + '\u2191\u2193 to navigate, Space to toggle, Enter to continue, Esc to go back', + )} + + + + ); + }; + + // Render custom review JSON + const renderCustomReviewJsonView = () => { + const generatedEnvKey = generateCustomApiKeyEnvKey( + customProtocol, + customBaseUrl.trim(), + ); + const normalizedIds = normalizeCustomModelIds(customModelIds); + const maskedKey = maskApiKey(customApiKey); + + // Build generationConfig preview lines + const hasThinking = advancedThinkingEnabled; + const hasModality = advancedModalityEnabled; + const hasGenConfig = hasThinking || hasModality; + + let genConfig: Record | undefined; + if (hasGenConfig) { + genConfig = {}; + if (hasModality) { + genConfig['modalities'] = { + image: true, + video: true, + audio: true, + }; + } + if (hasThinking) { + genConfig['extra_body'] = { + enable_thinking: true, + }; + } + } + + const modelEntries = normalizedIds.map((id) => { + const entry: Record = { + id, + name: id, + baseUrl: customBaseUrl.trim(), + envKey: generatedEnvKey, + }; + if (genConfig) { + entry['generationConfig'] = genConfig; + } + return entry; + }); + + const preview = { + env: { [generatedEnvKey]: maskedKey }, + modelProviders: { + [customProtocol]: modelEntries, + }, + security: { + auth: { + selectedType: customProtocol, + }, + }, + model: { + name: normalizedIds[0], + }, + }; + + const jsonPreview = JSON.stringify(preview, null, 2); + + return ( + + + + {t('The following JSON will be saved to settings.json:')} + + + + {jsonPreview} + + + + {t('Enter to save, Esc to go back')} + + + + ); + }; + + const renderOAuthProviderSelectView = () => ( + <> + + { + const index = oauthProviderItems.findIndex( + (item) => item.value === value, + ); + setOAuthProviderIndex(index); + }} + itemGap={1} + /> + + + + {t('Enter to select, ↑↓ to navigate, Esc to go back')} + ); @@ -659,8 +1226,18 @@ export function AuthDialog(): React.JSX.Element { return t('Enter Coding Plan API Key'); case 'api-key-type-select': return t('Select API Key Type'); - case 'custom-info': - return t('Custom Configuration'); + case 'custom-protocol-select': + return t('Step 1/6 \u00B7 Protocol'); + case 'custom-base-url-input': + return t('Step 2/6 \u00B7 Base URL'); + case 'custom-api-key-input': + return t('Step 3/6 \u00B7 API Key'); + case 'custom-model-id-input': + return t('Step 4/6 \u00B7 Model IDs'); + case 'custom-advanced-config': + return t('Step 5/6 \u00B7 Advanced Config'); + case 'custom-review-json': + return t('Step 6/6 \u00B7 Review'); case 'alibaba-standard-region-select': return t( 'Select Region for Alibaba Cloud ModelStudio Standard API Key', @@ -669,6 +1246,8 @@ export function AuthDialog(): React.JSX.Element { return t('Enter Alibaba Cloud ModelStudio Standard API Key'); case 'alibaba-standard-model-id-input': return t('Enter Model IDs'); + case 'oauth-provider-select': + return t('Select OAuth Provider'); default: return t('Select Authentication Method'); } @@ -694,7 +1273,15 @@ export function AuthDialog(): React.JSX.Element { renderAlibabaStandardApiKeyInputView()} {viewLevel === 'alibaba-standard-model-id-input' && renderAlibabaStandardModelIdInputView()} - {viewLevel === 'custom-info' && renderCustomInfoView()} + {viewLevel === 'custom-protocol-select' && + renderCustomProtocolSelectView()} + {viewLevel === 'custom-base-url-input' && renderCustomBaseUrlInputView()} + {viewLevel === 'custom-api-key-input' && renderCustomApiKeyInputView()} + {viewLevel === 'custom-model-id-input' && renderCustomModelIdInputView()} + {viewLevel === 'custom-advanced-config' && + renderCustomAdvancedConfigView()} + {viewLevel === 'custom-review-json' && renderCustomReviewJsonView()} + {viewLevel === 'oauth-provider-select' && renderOAuthProviderSelectView()} {(authError || errorMessage) && ( diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts new file mode 100644 index 000000000..53ca65b86 --- /dev/null +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { + useAuthCommand, + generateCustomApiKeyEnvKey, + normalizeCustomModelIds, + maskApiKey, +} from './useAuth.js'; +import { + OPENROUTER_OAUTH_CALLBACK_URL, + applyOpenRouterModelsConfiguration, + createOpenRouterOAuthSession, + runOpenRouterOAuthLogin, +} from '../../commands/auth/openrouterOAuth.js'; + +vi.mock('../hooks/useQwenAuth.js', () => ({ + useQwenAuth: vi.fn(() => ({ + qwenAuthState: {}, + cancelQwenAuth: vi.fn(), + })), +})); + +vi.mock('../../utils/settingsUtils.js', () => ({ + backupSettingsFile: vi.fn(), +})); + +vi.mock('../../config/modelProvidersScope.js', () => ({ + getPersistScopeForModelSelection: vi.fn(() => 'user'), +})); + +vi.mock('../../commands/auth/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', + })), + applyOpenRouterModelsConfiguration: vi.fn(async () => ({ + updatedConfigs: [ + { + id: 'openai/gpt-4o-mini:free', + name: 'OpenRouter · GPT-4o mini', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'OPENROUTER_API_KEY', + }, + ], + activeModelId: 'openai/gpt-4o-mini:free', + persistScope: 'user', + })), + runOpenRouterOAuthLogin: vi.fn( + () => new Promise(() => undefined) as Promise<{ apiKey: string }>, + ), +})); + +const createSettings = () => ({ + merged: { + modelProviders: {}, + }, + setValue: vi.fn(), + forScope: vi.fn(() => ({ + path: '/tmp/settings.json', + })), +}); + +const createConfig = () => ({ + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + getUsageStatisticsEnabled: vi.fn(() => false), + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(async () => undefined), +}); + +describe('useAuthCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('closes auth dialog immediately when starting OpenRouter OAuth', async () => { + const settings = createSettings(); + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + 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'), + }); + expect(result.current.isAuthDialogOpen).toBe(false); + expect(addItem).not.toHaveBeenCalled(); + }); + + it('cancels OpenRouter OAuth wait and reopens the auth dialog', async () => { + const settings = createSettings(); + const config = createConfig(); + const addItem = vi.fn(); + + const { result } = renderHook(() => + 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), + ); + + 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', + }); + + const { result } = renderHook(() => + useAuthCommand(settings as never, config as never, addItem), + ); + + await act(async () => { + await result.current.handleOpenRouterSubmit(); + }); + + expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.anything(), + config: expect.anything(), + apiKey: 'oauth-key-123', + reloadConfig: true, + }), + ); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Successfully configured OpenRouter.' }), + expect.any(Number), + ); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Use /model to switch models.' }), + expect.any(Number), + ); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Want more OpenRouter models? Use /manage-models to browse and enable them.', + }), + expect.any(Number), + ); + }); +}); + +describe('generateCustomApiKeyEnvKey', () => { + it('generates env key from openai protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'https://api.openai.com/v1', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_OPENAI_COM_V1'); + }); + + it('generates env key from anthropic protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'anthropic', + 'https://api.anthropic.com/v1', + ); + expect(key).toBe( + 'QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1', + ); + }); + + it('generates env key from gemini protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'gemini', + 'https://generativelanguage.googleapis.com', + ); + expect(key).toBe( + 'QWEN_CUSTOM_API_KEY_GEMINI_HTTPS_GENERATIVELANGUAGE_GOOGLEAPIS_COM', + ); + }); + + it('handles localhost URLs', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'http://localhost:11434/v1', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_11434_V1'); + }); + + it('normalizes trailing slashes and special chars', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'https://openrouter.ai/api/v1/', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1'); + }); + + it('different protocols with same base URL produce different keys', () => { + const baseUrl = 'https://api.example.com/v1'; + const openaiKey = generateCustomApiKeyEnvKey('openai', baseUrl); + const anthropicKey = generateCustomApiKeyEnvKey('anthropic', baseUrl); + expect(openaiKey).not.toBe(anthropicKey); + expect(openaiKey).toContain('OPENAI'); + expect(anthropicKey).toContain('ANTHROPIC'); + }); +}); + +describe('normalizeCustomModelIds', () => { + it('splits comma-separated model IDs', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder,openai/gpt-4.1'); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('trims whitespace from each model ID', () => { + const result = normalizeCustomModelIds( + ' qwen/qwen3-coder , openai/gpt-4.1 ', + ); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('deduplicates while preserving order', () => { + const result = normalizeCustomModelIds( + 'qwen/qwen3-coder,openai/gpt-4.1,qwen/qwen3-coder', + ); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('removes empty entries', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder,,openai/gpt-4.1'); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('returns empty array for empty input', () => { + const result = normalizeCustomModelIds(''); + expect(result).toEqual([]); + }); + + it('returns empty array for whitespace-only input', () => { + const result = normalizeCustomModelIds(' , , '); + expect(result).toEqual([]); + }); + + it('handles single model ID', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder'); + expect(result).toEqual(['qwen/qwen3-coder']); + }); +}); + +describe('maskApiKey', () => { + it('masks a standard API key showing first 3 and last 4 chars', () => { + const result = maskApiKey('sk-or-v1-1234567890abcdef'); + expect(result).toBe('sk-...cdef'); + }); + + it('shows placeholder for empty string', () => { + const result = maskApiKey(''); + expect(result).toBe('(not set)'); + }); + + it('masks short keys with asterisks', () => { + const result = maskApiKey('abc'); + expect(result).toBe('***'); + }); + + it('masks 6-char keys with asterisks', () => { + const result = maskApiKey('abcdef'); + expect(result).toBe('***'); + }); + + it('trims whitespace before masking', () => { + const result = maskApiKey(' sk-or-v1-1234567890abcdef '); + expect(result).toBe('sk-...cdef'); + }); +}); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index eb32d7f87..c16c6060e 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -39,6 +39,53 @@ import { DASHSCOPE_STANDARD_API_KEY_ENV_KEY, type AlibabaStandardRegion, } from '../../constants/alibabaStandardApiKey.js'; +import { + applyOpenRouterModelsConfiguration, + createOpenRouterOAuthSession, + OPENROUTER_OAUTH_CALLBACK_URL, + runOpenRouterOAuthLogin, +} from '../../commands/auth/openrouterOAuth.js'; + +/** + * Generate a Qwen-managed env key from protocol and base URL. + * Format: QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL} + */ +export function generateCustomApiKeyEnvKey( + protocol: string, + baseUrl: string, +): string { + const normalize = (value: string) => + value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + return `QWEN_CUSTOM_API_KEY_${normalize(protocol)}_${normalize(baseUrl)}`; +} + +/** + * Normalize model IDs: split by comma, trim, deduplicate, remove empty. + */ +export function normalizeCustomModelIds(modelIdsInput: string): string[] { + return modelIdsInput + .split(',') + .map((id) => id.trim()) + .filter((id, index, array) => id.length > 0 && array.indexOf(id) === index); +} + +/** + * Mask an API key for display: show first 3 and last 4 chars. + */ +export function maskApiKey(apiKey: string): string { + const trimmed = apiKey.trim(); + if (trimmed.length === 0) return '(not set)'; + if (trimmed.length <= 6) return '***'; + const head = trimmed.slice(0, 3); + const tail = trimmed.slice(-4); + return `${head}...${tail}`; +} export type { QwenAuthState } from '../hooks/useQwenAuth.js'; @@ -61,6 +108,13 @@ export const useAuthCommand = ( const [pendingAuthType, setPendingAuthType] = useState( undefined, ); + const [externalAuthState, setExternalAuthState] = useState<{ + title: string; + message: string; + detail?: string; + } | null>(null); + const [openRouterAuthAbortController, setOpenRouterAuthAbortController] = + useState(null); const { qwenAuthState, cancelQwenAuth } = useQwenAuth( pendingAuthType, @@ -81,6 +135,7 @@ export const useAuthCommand = ( const handleAuthFailure = useCallback( (error: unknown) => { setIsAuthenticating(false); + setExternalAuthState(null); const errorMessage = t('Failed to authenticate. Message: {{message}}', { message: getErrorMessage(error), }); @@ -276,6 +331,11 @@ export const useAuthCommand = ( cancelQwenAuth(); } + if (isAuthenticating && pendingAuthType === AuthType.USE_OPENAI) { + openRouterAuthAbortController?.abort(); + setOpenRouterAuthAbortController(null); + } + // Log authentication cancellation if (isAuthenticating && pendingAuthType) { const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled'); @@ -284,9 +344,16 @@ export const useAuthCommand = ( // Do not reset pendingAuthType here, persist the previously selected type. setIsAuthenticating(false); + setExternalAuthState(null); setIsAuthDialogOpen(true); setAuthError(null); - }, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]); + }, [ + isAuthenticating, + pendingAuthType, + cancelQwenAuth, + config, + openRouterAuthAbortController, + ]); /** * Handle coding plan submission - generates configs from template and stores api-key @@ -552,6 +619,284 @@ export const useAuthCommand = ( [settings, config, handleAuthFailure, addItem, onAuthChange], ); + 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(); + setOpenRouterAuthAbortController(abortController); + const oauthResult = await runOpenRouterOAuthLogin( + OPENROUTER_OAUTH_CALLBACK_URL, + { + abortSignal: abortController.signal, + session: oauthSession, + }, + ); + setOpenRouterAuthAbortController(null); + setExternalAuthState({ + title: t('OpenRouter Authentication'), + message: t('Finalizing OpenRouter setup...'), + detail: t( + 'Syncing OpenRouter models and updating your local configuration.', + ), + }); + const selectedKey = oauthResult.apiKey; + if (!selectedKey) { + throw new Error( + t('OpenRouter authentication completed without an API key.'), + ); + } + + const persistScope = getPersistScopeForModelSelection(settings); + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + await applyOpenRouterModelsConfiguration({ + settings, + config, + apiKey: selectedKey, + reloadConfig: true, + }); + await config.refreshAuth(AuthType.USE_OPENAI); + + setAuthError(null); + setExternalAuthState(null); + setAuthState(AuthState.Authenticated); + setPendingAuthType(undefined); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + onAuthChange?.(); + + addItem( + { + type: MessageType.INFO, + text: t('Successfully configured OpenRouter.'), + }, + Date.now(), + ); + + addItem( + { + type: MessageType.INFO, + text: t('Use /model to switch models.'), + }, + Date.now(), + ); + + addItem( + { + type: MessageType.INFO, + text: t( + 'Want more OpenRouter models? Use /manage-models to browse and enable them.', + ), + }, + Date.now(), + ); + + const authEvent = new AuthEvent(AuthType.USE_OPENAI, 'manual', 'success'); + logAuth(config, authEvent); + } catch (error) { + setOpenRouterAuthAbortController(null); + if (error instanceof DOMException && error.name === 'AbortError') { + setExternalAuthState(null); + setPendingAuthType(undefined); + setIsAuthenticating(false); + setIsAuthDialogOpen(true); + return; + } + handleAuthFailure(error); + } + }, [ + settings, + config, + handleAuthFailure, + addItem, + onAuthChange, + setOpenRouterAuthAbortController, + ]); + + /** + * Handle custom API key setup wizard submission. + * Persists key to env[generatedEnvKey] and creates modelProviders entries. + */ + const handleCustomApiKeySubmit = useCallback( + async ( + protocol: + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + baseUrl: string, + apiKey: string, + modelIdsInput: string, + generationConfig?: { + enableThinking?: boolean; + multimodal?: { + image?: boolean; + video?: boolean; + audio?: boolean; + }; + maxTokens?: number; + }, + ) => { + try { + setIsAuthenticating(true); + setAuthError(null); + + const trimmedApiKey = apiKey.trim(); + const trimmedBaseUrl = baseUrl.trim(); + const modelIds = normalizeCustomModelIds(modelIdsInput); + + if (!trimmedApiKey) { + throw new Error(t('API key cannot be empty.')); + } + if (!trimmedBaseUrl) { + throw new Error(t('Base URL cannot be empty.')); + } + if (!/^https?:\/\//i.test(trimmedBaseUrl)) { + throw new Error(t('Base URL must start with http:// or https://.')); + } + if (modelIds.length === 0) { + throw new Error(t('Model IDs cannot be empty.')); + } + + const generatedEnvKey = generateCustomApiKeyEnvKey( + protocol, + trimmedBaseUrl, + ); + const persistScope = getPersistScopeForModelSelection(settings); + + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + // Persist API key to env + settings.setValue( + persistScope, + `env.${generatedEnvKey}`, + trimmedApiKey, + ); + process.env[generatedEnvKey] = trimmedApiKey; + + // Build generationConfig if any option is set + let genConfig: ProviderModelConfig['generationConfig'] | undefined; + if (generationConfig) { + const hasThinking = generationConfig.enableThinking === true; + const hasMultimodal = + generationConfig.multimodal && + (generationConfig.multimodal.image === true || + generationConfig.multimodal.video === true || + generationConfig.multimodal.audio === true); + const hasMaxTokens = + generationConfig.maxTokens !== undefined && + generationConfig.maxTokens > 0; + + if (hasThinking || hasMultimodal || hasMaxTokens) { + genConfig = {}; + if (hasMultimodal) { + genConfig.modalities = { + image: generationConfig.multimodal!.image ?? false, + video: generationConfig.multimodal!.video ?? false, + audio: generationConfig.multimodal!.audio ?? false, + }; + } + if (hasThinking) { + genConfig.extra_body = { enable_thinking: true }; + } + if (hasMaxTokens) { + genConfig.samplingParams = { + max_tokens: generationConfig.maxTokens, + }; + } + } + } + + // Build new model configs + const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({ + id: modelId, + name: modelId, + baseUrl: trimmedBaseUrl, + envKey: generatedEnvKey, + ...(genConfig ? { generationConfig: genConfig } : {}), + })); + + // Merge with existing configs: replace same generatedEnvKey, preserve rest + const existingConfigs = + ( + settings.merged.modelProviders as ModelProvidersConfig | undefined + )?.[protocol] || []; + + const preservedConfigs = existingConfigs.filter( + (existing) => existing.envKey !== generatedEnvKey, + ); + + const updatedConfigs = [...newConfigs, ...preservedConfigs]; + + // Persist modelProviders, security, model + settings.setValue( + persistScope, + `modelProviders.${protocol}`, + updatedConfigs, + ); + settings.setValue(persistScope, 'security.auth.selectedType', protocol); + settings.setValue(persistScope, 'model.name', modelIds[0]); + + // Hot-reload before refreshAuth + const updatedModelProviders: ModelProvidersConfig = { + ...(settings.merged.modelProviders as + | ModelProvidersConfig + | undefined), + [protocol]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + await config.refreshAuth(protocol); + + setAuthError(null); + setAuthState(AuthState.Authenticated); + setPendingAuthType(undefined); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + onAuthChange?.(); + + addItem( + { + type: MessageType.INFO, + text: t( + 'Custom API Key authenticated successfully. Settings updated with generated env key and model provider config.', + ), + }, + Date.now(), + ); + + addItem( + { + type: MessageType.INFO, + text: t('Tip: Use /model to switch between configured models.'), + }, + Date.now(), + ); + + const authEvent = new AuthEvent(protocol, 'manual', 'success'); + logAuth(config, authEvent); + } catch (error) { + handleAuthFailure(error); + } + }, + [settings, config, handleAuthFailure, addItem, onAuthChange], + ); + /** /** * We previously used a useEffect to trigger authentication automatically when @@ -600,10 +945,13 @@ export const useAuthCommand = ( isAuthDialogOpen, isAuthenticating, pendingAuthType, + externalAuthState, qwenAuthState, handleAuthSelect, handleCodingPlanSubmit, handleAlibabaStandardSubmit, + handleOpenRouterSubmit, + handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, }; diff --git a/packages/cli/src/ui/commands/manageModelsCommand.test.ts b/packages/cli/src/ui/commands/manageModelsCommand.test.ts new file mode 100644 index 000000000..2666d4f8b --- /dev/null +++ b/packages/cli/src/ui/commands/manageModelsCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @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 new file mode 100644 index 000000000..e16b18016 --- /dev/null +++ b/packages/cli/src/ui/commands/manageModelsCommand.ts @@ -0,0 +1,24 @@ +/** + * @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/rewindCommand.ts b/packages/cli/src/ui/commands/rewindCommand.ts index 07f78a1a5..020091795 100644 --- a/packages/cli/src/ui/commands/rewindCommand.ts +++ b/packages/cli/src/ui/commands/rewindCommand.ts @@ -16,7 +16,7 @@ export const rewindCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, action: async (): Promise => ({ - type: 'dialog', - dialog: 'rewind', - }), + type: 'dialog', + dialog: 'rewind', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2e3f8df62..9afd2ac9c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -173,6 +173,7 @@ export interface OpenDialogActionReturn { | 'memory' | 'model' | 'fast-model' + | 'manage-models' | 'subagent_create' | 'subagent_list' | 'trust' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e626a60df..f884d089a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -16,11 +16,13 @@ import { PluginChoicePrompt } from './PluginChoicePrompt.js'; import { ThemeDialog } from './ThemeDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; import { QwenOAuthProgress } from './QwenOAuthProgress.js'; +import { ExternalAuthProgress } from './ExternalAuthProgress.js'; import { AuthDialog } from '../auth/AuthDialog.js'; 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'; @@ -213,6 +215,14 @@ export const DialogManager = ({ /> ); } + if (uiState.isManageModelsDialogOpen) { + return ( + + ); + } if (uiState.isSettingsDialogOpen) { return ( @@ -310,6 +320,23 @@ export const DialogManager = ({ } if (uiState.isAuthenticating) { + if ( + uiState.pendingAuthType === AuthType.USE_OPENAI && + uiState.externalAuthState + ) { + return ( + { + uiActions.cancelAuthentication(); + uiActions.setAuthState(AuthState.Updating); + }} + /> + ); + } + // OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes // Qwen OAuth remains as a separate flow if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) { diff --git a/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx b/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx new file mode 100644 index 000000000..528d20237 --- /dev/null +++ b/packages/cli/src/ui/components/ExternalAuthProgress.test.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { ExternalAuthProgress } from './ExternalAuthProgress.js'; + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +describe('ExternalAuthProgress', () => { + it('shows cancel hint when cancel is available', () => { + const onCancel = vi.fn(); + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Esc to cancel'); + }); +}); diff --git a/packages/cli/src/ui/components/ExternalAuthProgress.tsx b/packages/cli/src/ui/components/ExternalAuthProgress.tsx new file mode 100644 index 000000000..cfea9a81d --- /dev/null +++ b/packages/cli/src/ui/components/ExternalAuthProgress.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; +import { useKeypress } from '../hooks/useKeypress.js'; + +interface ExternalAuthProgressProps { + title: string; + message: string; + detail?: string; + onCancel?: () => void; +} + +export function ExternalAuthProgress({ + title, + message, + detail, + onCancel, +}: ExternalAuthProgressProps): React.JSX.Element { + useKeypress( + (key) => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + onCancel?.(); + } + }, + { isActive: Boolean(onCancel) }, + ); + + return ( + + {title} + + + {message} + {detail ? {detail} : null} + + + + + {t('Please wait while authentication completes...')} + + {onCancel ? ( + {t('Esc to cancel')} + ) : null} + + + ); +} diff --git a/packages/cli/src/ui/components/ManageModelsDialog.test.tsx b/packages/cli/src/ui/components/ManageModelsDialog.test.tsx new file mode 100644 index 000000000..a39e3c1f8 --- /dev/null +++ b/packages/cli/src/ui/components/ManageModelsDialog.test.tsx @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + applyCatalogFilters, + buildModelLabel, + getNextEnabledTabSource, + getNextFocusMode, + type FilterMode, +} from './ManageModelsDialog.js'; +import type { ManageModelsCatalogEntry } from '../manageModels/manageModels.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', + }, + }; +} + +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'); + }); +}); diff --git a/packages/cli/src/ui/components/ManageModelsDialog.tsx b/packages/cli/src/ui/components/ManageModelsDialog.tsx new file mode 100644 index 000000000..98998d54f --- /dev/null +++ b/packages/cli/src/ui/components/ManageModelsDialog.tsx @@ -0,0 +1,648 @@ +/** + * @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 { 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; + } + if (key.name === 'down') { + setFocusMode('search'); + } + return; + } + + if (focusMode === 'search') { + if (key.name === 'left') { + setFilterMode((current) => cycleFilter(current, 'left')); + return; + } + if (key.name === 'right') { + setFilterMode((current) => cycleFilter(current, 'right')); + return; + } + if (key.name === 'up') { + setFocusMode('tabs'); + return; + } + if (key.name === 'down' && filteredEntries.length > 0) { + setFocusMode('list'); + } + return; + } + + if (focusMode === 'list') { + if (key.name === 'up') { + moveHighlight('up'); + return; + } + if (key.name === 'down') { + 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/components/shared/MultiSelect.tsx b/packages/cli/src/ui/components/shared/MultiSelect.tsx index b910430ba..7191d4fd6 100644 --- a/packages/cli/src/ui/components/shared/MultiSelect.tsx +++ b/packages/cli/src/ui/components/shared/MultiSelect.tsx @@ -19,9 +19,10 @@ export interface MultiSelectItem extends SelectionListItem { export interface MultiSelectProps { items: Array>; initialIndex?: number; - initialSelectedKeys?: string[]; + selectedKeys?: string[]; onConfirm: (selectedValues: T[]) => void; onChange?: (selectedValues: T[]) => void; + onSelectedKeysChange?: (selectedKeys: string[]) => void; onHighlight?: (value: T) => void; isFocused?: boolean; showNumbers?: boolean; @@ -43,32 +44,18 @@ function getSelectedValues( export function MultiSelect({ items, initialIndex = 0, - initialSelectedKeys = EMPTY_SELECTED_KEYS, + selectedKeys = EMPTY_SELECTED_KEYS, onConfirm, onChange, + onSelectedKeysChange, onHighlight, isFocused = true, showNumbers = true, showScrollArrows = false, maxItemsToShow = 10, }: MultiSelectProps): React.JSX.Element { - const [selectedKeys, setSelectedKeys] = useState>( - () => new Set(initialSelectedKeys), - ); const [scrollOffset, setScrollOffset] = useState(0); - - useEffect(() => { - setSelectedKeys((prev) => { - const next = new Set(initialSelectedKeys); - if ( - prev.size === next.size && - Array.from(next).every((key) => prev.has(key)) - ) { - return prev; - } - return next; - }); - }, [initialSelectedKeys]); + const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]); const { activeIndex } = useSelectionList({ items, @@ -81,7 +68,7 @@ export function MultiSelect({ showNumbers: false, onHighlight, onSelect: () => { - onConfirm(getSelectedValues(items, selectedKeys)); + onConfirm(getSelectedValues(items, selectedKeySet)); }, }); @@ -92,23 +79,19 @@ export function MultiSelect({ return; } - setSelectedKeys((prev) => { - const next = new Set(prev); - if (next.has(item.key)) { - next.delete(item.key); - } else { - next.add(item.key); - } - return next; - }); + const next = new Set(selectedKeySet); + if (next.has(item.key)) { + next.delete(item.key); + } else { + next.add(item.key); + } + const nextKeys = Array.from(next); + onSelectedKeysChange?.(nextKeys); + onChange?.(getSelectedValues(items, next)); }, - [items], + [items, onChange, onSelectedKeysChange, selectedKeySet], ); - useEffect(() => { - onChange?.(getSelectedValues(items, selectedKeys)); - }, [items, selectedKeys, onChange]); - useKeypress( (key) => { if (key.name === 'space' || key.sequence === ' ') { @@ -152,7 +135,7 @@ export function MultiSelect({ {visibleItems.map((item, index) => { const itemIndex = scrollOffset + index; const isActive = activeIndex === itemIndex; - const isChecked = selectedKeys.has(item.key); + const isChecked = selectedKeySet.has(item.key); const itemNumberText = `${String(itemIndex + 1).padStart( numberColumnWidth, diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 3e7b62258..b77848d0e 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -22,7 +22,7 @@ export interface TextInputProps { onChange: (text: string) => void; onSubmit?: () => void; /** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */ - onTab?: () => void; + onTab?: (key: Key) => void; /** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */ onUp?: () => void; /** Called when ↓ is pressed; if provided, prevents cursor-down in the buffer. */ @@ -80,7 +80,7 @@ export function TextInput({ // Tab completion: delegate to caller instead of inserting a tab character // During paste, let tab through as literal content (e.g. Excel tab-separated data) if (key.name === 'tab' && !key.paste) { - onTab?.(); + onTab?.(key); return; } diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c035e6421..052ff5491 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -52,6 +52,25 @@ export interface UIActions { region: AlibabaStandardRegion, modelIdsInput: string, ) => Promise; + handleOpenRouterSubmit: () => Promise; + handleCustomApiKeySubmit: ( + protocol: + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + baseUrl: string, + apiKey: string, + modelIdsInput: string, + generationConfig?: { + enableThinking?: boolean; + multimodal?: { + image?: boolean; + video?: boolean; + audio?: boolean; + }; + maxTokens?: number; + }, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; cancelAuthentication: () => void; @@ -64,6 +83,8 @@ 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 a0ddd9c9d..c987d99cd 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -18,7 +18,7 @@ import type { PluginChoiceRequest, } from '../types.js'; import type { TodoItem } from '../components/TodoDisplay.js'; -import type { QwenAuthState } from '../hooks/useQwenAuth.js'; +import type { ExternalAuthState, QwenAuthState } from '../hooks/useQwenAuth.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { @@ -48,6 +48,7 @@ export interface UIState { authError: string | null; isAuthDialogOpen: boolean; pendingAuthType: AuthType | undefined; + externalAuthState: ExternalAuthState | null; // Qwen OAuth state qwenAuthState: QwenAuthState; editorError: string | null; @@ -58,6 +59,7 @@ 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.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index fea4b12a0..fb1281f16 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -86,6 +86,7 @@ interface SlashCommandProcessorActions { openMemoryDialog: () => void; openSettingsDialog: () => void; openModelDialog: (options?: { fastModelMode?: boolean }) => void; + openManageModelsDialog: () => void; openTrustDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; @@ -595,6 +596,9 @@ 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/useManageModelsCommand.test.ts b/packages/cli/src/ui/hooks/useManageModelsCommand.test.ts new file mode 100644 index 000000000..f9d46218f --- /dev/null +++ b/packages/cli/src/ui/hooks/useManageModelsCommand.test.ts @@ -0,0 +1,40 @@ +/** + * @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 new file mode 100644 index 000000000..ed1f2965e --- /dev/null +++ b/packages/cli/src/ui/hooks/useManageModelsCommand.ts @@ -0,0 +1,32 @@ +/** + * @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/useQwenAuth.ts b/packages/cli/src/ui/hooks/useQwenAuth.ts index 2b1819c1c..f3bbf7e19 100644 --- a/packages/cli/src/ui/hooks/useQwenAuth.ts +++ b/packages/cli/src/ui/hooks/useQwenAuth.ts @@ -24,6 +24,12 @@ export interface QwenAuthState { authMessage: string | null; } +export interface ExternalAuthState { + title: string; + message: string; + detail?: string; +} + export const useQwenAuth = ( pendingAuthType: AuthType | undefined, isAuthenticating: boolean, diff --git a/packages/cli/src/ui/manageModels/manageModels.test.ts b/packages/cli/src/ui/manageModels/manageModels.test.ts new file mode 100644 index 000000000..8ad3568c8 --- /dev/null +++ b/packages/cli/src/ui/manageModels/manageModels.test.ts @@ -0,0 +1,140 @@ +/** + * @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('../../commands/auth/openrouterOAuth.js', () => ({ + OPENROUTER_DEFAULT_MODEL: 'openai/gpt-4o-mini', + 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 new file mode 100644 index 000000000..c2d4bfe12 --- /dev/null +++ b/packages/cli/src/ui/manageModels/manageModels.ts @@ -0,0 +1,211 @@ +/** + * @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 '../../commands/auth/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/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 06a628881..816f41fa5 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -663,10 +663,10 @@ export class ShellExecutionService { const finalOutput = shellExecutionConfig.disableDynamicLineTrimming ? newOutput : trimmedOutput; - const finalOutputComparison = shellExecutionConfig - .disableDynamicLineTrimming - ? newOutputComparison - : trimmedOutputComparison; + const finalOutputComparison = + shellExecutionConfig.disableDynamicLineTrimming + ? newOutputComparison + : trimmedOutputComparison; if (!areAnsiOutputsEqual(outputComparison, finalOutputComparison)) { outputComparison = finalOutputComparison; diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 5f1bc1034..a1f529cdf 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -40,6 +40,40 @@ vi.mock('mime/lite', () => ({ getType: vi.fn(), })); +// Mock execFile so isPdftotextAvailable does not spawn a real process. +// On platforms where pdftotext is not installed (e.g. Windows CI), +// the 5-second execFile timeout can exceed the default 5s test timeout. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn( + ( + _command: string, + _args: string[], + _optionsOrCallback: unknown, + _callback?: unknown, + ) => { + // Resolve the callback (supports both signatures of execFile) + const cb = + typeof _optionsOrCallback === 'function' + ? _optionsOrCallback + : _callback; + const error = Object.assign(new Error('Command not found'), { + code: 'ENOENT', + }); + if (typeof cb === 'function') { + setImmediate(() => cb(error, '', '')); + } + return { + kill: vi.fn(), + on: vi.fn(), + } as unknown as import('node:child_process').ChildProcess; + }, + ), + }; +}); + const mockMimeGetType = mime.getType as Mock; describe('fileUtils', () => { diff --git a/packages/core/src/utils/terminalSerializer.test.ts b/packages/core/src/utils/terminalSerializer.test.ts index fe3123bef..ab067d186 100644 --- a/packages/core/src/utils/terminalSerializer.test.ts +++ b/packages/core/src/utils/terminalSerializer.test.ts @@ -188,7 +188,12 @@ describe('terminalSerializer', () => { unwrapWrappedLines: true, }); const visibleText = result - .map((line) => line.map((token) => token.text).join('').trimEnd()) + .map((line) => + line + .map((token) => token.text) + .join('') + .trimEnd(), + ) .filter(Boolean); expect(visibleText).toEqual(['abcdefghijkl', 'short']);