diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index b90795bc7..511a8f270 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -7,6 +7,7 @@ import type { CommandModule, Argv } from 'yargs'; import { handleQwenAuth, + handleApiKeyAuth, runInteractiveAuth, showAuthStatus, } from './auth/handler.js'; @@ -50,6 +51,14 @@ const codePlanCommand = { }, }; +const apiKeyCommand = { + command: 'api-key', + describe: t('Authenticate using an API key'), + handler: async () => { + await handleApiKeyAuth(); + }, +}; + const statusCommand = { command: 'status', describe: t('Show current authentication status'), @@ -61,12 +70,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 with Coding Plan, API Key, or Qwen-OAuth', ), builder: (yargs: Argv) => yargs .command(qwenOauthCommand) .command(codePlanCommand) + .command(apiKeyCommand) .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..2b728fd77 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -24,6 +24,11 @@ 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 { + ALIBABA_STANDARD_API_KEY_ENDPOINTS, + DASHSCOPE_STANDARD_API_KEY_ENV_KEY, + type AlibabaStandardRegion, +} from '../../constants/alibabaStandardApiKey.js'; interface QwenAuthOptions { region?: string; @@ -49,6 +54,76 @@ interface MergedSettingsWithCodingPlan { env?: Record; } +/** + * Creates a minimal CliArgs for auth command config loading + */ +function createMinimalArgv(): CliArgs { + return { + query: undefined, + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + bare: undefined, + approvalMode: undefined, + telemetry: undefined, + checkpointing: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + experimentalLsp: undefined, + extensions: [], + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + chatRecording: undefined, + continue: undefined, + resume: undefined, + sessionId: undefined, + maxSessionTurns: undefined, + coreTools: undefined, + excludeTools: undefined, + disabledSlashCommands: undefined, + authType: undefined, + channel: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, + }; +} + +/** + * Loads settings and config for auth commands + */ +async function loadAuthConfig(settings: LoadedSettings) { + return loadCliConfig( + settings.merged, + createMinimalArgv(), + process.cwd(), + [], + { + userHooks: settings.getUserHooks(), + projectHooks: settings.getProjectHooks(), + }, + ); +} + /** * Handles the authentication process based on the specified command and options */ @@ -58,69 +133,7 @@ export async function handleQwenAuth( ) { try { const settings = loadSettings(); - - // Create a minimal argv for config loading - const minimalArgv: CliArgs = { - query: undefined, - model: undefined, - sandbox: undefined, - sandboxImage: undefined, - debug: undefined, - prompt: undefined, - promptInteractive: undefined, - yolo: undefined, - bare: undefined, - approvalMode: undefined, - telemetry: undefined, - checkpointing: undefined, - telemetryTarget: undefined, - telemetryOtlpEndpoint: undefined, - telemetryOtlpProtocol: undefined, - telemetryLogPrompts: undefined, - telemetryOutfile: undefined, - allowedMcpServerNames: undefined, - allowedTools: undefined, - acp: undefined, - experimentalAcp: undefined, - experimentalLsp: undefined, - extensions: [], - listExtensions: undefined, - openaiLogging: undefined, - openaiApiKey: undefined, - openaiBaseUrl: undefined, - openaiLoggingDir: undefined, - proxy: undefined, - includeDirectories: undefined, - screenReader: undefined, - inputFormat: undefined, - outputFormat: undefined, - includePartialMessages: undefined, - chatRecording: undefined, - continue: undefined, - resume: undefined, - sessionId: undefined, - maxSessionTurns: undefined, - coreTools: undefined, - excludeTools: undefined, - disabledSlashCommands: undefined, - authType: undefined, - channel: undefined, - systemPrompt: undefined, - appendSystemPrompt: undefined, - }; - - // Create a minimal config to access settings and storage - const config = await loadCliConfig( - settings.merged, - minimalArgv, - process.cwd(), - [], // No extensions for auth command - // Pass separated hooks for proper source attribution - { - userHooks: settings.getUserHooks(), - projectHooks: settings.getProjectHooks(), - }, - ); + const config = await loadAuthConfig(settings); if (command === 'qwen-oauth') { await handleQwenOAuth(config, settings); @@ -303,16 +316,21 @@ async function promptForRegion(): Promise { } /** - * Prompts the user to enter an API key + * Generic raw-mode text input prompt. + * @param promptText - Text displayed before the cursor + * @param options.mask - If true, echoes '*' instead of the typed character (for passwords) + * @param options.defaultValue - Value returned when the user presses Enter on empty input */ -async function promptForKey(): Promise { - // Create a simple password-style input (without echoing characters) +async function promptForInput( + promptText: string, + options: { mask?: boolean; defaultValue?: string } = {}, +): Promise { + const { mask = false, defaultValue } = options; const stdin = process.stdin; const stdout = process.stdout; - stdout.write(t('Enter your Coding Plan API key: ')); + stdout.write(promptText); - // Set raw mode to capture keystrokes const wasRaw = stdin.isRaw; if (stdin.setRawMode) { stdin.setRawMode(true); @@ -331,8 +349,10 @@ async function promptForKey(): Promise { if (stdin.setRawMode) { stdin.setRawMode(wasRaw); } - stdout.write('\n'); // New line after input - resolve(input); + stdout.write('\n'); + resolve( + defaultValue !== undefined && !input ? defaultValue : input, + ); return; case '\x03': // Ctrl+C stdin.removeListener('data', onData); @@ -351,10 +371,8 @@ async function promptForKey(): Promise { } break; default: - // Add character to input input += char; - // Print asterisk instead of the actual character for security - stdout.write('*'); + stdout.write(mask ? '*' : char); break; } } @@ -364,6 +382,15 @@ async function promptForKey(): Promise { }); } +/** + * Prompts the user to enter an API key (masked input) + */ +async function promptForKey( + promptText: string = t('Enter your Coding Plan API key: '), +): Promise { + return promptForInput(promptText, { mask: true }); +} + /** * Runs the interactive authentication flow */ @@ -377,6 +404,11 @@ export async function runInteractiveAuth() { 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models', ), }, + { + value: 'api-key' as const, + label: t('API Key'), + description: t('Bring your own API key'), + }, { value: 'qwen-oauth' as const, label: t('Qwen OAuth'), @@ -400,9 +432,217 @@ export async function runInteractiveAuth() { if (choice === 'coding-plan') { await handleQwenAuth('coding-plan', {}); + } else if (choice === 'api-key') { + await handleApiKeyAuth(); } } +/** + * Handles API Key authentication - shows sub-menu for Standard or Custom API key + */ +export async function handleApiKeyAuth() { + try { + const selector = new InteractiveSelector( + [ + { + value: 'alibaba-standard' as const, + label: t('Alibaba Cloud ModelStudio Standard API Key'), + description: t('Quick setup for Model Studio (China/International)'), + }, + { + value: 'custom' as const, + label: t('Custom API Key'), + description: t( + 'For other OpenAI / Anthropic / Gemini-compatible providers', + ), + }, + ], + t('Select API key type:'), + ); + + const choice = await selector.select(); + + if (choice === 'alibaba-standard') { + await handleAlibabaStandardApiKeyAuth(); + } else if (choice === 'custom') { + handleCustomApiKeyAuth(); + } + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +/** + * Handles Alibaba Cloud ModelStudio Standard API Key authentication + */ +async function handleAlibabaStandardApiKeyAuth(): Promise { + try { + const settings = loadSettings(); + const config = await loadAuthConfig(settings); + + // Step 1: Select region + const region = await promptForStandardRegion(); + + // Step 2: Enter API key + const apiKey = await promptForKey(t('Enter your API key: ')); + const trimmedApiKey = apiKey.trim(); + if (!trimmedApiKey) { + writeStderrLine(t('API key cannot be empty.')); + process.exit(1); + } + + // Step 3: Enter model IDs + const modelIdsInput = await promptForModelIds(); + const modelIds = modelIdsInput + .split(',') + .map((id) => id.trim()) + .filter( + (id, index, array) => id.length > 0 && array.indexOf(id) === index, + ); + if (modelIds.length === 0) { + writeStderrLine(t('Model IDs cannot be empty.')); + process.exit(1); + } + + writeStdoutLine( + t('Processing Alibaba Cloud ModelStudio Standard API Key...'), + ); + + // Persist settings + const baseUrl = ALIBABA_STANDARD_API_KEY_ENDPOINTS[region]; + const persistScope = getPersistScopeForModelSelection(settings); + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + // Store API key + settings.setValue( + persistScope, + `env.${DASHSCOPE_STANDARD_API_KEY_ENV_KEY}`, + trimmedApiKey, + ); + process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] = trimmedApiKey; + + // Build model configs + const newConfigs: ModelConfig[] = modelIds.map((modelId) => ({ + id: modelId, + name: `[ModelStudio Standard] ${modelId}`, + baseUrl, + envKey: DASHSCOPE_STANDARD_API_KEY_ENV_KEY, + })); + + // Get existing configs and filter out old Alibaba Standard entries + const existingConfigs = + (settings.merged.modelProviders as Record)?.[ + AuthType.USE_OPENAI + ] || []; + + const nonAlibabaStandardConfigs = existingConfigs.filter( + (existing) => + !( + existing.envKey === DASHSCOPE_STANDARD_API_KEY_ENV_KEY && + typeof existing.baseUrl === 'string' && + Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS).includes( + existing.baseUrl, + ) + ), + ); + + const updatedConfigs = [...newConfigs, ...nonAlibabaStandardConfigs]; + + // Persist model providers and auth settings + settings.setValue( + persistScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + settings.setValue( + persistScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + settings.setValue(persistScope, 'model.name', modelIds[0]); + + // Reload and refresh + const updatedModelProviders: Record = { + ...(settings.merged.modelProviders as Record), + [AuthType.USE_OPENAI]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + await config.refreshAuth(AuthType.USE_OPENAI); + + writeStdoutLine( + t( + 'Successfully configured Alibaba Cloud ModelStudio Standard API Key with {{modelCount}} model(s).', + { modelCount: String(modelIds.length) }, + ), + ); + process.exit(0); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +/** + * Handles Custom API Key - prints docs link + */ +function handleCustomApiKeyAuth(): void { + writeStdoutLine( + t( + '\nYou can configure your API key and models in settings.json.\nRefer to the documentation for setup instructions:\n https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n', + ), + ); + process.exit(0); +} + +/** + * Prompts the user to select a region for ModelStudio Standard API Key + */ +async function promptForStandardRegion(): Promise { + const selector = new InteractiveSelector( + [ + { + value: 'cn-beijing' as AlibabaStandardRegion, + label: t('China (Beijing)'), + description: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }, + { + value: 'sg-singapore' as AlibabaStandardRegion, + label: t('Singapore'), + description: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + }, + { + value: 'us-virginia' as AlibabaStandardRegion, + label: t('US (Virginia)'), + description: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + }, + { + value: 'cn-hongkong' as AlibabaStandardRegion, + label: t('China (Hong Kong)'), + description: + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }, + ], + t('Select region:'), + ); + + return await selector.select(); +} + +/** + * Prompts the user to enter comma-separated model IDs + */ +async function promptForModelIds(): Promise { + const defaultModels = 'qwen3.5-plus,glm-5,kimi-k2.5'; + return promptForInput( + t('Enter model IDs (comma-separated, default: {{default}}): ', { + default: defaultModels, + }), + { defaultValue: defaultModels }, + ); +} + /** * Shows the current authentication status */ @@ -421,12 +661,15 @@ export async function showAuthStatus(): Promise { writeStdoutLine(t('Run one of the following commands to get started:\n')); writeStdoutLine( t( - ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan', ), ); + writeStdoutLine( + t(' qwen auth api-key - Authenticate with an API key'), + ); writeStdoutLine( t( - ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)', ), ); writeStdoutLine(t('Or simply run:')); @@ -451,12 +694,15 @@ export async function showAuthStatus(): Promise { const codingPlanVersion = mergedSettings.codingPlan?.version; const modelName = mergedSettings.model?.name; - // Check if API key is set in environment - const hasApiKey = + // Check which API key type is configured + const hasCodingPlanKey = !!process.env[CODING_PLAN_ENV_KEY] || !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + const hasStandardKey = + !!process.env[DASHSCOPE_STANDARD_API_KEY_ENV_KEY] || + !!mergedSettings.env?.[DASHSCOPE_STANDARD_API_KEY_ENV_KEY]; - if (hasApiKey) { + if (hasCodingPlanKey) { writeStdoutLine( t('✓ Authentication Method: Alibaba Cloud Coding Plan'), ); @@ -484,16 +730,32 @@ export async function showAuthStatus(): Promise { } writeStdoutLine(t(' Status: API key configured\n')); - } else { + } else if (hasStandardKey) { writeStdoutLine( t( - '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + '✓ Authentication Method: Alibaba Cloud ModelStudio Standard API Key', ), ); + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + writeStdoutLine(t(' Status: API key configured\n')); + } else { writeStdoutLine( - t(' Issue: API key not found in environment or settings\n'), + t('✓ Authentication Method: OpenAI-compatible API Key'), ); - writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n')); + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + writeStdoutLine(t(' Status: Configured\n')); } } else { writeStdoutLine(