refactor(cli): remove legacy qwen auth CLI subcommand, redirect to /auth TUI dialog (#3959)

The `qwen auth` CLI subcommand (with subcommands like qwen-oauth,
coding-plan, api-key, openrouter, status) has been superseded by the
richer /auth TUI dialog introduced in the provider-first auth registry
(#3864). Running `qwen auth` now prints a deprecation notice pointing
users to the /auth TUI dialog (interactive), env vars (CI/headless),
or /doctor (status check).

Changes:
- Replace auth.ts with a stub that prints a removal notice and exits
- Delete handler.ts (734 lines), interactiveSelector.ts, and their
  tests (interactiveSelector.test.ts, openrouter.test.ts, status.test.ts)
- Update /auth slash command to handle non-interactive/ACP modes gracefully
- Enrich /doctor auth check with provider-aware diagnostics using
  findProviderByCredentials
- Mark `auth` as a subcommand that handles its own exit in config.ts

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
pomeloandQwen-Coder authored and GitHub committed 2026-05-11 16:44:09 +08:00
1 parent 84ecb5b8a3
commit 7e11428545
19 files changed
+403 -2538

No files matched your search

+5 -3
View File
@@ -29,9 +29,11 @@ provider abstraction.
The user-facing flows are:
- `qwen auth openrouter --key <key>` for automation or direct API-key setup.
- `qwen auth openrouter` for browser-based OAuth.
- `/auth` → API Key → OpenRouter for the TUI flow.
- `/auth` → OpenRouter for the interactive TUI flow.
- Environment variables for automation or direct API-key setup:
`OPENROUTER_API_KEY` plus `OPENAI_BASE_URL=https://openrouter.ai/api/v1`.
- `~/.qwen/settings.json` for scripted setup that needs explicit model provider
entries.
Browser OAuth uses OpenRouter's PKCE flow and writes the exchanged API key into
settings before refreshing auth as `AuthType.USE_OPENAI`.
+26 -71
View File
@@ -10,7 +10,7 @@ Qwen Code supports three authentication methods. Pick the one that matches how y
> [!warning]
>
> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen auth` to configure.
> The Qwen OAuth free tier was discontinued on 2026-04-15. Existing cached tokens may continue working briefly, but new requests will be rejected. Please switch to Alibaba Cloud Coding Plan, [OpenRouter](https://openrouter.ai), [Fireworks AI](https://app.fireworks.ai), or another provider. Run `qwen` and use `/auth` to configure.
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again.
- **Requirements**: a `qwen.ai` account + internet access (at least for the first login).
@@ -23,15 +23,11 @@ Start the CLI and follow the browser flow:
qwen
```
Or authenticate directly without starting a session:
```bash
qwen auth qwen-oauth
```
Then run `/auth` and choose the OAuth provider from the interactive dialog.
> [!note]
>
> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow.
> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow.
> In these cases, please use the Alibaba Cloud Coding Plan or API Key authentication method.
## 💳 Option 2: Alibaba Cloud Coding Plan
@@ -52,24 +48,22 @@ Alibaba Cloud Coding Plan is available in two regions:
### Interactive setup
You can set up Coding Plan authentication in two ways:
**Option A: From the terminal (recommended for first-time setup)**
```bash
# Interactive — prompts for region and API key
qwen auth coding-plan
# Or non-interactive — pass region and key directly
qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx
```
**Option B: Inside a Qwen Code session**
Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key.
After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5).
### Headless or scripted setup
For CI, containers, or scripts, configure Coding Plan with environment variables or `settings.json` instead of the removed `qwen auth coding-plan` command.
```bash
export BAILIAN_CODING_PLAN_API_KEY="sk-sp-xxxxxxxxx"
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
Use `https://coding.dashscope.aliyuncs.com/v1` for the China (Beijing) endpoint, or `https://coding-intl.dashscope.aliyuncs.com/v1` for the international endpoint.
### Alternative: configure via `settings.json`
If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwen/settings.json`:
@@ -312,59 +306,20 @@ qwen --model "qwen3-coder-plus"
qwen --model "qwen3.5-plus"
```
## `qwen auth` CLI command
## Removed `qwen auth` CLI command
In addition to the in-session `/auth` slash command, Qwen Code provides a standalone `qwen auth` CLI command for managing authentication directly from the terminal — without starting an interactive session first.
The standalone `qwen auth` CLI command has been removed. Use these replacements instead:
### Interactive mode
| Previous use case | Replacement |
| -------------------------------- | ------------------------------------------------------------------------------------------- |
| Interactive authentication setup | Run `qwen`, then use `/auth` |
| Coding Plan setup | Use `/auth`, or set `BAILIAN_CODING_PLAN_API_KEY` with the Coding Plan base URL |
| OpenRouter setup | Use `/auth`, or set `OPENROUTER_API_KEY` and `OPENAI_BASE_URL=https://openrouter.ai/api/v1` |
| API-key or custom provider setup | Configure `~/.qwen/settings.json`, `.env`, or provider-specific environment variables |
| Check current authentication | Run `/doctor` inside Qwen Code |
| OAuth browser flow | Run `qwen` interactively and use `/auth`; OAuth cannot be configured with env vars alone |
Run `qwen auth` without arguments to get an interactive menu:
```bash
qwen auth
```
You'll see a selector with arrow-key navigation:
```
Select authentication method:
Alibaba Cloud Coding Plan - Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models
API Key - Bring your own API key
Qwen OAuth - Discontinued — switch to Coding Plan or API Key
(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)
```
### Subcommands
| Command | Description |
| ---------------------------------------------------- | ------------------------------------------------- |
| `qwen auth` | Interactive authentication setup |
| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan |
| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) |
| `qwen auth api-key` | Authenticate with an API key |
| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth (discontinued) |
| `qwen auth status` | Show current authentication status |
**Examples:**
```bash
# Authenticate with Qwen OAuth directly
qwen auth qwen-oauth
# Set up Coding Plan interactively (prompts for region and key)
qwen auth coding-plan
# Set up Coding Plan non-interactively (useful for CI/scripting)
qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx
# Set up API key (ModelStudio Standard or custom provider)
qwen auth api-key
# Check your current auth configuration
qwen auth status
```
Legacy invocations such as `qwen auth status` now print a removal notice with these migration paths.
## Security notes
+8 -12
View File
@@ -234,22 +234,18 @@ Commands for obtaining information and performing system settings.
| `Ctrl/cmd+Z` | Undo input | Text editing |
| `Ctrl/cmd+Shift+Z` | Redo input | Text editing |
### 1.10 CLI Auth Subcommands
### 1.10 Authentication Commands
In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal:
Use `/auth` inside a Qwen Code session to configure authentication. Use `/doctor` to inspect the current authentication and environment status.
| Command | Description |
| ---------------------------------------------------- | ------------------------------------------------------------- |
| `qwen auth` | Interactive authentication setup |
| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan |
| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) |
| `qwen auth api-key` | Authenticate with an API key |
| `qwen auth qwen-oauth` | ~~Authenticate with Qwen OAuth~~ (discontinued on 2026-04-15) |
| `qwen auth status` | Show current authentication status |
| Command | Description |
| --------- | ------------------------------------------ |
| `/auth` | Configure authentication interactively |
| `/doctor` | Show authentication and environment checks |
> [!tip]
> [!note]
>
> These commands run outside of a Qwen Code session. Use them to configure authentication before starting a session, or in scripts and CI environments. See the [Authentication](../configuration/auth) page for full details.
> The standalone `qwen auth` CLI command has been removed. Legacy invocations such as `qwen auth status` print a removal notice with migration guidance. See the [Authentication](../configuration/auth) page for full details.
## 2. @ Commands (Introducing Files)
+2 -4
View File
@@ -87,7 +87,7 @@ Choose your preferred authentication method:
> [!tip]
>
> You can also configure authentication directly from the terminal without starting a session by running `qwen auth`. Use `qwen auth status` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details.
> To configure authentication, start Qwen Code and run `/auth`. Use `/doctor` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details.
## Step 3: Start your first session
@@ -230,9 +230,7 @@ Here are the most important commands for daily use:
| --------------------- | ------------------------------------------------ | ----------------------------- |
| `qwen` | start Qwen Code | `qwen` |
| `/auth` | Change authentication method (in session) | `/auth` |
| `qwen auth` | Configure authentication from the terminal | `qwen auth` |
| `qwen auth api-key` | Configure API key authentication | `qwen auth api-key` |
| `qwen auth status` | Check current authentication status | `qwen auth status` |
| `/doctor` | Check current authentication and environment | `/doctor` |
| `/help` | Display help information for available commands | `/help` or `/?` |
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
| `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) |
+106
View File
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import yargs from 'yargs';
import { authCommand, buildRemovalNotice, printRemovalNotice } from './auth.js';
describe('auth command', () => {
const originalNoColor = process.env['NO_COLOR'];
const originalIsTTY = process.stdout.isTTY;
beforeEach(() => {
process.env['NO_COLOR'] = '1';
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
});
});
afterEach(() => {
if (originalNoColor === undefined) {
delete process.env['NO_COLOR'];
} else {
process.env['NO_COLOR'] = originalNoColor;
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
vi.restoreAllMocks();
});
it('builds a removal notice with migration paths and no ANSI in non-TTY', () => {
const notice = buildRemovalNotice();
expect(notice).toContain('qwen auth has been removed');
expect(notice).toContain('/auth');
expect(notice).toContain('/doctor');
expect(notice).toContain('BAILIAN_CODING_PLAN_API_KEY');
expect(notice).toContain('https://coding.dashscope.aliyuncs.com/v1');
expect(notice).toContain('https://coding-intl.dashscope.aliyuncs.com/v1');
expect(notice).toContain('OPENROUTER_API_KEY');
expect(notice).not.toContain('\x1b[');
expect(notice).not.toContain('v0.15.8');
});
it('writes the notice before exiting', () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('exit');
}) as never);
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((
_chunk: unknown,
callback?: () => void,
) => {
callback?.();
return true;
}) as never);
expect(() => printRemovalNotice()).toThrow('exit');
expect(write).toHaveBeenCalledWith(
expect.any(String),
expect.any(Function),
);
expect(exit).toHaveBeenCalledWith(0);
});
it.each([
['auth'],
['auth status'],
['auth api-key'],
['auth qwen-oauth'],
['auth openrouter --key test-key'],
['auth coding-plan --region china --key sk-sp-test'],
['auth --key test-key'],
])('routes `%s` to the removal notice', async (command) => {
const exit = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((
_chunk: unknown,
callback?: () => void,
) => {
callback?.();
return true;
}) as never);
await yargs(command.split(' '))
.scriptName('qwen')
.command(authCommand)
.strict()
.fail((message, error) => {
throw error ?? new Error(message);
})
.parseAsync();
expect(write).toHaveBeenCalledWith(
expect.any(String),
expect.any(Function),
);
expect(exit).toHaveBeenCalledWith(0);
});
});
+51 -86
View File
@@ -4,98 +4,63 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, Argv } from 'yargs';
import {
handleQwenAuth,
handleApiKeyAuth,
runInteractiveAuth,
showAuthStatus,
} from './auth/handler.js';
import type { Argv, CommandModule } from 'yargs';
import { t } from '../i18n/index.js';
// Define subcommands separately
const qwenOauthCommand = {
command: 'qwen-oauth',
describe: t('Authenticate using Qwen OAuth'),
handler: async () => {
await handleQwenAuth('qwen-oauth', {});
},
const shouldUseColor = () =>
Boolean(process.stdout.isTTY && !process.env['NO_COLOR']);
const color = (value: string, code: string) =>
shouldUseColor() ? `\x1b[${code}m${value}\x1b[0m` : value;
const cyan = (value: string) => color(value, '36');
const yellow = (value: string) => color(value, '33');
export const buildRemovalNotice = (): string =>
[
'',
yellow(t('⚠ qwen auth has been removed.')),
'',
` ${cyan(t('Interactive'))} → ${t('run qwen and use /auth to configure providers')}`,
` ${cyan(t('CI / Headless'))} → ${t('set provider environment variables, for example OPENAI_API_KEY + OPENAI_BASE_URL + OPENAI_MODEL')}`,
` ${t('or pass --openai-api-key, --openai-base-url, --model')}`,
` ${cyan(t('Coding Plan'))} → ${t('set BAILIAN_CODING_PLAN_API_KEY and use the Coding Plan base URL for your region')}`,
` ${t('China: https://coding.dashscope.aliyuncs.com/v1')}`,
` ${t('International: https://coding-intl.dashscope.aliyuncs.com/v1')}`,
` ${cyan(t('OpenRouter'))} → ${t('set OPENROUTER_API_KEY and OPENAI_BASE_URL=https://openrouter.ai/api/v1')}`,
` ${cyan(t('Qwen OAuth'))} → ${t('run qwen interactively and use /auth; OAuth cannot be configured with env vars alone')}`,
` ${cyan(t('Scripted'))} → ${t('edit ~/.qwen/settings.json, or run qwen interactively once')}`,
'',
` ${t('Check auth status')} → ${cyan('/doctor')}`,
'',
].join('\n');
export const printRemovalNotice = () => {
process.stdout.write(buildRemovalNotice(), () => process.exit(0));
};
const codePlanCommand = {
command: 'coding-plan',
describe: t('Authenticate using Alibaba Cloud Coding Plan'),
builder: (yargs: Argv) =>
yargs
.option('base-url', {
alias: 'u',
describe: t('Base URL for Coding Plan'),
type: 'string',
})
.option('key', {
alias: 'k',
describe: t('API key for Coding Plan'),
type: 'string',
}),
handler: async (argv: { 'base-url'?: string; key?: string }) => {
const baseUrl = argv['base-url'];
const key = argv['key'] as string | undefined;
if (baseUrl && key) {
await handleQwenAuth('coding-plan', { baseUrl, key });
} else {
await handleQwenAuth('coding-plan', {});
}
},
};
const apiKeyCommand = {
command: 'api-key',
describe: t('Authenticate using an API key'),
handler: async () => {
await handleApiKeyAuth();
},
};
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'),
handler: async () => {
await showAuthStatus();
},
};
const legacySubcommands = [
'status',
'coding-plan',
'openrouter',
'api-key',
'qwen-oauth',
];
export const authCommand: CommandModule = {
command: 'auth',
describe: t(
'Configure Qwen authentication with OpenRouter, Coding Plan, API Key, or Qwen-OAuth',
),
builder: (yargs: Argv) =>
yargs
.command(qwenOauthCommand)
.command(codePlanCommand)
.command(openRouterCommand)
.command(apiKeyCommand)
.command(statusCommand)
.demandCommand(0) // Don't require a subcommand
.version(false),
handler: async () => {
// This handler is for when no subcommand is provided - show interactive menu
await runInteractiveAuth();
describe: t('Configure authentication (removed)'),
builder: (yargs: Argv) => {
let y = yargs.version(false).strict(false);
for (const name of legacySubcommands) {
y = y.command({
command: `${name} [legacyArgs..]`,
describe: false,
builder: (subYargs: Argv) => subYargs.strict(false),
handler: printRemovalNotice,
});
}
return y;
},
handler: printRemovalNotice,
};
-738
View File
@@ -1,738 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
getErrorMessage,
type Config,
type ProviderModelConfig as ModelConfig,
} 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 { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js';
import { codingPlanProvider } from '../../auth/providers/alibaba/codingPlan.js';
import { createOpenRouterProviderInstallPlan } from '../../auth/providers/oauth/openrouter.js';
import {
buildInstallPlan,
resolveBaseUrl,
resolveMetadataKey,
getDefaultModelIds,
PROVIDER_METADATA_NS,
} from '../../auth/providerConfig.js';
import { findProviderByCredentials } from '../../auth/allProviders.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 {
createOpenRouterOAuthSession,
isOpenRouterConfig,
OPENROUTER_ENV_KEY,
runOpenRouterOAuthLogin,
} from '../../auth/providers/oauth/openrouterOAuth.js';
function formatElapsedTime(startMs: number): string {
return `${((Date.now() - startMs) / 1000).toFixed(2)}s`;
}
interface QwenAuthOptions {
baseUrl?: string;
key?: string;
}
interface MergedSettingsWithCodingPlan {
security?: {
auth?: {
selectedType?: string;
apiKey?: string;
baseUrl?: string;
};
};
model?: {
name?: string;
};
modelProviders?: Record<string, ModelConfig[]>;
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,
mcpConfig: 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
*/
export async function handleQwenAuth(
command: 'qwen-oauth' | 'coding-plan' | 'openrouter',
options: QwenAuthOptions,
) {
try {
const settings = loadSettings();
const config = await loadAuthConfig(settings);
if (command === 'qwen-oauth') {
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
writeStdoutLine(t('Authentication completed successfully.'));
process.exit(0);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
/**
* Handles Qwen OAuth authentication
*/
async function handleQwenOAuth(
config: Config,
settings: LoadedSettings,
): Promise<void> {
writeStdoutLine(t('Starting Qwen OAuth authentication...'));
try {
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Persist the auth type
const authTypeScope = getPersistScopeForModelSelection(settings);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
writeStdoutLine(t('Successfully authenticated with Qwen OAuth.'));
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Qwen OAuth: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Handles Alibaba Cloud Coding Plan authentication
*/
async function handleCodePlanAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<void> {
const { baseUrl, key } = options;
let selectedBaseUrl: string;
let selectedKey: string;
if (baseUrl && key) {
selectedBaseUrl = baseUrl;
selectedKey = key;
} else {
selectedBaseUrl = await promptForCodingPlanBaseUrl();
selectedKey = await promptForKey(t('Enter your Coding Plan API key: '));
}
writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...'));
try {
const resolved = resolveBaseUrl(codingPlanProvider, selectedBaseUrl);
const installPlan = buildInstallPlan(codingPlanProvider, {
baseUrl: resolved,
apiKey: selectedKey,
modelIds: getDefaultModelIds(codingPlanProvider),
});
await applyProviderInstallPlan(installPlan, { settings, config });
writeStdoutLine(
t('Successfully authenticated with Alibaba Cloud Coding Plan.'),
);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Coding Plan: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Handles OpenRouter API key setup.
*/
async function handleOpenRouterAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<void> {
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 modelsStartMs = Date.now();
const installPlan = await createOpenRouterProviderInstallPlan({
apiKey: selectedKey,
});
await applyProviderInstallPlan(installPlan, { settings, config });
writeStdoutLine(
t('Fetched OpenRouter models in {{elapsed}}.', {
elapsed: formatElapsedTime(modelsStartMs),
}),
);
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);
}
}
async function promptForCodingPlanBaseUrl(): Promise<string> {
const baseUrlOptions = Array.isArray(codingPlanProvider.baseUrl)
? codingPlanProvider.baseUrl
: [];
const selector = new InteractiveSelector(
baseUrlOptions.map((opt) => ({
value: opt.url,
label: t(opt.label),
description: opt.url,
})),
t('Select Base URL for Coding Plan:'),
);
return await selector.select();
}
/**
* 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 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(promptText);
const wasRaw = stdin.isRaw;
if (stdin.setRawMode) {
stdin.setRawMode(true);
}
stdin.resume();
return new Promise<string>((resolve, reject) => {
let input = '';
const onData = (chunk: string) => {
for (const char of chunk) {
switch (char) {
case '\r': // Enter
case '\n':
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('\n');
resolve(
defaultValue !== undefined && !input ? defaultValue : input,
);
return;
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('^C\n');
reject(new Error('Interrupted'));
return;
case '\x08': // Backspace
case '\x7F': // Delete
if (input.length > 0) {
input = input.slice(0, -1);
// Move cursor back, print space, move back again
stdout.write('\x1B[D \x1B[D');
}
break;
default:
input += char;
stdout.write(mask ? '*' : char);
break;
}
}
};
stdin.on('data', onData);
});
}
/**
* 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
*/
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'),
description: t(
'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'),
description: t('Discontinued — switch to Coding Plan or API Key'),
},
],
t('Select authentication method:'),
);
let choice = await selector.select();
// If user selects discontinued Qwen OAuth, warn and re-prompt
while (choice === 'qwen-oauth') {
writeStdoutLine(
t(
'\n⚠ Qwen OAuth free tier was discontinued on 2026-04-15. Please select another option.\n',
),
);
choice = await selector.select();
}
if (choice === 'coding-plan') {
await handleQwenAuth('coding-plan', {});
} else if (choice === 'api-key') {
await handleApiKeyAuth();
} else if (choice === 'openrouter') {
await handleQwenAuth('openrouter', {});
}
}
/**
* Handles API Key authentication - directs user to documentation.
*
* Intentionally simplified: the full interactive provider setup is now
* available through the `/auth` slash command in the UI. The CLI sub-command
* (`qwen auth api-key`) serves as a lightweight fallback that points users
* to the docs. A future improvement could wire this into the provider
* registry for a fully interactive CLI flow.
*/
export async function handleApiKeyAuth() {
handleCustomApiKeyAuth();
}
/**
* Handles Custom API Key - prints docs link
*/
function handleCustomApiKeyAuth(): void {
writeStdoutLine('');
writeStdoutLine(
t('You can configure your API key and models in settings.json'),
);
writeStdoutLine(t('Refer to the documentation for setup instructions'));
writeStdoutLine(
' https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n',
);
process.exit(0);
}
/**
* Shows the current authentication status
*/
export async function showAuthStatus(): Promise<void> {
try {
const settings = loadSettings();
const mergedSettings = settings.merged as MergedSettingsWithCodingPlan;
writeStdoutLine(t('\n=== Authentication Status ===\n'));
// Check for selected auth type
const selectedType = mergedSettings.security?.auth?.selectedType;
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 coding-plan - Authenticate with Alibaba Cloud Coding Plan',
),
);
writeStdoutLine(
t(' qwen auth api-key - Authenticate with an API key'),
);
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)',
),
);
writeStdoutLine('');
writeStdoutLine(t('Or simply run:'));
writeStdoutLine(
t(' qwen auth - Interactive authentication setup\n'),
);
process.exit(0);
}
// Display status based on auth type
if (selectedType === AuthType.QWEN_OAUTH) {
writeStdoutLine(t('✓ Authentication Method: Qwen OAuth'));
writeStdoutLine(t(' Type: Free tier (discontinued 2026-04-15)'));
writeStdoutLine(t(' Limit: No longer available'));
writeStdoutLine(t(' Models: Qwen latest models'));
writeStdoutLine(
t('\n ⚠ Run /auth to switch to Coding Plan or another provider.\n'),
);
} else if (selectedType === AuthType.USE_OPENAI) {
const modelName = mergedSettings.model?.name;
const openAiProviders =
mergedSettings.modelProviders?.[AuthType.USE_OPENAI] || [];
const activeConfig = modelName
? openAiProviders.find((c) => c.id === modelName)
: openAiProviders[0];
const isActiveOpenRouter = activeConfig
? isOpenRouterConfig(activeConfig)
: false;
const hasOpenRouterApiKey =
!!process.env[OPENROUTER_ENV_KEY] ||
!!mergedSettings.env?.[OPENROUTER_ENV_KEY];
const foundProvider = activeConfig
? findProviderByCredentials(activeConfig.baseUrl, activeConfig.envKey)
: undefined;
const managedProvider =
foundProvider && resolveMetadataKey(foundProvider)
? foundProvider
: undefined;
if (isActiveOpenRouter) {
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('⚠️ Authentication Method: OpenRouter (Incomplete)'),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth openrouter` to re-configure.\n'));
}
} else if (managedProvider) {
const envKey =
typeof managedProvider.envKey === 'string'
? managedProvider.envKey
: '';
const metaKey = resolveMetadataKey(managedProvider)!;
const ns = (mergedSettings as Record<string, unknown>)[
PROVIDER_METADATA_NS
] as Record<string, unknown> | undefined;
const metadata = ns?.[metaKey] as
| { version?: string; baseUrl?: string }
| undefined;
const hasApiKey =
!!process.env[envKey] || !!mergedSettings.env?.[envKey];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: {{plan}}', {
plan: t(managedProvider.label),
}),
);
if (metadata?.baseUrl) {
writeStdoutLine(
t(' Base URL: {{baseUrl}}', { baseUrl: metadata.baseUrl }),
);
}
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
if (metadata?.version) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: metadata.version.substring(0, 8) + '...',
}),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t('⚠️ Authentication Method: {{plan}} (Incomplete)', {
plan: t(managedProvider.label),
}),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(
t(' Run `qwen auth` to re-configure authentication.\n'),
);
}
} else if (activeConfig) {
let hasApiKey: boolean;
if (activeConfig.envKey) {
hasApiKey =
!!process.env[activeConfig.envKey] ||
!!mergedSettings.env?.[activeConfig.envKey];
} else {
hasApiKey =
!!process.env['OPENAI_API_KEY'] ||
!!mergedSettings.env?.['OPENAI_API_KEY'] ||
!!mergedSettings.security?.auth?.apiKey;
}
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: OpenAI-compatible Provider'),
);
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
const baseUrl =
activeConfig.baseUrl || mergedSettings.security?.auth?.baseUrl;
if (baseUrl) {
writeStdoutLine(t(' Base URL: {{baseUrl}}', { baseUrl }));
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: OpenAI-compatible Provider (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth` to re-configure.\n'));
}
} else {
const hasGenericApiKey =
!!process.env['OPENAI_API_KEY'] ||
!!mergedSettings.env?.['OPENAI_API_KEY'] ||
!!mergedSettings.security?.auth?.apiKey;
if (hasGenericApiKey) {
writeStdoutLine(
t('✓ Authentication Method: OpenAI-compatible Provider'),
);
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
const baseUrl = mergedSettings.security?.auth?.baseUrl;
if (baseUrl) {
writeStdoutLine(t(' Base URL: {{baseUrl}}', { baseUrl }));
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: OpenAI-compatible Provider (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth` to re-configure.\n'));
}
}
} else {
writeStdoutLine(
t('✓ Authentication Method: {{type}}', { type: selectedType }),
);
writeStdoutLine(t(' Status: Configured\n'));
}
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to check authentication status: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
@@ -1,421 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InteractiveSelector } from './interactiveSelector.js';
import { stdin, stdout } from 'node:process';
describe('InteractiveSelector', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1', description: 'First option' },
{ value: 'option2', label: 'Option 2', description: 'Second option' },
{ value: 'option3', label: 'Option 3', description: 'Third option' },
];
const mockPrompt = 'Select an option:';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create an instance with default prompt', () => {
const selector = new InteractiveSelector(mockOptions);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
it('should create an instance with custom prompt', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
});
describe('select', () => {
it('should reject if raw mode is not available', async () => {
// Mock stdin without setRawMode
const originalSetRawMode = stdin.setRawMode;
(stdin as any).setRawMode = undefined;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
await expect(selector.select()).rejects.toThrow(
'Raw mode not available. Please run in an interactive terminal.',
);
// Restore
(stdin as any).setRawMode = originalSetRawMode;
});
it('should select first option with Enter key', async () => {
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockSetEncoding = vi.fn();
const mockRemoveListener = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
// Simulate Enter key press
setTimeout(() => callback('\r'), 0);
return stdin;
});
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).setEncoding = mockSetEncoding;
(stdin as any).removeListener = mockRemoveListener;
(stdin as any).on = mockOn;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const result = await selector.select();
expect(result).toBe('option1');
expect(mockSetRawMode).toHaveBeenCalledWith(true);
expect(mockResume).toHaveBeenCalled();
stdoutWriteSpy.mockRestore();
});
it('should select second option after arrow down then Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate arrow down
dataCallback('\x1B[B');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should handle arrow up navigation', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down twice
dataCallback('\x1B[B');
dataCallback('\x1B[B');
// Move up once
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should reject with Ctrl+C', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate Ctrl+C
setTimeout(() => dataCallback('\x03'), 0);
await expect(selectPromise).rejects.toThrow('Interrupted');
});
it('should wrap around when navigating past last option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down past last option (should wrap to first)
dataCallback('\x1B[B');
dataCallback('\x1B[B');
dataCallback('\x1B[B'); // Now at option1 again (wrapped)
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should wrap around when navigating before first option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move up from first option (should wrap to last)
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option3');
stdoutWriteSpy.mockRestore();
});
it('should ignore arrow left/right keys', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Press arrow right (should be ignored)
dataCallback('\x1B[C');
// Press arrow left (should be ignored)
dataCallback('\x1B[D');
// Press Enter - should still select first option
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should handle newline character as Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate newline
setTimeout(() => dataCallback('\n'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
});
describe('renderMenu', () => {
it('should render menu with correct formatting', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).renderMenu();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('Select an option:');
expect(output).toContain('Option 1');
expect(output).toContain('Option 2');
expect(output).toContain('Option 3');
expect(output).toContain('First option');
expect(output).toContain('Second option');
expect(output).toContain('Third option');
expect(output).toContain('↑ ↓');
expect(output).toContain('Enter');
expect(output).toContain('Ctrl+C');
stdoutWriteSpy.mockRestore();
});
it('should highlight selected option', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
(selector as any).selectedIndex = 1;
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
// Selected option should have cyan color code
expect(output).toContain('\x1B[36m');
stdoutWriteSpy.mockRestore();
});
it('should calculate correct total lines', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).calculateTotalLines();
// Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7
expect((selector as any).calculateTotalLines()).toBe(7);
});
it('should handle options without descriptions', () => {
const simpleOptions = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
];
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(simpleOptions, mockPrompt);
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('A');
expect(output).toContain('B');
stdoutWriteSpy.mockRestore();
});
});
});
@@ -1,166 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { stdin, stdout } from 'node:process';
import { t } from '../../i18n/index.js';
/**
* Represents an option in the interactive selector
*/
interface Option<T> {
value: T;
label: string;
description?: string;
}
/**
* Interactive selector that allows users to navigate with arrow keys
*/
export class InteractiveSelector<T> {
private selectedIndex = 0;
private isListening = false;
constructor(
private options: Array<Option<T>>,
private prompt: string = t('Select an option:'),
) {}
/**
* Shows the interactive menu and waits for user selection
*/
async select(): Promise<T> {
return new Promise((resolve, reject) => {
this.isListening = true;
// Display initial menu
this.renderMenu();
// Check if stdin supports raw mode
if (!stdin.setRawMode) {
// Fallback to readline if raw mode is not available (e.g., when piped)
reject(
new Error(
t('Raw mode not available. Please run in an interactive terminal.'),
),
);
return;
}
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
const onData = (chunk: string) => {
if (!this.isListening) return;
for (const char of chunk) {
switch (char) {
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
reject(new Error('Interrupted'));
return;
case '\r': // Enter
case '\n': // Newline
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
resolve(this.options[this.selectedIndex].value);
return;
case '\x1B': // ESC sequence
// Next character will be [, then A, B, C, or D
break;
default:
// Handle other characters if needed
break;
}
}
// Handle escape sequences
if (chunk.startsWith('\x1B')) {
if (chunk === '\x1B[A') {
// Arrow up
this.moveUp();
} else if (chunk === '\x1B[B') {
// Arrow down
this.moveDown();
} else if (chunk === '\x1B[C') {
// Arrow right
// Do nothing for now
} else if (chunk === '\x1B[D') {
// Arrow left
// Do nothing for now
}
}
};
stdin.on('data', onData);
});
}
/**
* Renders the menu to stdout
*/
private renderMenu(): void {
// Calculate how many lines we need to clear
const totalLines = this.calculateTotalLines();
// Clear the screen area we'll be using
if (totalLines > 0) {
stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down
}
// Write the prompt
stdout.write(`${this.prompt}\n\n`);
// Write each option - combine label and description on same line
this.options.forEach((option, index) => {
const isSelected = index === this.selectedIndex;
const indicator = isSelected ? '> ' : ' ';
const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others
const reset = '\x1B[0m';
// Combine label and description in one line
let line = `${indicator}${color}${option.label}`;
if (option.description) {
line += ` - ${option.description}`;
}
line += `${reset}\n`;
stdout.write(line);
});
// Add instructions
stdout.write(
`\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`,
);
}
/**
* Calculates the total number of lines to clear
*/
private calculateTotalLines(): number {
// Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1)
return 4 + this.options.length;
}
/**
* Moves selection up
*/
private moveUp(): void {
this.selectedIndex =
(this.selectedIndex - 1 + this.options.length) % this.options.length;
this.renderMenu();
}
/**
* Moves selection down
*/
private moveDown(): void {
this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
this.renderMenu();
}
}
@@ -1,310 +0,0 @@
/**
* @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,
mockReloadModelProvidersConfig,
mockSyncAfterAuthRefresh,
} = vi.hoisted(() => {
const mockRefreshAuth = vi.fn();
const mockReloadModelProvidersConfig = vi.fn();
const mockSyncAfterAuthRefresh = vi.fn();
return {
mockRefreshAuth,
mockSetValue: vi.fn(),
mockForScope: vi.fn(() => ({ path: '/user.json' })),
mockBackupSettingsFile: vi.fn(),
mockReloadModelProvidersConfig,
mockSyncAfterAuthRefresh,
mockLoadCliConfig: vi.fn(async () => ({
refreshAuth: mockRefreshAuth,
reloadModelProvidersConfig: mockReloadModelProvidersConfig,
getModelsConfig: vi.fn(() => ({
syncAfterAuthRefresh: mockSyncAfterAuthRefresh,
})),
})),
};
});
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/config.js', () => ({
loadCliConfig: mockLoadCliConfig,
}));
vi.mock('../../utils/settingsUtils.js', () => ({
backupSettingsFile: mockBackupSettingsFile,
restoreSettingsFromBackup: vi.fn(),
cleanupSettingsBackup: vi.fn(),
}));
vi.mock('../../config/modelProvidersScope.js', () => ({
getPersistScopeForModelSelection: vi.fn(() => 'user'),
}));
vi.mock('../../auth/providers/oauth/openrouter.js', () => ({
openRouterProvider: {
id: 'openrouter',
label: 'OpenRouter',
category: 'third-party',
protocol: 'openai',
setupMethods: [{ type: 'oauth' }],
ownsModel: (model: { baseUrl?: string }) =>
model.baseUrl === 'https://openrouter.ai/api/v1',
},
createOpenRouterProviderInstallPlan: vi.fn(async ({ apiKey }) => ({
providerId: 'openrouter',
authType: 'openai',
env: {
OPENROUTER_API_KEY: apiKey,
},
modelSelection: {
modelId: 'z-ai/glm-4.5-air:free',
},
modelProviders: [
{
authType: 'openai',
models: [
{
id: 'z-ai/glm-4.5-air:free',
name: 'OpenRouter · GLM 4.5 Air',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'openai/gpt-oss-120b:free',
name: 'OpenRouter · GPT OSS 120B',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
mergeStrategy: 'prepend-and-remove-owned',
ownsModel: (model: { baseUrl?: string }) =>
(model.baseUrl ?? '').includes('openrouter.ai'),
},
],
})),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
vi.mock('../../auth/providers/oauth/openrouterOAuth.js', () => ({
OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY',
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
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',
})),
runOpenRouterOAuthLogin: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import { runOpenRouterOAuthLogin } from '../../auth/providers/oauth/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<string, unknown>,
): 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',
'z-ai/glm-4.5-air:free',
);
const modelProvidersCall = mockSetValue.mock.calls.find(
(call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`,
);
expect(modelProvidersCall).toBeDefined();
expect(modelProvidersCall?.[2]).toEqual([
{
id: 'z-ai/glm-4.5-air:free',
name: 'OpenRouter · GLM 4.5 Air',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'openai/gpt-oss-120b:free',
name: 'OpenRouter · GPT OSS 120B',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
]);
expect(mockReloadModelProvidersConfig).toHaveBeenCalledWith(
expect.objectContaining({
[AuthType.USE_OPENAI]: expect.arrayContaining([
expect.objectContaining({ id: 'z-ai/glm-4.5-air:free' }),
]),
}),
);
expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'z-ai/glm-4.5-air:free',
);
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: 'z-ai/glm-4.5-air:free',
name: 'OpenRouter · GLM 4.5 Air',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'openai/gpt-oss-120b:free',
name: 'OpenRouter · GPT OSS 120B',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: '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('applies OpenRouter provider updates through the shared installer', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await handleQwenAuth('openrouter', { key: 'or-key-dynamic' });
expect(mockSetValue).toHaveBeenCalledWith(
'user',
'env.OPENROUTER_API_KEY',
'or-key-dynamic',
);
expect(mockReloadModelProvidersConfig).toHaveBeenCalled();
expect(mockSyncAfterAuthRefresh).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'z-ai/glm-4.5-air:free',
);
expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
});
});
@@ -1,707 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { showAuthStatus } from './handler.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import {
CODING_PLAN_ENV_KEY,
CODING_PLAN_CHINA_BASE_URL,
CODING_PLAN_GLOBAL_BASE_URL,
codingPlanProvider,
} from '../../auth/providers/alibaba/codingPlan.js';
import { buildProviderTemplate } from '../../auth/providerConfig.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
const codingPlanProviders = (baseUrl: string = CODING_PLAN_CHINA_BASE_URL) => ({
[AuthType.USE_OPENAI]: buildProviderTemplate(codingPlanProvider, baseUrl),
});
describe('showAuthStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env[CODING_PLAN_ENV_KEY];
delete process.env['OPENAI_API_KEY'];
delete process.env['OPENROUTER_API_KEY'];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
delete process.env['OPENAI_API_KEY'];
delete process.env['OPENROUTER_API_KEY'];
});
const createMockSettings = (
merged: Record<string, unknown>,
): LoadedSettings =>
({
merged,
system: { settings: {}, path: '/system.json' },
systemDefaults: { settings: {}, path: '/system-defaults.json' },
user: { settings: {}, path: '/user.json' },
workspace: { settings: {}, path: '/workspace.json' },
forScope: vi.fn(),
setValue: vi.fn(),
isTrusted: true,
}) as unknown as LoadedSettings;
it('should show message when no authentication is configured', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await 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'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth coding-plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Qwen OAuth status when configured', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.QWEN_OAUTH,
},
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Qwen OAuth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Free tier (discontinued 2026-04-15)'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('No longer available'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan status when configured with API key', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'abc123def456',
},
},
model: {
name: 'qwen3.5-plus',
},
modelProviders: codingPlanProviders(),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Coding Plan'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
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({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_GLOBAL_BASE_URL,
},
},
modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Incomplete'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
});
it('should show Coding Plan base URL for China endpoint', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
},
},
model: {
name: 'qwen3.5-plus',
},
modelProviders: codingPlanProviders(),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining(CODING_PLAN_CHINA_BASE_URL),
);
});
it('should show Coding Plan base URL for global endpoint', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_GLOBAL_BASE_URL,
},
},
model: {
name: 'qwen3-coder-plus',
},
modelProviders: codingPlanProviders(CODING_PLAN_GLOBAL_BASE_URL),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining(CODING_PLAN_GLOBAL_BASE_URL),
);
});
it('should show current model name', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
},
},
model: {
name: 'qwen3.5-plus',
},
modelProviders: codingPlanProviders(),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen3.5-plus'),
);
});
it('should show config version (truncated)', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
providerMetadata: {
'coding-plan': {
baseUrl: CODING_PLAN_CHINA_BASE_URL,
version: 'abc123def456789',
},
},
model: {
name: 'qwen3.5-plus',
},
modelProviders: codingPlanProviders(),
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('abc123de...'),
);
});
it('should handle errors and exit with code 1', async () => {
const error = new Error('Settings load failed');
vi.mocked(loadSettings).mockImplementation(() => {
throw error;
});
await showAuthStatus();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('Failed to check authentication status'),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
describe('OpenAI-compatible provider (no Coding Plan)', () => {
afterEach(() => {
delete process.env['OPENAI_API_KEY'];
delete process.env['CUSTOM_API_KEY'];
delete process.env['XUNFEI_API_KEY'];
delete process.env[CODING_PLAN_ENV_KEY];
});
it('should show OpenAI-compatible status with OPENAI_API_KEY', async () => {
process.env['OPENAI_API_KEY'] = 'test-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'gpt-4o',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('gpt-4o'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(writeStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenAI-compatible status with custom envKey from modelProviders', async () => {
process.env['CUSTOM_API_KEY'] = 'test-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'custom-model',
},
modelProviders: {
openai: [
{
id: 'custom-model',
envKey: 'CUSTOM_API_KEY',
baseUrl: 'https://custom-api.example.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('custom-model'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('https://custom-api.example.com/v1'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenAI-compatible status with settings.security.auth.apiKey', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
apiKey: 'settings-api-key',
baseUrl: 'https://my-provider.example.com/v1',
},
},
model: {
name: 'my-model',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('https://my-provider.example.com/v1'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show incomplete when no API key is found for OpenAI-compatible provider', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider (Incomplete)'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
expect(writeStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
});
it('should detect API key via default model when model.name is unset', async () => {
process.env['CUSTOM_API_KEY'] = 'test-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
modelProviders: {
openai: [
{
id: 'default-model',
envKey: 'CUSTOM_API_KEY',
baseUrl: 'https://default-api.example.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('https://default-api.example.com/v1'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Incomplete when explicit envKey is missing even if OPENAI_API_KEY is set', async () => {
process.env['OPENAI_API_KEY'] = 'fallback-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'custom-model',
},
modelProviders: {
openai: [
{
id: 'custom-model',
envKey: 'CUSTOM_API_KEY',
baseUrl: 'https://custom-api.example.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider (Incomplete)'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
});
it('should not bind to unrelated provider entry when model.name does not match', async () => {
process.env['OPENAI_API_KEY'] = 'test-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'manual-model-not-in-providers',
},
modelProviders: {
openai: [
{
id: 'other-model',
envKey: 'OTHER_API_KEY',
baseUrl: 'https://other-api.example.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
// Should NOT show the unrelated provider's base URL
expect(writeStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('https://other-api.example.com/v1'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenAI-compatible when stale codingPlan.region exists but active model is generic', async () => {
process.env['XUNFEI_API_KEY'] = 'active-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'stale-version',
},
model: {
name: 'spark-v4',
},
modelProviders: {
openai: [
{
id: 'spark-v4',
envKey: 'XUNFEI_API_KEY',
baseUrl: 'https://spark-api-open.xf-yun.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(writeStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenAI-compatible when stale Coding Plan key exists but active model is generic', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'stale-coding-plan-key';
process.env['XUNFEI_API_KEY'] = 'active-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'spark-v4',
},
modelProviders: {
openai: [
{
id: 'spark-v4',
envKey: 'XUNFEI_API_KEY',
baseUrl: 'https://spark-api-open.xf-yun.com/v1',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenAI-compatible Provider'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('spark-v4'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(writeStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
});
});
+3 -3
View File
@@ -38,7 +38,6 @@ import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import type { Settings } from './settings.js';
import { loadSettings, SettingScope } from './settings.js';
import { authCommand } from '../commands/auth.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
@@ -56,6 +55,7 @@ import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { channelCommand } from '../commands/channel.js';
import { authCommand } from '../commands/auth.js';
import { reviewCommand } from '../commands/review.js';
// UUID v4 regex pattern for validation
@@ -959,7 +959,6 @@ export async function parseArguments(): Promise<CliArgs> {
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand)
// Register Auth subcommands
.command(authCommand)
// Register Hooks subcommands
.command(hooksCommand)
@@ -987,11 +986,12 @@ export async function parseArguments(): Promise<CliArgs> {
result._.length > 0 &&
(result._[0] === 'mcp' ||
result._[0] === 'extensions' ||
result._[0] === 'auth' ||
result._[0] === 'hooks' ||
result._[0] === 'channel' ||
result._[0] === 'review')
) {
// MCP/Extensions/Hooks/Channel/Review commands handle their own
// MCP/Extensions/Auth/Hooks/Channel/Review commands handle their own
// execution and exit. Returning here would let the main interactive
// flow run, which would prompt for stdin input despite the user
// having already invoked a subcommand.
@@ -29,6 +29,47 @@ describe('authCommand', () => {
});
});
it('should return a dialog action when execution mode is undefined', () => {
if (!authCommand.action) {
throw new Error('The auth command must have an action.');
}
mockContext.executionMode = undefined;
const result = authCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'auth',
});
});
it.each(['non_interactive', 'acp'] as const)(
'should return an info message in %s mode',
(executionMode) => {
if (!authCommand.action) {
throw new Error('The auth command must have an action.');
}
mockContext.executionMode = executionMode;
const result = authCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Authentication configuration is only available in interactive mode. To configure authentication, run Qwen Code interactively and use /auth, or set environment variables: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL.',
});
},
);
it('should support interactive, non-interactive, and ACP modes', () => {
expect(authCommand.supportedModes).toEqual([
'interactive',
'non_interactive',
'acp',
]);
});
it('should have the correct name and description', () => {
expect(authCommand.name).toBe('auth');
expect(authCommand.description).toBe(
+25 -6
View File
@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import type {
OpenDialogActionReturn,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
@@ -15,9 +19,24 @@ export const authCommand: SlashCommand = {
return t('Configure authentication information for login');
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'auth',
}),
supportedModes: ['interactive', 'non_interactive', 'acp'],
action: (
context,
_args,
): OpenDialogActionReturn | SlashCommandActionReturn => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'info',
content: t(
'Authentication configuration is only available in interactive mode. To configure authentication, run Qwen Code interactively and use /auth, or set environment variables: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL.',
),
};
}
return {
type: 'dialog',
dialog: 'auth',
};
},
};
@@ -10,9 +10,18 @@ import { type CommandContext } from '../ui/commands/types.js';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import * as systemInfoUtils from './systemInfo.js';
import * as authModule from '../config/auth.js';
import * as allProviders from '../auth/allProviders.js';
vi.mock('./systemInfo.js');
vi.mock('../config/auth.js');
vi.mock('../auth/allProviders.js', async (importOriginal) => {
const actual =
(await importOriginal()) as typeof import('../auth/allProviders.js');
return {
...actual,
findProviderByCredentials: vi.fn(actual.findProviderByCredentials),
};
});
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
@@ -40,6 +49,11 @@ describe('runDoctorChecks', () => {
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getContentGeneratorConfig: vi.fn().mockReturnValue({
model: 'openrouter/auto',
baseUrl: 'https://openrouter.ai/api/v1',
apiKeyEnvKey: 'OPENROUTER_API_KEY',
}),
getMcpServers: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([{ name: 'tool1' }]),
@@ -61,6 +75,7 @@ describe('runDoctorChecks', () => {
});
afterEach(() => {
delete process.env['OPENROUTER_API_KEY'];
vi.clearAllMocks();
});
@@ -128,6 +143,79 @@ describe('runDoctorChecks', () => {
expect(authCheck!.status).toBe('pass');
});
it('should show provider detail when credentials match a known provider', async () => {
process.env['OPENROUTER_API_KEY'] = 'test-key';
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).toContain('Provider: OpenRouter');
expect(authCheck!.detail).toContain(
'Base URL: https://openrouter.ai/api/v1',
);
expect(authCheck!.detail).toContain('Model: openrouter/auto');
expect(authCheck!.detail).toContain(
'API key: configured (OPENROUTER_API_KEY)',
);
});
it('should show a missing API key in provider detail', async () => {
delete process.env['OPENROUTER_API_KEY'];
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).toContain('API key: OPENROUTER_API_KEY not set');
});
it('should treat an empty API key environment variable as not set', async () => {
process.env['OPENROUTER_API_KEY'] = '';
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).toContain('API key: OPENROUTER_API_KEY not set');
});
it('should omit provider detail when credentials do not match a known provider', async () => {
vi.mocked(allProviders.findProviderByCredentials).mockReturnValueOnce(
undefined,
);
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).not.toContain('Provider:');
expect(authCheck!.detail).toContain(
'Base URL: https://openrouter.ai/api/v1',
);
});
it('should fall back to config model when content generator config is missing', async () => {
const config = mockContext.services.config;
vi.mocked(config!.getContentGeneratorConfig).mockImplementationOnce(
() => undefined as never,
);
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).toBe('Model: gpt-4');
});
it('should omit detail when no enriched auth fields are available', async () => {
const config = mockContext.services.config;
vi.mocked(config!.getModel).mockReturnValueOnce(undefined as never);
vi.mocked(config!.getContentGeneratorConfig).mockReturnValueOnce(
{} as never,
);
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.detail).toBeUndefined();
});
it('should pass tool registry check when registry is loaded', async () => {
const results = await runDoctorChecks(mockContext);
const toolCheck = results.find((r) => r.name === 'Tool registry');
+37 -5
View File
@@ -8,6 +8,7 @@ import process from 'node:process';
import os from 'node:os';
import { getNpmVersion, getGitVersion } from './systemInfo.js';
import { validateAuthMethod } from '../config/auth.js';
import { findProviderByCredentials } from '../auth/allProviders.js';
import type { CommandContext } from '../ui/commands/types.js';
import type { DoctorCheckResult } from '../ui/types.js';
import {
@@ -71,7 +72,8 @@ function checkPlatform(): DoctorCheckResult {
}
function checkAuth(context: CommandContext): DoctorCheckResult {
const authType = context.services.config?.getAuthType();
const config = context.services.config;
const authType = config?.getAuthType();
if (!authType) {
return {
category: t('Authentication'),
@@ -82,10 +84,7 @@ function checkAuth(context: CommandContext): DoctorCheckResult {
};
}
const error = validateAuthMethod(
authType,
context.services.config ?? undefined,
);
const error = validateAuthMethod(authType, config ?? undefined);
if (error) {
return {
category: t('Authentication'),
@@ -96,11 +95,44 @@ function checkAuth(context: CommandContext): DoctorCheckResult {
};
}
// Build enriched diagnostic information
const cgConfig =
typeof config?.getContentGeneratorConfig === 'function'
? config.getContentGeneratorConfig()
: undefined;
const model = cgConfig?.model ?? config?.getModel();
const baseUrl = cgConfig?.baseUrl;
const envKey = cgConfig?.apiKeyEnvKey;
const provider = findProviderByCredentials(baseUrl, envKey);
const detailParts: string[] = [];
if (provider) {
detailParts.push(
t('Provider: {{provider}}', { provider: t(provider.label) }),
);
}
if (baseUrl) {
detailParts.push(t('Base URL: {{baseUrl}}', { baseUrl }));
}
if (model) {
detailParts.push(t('Model: {{model}}', { model }));
}
if (envKey) {
const hasKey = !!process.env[envKey];
detailParts.push(
hasKey
? t('API key: configured ({{envKey}})', { envKey })
: t('API key: {{envKey}} not set', { envKey }),
);
}
return {
category: t('Authentication'),
name: t('API key'),
status: 'pass',
message: t('configured ({{authType}})', { authType }),
detail: detailParts.length > 0 ? detailParts.join('\n') : undefined,
};
}
+1 -4
View File
@@ -1490,10 +1490,7 @@ export class Config {
// sidecar that happens to share the outgoing session id —
// mirrors kimi-cli PR #2082's "write only when a session is
// established for this process" rule.
if (
this.runtimeStatusEnabled &&
previousSessionId !== this.sessionId
) {
if (this.runtimeStatusEnabled && previousSessionId !== this.sessionId) {
const oldPath = this.storage.getRuntimeStatusPath(previousSessionId);
const newPath = this.storage.getRuntimeStatusPath(this.sessionId);
const cliVersion = this.cliVersion ?? null;
+5 -1
View File
@@ -251,7 +251,11 @@ export class Storage {
* the same directory used for chat history to find live sessions.
*/
getRuntimeStatusPath(sessionId: string): string {
return path.join(this.getProjectDir(), 'chats', `${sessionId}.runtime.json`);
return path.join(
this.getProjectDir(),
'chats',
`${sessionId}.runtime.json`,
);
}
getProjectTempCheckpointsDir(): string {
@@ -5,7 +5,11 @@
*/
import { describe, it, expect } from 'vitest';
import { deriveTraceId, randomSpanId, randomHexString } from './trace-id-utils.js';
import {
deriveTraceId,
randomSpanId,
randomHexString,
} from './trace-id-utils.js';
describe('deriveTraceId', () => {
it('returns a 32-char hex string', () => {