mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
84ecb5b8a3
commit
7e11428545
19 files changed
+403
-2538
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user