fix(cli): add "API Key" option to qwen auth interactive menu

The `qwen auth` CLI command only showed 2 options (Coding Plan, Qwen OAuth),
while the interactive `/auth` dialog showed 3 (Coding Plan, API Key, Qwen OAuth).
Users following the README instructions to configure OpenRouter/Fireworks via
`qwen auth` had no API Key entry point.

- Add "API Key" option to the `runInteractiveAuth` menu with two sub-paths:
  "Alibaba Cloud ModelStudio Standard API Key" (guided flow) and
  "Custom API Key" (prints docs link)
- Add `qwen auth api-key` yargs subcommand for direct access
- Extract `createMinimalArgv` / `loadAuthConfig` helpers to eliminate duplicated
  CliArgs boilerplate
- Extract `promptForInput` to share raw-mode stdin logic between `promptForKey`
  and `promptForModelIds`
- Improve `showAuthStatus` to distinguish Coding Plan, Standard API Key, and
  generic OpenAI-compatible configurations
- Align menu labels and descriptions with the interactive `/auth` dialog

Closes #3413

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye.djy 2026-04-26 01:27:35 +08:00
parent 83d1e6dcae
commit 0d5221490d
2 changed files with 355 additions and 83 deletions

View file

@ -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),

View file

@ -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<string, string>;
}
/**
* 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<CodingPlanRegion> {
}
/**
* 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<string> {
// Create a simple password-style input (without echoing characters)
async function promptForInput(
promptText: string,
options: { mask?: boolean; defaultValue?: string } = {},
): Promise<string> {
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<string> {
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<string> {
}
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<string> {
});
}
/**
* Prompts the user to enter an API key (masked input)
*/
async function promptForKey(
promptText: string = t('Enter your Coding Plan API key: '),
): Promise<string> {
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<void> {
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<string, ModelConfig[]>)?.[
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<string, ModelConfig[]> = {
...(settings.merged.modelProviders as Record<string, ModelConfig[]>),
[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<AlibabaStandardRegion> {
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<string> {
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<void> {
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<void> {
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<void> {
}
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(