Feat/openrouter auth (#3576)

* feat(cli): add OpenRouter auth flow

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): add OpenRouter model management UI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): align OpenRouter OAuth fallback session

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(cli): unify OpenRouter model setup flow

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(auth): update OAuth description with provider examples and i18n support

- Updated OAuth option description to include provider examples (OpenRouter, ModelScope)
- Added internationalization support for new description text
- Updated all language files (en, zh, de, fr, ja, pt, ru) with translations

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs: simplify OpenRouter design docs

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(auth): fix OpenRouter OAuth mock typing

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(auth): sync AuthDialog tests with new three-option main menu layout

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Update assertions that referenced removed 'Qwen OAuth' and 'OpenRouter' options in the main/API-key views to match the refactored OAUTH / CODING_PLAN / API_KEY structure.

* fix(i18n): add missing zh-TW translation for browser-based auth key

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

zh-TW.js was generated from main's en.js which had already removed this key, but the PR re-adds it in en.js. Sync zh-TW with the new translation.

* feat(cli): Improve custom auth wizard with step indicators and cleaner advanced config (#3607)

* feat(cli): Add custom API key auth wizard with 6-step setup flow

Replace the documentation-only

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"Custom API Key" screen with an
in-terminal wizard: Protocol select → Base URL input → API Key input →
Model ID input → JSON review → Save.

- Add 5 new ViewLevels and render functions in AuthDialog
- Implement utility functions: generateCustomApiKeyEnvKey (normalization),
  normalizeCustomModelIds (split/trim/dedupe), maskApiKey (display)
- Implement handleCustomApiKeySubmit in useAuth with backup, env key
  generation, modelProviders merge, auth refresh, and user feedback
- Wire handler through UIActionsContext and AppContainer
- Add 18 unit tests for utilities, 4 wizard flow integration tests

* feat(cli): Improve custom auth wizard with step indicators and cleaner advanced config

- Add step indicators (Step 1/6 · Protocol) to each wizard screen
- Remove redundant Protocol/Endpoint context from each step for focus
- Redesign advanced config: add descriptions to thinking/modality toggles
- Remove max tokens option; keep only thinking and modality settings
- Add ↑↓ arrow navigation with Space toggle and Enter to continue
- Generation config flows through review JSON and final submit

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test: Fix Windows CI failures in fileUtils and AuthDialog tests

- fileUtils.test.ts: Mock node:child_process execFile to prevent
  pdftotext spawn that times out on Windows (ENOENT, 5s timeout)
- AuthDialog.test.tsx: Add char-by-char typeText() helper to work
  around Node 24.x + ink TextInput compatibility issue on Windows

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): Reset advanced wizard state and use JSON.stringify for settings preview

- Reset advancedThinkingEnabled, advancedModalityEnabled, and
  focusedConfigIndex when re-entering custom wizard to prevent
  state leakage between configurations
- Replace hand-rolled JSON string concatenation with
  JSON.stringify for settings.json preview to properly escape
  special characters in model IDs and base URLs

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): harden OpenRouter OAuth callback handling

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): stabilize OpenRouter state mismatch test

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): stabilize custom auth wizard navigation

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
pomelo 2026-04-27 14:47:44 +08:00 committed by GitHub
parent 96bc874197
commit 7fe853a782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 5666 additions and 129 deletions

1
.gitignore vendored
View file

@ -89,3 +89,4 @@ storybook-static
# Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js)
packages/core/src/skills/bundled/qc-helper/docs
tmp/

View file

@ -0,0 +1,89 @@
# OpenRouter Auth and Model Management Design
This document captures the design intent behind the OpenRouter auth flow and the
model management changes introduced with it. It intentionally focuses on the
product and architectural choices, not implementation history.
## Goals
- Let users authenticate with OpenRouter from both CLI and `/auth`.
- Reuse the existing OpenAI-compatible provider path instead of adding a new auth
type for OpenRouter.
- Make the first-run experience usable without asking users to manage hundreds of
models immediately.
- Keep a clear path toward richer model management via `/manage-models`.
## OpenRouter Auth
OpenRouter is integrated as an OpenAI-compatible provider:
- auth type: `AuthType.USE_OPENAI`
- provider settings: `modelProviders.openai`
- API key env var: `OPENROUTER_API_KEY`
- base URL: `https://openrouter.ai/api/v1`
This avoids introducing an OpenRouter-specific `AuthType` when the runtime model
provider path is already OpenAI-compatible. It keeps auth status, model
resolution, provider selection, and settings schema aligned with the existing
provider abstraction.
The user-facing flows are:
- `qwen auth openrouter --key <key>` for automation or direct API-key setup.
- `qwen auth openrouter` for browser-based OAuth.
- `/auth` → API Key → OpenRouter for the TUI flow.
Browser OAuth uses OpenRouter's PKCE flow and writes the exchanged API key into
settings before refreshing auth as `AuthType.USE_OPENAI`.
## Model Management
OpenRouter exposes a large dynamic model catalog. Writing every discovered model
into `modelProviders.openai` would make `/model` noisy and would turn a long-term
settings field into a cache of a remote catalog.
The key design split is:
- **Catalog**: the full set of models discovered from a source such as
OpenRouter.
- **Enabled set**: the smaller set of models that should appear in `/model` and
be persisted in user settings.
For the initial OpenRouter flow, auth should finish with a useful default enabled
set instead of interrupting the user with a large picker. The recommended set
should be small, stable, and biased toward models that let users try the product
successfully, including free models when available.
`/model` remains a fast model switcher. It should not become the place where
users browse and curate a full provider catalog.
## `/manage-models`
Richer model management belongs in a separate `/manage-models` entry point. That
flow should let users:
- browse discovered models;
- search by id, display name, provider prefix, and derived tags such as `free` or
`vision`;
- see which models are currently enabled;
- enable or disable models in batches.
The source dimension must remain part of this design. OpenRouter is only the
first dynamic catalog source; future sources such as ModelScope and ModelStudio
should fit the same shape. UI complexity can be reduced, but the underlying
source abstraction should stay available as the extension point.
## Current Boundary
This change should do the minimum needed to make OpenRouter auth and model setup
pleasant:
- OAuth or key-based auth configures OpenRouter through the existing
OpenAI-compatible provider path.
- The initial enabled model set is curated instead of dumping the full catalog
into settings.
- Full catalog storage, browsing, filtering, and batch management are deferred to
`/manage-models`.
The design principle is simple: authentication should get users to a working
state quickly, while model curation should live in a dedicated management flow.

View file

@ -50,6 +50,21 @@ const codePlanCommand = {
},
};
const openRouterCommand = {
command: 'openrouter',
describe: t('Authenticate using OpenRouter API key setup'),
builder: (yargs: Argv) =>
yargs.option('key', {
alias: 'k',
describe: t('API key for OpenRouter'),
type: 'string',
}),
handler: async (argv: { key?: string }) => {
const key = argv['key'] as string | undefined;
await handleQwenAuth('openrouter', { key });
},
};
const statusCommand = {
command: 'status',
describe: t('Show current authentication status'),
@ -61,12 +76,13 @@ const statusCommand = {
export const authCommand: CommandModule = {
command: 'auth',
describe: t(
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
'Configure Qwen authentication information with Qwen-OAuth, OpenRouter, or Alibaba Cloud Coding Plan',
),
builder: (yargs: Argv) =>
yargs
.command(qwenOauthCommand)
.command(codePlanCommand)
.command(openRouterCommand)
.command(statusCommand)
.demandCommand(0) // Don't require a subcommand
.version(false),

View file

@ -12,18 +12,29 @@ import {
} from '@qwen-code/qwen-code-core';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { t } from '../../i18n/index.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
getCodingPlanConfig,
isCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '@qwen-code/qwen-code-core';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
} from '../../constants/codingPlan.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { CliArgs } from '../../config/config.js';
import { InteractiveSelector } from './interactiveSelector.js';
import {
applyOpenRouterModelsConfiguration,
createOpenRouterOAuthSession,
isOpenRouterConfig,
OPENROUTER_ENV_KEY,
runOpenRouterOAuthLogin,
} from './openrouterOAuth.js';
function formatElapsedTime(startMs: number): string {
return `${((Date.now() - startMs) / 1000).toFixed(2)}s`;
}
interface QwenAuthOptions {
region?: string;
@ -53,7 +64,7 @@ interface MergedSettingsWithCodingPlan {
* Handles the authentication process based on the specified command and options
*/
export async function handleQwenAuth(
command: 'qwen-oauth' | 'coding-plan',
command: 'qwen-oauth' | 'coding-plan' | 'openrouter',
options: QwenAuthOptions,
) {
try {
@ -126,6 +137,8 @@ export async function handleQwenAuth(
await handleQwenOAuth(config, settings);
} else if (command === 'coding-plan') {
await handleCodePlanAuth(config, settings, options);
} else if (command === 'openrouter') {
await handleOpenRouterAuth(config, settings, options);
}
// Exit after authentication is complete
@ -192,7 +205,7 @@ async function handleCodePlanAuth(
} else {
// Otherwise, prompt interactively
selectedRegion = await promptForRegion();
selectedKey = await promptForKey();
selectedKey = await promptForAuthKey(t('Enter your Coding Plan API key: '));
}
writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...'));
@ -279,6 +292,105 @@ async function handleCodePlanAuth(
}
}
/**
* Handles OpenRouter API key setup.
*/
async function handleOpenRouterAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<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 authTypeScope = getPersistScopeForModelSelection(settings);
const settingsFile = settings.forScope(authTypeScope);
backupSettingsFile(settingsFile.path);
const modelsStartMs = Date.now();
await applyOpenRouterModelsConfiguration({
settings,
config,
apiKey: selectedKey,
reloadConfig: true,
});
writeStdoutLine(
t('Fetched OpenRouter models in {{elapsed}}.', {
elapsed: formatElapsedTime(modelsStartMs),
}),
);
const refreshStartMs = Date.now();
await config.refreshAuth(AuthType.USE_OPENAI);
writeStdoutLine(
t('Refreshed OpenRouter auth in {{elapsed}}.', {
elapsed: formatElapsedTime(refreshStartMs),
}),
);
writeStdoutLine(
t('Total OpenRouter setup time: {{elapsed}}.', {
elapsed: formatElapsedTime(authStartMs),
}),
);
writeStdoutLine(t('Successfully configured OpenRouter.'));
} catch (error) {
writeStderrLine(
t('Failed to configure OpenRouter: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Prompts the user to select a region using an interactive selector
*/
@ -305,12 +417,12 @@ async function promptForRegion(): Promise<CodingPlanRegion> {
/**
* Prompts the user to enter an API key
*/
async function promptForKey(): Promise<string> {
async function promptForAuthKey(prompt: string): Promise<string> {
// Create a simple password-style input (without echoing characters)
const stdin = process.stdin;
const stdout = process.stdout;
stdout.write(t('Enter your Coding Plan API key: '));
stdout.write(prompt);
// Set raw mode to capture keystrokes
const wasRaw = stdin.isRaw;
@ -370,6 +482,13 @@ async function promptForKey(): Promise<string> {
export async function runInteractiveAuth() {
const selector = new InteractiveSelector(
[
{
value: 'openrouter' as const,
label: t('OpenRouter'),
description: t(
'API key setup · OpenAI-compatible provider via OpenRouter',
),
},
{
value: 'coding-plan' as const,
label: t('Alibaba Cloud Coding Plan'),
@ -400,6 +519,8 @@ export async function runInteractiveAuth() {
if (choice === 'coding-plan') {
await handleQwenAuth('coding-plan', {});
} else if (choice === 'openrouter') {
await handleQwenAuth('openrouter', {});
}
}
@ -419,6 +540,9 @@ export async function showAuthStatus(): Promise<void> {
if (!selectedType) {
writeStdoutLine(t('⚠️ No authentication method configured.\n'));
writeStdoutLine(t('Run one of the following commands to get started:\n'));
writeStdoutLine(
t(' qwen auth openrouter - Configure OpenRouter API key'),
);
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
@ -446,54 +570,83 @@ export async function showAuthStatus(): Promise<void> {
t('\n ⚠ Run /auth to switch to Coding Plan or another provider.\n'),
);
} else if (selectedType === AuthType.USE_OPENAI) {
// Check for Coding Plan configuration
const codingPlanRegion = mergedSettings.codingPlan?.region;
const codingPlanVersion = mergedSettings.codingPlan?.version;
const modelName = mergedSettings.model?.name;
const openAiProviders =
mergedSettings.modelProviders?.[AuthType.USE_OPENAI] || [];
const hasOpenRouterConfig = openAiProviders.some(isOpenRouterConfig);
const hasOpenRouterApiKey =
!!process.env[OPENROUTER_ENV_KEY] ||
!!mergedSettings.env?.[OPENROUTER_ENV_KEY];
// Check if API key is set in environment
const hasApiKey =
!!process.env[CODING_PLAN_ENV_KEY] ||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
);
if (codingPlanRegion) {
const regionDisplay =
codingPlanRegion === CodingPlanRegion.CHINA
? t('中国 (China) - 阿里云百炼')
: t('Global - Alibaba Cloud');
writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay }));
}
if (modelName) {
if (hasOpenRouterConfig) {
if (hasOpenRouterApiKey) {
writeStdoutLine(t('✓ Authentication Method: OpenRouter'));
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
t('⚠️ Authentication Method: OpenRouter (Incomplete)'),
);
}
if (codingPlanVersion) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: codingPlanVersion.substring(0, 8) + '...',
}),
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth openrouter` to re-configure.\n'));
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n'));
// Check for Coding Plan configuration
const hasApiKey =
!!process.env[CODING_PLAN_ENV_KEY] ||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
);
if (codingPlanRegion) {
const regionDisplay =
codingPlanRegion === CodingPlanRegion.CHINA
? t('中国 (China) - 阿里云百炼')
: t('Global - Alibaba Cloud');
writeStdoutLine(
t(' Region: {{region}}', { region: regionDisplay }),
);
}
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
if (codingPlanVersion) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: codingPlanVersion.substring(0, 8) + '...',
}),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(
t(' Run `qwen auth coding-plan` to re-configure.\n'),
);
}
}
} else {
writeStdoutLine(

View file

@ -0,0 +1,304 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { handleQwenAuth } from './handler.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
const {
mockRefreshAuth,
mockSetValue,
mockForScope,
mockBackupSettingsFile,
mockLoadCliConfig,
} = vi.hoisted(() => {
const mockRefreshAuth = vi.fn();
return {
mockRefreshAuth,
mockSetValue: vi.fn(),
mockForScope: vi.fn(() => ({ path: '/user.json' })),
mockBackupSettingsFile: vi.fn(),
mockLoadCliConfig: vi.fn(async () => ({
refreshAuth: mockRefreshAuth,
})),
};
});
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/config.js', () => ({
loadCliConfig: mockLoadCliConfig,
}));
vi.mock('../../utils/settingsUtils.js', () => ({
backupSettingsFile: mockBackupSettingsFile,
}));
vi.mock('../../config/modelProvidersScope.js', () => ({
getPersistScopeForModelSelection: vi.fn(() => 'user'),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
vi.mock('./openrouterOAuth.js', () => ({
OPENROUTER_ENV_KEY: 'OPENROUTER_API_KEY',
OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback',
createOpenRouterOAuthSession: vi.fn(() => ({
callbackUrl: 'http://localhost:3000/openrouter/callback',
codeVerifier: 'test-verifier',
authorizationUrl: 'https://openrouter.ai/auth?manual=1',
})),
applyOpenRouterModelsConfiguration: vi.fn(async ({ settings, apiKey }) => {
process.env['OPENROUTER_API_KEY'] = apiKey;
settings.setValue('user', 'env.OPENROUTER_API_KEY', apiKey);
settings.setValue(
'user',
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
settings.setValue('user', 'model.name', 'openai/gpt-4o-mini:free');
settings.setValue('user', `modelProviders.${AuthType.USE_OPENAI}`, [
{
id: 'openai/gpt-4o-mini:free',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
]);
return {
updatedConfigs: [
{
id: 'openai/gpt-4o-mini:free',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
activeModelId: 'openai/gpt-4o-mini:free',
persistScope: 'user',
};
}),
runOpenRouterOAuthLogin: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import {
applyOpenRouterModelsConfiguration,
runOpenRouterOAuthLogin,
} from './openrouterOAuth.js';
describe('handleQwenAuth openrouter', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env['OPENROUTER_API_KEY'];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env['OPENROUTER_API_KEY'];
});
const createMockSettings = (
merged: Record<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',
'openai/gpt-4o-mini:free',
);
const modelProvidersCall = mockSetValue.mock.calls.find(
(call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`,
);
expect(modelProvidersCall).toBeDefined();
expect(modelProvidersCall?.[2]).toEqual([
{
id: 'openai/gpt-4o-mini:free',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
]);
expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.anything(),
config: expect.anything(),
apiKey: 'or-key-123',
reloadConfig: true,
}),
);
expect(mockRefreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
expect(process.env['OPENROUTER_API_KEY']).toBe('or-key-123');
});
it('replaces existing OpenRouter configs instead of duplicating them', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [
{
id: 'old/model',
name: 'Old OpenRouter Model',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
},
}),
);
await handleQwenAuth('openrouter', { key: 'or-key-456' });
const modelProvidersCall = mockSetValue.mock.calls.find(
(call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`,
);
expect(modelProvidersCall?.[2]).toEqual([
{
id: 'openai/gpt-4o-mini:free',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
]);
});
it('uses OAuth flow when key is not provided', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
vi.mocked(runOpenRouterOAuthLogin).mockResolvedValue({
apiKey: 'oauth-key-123',
userId: 'user-1',
authorizationUrl: 'https://openrouter.ai/auth?manual=1',
});
await handleQwenAuth('openrouter', {});
expect(runOpenRouterOAuthLogin).toHaveBeenCalledTimes(1);
expect(mockSetValue).toHaveBeenCalledWith(
'user',
'env.OPENROUTER_API_KEY',
'oauth-key-123',
);
expect(process.env['OPENROUTER_API_KEY']).toBe('oauth-key-123');
});
it('delegates OpenRouter provider updates to the shared configuration helper', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await handleQwenAuth('openrouter', { key: 'or-key-dynamic' });
expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.anything(),
config: expect.anything(),
apiKey: 'or-key-dynamic',
reloadConfig: true,
}),
);
});
});

View file

@ -0,0 +1,730 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import {
buildOpenRouterAuthorizationUrl,
createOpenRouterOAuthSession,
createOAuthState,
createPkcePair,
exchangeAuthCodeForApiKey,
fetchOpenRouterModels,
getOpenRouterModelsWithFallback,
getPreferredOpenRouterModelId,
mergeOpenRouterConfigs,
OPENROUTER_DEFAULT_MODELS,
OPENROUTER_MODELS_URL,
OPENROUTER_OAUTH_AUTHORIZE_URL,
OPENROUTER_OAUTH_EXCHANGE_URL,
runOpenRouterOAuthLogin,
selectRecommendedOpenRouterModels,
startOAuthCallbackListener,
applyOpenRouterModelsConfiguration,
} from './openrouterOAuth.js';
import { request } from 'node:http';
describe('openrouterOAuth', () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('creates a valid PKCE pair', () => {
const pkce = createPkcePair();
expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9\-_]+$/);
expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9\-_]+$/);
expect(pkce.codeVerifier.length).toBeGreaterThan(20);
expect(pkce.codeChallenge.length).toBeGreaterThan(20);
});
it('builds OpenRouter authorization URL with required params', () => {
const url = buildOpenRouterAuthorizationUrl({
callbackUrl: 'http://localhost:3000/openrouter/callback',
codeChallenge: 'challenge123',
state: 'state-123',
codeChallengeMethod: 'S256',
limit: 100,
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
OPENROUTER_OAUTH_AUTHORIZE_URL,
);
expect(parsed.searchParams.get('callback_url')).toBe(
'http://localhost:3000/openrouter/callback',
);
expect(parsed.searchParams.get('code_challenge')).toBe('challenge123');
expect(parsed.searchParams.get('state')).toBe('state-123');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('limit')).toBe('100');
});
it('creates a random OAuth state token', () => {
const state = createOAuthState();
expect(state).toMatch(/^[A-Za-z0-9\-_]+$/);
expect(state.length).toBeGreaterThan(20);
});
it('exchanges auth code for API key', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
key: 'or-key-123',
user_id: 'user-1',
}),
}));
vi.stubGlobal('fetch', fetchMock);
const result = await exchangeAuthCodeForApiKey({
code: 'auth-code-123',
codeVerifier: 'verifier-123',
});
expect(fetchMock).toHaveBeenCalledWith(
OPENROUTER_OAUTH_EXCHANGE_URL,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
}),
}),
);
expect(result).toEqual({
apiKey: 'or-key-123',
userId: 'user-1',
});
expect(typeof result.apiKey).toBe('string');
});
it('throws when exchange response does not contain key', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({}),
})),
);
await expect(
exchangeAuthCodeForApiKey({
code: 'auth-code-123',
codeVerifier: 'verifier-123',
}),
).rejects.toThrow('no key was returned');
});
it('resolves callback code without waiting for server close completion', async () => {
const listener = startOAuthCallbackListener(
'http://localhost:3100/openrouter/callback',
5000,
'state-123',
);
await listener.ready;
const codePromise = listener.waitForCode;
await new Promise<void>((resolve, reject) => {
const req = request(
'http://localhost:3100/openrouter/callback?code=fast-code-123&state=state-123',
(res) => {
res.resume();
res.on('end', resolve);
},
);
req.on('error', reject);
req.end();
});
await expect(codePromise).resolves.toBe('fast-code-123');
});
it('rejects callback codes with mismatched OAuth state', async () => {
const listener = startOAuthCallbackListener(
'http://localhost:3101/openrouter/callback',
5000,
'expected-state',
);
await listener.ready;
const codePromise = listener.waitForCode.catch((error: unknown) => error);
await new Promise<void>((resolve, reject) => {
const req = request(
'http://localhost:3101/openrouter/callback?code=fast-code-123&state=wrong-state',
(res) => {
expect(res.statusCode).toBe(400);
res.resume();
res.on('end', resolve);
},
);
req.on('error', reject);
req.end();
});
await expect(codePromise).resolves.toEqual(
expect.objectContaining({
message: expect.stringContaining('Invalid OAuth state'),
}),
);
}, 15_000);
it('creates a reusable OAuth session for manual fallback links', () => {
const session = createOpenRouterOAuthSession(
'http://localhost:3000/openrouter/callback',
{
codeVerifier: 'verifier-123',
codeChallenge: 'challenge-123',
},
'state-123',
);
expect(session).toEqual({
callbackUrl: 'http://localhost:3000/openrouter/callback',
codeVerifier: 'verifier-123',
state: 'state-123',
authorizationUrl: expect.stringContaining('code_challenge=challenge-123'),
});
expect(session.authorizationUrl).toContain('state=state-123');
});
it('returns OAuth result without waiting for slow listener close', async () => {
let resolveClose!: () => void;
const listener = {
ready: Promise.resolve(),
waitForCode: Promise.resolve('auth-code-123'),
close: vi.fn(
() =>
new Promise<void>((resolve) => {
resolveClose = resolve;
}),
),
};
const openBrowser = vi.fn(async () => ({}) as never);
const exchangeApiKey = vi.fn(async () => ({
apiKey: 'or-key-123',
userId: 'user-1',
}));
const resultPromise = runOpenRouterOAuthLogin(
'http://localhost:3000/openrouter/callback',
{
openBrowser,
startListener: vi.fn(() => listener),
exchangeApiKey,
now: () => 1000,
},
);
await expect(resultPromise).resolves.toMatchObject({
apiKey: 'or-key-123',
userId: 'user-1',
authorizationUrl: expect.stringContaining('https://openrouter.ai/auth'),
});
expect(listener.close).toHaveBeenCalled();
resolveClose();
});
it('passes the session state to the OAuth callback listener', async () => {
const listener = {
ready: Promise.resolve(),
waitForCode: Promise.resolve('auth-code-123'),
close: vi.fn(async () => undefined),
};
const openBrowser = vi.fn(async () => ({}) as never);
const startListener = vi.fn(() => listener);
const exchangeApiKey = vi.fn(async () => ({
apiKey: 'or-key-123',
userId: 'user-1',
}));
await runOpenRouterOAuthLogin('http://localhost:3000/openrouter/callback', {
openBrowser,
startListener,
exchangeApiKey,
session: {
callbackUrl: 'http://localhost:3000/openrouter/callback',
codeVerifier: 'verifier-123',
state: 'state-123',
authorizationUrl: 'https://openrouter.ai/auth?state=state-123',
},
});
expect(startListener).toHaveBeenCalledWith(
'http://localhost:3000/openrouter/callback',
expect.any(Number),
'state-123',
);
});
it('records wait and exchange timings during OAuth login', async () => {
const listener = {
ready: Promise.resolve(),
waitForCode: Promise.resolve('auth-code-123'),
close: vi.fn(async () => undefined),
};
const openBrowser = vi.fn(async () => ({}) as never);
const exchangeApiKey = vi.fn(async () => ({
apiKey: 'or-key-123',
userId: 'user-1',
}));
const now = vi
.fn<() => number>()
.mockReturnValueOnce(1000)
.mockReturnValueOnce(2200)
.mockReturnValueOnce(3000)
.mockReturnValueOnce(3450);
const result = await runOpenRouterOAuthLogin(
'http://localhost:3000/openrouter/callback',
{
openBrowser,
startListener: () => listener,
exchangeApiKey,
now,
},
);
expect(openBrowser).toHaveBeenCalledWith(
expect.stringContaining('https://openrouter.ai/auth'),
);
expect(exchangeApiKey).toHaveBeenCalledWith({
code: 'auth-code-123',
codeVerifier: expect.any(String),
});
expect(result).toEqual({
apiKey: 'or-key-123',
userId: 'user-1',
authorizationUrl: expect.stringContaining('https://openrouter.ai/auth'),
authorizationCodeWaitMs: 1200,
apiKeyExchangeMs: 450,
});
expect(listener.close).toHaveBeenCalled();
});
it('allows cancelling OAuth wait with process signals after opening the browser', async () => {
let sigintHandler: ((signal: NodeJS.Signals) => void) | undefined;
let sigtermHandler: ((signal: NodeJS.Signals) => void) | undefined;
const signalTarget = {
once: vi.fn(
(
event: 'SIGINT' | 'SIGTERM',
handler: (signal: NodeJS.Signals) => void,
) => {
if (event === 'SIGINT') {
sigintHandler = handler;
} else {
sigtermHandler = handler;
}
},
),
removeListener: vi.fn(
(
_event: 'SIGINT' | 'SIGTERM',
_handler: (signal: NodeJS.Signals) => void,
) => undefined,
),
};
const listener = {
ready: Promise.resolve(),
waitForCode: new Promise<string>(() => undefined),
close: vi.fn(async () => undefined),
};
const openBrowser = vi.fn(async () => ({}) as never);
const exchangeApiKey = vi.fn();
const resultPromise = runOpenRouterOAuthLogin(
'http://localhost:3000/openrouter/callback',
{
openBrowser,
startListener: () => listener,
exchangeApiKey,
signalTarget,
},
);
await vi.waitFor(() => {
expect(openBrowser).toHaveBeenCalledTimes(1);
expect(sigintHandler).toBeTypeOf('function');
expect(sigtermHandler).toBeTypeOf('function');
});
sigintHandler?.('SIGINT');
await expect(resultPromise).rejects.toThrow(
'OpenRouter OAuth cancelled by user (SIGINT) while waiting for browser authorization.',
);
expect(exchangeApiKey).not.toHaveBeenCalled();
expect(listener.close).toHaveBeenCalled();
expect(signalTarget.removeListener).toHaveBeenCalledWith(
'SIGINT',
sigintHandler,
);
expect(signalTarget.removeListener).toHaveBeenCalledWith(
'SIGTERM',
sigtermHandler,
);
});
it('allows cancelling OAuth wait with an abort signal', async () => {
const abortController = new AbortController();
const listener = {
ready: Promise.resolve(),
waitForCode: new Promise<string>(() => undefined),
close: vi.fn(async () => undefined),
};
const openBrowser = vi.fn(async () => ({}) as never);
const exchangeApiKey = vi.fn();
const resultPromise = runOpenRouterOAuthLogin(
'http://localhost:3000/openrouter/callback',
{
openBrowser,
startListener: () => listener,
exchangeApiKey,
abortSignal: abortController.signal,
},
);
await vi.waitFor(() => {
expect(openBrowser).toHaveBeenCalledTimes(1);
});
abortController.abort();
await expect(resultPromise).rejects.toMatchObject({
name: 'AbortError',
message: 'OpenRouter OAuth cancelled.',
});
expect(exchangeApiKey).not.toHaveBeenCalled();
expect(listener.close).toHaveBeenCalled();
});
it('fetches dynamic OpenRouter text models with free-first ordering', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
data: [
{
id: 'openai/gpt-5-mini',
name: 'GPT-5 Mini',
context_length: 128000,
architecture: {
input_modalities: ['text', 'image'],
output_modalities: ['text'],
},
pricing: {
prompt: '0.000001',
completion: '0.000003',
},
},
{
id: 'minimax/minimax-m1',
name: 'MiniMax M1',
architecture: {
input_modalities: ['text'],
output_modalities: ['text'],
},
pricing: {
prompt: '0',
completion: '0',
},
},
{
id: 'qwen/qwen3-coder:free',
name: 'Qwen3 Coder',
architecture: {
input_modalities: ['text'],
output_modalities: ['text'],
},
pricing: {
prompt: '0',
completion: '0',
},
},
{
id: 'zhipu/glm-4.5',
name: 'GLM 4.5',
architecture: {
input_modalities: ['text'],
output_modalities: ['text'],
},
pricing: {
prompt: '0.000002',
completion: '0.000004',
},
},
{
id: 'black-forest-labs/flux',
name: 'Flux',
architecture: {
input_modalities: ['text'],
output_modalities: ['image'],
},
},
],
}),
}));
vi.stubGlobal('fetch', fetchMock);
const models = await fetchOpenRouterModels();
expect(fetchMock).toHaveBeenCalledWith(
OPENROUTER_MODELS_URL,
expect.objectContaining({ method: 'GET' }),
);
expect(models).toEqual([
{
id: 'qwen/qwen3-coder:free',
name: 'OpenRouter · Qwen3 Coder',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'minimax/minimax-m1',
name: 'OpenRouter · MiniMax M1',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'openai/gpt-5-mini',
name: 'OpenRouter · GPT-5 Mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
capabilities: { vision: true },
generationConfig: { contextWindowSize: 128000 },
},
{
id: 'zhipu/glm-4.5',
name: 'OpenRouter · GLM 4.5',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
]);
});
it('selects a recommended OpenRouter subset instead of returning the full catalog', () => {
const recommended = selectRecommendedOpenRouterModels(
[
{
id: 'qwen/qwen3-coder:free',
name: 'OpenRouter · Qwen3 Coder',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'qwen/qwen3-max',
name: 'OpenRouter · Qwen3 Max',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'glm/glm-4.5-air:free',
name: 'OpenRouter · GLM 4.5 Air',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'minimax/minimax-m1',
name: 'OpenRouter · MiniMax M1',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'google/gemini-2.5-flash',
name: 'OpenRouter · Gemini 2.5 Flash',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'openai/gpt-5-mini',
name: 'OpenRouter · GPT-5 Mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
capabilities: { vision: true },
},
{
id: 'deepseek/deepseek-r1',
name: 'OpenRouter · DeepSeek R1',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
generationConfig: { contextWindowSize: 1048576 },
},
{
id: 'meta/llama-3.3-70b',
name: 'OpenRouter · Llama 3.3 70B',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
6,
);
expect(recommended.map((model) => model.id)).toEqual([
'qwen/qwen3-coder:free',
'glm/glm-4.5-air:free',
'qwen/qwen3-max',
'minimax/minimax-m1',
'anthropic/claude-3.7-sonnet',
'google/gemini-2.5-flash',
]);
});
it('applies OpenRouter configuration to settings and reloads providers', async () => {
const settings = {
merged: {
modelProviders: {
[AuthType.USE_OPENAI]: [
{ id: 'custom/model', baseUrl: 'https://example.com/v1' },
],
},
},
user: { settings: { modelProviders: {} }, path: '/user.json' },
workspace: { settings: {}, path: '/workspace.json' },
system: { settings: {}, path: '/system.json' },
systemDefaults: { settings: {}, path: '/system-defaults.json' },
setValue: vi.fn(),
forScope: vi.fn(),
} as unknown as LoadedSettings;
const config = {
reloadModelProvidersConfig: vi.fn(),
} as unknown as Config;
const fetchSpy = vi
.spyOn(
await import('./openrouterOAuth.js'),
'getOpenRouterModelsWithFallback',
)
.mockResolvedValue([
{
id: 'openai/gpt-4o-mini',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
]);
const result = await applyOpenRouterModelsConfiguration({
settings,
config,
apiKey: 'or-key-123',
reloadConfig: true,
});
expect(settings.setValue).toHaveBeenCalledWith(
expect.anything(),
'env.OPENROUTER_API_KEY',
'or-key-123',
);
const modelProvidersCall = vi
.mocked(settings.setValue)
.mock.calls.find(
(call) => call[1] === `modelProviders.${AuthType.USE_OPENAI}`,
);
expect(modelProvidersCall).toBeDefined();
expect(modelProvidersCall?.[2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
}),
expect.objectContaining({
id: 'custom/model',
baseUrl: 'https://example.com/v1',
}),
]),
);
expect(config.reloadModelProvidersConfig).toHaveBeenCalled();
expect(result.activeModelId).toBeDefined();
fetchSpy.mockRestore();
});
it('prefers the default OpenRouter model when it remains enabled', () => {
expect(
getPreferredOpenRouterModelId([
{ id: 'anthropic/claude-3.7-sonnet' },
{ id: 'openai/gpt-4o-mini' },
] as never),
).toBe('openai/gpt-4o-mini');
});
it('falls back to the first enabled OpenRouter model when the default is unavailable', () => {
expect(
getPreferredOpenRouterModelId([
{ id: 'anthropic/claude-3.7-sonnet' },
] as never),
).toBe('anthropic/claude-3.7-sonnet');
});
it('falls back to default models when dynamic fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: false,
status: 500,
text: async () => 'server error',
})),
);
await expect(getOpenRouterModelsWithFallback()).resolves.toEqual(
OPENROUTER_DEFAULT_MODELS,
);
});
it('replaces only existing OpenRouter configs when merging dynamic models', () => {
const merged = mergeOpenRouterConfigs(
[
{
id: 'old/model',
name: 'Old OpenRouter Model',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
[
{
id: 'openai/gpt-5-mini',
name: 'OpenRouter · GPT-5 Mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
);
expect(merged).toEqual([
{
id: 'openai/gpt-5-mini',
name: 'OpenRouter · GPT-5 Mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
{
id: 'gpt-4.1',
name: 'OpenAI GPT-4.1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
]);
});
});

View file

@ -0,0 +1,751 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createServer, type Server } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import open from 'open';
import {
AuthType,
type Config,
type ModelProvidersConfig,
type ProviderModelConfig as ModelConfig,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
export const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY';
export const OPENROUTER_DEFAULT_MODEL = 'openai/gpt-4o-mini';
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
export const OPENROUTER_OAUTH_AUTHORIZE_URL = 'https://openrouter.ai/auth';
export const OPENROUTER_OAUTH_EXCHANGE_URL =
'https://openrouter.ai/api/v1/auth/keys';
export const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
export const OPENROUTER_OAUTH_CALLBACK_URL =
'http://localhost:3000/openrouter/callback';
const OPENROUTER_CODE_CHALLENGE_METHOD = 'S256';
const OPENROUTER_OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
const OPENROUTER_MINIMUM_TEXT_MODELS = 1;
export const OPENROUTER_DEFAULT_MODELS: ModelConfig[] = [
{
id: 'openai/gpt-4o-mini',
name: 'OpenRouter · GPT-4o mini',
baseUrl: OPENROUTER_BASE_URL,
envKey: OPENROUTER_ENV_KEY,
},
{
id: 'anthropic/claude-3.7-sonnet',
name: 'OpenRouter · Claude 3.7 Sonnet',
baseUrl: OPENROUTER_BASE_URL,
envKey: OPENROUTER_ENV_KEY,
},
{
id: 'google/gemini-2.5-flash',
name: 'OpenRouter · Gemini 2.5 Flash',
baseUrl: OPENROUTER_BASE_URL,
envKey: OPENROUTER_ENV_KEY,
},
];
export interface OpenRouterOAuthResult {
apiKey: string;
userId?: string;
authorizationUrl?: string;
authorizationCodeWaitMs?: number;
apiKeyExchangeMs?: number;
}
export interface PkcePair {
codeVerifier: string;
codeChallenge: string;
}
export interface OpenRouterOAuthSession {
callbackUrl: string;
codeVerifier: string;
state: string;
authorizationUrl: string;
}
export interface OAuthCallbackListener {
ready: Promise<void>;
waitForCode: Promise<string>;
close: () => Promise<void>;
}
interface OpenRouterModelApiRecord {
id?: string;
name?: string;
description?: string;
context_length?: number;
architecture?: {
input_modalities?: string[];
output_modalities?: string[];
};
pricing?: {
prompt?: string;
completion?: string;
};
}
function toBase64Url(input: Buffer): string {
return input
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createPkcePair(): PkcePair {
const codeVerifier = toBase64Url(randomBytes(32));
const codeChallenge = toBase64Url(
createHash('sha256').update(codeVerifier).digest(),
);
return { codeVerifier, codeChallenge };
}
export function buildOpenRouterAuthorizationUrl(params: {
callbackUrl: string;
codeChallenge: string;
state: string;
codeChallengeMethod?: 'S256';
limit?: number;
}): string {
const url = new URL(OPENROUTER_OAUTH_AUTHORIZE_URL);
url.searchParams.set('callback_url', params.callbackUrl);
url.searchParams.set('code_challenge', params.codeChallenge);
url.searchParams.set('state', params.state);
url.searchParams.set(
'code_challenge_method',
params.codeChallengeMethod || OPENROUTER_CODE_CHALLENGE_METHOD,
);
if (typeof params.limit === 'number') {
url.searchParams.set('limit', String(params.limit));
}
return url.toString();
}
export function createOAuthState(): string {
return toBase64Url(randomBytes(32));
}
export function createOpenRouterOAuthSession(
callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL,
pkcePair = createPkcePair(),
state = createOAuthState(),
): OpenRouterOAuthSession {
return {
callbackUrl,
codeVerifier: pkcePair.codeVerifier,
state,
authorizationUrl: buildOpenRouterAuthorizationUrl({
callbackUrl,
codeChallenge: pkcePair.codeChallenge,
state,
codeChallengeMethod: OPENROUTER_CODE_CHALLENGE_METHOD,
}),
};
}
export function startOAuthCallbackListener(
callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL,
timeoutMs = OPENROUTER_OAUTH_TIMEOUT_MS,
expectedState?: string,
): OAuthCallbackListener {
const parsedUrl = new URL(callbackUrl);
if (parsedUrl.protocol !== 'http:') {
throw new Error(
'Only http localhost callback URLs are currently supported.',
);
}
let server: Server | undefined;
let timeout: NodeJS.Timeout | undefined;
let settled = false;
const close = async () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server!.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
server = undefined;
};
let resolveReady!: () => void;
let rejectReady!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
let resolveCode!: (code: string) => void;
let rejectCode!: (error: Error) => void;
const waitForCode = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const finish = (action: 'resolve' | 'reject', payload: string | Error) => {
if (settled) {
return;
}
settled = true;
if (action === 'resolve') {
resolveCode(payload as string);
} else {
rejectCode(payload as Error);
}
void close().catch(() => undefined);
};
server = createServer((req, res) => {
const requestUrl = new URL(req.url || '/', parsedUrl.origin);
if (requestUrl.pathname !== parsedUrl.pathname) {
res.statusCode = 404;
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`OpenRouter authorization failed: ${error}`);
void finish(
'reject',
new Error(`OpenRouter authorization failed: ${error}`),
);
return;
}
const callbackState = requestUrl.searchParams.get('state');
if (expectedState && callbackState !== expectedState) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Invalid OAuth state.');
void finish(
'reject',
new Error('Invalid OAuth state from OpenRouter callback.'),
);
return;
}
const code = requestUrl.searchParams.get('code');
if (!code) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Missing authorization code.');
void finish(
'reject',
new Error('Missing authorization code from OpenRouter callback.'),
);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(
'<html><body><h1>OpenRouter authentication complete.</h1><p>You can return to Qwen Code.</p></body></html>',
);
void finish('resolve', code);
});
server.once('error', (error) => {
rejectReady(error instanceof Error ? error : new Error(String(error)));
void finish(
'reject',
error instanceof Error ? error : new Error(String(error)),
);
});
const port = parsedUrl.port ? Number(parsedUrl.port) : 80;
server.listen(port, parsedUrl.hostname, () => {
resolveReady();
});
timeout = setTimeout(() => {
void finish(
'reject',
new Error('Timed out waiting for OpenRouter OAuth callback.'),
);
}, timeoutMs);
return {
ready,
waitForCode,
close,
};
}
function buildOpenRouterHeaders() {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git',
'X-OpenRouter-Title': 'Qwen Code',
};
}
const OPENROUTER_MODEL_PRIORITY_PREFIXES = ['qwen/', 'glm/', 'minimax/'];
const OPENROUTER_RECOMMENDED_MODEL_LIMIT = 16;
const OPENROUTER_FREE_MODEL_ID_HINT = ':free';
export function getPreferredOpenRouterModelId(
models: ModelConfig[],
): string | undefined {
return (
models.find((model) => model.id === OPENROUTER_DEFAULT_MODEL)?.id ||
models[0]?.id
);
}
function isOpenRouterFreeModelId(modelId: string): boolean {
const normalizedId = modelId.toLowerCase();
return (
normalizedId.includes(OPENROUTER_FREE_MODEL_ID_HINT) ||
normalizedId === 'openrouter/free'
);
}
function getOpenRouterModelPriority(modelId: string): number {
const normalizedId = modelId.toLowerCase();
const matchedIndex = OPENROUTER_MODEL_PRIORITY_PREFIXES.findIndex((prefix) =>
normalizedId.startsWith(prefix),
);
return matchedIndex === -1
? OPENROUTER_MODEL_PRIORITY_PREFIXES.length
: matchedIndex;
}
function isOpenRouterFreeConfig(model: ModelConfig): boolean {
return isOpenRouterFreeModelId(model.id);
}
function compareOpenRouterModels(a: ModelConfig, b: ModelConfig): number {
const freeDiff =
Number(isOpenRouterFreeConfig(b)) - Number(isOpenRouterFreeConfig(a));
if (freeDiff !== 0) {
return freeDiff;
}
const priorityDiff =
getOpenRouterModelPriority(a.id) - getOpenRouterModelPriority(b.id);
if (priorityDiff !== 0) {
return priorityDiff;
}
return a.id.localeCompare(b.id);
}
function toOpenRouterModelConfig(
model: OpenRouterModelApiRecord,
): ModelConfig | null {
if (!model.id) {
return null;
}
const outputModalities = model.architecture?.output_modalities || [];
const supportsTextOutput = outputModalities.length
? outputModalities.includes('text')
: true;
if (!supportsTextOutput) {
return null;
}
const inputModalities = model.architecture?.input_modalities || [];
const supportsVision = inputModalities.includes('image');
return {
id: model.id,
name: model.name
? `OpenRouter · ${model.name}`
: `OpenRouter · ${model.id}`,
baseUrl: OPENROUTER_BASE_URL,
envKey: OPENROUTER_ENV_KEY,
capabilities: supportsVision ? { vision: true } : undefined,
generationConfig:
typeof model.context_length === 'number'
? { contextWindowSize: model.context_length }
: undefined,
};
}
function chooseRepresentativeModel(
models: ModelConfig[],
predicate: (model: ModelConfig) => boolean,
selectedIds: Set<string>,
): ModelConfig | undefined {
return models.find((model) => predicate(model) && !selectedIds.has(model.id));
}
function addRecommendedModel(
target: ModelConfig[],
model: ModelConfig | undefined,
selectedIds: Set<string>,
limit: number,
): void {
if (!model || selectedIds.has(model.id) || target.length >= limit) {
return;
}
target.push(model);
selectedIds.add(model.id);
}
export function selectRecommendedOpenRouterModels(
models: ModelConfig[],
limit = OPENROUTER_RECOMMENDED_MODEL_LIMIT,
): ModelConfig[] {
if (models.length <= limit) {
return models;
}
const sorted = [...models].sort(compareOpenRouterModels);
const recommended: ModelConfig[] = [];
const selectedIds = new Set<string>();
const freeModels = sorted.filter((model) => isOpenRouterFreeConfig(model));
for (const model of freeModels.slice(0, Math.min(limit, 6))) {
addRecommendedModel(recommended, model, selectedIds, limit);
}
for (const prefix of OPENROUTER_MODEL_PRIORITY_PREFIXES) {
addRecommendedModel(
recommended,
chooseRepresentativeModel(
sorted,
(model) => model.id.toLowerCase().startsWith(prefix),
selectedIds,
),
selectedIds,
limit,
);
}
for (const family of ['anthropic/', 'google/', 'openai/']) {
addRecommendedModel(
recommended,
chooseRepresentativeModel(
sorted,
(model) => model.id.toLowerCase().startsWith(family),
selectedIds,
),
selectedIds,
limit,
);
}
addRecommendedModel(
recommended,
chooseRepresentativeModel(
sorted,
(model) => model.capabilities?.vision === true,
selectedIds,
),
selectedIds,
limit,
);
addRecommendedModel(
recommended,
chooseRepresentativeModel(
sorted,
(model) => (model.generationConfig?.contextWindowSize || 0) >= 1000000,
selectedIds,
),
selectedIds,
limit,
);
for (const model of sorted) {
if (recommended.length >= limit) {
break;
}
addRecommendedModel(recommended, model, selectedIds, limit);
}
return recommended;
}
export function isOpenRouterConfig(config: ModelConfig): boolean {
return (config.baseUrl || '').includes('openrouter.ai');
}
export function mergeOpenRouterConfigs(
existingConfigs: ModelConfig[],
openRouterModels = OPENROUTER_DEFAULT_MODELS,
): ModelConfig[] {
const nonOpenRouterConfigs = existingConfigs.filter(
(existing) => !isOpenRouterConfig(existing),
);
return [...openRouterModels, ...nonOpenRouterConfigs];
}
export interface ApplyOpenRouterModelsResult {
updatedConfigs: ModelConfig[];
activeModelId?: string;
persistScope: ReturnType<typeof getPersistScopeForModelSelection>;
}
export async function applyOpenRouterModelsConfiguration(params: {
settings: LoadedSettings;
config: Config;
apiKey: string;
reloadConfig: boolean;
}): Promise<ApplyOpenRouterModelsResult> {
const { settings, config, apiKey, reloadConfig } = params;
const persistScope = getPersistScopeForModelSelection(settings);
settings.setValue(persistScope, `env.${OPENROUTER_ENV_KEY}`, apiKey);
process.env[OPENROUTER_ENV_KEY] = apiKey;
const existingConfigs =
(settings.merged.modelProviders as ModelProvidersConfig | undefined)?.[
AuthType.USE_OPENAI
] || [];
const openRouterCatalog = await getOpenRouterModelsWithFallback();
const openRouterModels = selectRecommendedOpenRouterModels(openRouterCatalog);
const updatedConfigs = mergeOpenRouterConfigs(
existingConfigs,
openRouterModels,
);
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
settings.setValue(
persistScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
const activeModelId = getPreferredOpenRouterModelId(updatedConfigs);
if (activeModelId) {
settings.setValue(persistScope, 'model.name', activeModelId);
}
if (reloadConfig) {
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as ModelProvidersConfig | undefined),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
}
return {
updatedConfigs,
activeModelId,
persistScope,
};
}
export async function fetchOpenRouterModels(): Promise<ModelConfig[]> {
const response = await fetch(OPENROUTER_MODELS_URL, {
method: 'GET',
headers: buildOpenRouterHeaders(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`OpenRouter models request failed (${response.status}): ${errorText}`,
);
}
const data = (await response.json()) as {
data?: OpenRouterModelApiRecord[];
};
const records = Array.isArray(data.data) ? data.data : [];
const models = records
.map((record) => toOpenRouterModelConfig(record))
.filter((model): model is ModelConfig => model !== null)
.sort(compareOpenRouterModels);
if (models.length < OPENROUTER_MINIMUM_TEXT_MODELS) {
throw new Error(
'OpenRouter models request returned no usable text models.',
);
}
return models;
}
export async function getOpenRouterModelsWithFallback(): Promise<
ModelConfig[]
> {
try {
return await fetchOpenRouterModels();
} catch {
return OPENROUTER_DEFAULT_MODELS;
}
}
export async function exchangeAuthCodeForApiKey(params: {
code: string;
codeVerifier: string;
}): Promise<OpenRouterOAuthResult> {
const response = await fetch(OPENROUTER_OAUTH_EXCHANGE_URL, {
method: 'POST',
headers: buildOpenRouterHeaders(),
body: JSON.stringify({
code: params.code,
code_verifier: params.codeVerifier,
code_challenge_method: OPENROUTER_CODE_CHALLENGE_METHOD,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`OpenRouter API key exchange failed (${response.status}): ${errorText}`,
);
}
const data = (await response.json()) as {
key?: string;
user_id?: string;
};
if (!data.key) {
throw new Error(
'OpenRouter API key exchange succeeded but no key was returned.',
);
}
return {
apiKey: data.key,
userId: data.user_id,
};
}
interface OAuthSignalTarget {
once(event: NodeJS.Signals, listener: (signal: NodeJS.Signals) => void): void;
removeListener(
event: NodeJS.Signals,
listener: (signal: NodeJS.Signals) => void,
): void;
}
interface OpenRouterOAuthLoginDeps {
openBrowser?: typeof open;
startListener?: typeof startOAuthCallbackListener;
exchangeApiKey?: typeof exchangeAuthCodeForApiKey;
now?: () => number;
signalTarget?: OAuthSignalTarget;
abortSignal?: AbortSignal;
session?: OpenRouterOAuthSession;
}
export async function runOpenRouterOAuthLogin(
callbackUrl = OPENROUTER_OAUTH_CALLBACK_URL,
deps: OpenRouterOAuthLoginDeps = {},
): Promise<OpenRouterOAuthResult> {
const session = deps.session || createOpenRouterOAuthSession(callbackUrl);
const {
callbackUrl: effectiveCallbackUrl,
codeVerifier,
state,
authorizationUrl: authUrl,
} = session;
const openBrowser = deps.openBrowser || open;
const startListener = deps.startListener || startOAuthCallbackListener;
const exchangeApiKey = deps.exchangeApiKey || exchangeAuthCodeForApiKey;
const now = deps.now || Date.now;
const signalTarget = deps.signalTarget || process;
const abortSignal = deps.abortSignal;
const listener = startListener(
effectiveCallbackUrl,
OPENROUTER_OAUTH_TIMEOUT_MS,
state,
);
let cleanupSignalHandlers = () => {};
let cleanupAbortListener = () => {};
try {
await listener.ready;
await openBrowser(authUrl);
const waitForCancel = new Promise<never>((_, reject) => {
const handleSignal = (signal: NodeJS.Signals) => {
reject(
new Error(
`OpenRouter OAuth cancelled by user (${signal}) while waiting for browser authorization.`,
),
);
};
signalTarget.once('SIGINT', handleSignal);
signalTarget.once('SIGTERM', handleSignal);
cleanupSignalHandlers = () => {
signalTarget.removeListener('SIGINT', handleSignal);
signalTarget.removeListener('SIGTERM', handleSignal);
};
});
const waitForAbort = new Promise<never>((_, reject) => {
if (!abortSignal) {
return;
}
const handleAbort = () => {
reject(new DOMException('OpenRouter OAuth cancelled.', 'AbortError'));
};
if (abortSignal.aborted) {
handleAbort();
return;
}
abortSignal.addEventListener('abort', handleAbort, { once: true });
cleanupAbortListener = () => {
abortSignal.removeEventListener('abort', handleAbort);
};
});
const waitStartMs = now();
const code = await Promise.race([
listener.waitForCode,
waitForCancel,
waitForAbort,
]);
cleanupSignalHandlers();
cleanupAbortListener();
const authorizationCodeWaitMs = now() - waitStartMs;
const exchangeStartMs = now();
const exchangeResult = await exchangeApiKey({ code, codeVerifier });
const apiKeyExchangeMs = now() - exchangeStartMs;
return {
...exchangeResult,
authorizationUrl: authUrl,
authorizationCodeWaitMs,
apiKeyExchangeMs,
};
} finally {
cleanupSignalHandlers();
cleanupAbortListener();
void listener.close().catch(() => undefined);
}
}

View file

@ -6,7 +6,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { showAuthStatus } from './handler.js';
import { AuthType, CODING_PLAN_ENV_KEY } from '@qwen-code/qwen-code-core';
import { AuthType } from '@qwen-code/qwen-code-core';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', () => ({
@ -26,11 +27,13 @@ describe('showAuthStatus', () => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env[CODING_PLAN_ENV_KEY];
delete process.env['OPENROUTER_API_KEY'];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
delete process.env['OPENROUTER_API_KEY'];
});
const createMockSettings = (
@ -55,6 +58,9 @@ describe('showAuthStatus', () => {
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('No authentication method configured'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth openrouter'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth qwen-oauth'),
);
@ -120,6 +126,77 @@ describe('showAuthStatus', () => {
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenRouter status when configured with API key', async () => {
process.env['OPENROUTER_API_KEY'] = 'test-openrouter-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
model: {
name: 'openai/gpt-4o-mini',
},
modelProviders: {
[AuthType.USE_OPENAI]: [
{
id: 'openai/gpt-4o-mini',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenRouter'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('openai/gpt-4o-mini'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show OpenRouter as incomplete when API key is missing', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
modelProviders: {
[AuthType.USE_OPENAI]: [
{
id: 'openai/gpt-4o-mini',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('OpenRouter (Incomplete)'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth openrouter'),
);
});
it('should show Coding Plan as incomplete when API key is missing', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({

View file

@ -1301,6 +1301,8 @@ export default {
'Kostenpflichtig \u00B7 Bis zu 6.000 Anfragen/5 Std. \u00B7 Alle Alibaba Cloud Coding Plan Modelle',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Eigenen API-Schlüssel verwenden',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'Browserbasierte Authentifizierung mit externen Anbietern (z. B. OpenRouter, ModelScope)',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Verwenden Sie Coding Plan-Anmeldedaten oder Ihre eigenen API-Schlüssel/Anbieter.',

View file

@ -1361,6 +1361,8 @@ export default {
'Paid \u00B7 Up to 6,000 requests/5 hrs \u00B7 All Alibaba Cloud Coding Plan Models',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Bring your own API key',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use coding plan credentials or your own api-keys/providers.',

View file

@ -1345,6 +1345,8 @@ export default {
"Payant · Jusqu'à 6 000 requêtes/5h · Tous les modèles Alibaba Cloud Coding Plan",
'Alibaba Cloud Coding Plan': 'Plan de codage Alibaba Cloud',
'Bring your own API key': 'Apportez votre propre clé API',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'Authentification basée sur le navigateur avec des fournisseurs tiers (par exemple OpenRouter, ModelScope)',
'API-KEY': 'CLÉ-API',
'Use coding plan credentials or your own api-keys/providers.':
'Utilisez les identifiants du plan de codage ou vos propres clés API/fournisseurs.',

View file

@ -1022,6 +1022,8 @@ export default {
'有料 \u00B7 5時間最大6,000リクエスト \u00B7 すべての Alibaba Cloud Coding Plan モデル',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': '自分のAPIキーを使用',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'サードパーティプロバイダーによるブラウザベースの認証OpenRouter、ModelScope',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Coding Planの認証情報またはご自身のAPIキー/プロバイダーをご利用ください。',

View file

@ -1309,6 +1309,8 @@ export default {
'Pago \u00B7 Até 6.000 solicitações/5 hrs \u00B7 Todos os modelos Alibaba Cloud Coding Plan',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Traga sua própria chave API',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'Autenticação baseada em navegador com provedores terceiros (por exemplo, OpenRouter, ModelScope)',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Use credenciais do Coding Plan ou suas próprias chaves API/provedores.',

View file

@ -1231,6 +1231,8 @@ export default {
'Платно \u00B7 До 6 000 запросов/5 часов \u00B7 Все модели Alibaba Cloud Coding Plan',
'Alibaba Cloud Coding Plan': 'Alibaba Cloud Coding Plan',
'Bring your own API key': 'Используйте свой API-ключ',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'Браузерная аутентификация с использованием сторонних провайдеров (например, OpenRouter, ModelScope)',
'API-KEY': 'API-KEY',
'Use coding plan credentials or your own api-keys/providers.':
'Используйте учетные данные Coding Plan или свои собственные API-ключи/провайдеры.',

View file

@ -1141,6 +1141,8 @@ export default {
'Alibaba Cloud Coding Plan': '阿里雲百鍊 Coding Plan',
'Bring your own API key': '使用自己的 API 密鑰',
'API-KEY': 'API-KEY',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'基於瀏覽器的第三方提供商認證(例如 OpenRouter、ModelScope',
'Use coding plan credentials or your own api-keys/providers.':
'使用 Coding Plan 憑證或您自己的 API 密鑰/提供商。',
OpenAI: 'OpenAI',

View file

@ -1291,6 +1291,8 @@ export default {
'付费 \u00B7 每 5 小时最多 6,000 次请求 \u00B7 支持阿里云百炼 Coding Plan 全部模型',
'Alibaba Cloud Coding Plan': '阿里云百炼 Coding Plan',
'Bring your own API key': '使用自己的 API 密钥',
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)':
'基于浏览器的第三方提供商认证(例如 OpenRouter、ModelScope',
'Use coding plan credentials or your own api-keys/providers.':
'使用 Coding Plan 凭证或您自己的 API 密钥/提供商。',
OpenAI: 'OpenAI',

View file

@ -36,6 +36,7 @@ import { dreamCommand } from '../ui/commands/dreamCommand.js';
import { forgetCommand } from '../ui/commands/forgetCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { manageModelsCommand } from '../ui/commands/manageModelsCommand.js';
import { rememberCommand } from '../ui/commands/rememberCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
@ -118,6 +119,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: []),
memoryCommand,
modelCommand,
manageModelsCommand,
rememberCommand,
planCommand,
permissionsCommand,

View file

@ -191,9 +191,17 @@ describe('AppContainer State Management', () => {
onAuthError: vi.fn(),
isAuthDialogOpen: false,
isAuthenticating: false,
pendingAuthType: undefined,
externalAuthState: null,
qwenAuthState: {
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
},
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
openAuthDialog: vi.fn(),
cancelAuthentication: vi.fn(),
});
@ -1397,7 +1405,17 @@ describe('AppContainer State Management', () => {
onAuthError: vi.fn(),
isAuthDialogOpen: false,
isAuthenticating: true,
pendingAuthType: undefined,
externalAuthState: null,
qwenAuthState: {
deviceAuth: null,
authStatus: 'idle',
authMessage: null,
},
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
openAuthDialog: vi.fn(),
cancelAuthentication: vi.fn(),
});

View file

@ -70,6 +70,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useManageModelsCommand } from './hooks/useManageModelsCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
@ -518,10 +519,13 @@ export const AppContainer = (props: AppContainerProps) => {
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
externalAuthState,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleOpenRouterSubmit,
handleCustomApiKeySubmit,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem, refreshStatic);
@ -591,6 +595,11 @@ export const AppContainer = (props: AppContainerProps) => {
openModelDialog,
closeModelDialog,
} = useModelCommand();
const {
isManageModelsDialogOpen,
openManageModelsDialog,
closeManageModelsDialog,
} = useManageModelsCommand();
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
useArenaCommand();
@ -656,6 +665,7 @@ export const AppContainer = (props: AppContainerProps) => {
openMemoryDialog,
openSettingsDialog,
openModelDialog,
openManageModelsDialog,
openTrustDialog,
openArenaDialog,
openPermissionsDialog,
@ -687,6 +697,7 @@ export const AppContainer = (props: AppContainerProps) => {
openMemoryDialog,
openSettingsDialog,
openModelDialog,
openManageModelsDialog,
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
@ -1551,6 +1562,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen ||
isMemoryDialogOpen ||
isModelDialogOpen ||
isManageModelsDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
@ -2237,6 +2249,7 @@ export const AppContainer = (props: AppContainerProps) => {
authError,
isAuthDialogOpen,
pendingAuthType,
externalAuthState,
// Qwen OAuth state
qwenAuthState,
editorError,
@ -2247,6 +2260,7 @@ export const AppContainer = (props: AppContainerProps) => {
isMemoryDialogOpen,
isModelDialogOpen,
isFastModelMode,
isManageModelsDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
@ -2356,6 +2370,7 @@ export const AppContainer = (props: AppContainerProps) => {
authError,
isAuthDialogOpen,
pendingAuthType,
externalAuthState,
// Qwen OAuth state
qwenAuthState,
editorError,
@ -2366,6 +2381,7 @@ export const AppContainer = (props: AppContainerProps) => {
isMemoryDialogOpen,
isModelDialogOpen,
isFastModelMode,
isManageModelsDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
@ -2484,12 +2500,16 @@ export const AppContainer = (props: AppContainerProps) => {
cancelAuthentication,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleOpenRouterSubmit,
handleCustomApiKeySubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeMemoryDialog,
closeModelDialog,
openModelDialog,
openManageModelsDialog,
closeManageModelsDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
@ -2554,12 +2574,16 @@ export const AppContainer = (props: AppContainerProps) => {
cancelAuthentication,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleOpenRouterSubmit,
handleCustomApiKeySubmit,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
closeMemoryDialog,
closeModelDialog,
openModelDialog,
openManageModelsDialog,
closeManageModelsDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,

View file

@ -34,6 +34,7 @@ const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as Partial<UIActions>;
@ -69,6 +70,118 @@ const renderAuthDialog = (
);
};
/**
* Type text into the terminal one character at a time.
* Works around a Node 24.x + ink compatibility issue on Windows
* where bulk stdin.write() may not propagate to TextInput correctly.
*/
const typeText = async (
stdin: { write: (s: string) => void },
text: string,
) => {
const delay = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
for (const char of text) {
stdin.write(char);
await delay(5);
}
await delay(30);
};
const escapeRegExp = (text: string) =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectSelectedOption = (frame: string | undefined, label: string) => {
expect(frame).toMatch(
new RegExp(`\\s*(?:\\d+\\.\\s*)?${escapeRegExp(label)}`),
);
};
const waitForSelectedOption = async (
lastFrame: () => string | undefined,
label: string,
) => {
await vi.waitFor(() => {
expectSelectedOption(lastFrame(), label);
});
};
const pressEnterAndWaitFor = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
expectedText: string,
) => {
stdin.write('\r');
await vi.waitFor(() => {
expect(lastFrame()).toContain(expectedText);
});
};
const moveDownAndWaitForSelection = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
label: string,
) => {
stdin.write('\u001b[B');
await waitForSelectedOption(lastFrame, label);
};
const navigateToCustomProtocolSelect = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
) => {
await waitForSelectedOption(lastFrame, 'OAuth');
await moveDownAndWaitForSelection(
stdin,
lastFrame,
'Alibaba Cloud Coding Plan',
);
await moveDownAndWaitForSelection(stdin, lastFrame, 'API Key');
await pressEnterAndWaitFor(stdin, lastFrame, 'Select API Key Type');
await waitForSelectedOption(
lastFrame,
'Alibaba Cloud ModelStudio Standard API Key',
);
await moveDownAndWaitForSelection(stdin, lastFrame, 'Custom API Key');
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 1/6 · Protocol');
};
const navigateToCustomBaseUrlInput = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
) => {
await navigateToCustomProtocolSelect(stdin, lastFrame);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 2/6 · Base URL');
};
const navigateToCustomApiKeyInput = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
) => {
await navigateToCustomBaseUrlInput(stdin, lastFrame);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 3/6 · API Key');
};
const navigateToCustomModelIdInput = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
apiKey = 'sk-test',
) => {
await navigateToCustomApiKeyInput(stdin, lastFrame);
await typeText(stdin, apiKey);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 4/6 · Model IDs');
};
const navigateToCustomAdvancedConfig = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
apiKey = 'sk-test',
modelIds = 'model-1,model-2',
) => {
await navigateToCustomModelIdInput(stdin, lastFrame, apiKey);
await typeText(stdin, modelIds);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 5/6 · Advanced Config');
};
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@ -308,8 +421,8 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// QWEN_OAUTH is the first option, so it should be selected
expect(lastFrame()).toContain('Qwen OAuth');
// QWEN_OAUTH maps to 'OAUTH' in the new three-option main menu
expect(lastFrame()).toContain('OAuth');
});
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
@ -391,8 +504,8 @@ describe('AuthDialog', () => {
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('Qwen OAuth');
// it will just show the default OAuth option
expect(lastFrame()).toContain('OAuth');
});
});
@ -558,4 +671,524 @@ describe('AuthDialog', () => {
expect(handleAuthSelect).toHaveBeenCalledWith(undefined);
unmount();
});
it('should show OpenRouter in API key options', async () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);
const { stdin, lastFrame, unmount } = renderAuthDialog(settings);
await wait();
// OAuth is selected by default, press Enter to enter OAuth provider list
stdin.write('\r');
await wait();
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('OpenRouter');
expect(frame).toContain('Browser OAuth');
});
unmount();
});
it('should trigger OpenRouter OAuth from API key options', async () => {
const handleOpenRouterSubmit = vi.fn().mockResolvedValue(undefined);
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);
const { stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleOpenRouterSubmit },
);
await wait();
// OAuth is selected by default, press Enter to enter OAuth provider list
stdin.write('\r');
await wait();
// OpenRouter is the first option, press Enter to trigger OAuth
stdin.write('\r');
await wait();
await vi.waitFor(() => {
expect(handleOpenRouterSubmit).toHaveBeenCalledTimes(1);
});
unmount();
});
});
const isUnreliableTuiInputEnvironment =
process.platform === 'win32' ||
(process.env['CI'] === 'true' && process.version.startsWith('v20.'));
const itWhenTuiInputReliable = isUnreliableTuiInputEnvironment ? it.skip : it;
describe('AuthDialog Custom API Key Wizard', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
const createStandardSettings = (): LoadedSettings =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
originalSettings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
originalSettings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
originalSettings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
true,
new Set(),
);
itWhenTuiInputReliable(
'navigates to protocol selection when Custom API Key is selected',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn();
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomProtocolSelect(stdin, lastFrame);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Step 1/6 · Protocol');
expect(frame).toContain('OpenAI-compatible');
expect(frame).toContain('Anthropic-compatible');
expect(frame).toContain('Gemini-compatible');
});
unmount();
},
);
itWhenTuiInputReliable(
'navigates to base URL input after selecting a protocol',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn();
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomBaseUrlInput(stdin, lastFrame);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Step 2/6 · Base URL');
expect(frame).toContain('Enter the API endpoint');
});
unmount();
},
);
itWhenTuiInputReliable(
'shows review screen with JSON after entering model IDs',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn();
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomAdvancedConfig(
stdin,
lastFrame,
'sk-test-key-12345',
'qwen/qwen3-coder,gpt-4.1',
);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 6/6 · Review');
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Step 6/6 · Review');
expect(frame).toContain('The following JSON will be saved');
expect(frame).toContain('QWEN_CUSTOM_API_KEY_OPENAI');
expect(frame).toContain('qwen/qwen3-coder');
expect(frame).toContain('gpt-4.1');
expect(frame).toContain('Enter to save');
});
unmount();
},
);
itWhenTuiInputReliable(
'calls handleCustomApiKeySubmit on Enter in review view',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined);
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomAdvancedConfig(
stdin,
lastFrame,
'sk-test',
'model-1,model-2',
);
await pressEnterAndWaitFor(stdin, lastFrame, 'Step 6/6 · Review');
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Enter to save');
});
stdin.write('\r'); // Enter to save
await wait();
await vi.waitFor(() => {
expect(handleCustomApiKeySubmit).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'https://api.openai.com/v1',
'sk-test',
'model-1,model-2',
undefined,
);
});
unmount();
},
);
itWhenTuiInputReliable(
'shows advanced config screen after entering model IDs',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn();
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomAdvancedConfig(
stdin,
lastFrame,
'sk-test',
'model-1,model-2',
);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Step 5/6 · Advanced Config');
expect(frame).toContain(
'Optional: configure advanced generation settings',
);
expect(frame).toContain('Enable thinking');
expect(frame).toContain('Enable modality');
expect(frame).toContain('Enter to continue');
});
unmount();
},
);
itWhenTuiInputReliable(
'passes generationConfig when advanced options are toggled',
async () => {
const settings = createStandardSettings();
const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined);
const mockUIState = {
authError: null,
pendingAuthType: undefined,
} as UIState;
const mockUIActions = {
handleAuthSelect: vi.fn(),
handleCodingPlanSubmit: vi.fn(),
handleAlibabaStandardSubmit: vi.fn(),
handleOpenRouterSubmit: vi.fn(),
handleCustomApiKeySubmit,
onAuthError: vi.fn(),
handleRetryLastPrompt: vi.fn(),
} as unknown as UIActions;
const mockConfig = {
getAuthType: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({})),
} as unknown as Config;
const { stdin, lastFrame, unmount } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<UIActionsContext.Provider value={mockUIActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings, config: mockConfig },
);
await navigateToCustomAdvancedConfig(
stdin,
lastFrame,
'sk-test',
'model-1',
);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Step 5/6 · Advanced Config');
});
// Toggle thinking (press Space — thinking is initially focused)
stdin.write(' ');
await wait();
// Navigate down to modality, toggle (press ↓ then Space)
stdin.write('\u001b[B');
await wait();
stdin.write(' ');
await wait();
// Press Enter to continue to review
stdin.write('\r');
await wait();
// Verify review includes generationConfig
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('"generationConfig"');
expect(frame).toContain('"enable_thinking"');
expect(frame).toContain('"image": true');
expect(frame).toContain('"video": true');
expect(frame).toContain('"audio": true');
});
// Press Enter to save
stdin.write('\r');
await wait();
await vi.waitFor(() => {
expect(handleCustomApiKeySubmit).toHaveBeenCalledWith(
AuthType.USE_OPENAI,
'https://api.openai.com/v1',
'sk-test',
'model-1',
{
enableThinking: true,
multimodal: {
image: true,
video: true,
audio: true,
},
},
);
});
unmount();
},
);
});

View file

@ -26,6 +26,11 @@ import {
ALIBABA_STANDARD_API_KEY_ENDPOINTS,
type AlibabaStandardRegion,
} from '../../constants/alibabaStandardApiKey.js';
import {
generateCustomApiKeyEnvKey,
normalizeCustomModelIds,
maskApiKey,
} from './useAuth.js';
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/';
@ -43,8 +48,15 @@ function parseDefaultAuthType(
}
// Main menu option type
type MainOption = typeof AuthType.QWEN_OAUTH | 'CODING_PLAN' | 'API_KEY';
type ApiKeyOption = 'ALIBABA_STANDARD_API_KEY' | 'CUSTOM_API_KEY';
type MainOption = 'OAUTH' | 'CODING_PLAN' | 'API_KEY';
type ApiKeyOption =
| 'OPENROUTER_OAUTH'
| 'ALIBABA_STANDARD_API_KEY'
| 'CUSTOM_API_KEY';
type OAuthOption =
| 'OPENROUTER_OAUTH'
| 'MODELSCOPE_OAUTH'
| 'QWEN_OAUTH_DISCONTINUED';
// View level for navigation
type ViewLevel =
@ -55,7 +67,13 @@ type ViewLevel =
| 'alibaba-standard-region-select'
| 'alibaba-standard-api-key-input'
| 'alibaba-standard-model-id-input'
| 'custom-info';
| 'custom-protocol-select'
| 'custom-base-url-input'
| 'custom-api-key-input'
| 'custom-model-id-input'
| 'custom-advanced-config'
| 'custom-review-json'
| 'oauth-provider-select';
const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5';
const ALIBABA_STANDARD_API_DOCUMENTATION_URLS: Record<
@ -77,6 +95,8 @@ export function AuthDialog(): React.JSX.Element {
handleAuthSelect: onAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleOpenRouterSubmit,
handleCustomApiKeySubmit,
onAuthError,
} = useUIActions();
const config = useConfig();
@ -90,6 +110,7 @@ export function AuthDialog(): React.JSX.Element {
const [alibabaStandardRegionIndex, setAlibabaStandardRegionIndex] =
useState<number>(0);
const [apiKeyTypeIndex, setApiKeyTypeIndex] = useState<number>(0);
const [oauthProviderIndex, setOAuthProviderIndex] = useState<number>(0);
const [alibabaStandardRegion, setAlibabaStandardRegion] =
useState<AlibabaStandardRegion>('cn-beijing');
const [alibabaStandardApiKey, setAlibabaStandardApiKey] = useState('');
@ -100,6 +121,30 @@ export function AuthDialog(): React.JSX.Element {
const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] =
useState<string | null>(null);
// Custom API Key wizard state
const [customProtocolIndex, setCustomProtocolIndex] = useState<number>(0);
const [customProtocol, setCustomProtocol] = useState<AuthType>(
AuthType.USE_OPENAI,
);
const [customBaseUrl, setCustomBaseUrl] = useState('');
const [customBaseUrlError, setCustomBaseUrlError] = useState<string | null>(
null,
);
const [customApiKey, setCustomApiKey] = useState('');
const [customApiKeyError, setCustomApiKeyError] = useState<string | null>(
null,
);
const [customModelIds, setCustomModelIds] = useState('');
const [customModelIdsError, setCustomModelIdsError] = useState<string | null>(
null,
);
// Advanced generation config state
const [advancedThinkingEnabled, setAdvancedThinkingEnabled] = useState(false);
const [advancedModalityEnabled, setAdvancedModalityEnabled] = useState(false);
const [focusedConfigIndex, setFocusedConfigIndex] = useState(0);
// 0 = thinking, 1 = modality
// Main authentication entries (flat three-option layout)
const mainItems = [
{
@ -119,11 +164,13 @@ export function AuthDialog(): React.JSX.Element {
value: 'API_KEY' as MainOption,
},
{
key: AuthType.QWEN_OAUTH,
title: t('Qwen OAuth'),
label: t('Qwen OAuth'),
description: t('Discontinued — switch to Coding Plan or API Key'),
value: AuthType.QWEN_OAUTH as MainOption,
key: 'OAUTH',
title: t('OAuth'),
label: t('OAuth'),
description: t(
'Browser-based authentication with third-party providers (e.g. OpenRouter, ModelScope)',
),
value: 'OAUTH' as MainOption,
},
];
@ -210,6 +257,38 @@ export function AuthDialog(): React.JSX.Element {
},
];
const protocolItems = [
{
key: AuthType.USE_OPENAI,
title: t('OpenAI-compatible'),
label: t('OpenAI-compatible'),
description: t(
'OpenAI Chat Completions API (OpenRouter, vLLM, Ollama, LM Studio, Fireworks, etc.)',
),
value: AuthType.USE_OPENAI as AuthType,
},
{
key: AuthType.USE_ANTHROPIC,
title: t('Anthropic-compatible'),
label: t('Anthropic-compatible'),
description: t('Anthropic Messages API'),
value: AuthType.USE_ANTHROPIC as AuthType,
},
{
key: AuthType.USE_GEMINI,
title: t('Gemini-compatible'),
label: t('Gemini-compatible'),
description: t('Google Gemini API'),
value: AuthType.USE_GEMINI as AuthType,
},
];
const DEFAULT_CUSTOM_BASE_URLS: Partial<Record<AuthType, string>> = {
[AuthType.USE_OPENAI]: 'https://api.openai.com/v1',
[AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1',
[AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com',
};
const apiKeyTypeItems = [
{
key: 'ALIBABA_STANDARD_API_KEY',
@ -229,8 +308,36 @@ export function AuthDialog(): React.JSX.Element {
},
];
const oauthProviderItems = [
{
key: 'OPENROUTER_OAUTH',
title: t('OpenRouter'),
label: t('OpenRouter'),
description: t(
'Browser OAuth · Auto-configure API key and OpenRouter models',
),
value: 'OPENROUTER_OAUTH' as OAuthOption,
},
{
key: 'MODELSCOPE_OAUTH',
title: t('ModelScope'),
label: t('ModelScope'),
description: t(
'Browser OAuth · Auto-configure API key and ModelScope models',
),
value: 'MODELSCOPE_OAUTH' as OAuthOption,
},
{
key: 'QWEN_OAUTH_DISCONTINUED',
title: t('Qwen'),
label: t('Qwen'),
description: t('Discontinued — switch to Coding Plan or API Key'),
value: 'QWEN_OAUTH_DISCONTINUED' as OAuthOption,
},
];
// Map an AuthType to the corresponding main menu option.
// QWEN_OAUTH maps directly; USE_OPENAI maps to:
// QWEN_OAUTH maps to 'OAUTH'; USE_OPENAI maps to:
// - CODING_PLAN when current config matches coding plan
// - API_KEY for other OpenAI / Anthropic / Gemini-compatible configs
const contentGenConfig = config.getContentGeneratorConfig();
@ -240,7 +347,7 @@ export function AuthDialog(): React.JSX.Element {
contentGenConfig?.apiKeyEnvKey,
) !== false;
const authTypeToMainOption = (authType: AuthType): MainOption => {
if (authType === AuthType.QWEN_OAUTH) return AuthType.QWEN_OAUTH;
if (authType === AuthType.QWEN_OAUTH) return 'OAUTH';
if (authType === AuthType.USE_OPENAI && isCurrentlyCodingPlan) {
return 'CODING_PLAN';
}
@ -269,8 +376,8 @@ export function AuthDialog(): React.JSX.Element {
return item.value === authTypeToMainOption(defaultAuthType);
}
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH;
// Priority 4: default to OAUTH
return item.value === 'OAUTH';
}),
);
@ -289,13 +396,8 @@ export function AuthDialog(): React.JSX.Element {
return;
}
// Qwen OAuth free tier discontinued — show warning instead of proceeding
if (value === AuthType.QWEN_OAUTH) {
setErrorMessage(
t(
'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.',
),
);
if (value === 'OAUTH') {
setViewLevel('oauth-provider-select');
return;
}
@ -313,7 +415,53 @@ export function AuthDialog(): React.JSX.Element {
return;
}
setViewLevel('custom-info');
// Reset custom wizard state and go to protocol selection
setCustomProtocolIndex(0);
setCustomProtocol(AuthType.USE_OPENAI);
setCustomBaseUrl('');
setCustomBaseUrlError(null);
setCustomApiKey('');
setCustomApiKeyError(null);
setCustomModelIds('');
setCustomModelIdsError(null);
setAdvancedThinkingEnabled(false);
setAdvancedModalityEnabled(false);
setFocusedConfigIndex(0);
setViewLevel('custom-protocol-select');
};
const handleOAuthProviderSelect = async (value: OAuthOption) => {
setErrorMessage(null);
onAuthError(null);
if (value === 'OPENROUTER_OAUTH') {
await handleOpenRouterSubmit();
return;
}
// Qwen OAuth free tier discontinued — show warning instead of proceeding
if (value === 'QWEN_OAUTH_DISCONTINUED') {
setErrorMessage(
t(
'Qwen OAuth free tier was discontinued on 2026-04-15. Please select Coding Plan or API Key instead.',
),
);
return;
}
// Future: Add support for ModelScope OAuth when implemented
if (value === 'MODELSCOPE_OAUTH') {
// Currently not implemented, show message
setErrorMessage(
t(
'ModelScope OAuth is not yet implemented. Please select another option.',
),
);
return;
}
// For other OAuth providers, you can extend the functionality here
await onAuthSelect(AuthType.USE_OPENAI);
};
const handleRegionSelect = async (selectedRegion: CodingPlanRegion) => {
@ -381,6 +529,89 @@ export function AuthDialog(): React.JSX.Element {
);
};
const handleCustomProtocolSelect = (protocol: AuthType) => {
setErrorMessage(null);
onAuthError(null);
setCustomProtocol(protocol);
const defaultUrl = DEFAULT_CUSTOM_BASE_URLS[protocol] ?? '';
setCustomBaseUrl(defaultUrl);
setCustomBaseUrlError(null);
setViewLevel('custom-base-url-input');
};
const handleCustomBaseUrlSubmit = () => {
const trimmedUrl = customBaseUrl.trim();
if (!trimmedUrl) {
setCustomBaseUrlError(t('Base URL cannot be empty.'));
return;
}
if (!/^https?:\/\//i.test(trimmedUrl)) {
setCustomBaseUrlError(t('Base URL must start with http:// or https://.'));
return;
}
setCustomBaseUrlError(null);
setCustomApiKey('');
setCustomApiKeyError(null);
setViewLevel('custom-api-key-input');
};
const handleCustomApiKeySubmitLocal = () => {
const trimmedKey = customApiKey.trim();
if (!trimmedKey) {
setCustomApiKeyError(t('API key cannot be empty.'));
return;
}
setCustomApiKeyError(null);
setCustomModelIds('');
setCustomModelIdsError(null);
setViewLevel('custom-model-id-input');
};
const handleCustomModelIdSubmit = () => {
const normalized = normalizeCustomModelIds(customModelIds);
if (normalized.length === 0) {
setCustomModelIdsError(t('Model IDs cannot be empty.'));
return;
}
setCustomModelIdsError(null);
setViewLevel('custom-advanced-config');
};
const handleAdvancedConfigSubmit = () => {
setViewLevel('custom-review-json');
};
const handleCustomReviewSubmit = () => {
const trimmedBaseUrl = customBaseUrl.trim();
const trimmedApiKey = customApiKey.trim();
const trimmedModelIds = customModelIds;
// Build generationConfig only if any advanced option is set
const hasThinking = advancedThinkingEnabled;
const hasModality = advancedModalityEnabled;
const generationConfig =
hasThinking || hasModality
? {
enableThinking: hasThinking ? true : undefined,
multimodal: hasModality
? { image: true, video: true, audio: true }
: undefined,
}
: undefined;
void handleCustomApiKeySubmit(
customProtocol as
| AuthType.USE_OPENAI
| AuthType.USE_ANTHROPIC
| AuthType.USE_GEMINI,
trimmedBaseUrl,
trimmedApiKey,
trimmedModelIds,
generationConfig,
);
};
const handleGoBack = () => {
setErrorMessage(null);
onAuthError(null);
@ -391,14 +622,26 @@ export function AuthDialog(): React.JSX.Element {
setViewLevel('region-select');
} else if (viewLevel === 'api-key-type-select') {
setViewLevel('main');
} else if (viewLevel === 'custom-info') {
} else if (viewLevel === 'custom-protocol-select') {
setViewLevel('api-key-type-select');
} else if (viewLevel === 'custom-base-url-input') {
setViewLevel('custom-protocol-select');
} else if (viewLevel === 'custom-api-key-input') {
setViewLevel('custom-base-url-input');
} else if (viewLevel === 'custom-model-id-input') {
setViewLevel('custom-api-key-input');
} else if (viewLevel === 'custom-advanced-config') {
setViewLevel('custom-model-id-input');
} else if (viewLevel === 'custom-review-json') {
setViewLevel('custom-advanced-config');
} else if (viewLevel === 'alibaba-standard-region-select') {
setViewLevel('api-key-type-select');
} else if (viewLevel === 'alibaba-standard-api-key-input') {
setViewLevel('alibaba-standard-region-select');
} else if (viewLevel === 'alibaba-standard-model-id-input') {
setViewLevel('alibaba-standard-api-key-input');
} else if (viewLevel === 'oauth-provider-select') {
setViewLevel('main');
}
};
@ -411,7 +654,18 @@ export function AuthDialog(): React.JSX.Element {
return;
}
if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') {
if (viewLevel === 'api-key-input') {
handleGoBack();
return;
}
if (
viewLevel === 'custom-protocol-select' ||
viewLevel === 'custom-base-url-input' ||
viewLevel === 'custom-api-key-input' ||
viewLevel === 'custom-model-id-input' ||
viewLevel === 'custom-advanced-config' ||
viewLevel === 'custom-review-json'
) {
handleGoBack();
return;
}
@ -419,7 +673,8 @@ export function AuthDialog(): React.JSX.Element {
viewLevel === 'api-key-type-select' ||
viewLevel === 'alibaba-standard-region-select' ||
viewLevel === 'alibaba-standard-api-key-input' ||
viewLevel === 'alibaba-standard-model-id-input'
viewLevel === 'alibaba-standard-model-id-input' ||
viewLevel === 'oauth-provider-select'
) {
handleGoBack();
return;
@ -443,6 +698,50 @@ export function AuthDialog(): React.JSX.Element {
{ isActive: true },
);
// Handle Enter key for review view to save
useKeypress(
(key) => {
if (key.name === 'return' && viewLevel === 'custom-review-json') {
handleCustomReviewSubmit();
}
},
{ isActive: true },
);
// Advanced config keypress: ↑↓ to navigate, Space to toggle, Enter to submit
useKeypress(
(key) => {
if (viewLevel !== 'custom-advanced-config') return;
const { name } = key;
if (name === 'up') {
setFocusedConfigIndex((v) => (v <= 0 ? 1 : v - 1));
return;
}
if (name === 'down') {
setFocusedConfigIndex((v) => (v >= 1 ? 0 : v + 1));
return;
}
if (name === 'space') {
if (focusedConfigIndex === 0) {
setAdvancedThinkingEnabled((v) => !v);
} else {
setAdvancedModalityEnabled((v) => !v);
}
return;
}
if (name === 'return') {
handleAdvancedConfigSubmit();
return;
}
},
{ isActive: true },
);
// Render main auth selection
const renderMainView = () => (
<>
@ -625,26 +924,294 @@ export function AuthDialog(): React.JSX.Element {
</Box>
);
// Render custom mode info
const renderCustomInfoView = () => (
// Render custom protocol selection
const renderCustomProtocolSelectView = () => (
<>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={protocolItems}
initialIndex={customProtocolIndex}
onSelect={handleCustomProtocolSelect}
onHighlight={(value) => {
const index = protocolItems.findIndex(
(item) => item.value === value,
);
setCustomProtocolIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
</Text>
</Box>
</>
);
// Render custom base URL input
const renderCustomBaseUrlInputView = () => (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.primary}>
{t('You can configure your API key and models in settings.json')}
{t('Enter the API endpoint for this protocol.')}
</Text>
</Box>
<Box marginTop={1}>
<Text>{t('Refer to the documentation for setup instructions')}</Text>
<TextInput
value={customBaseUrl}
onChange={(value) => {
setCustomBaseUrl(value);
if (customBaseUrlError) {
setCustomBaseUrlError(null);
}
}}
onSubmit={handleCustomBaseUrlSubmit}
placeholder="https://api.openai.com/v1"
/>
</Box>
<Box marginTop={0}>
{customBaseUrlError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{customBaseUrlError}</Text>
</Box>
)}
<Box marginTop={1}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.text.link}>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
{t(
'Need advanced generationConfig or capabilities? See documentation',
)}
</Text>
</Link>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
<Text color={theme.text.secondary}>
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>
);
// Render custom API key input
const renderCustomApiKeyInputView = () => (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.primary}>
{t('Enter the API key for this endpoint.')}
</Text>
</Box>
<Box marginTop={1}>
<TextInput
value={customApiKey}
onChange={(value) => {
setCustomApiKey(value);
if (customApiKeyError) {
setCustomApiKeyError(null);
}
}}
onSubmit={handleCustomApiKeySubmitLocal}
placeholder="sk-..."
/>
</Box>
{customApiKeyError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{customApiKeyError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>
);
// Render custom model ID input
const renderCustomModelIdInputView = () => (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.primary}>
{t('Enter one or more model IDs, separated by commas.')}
</Text>
</Box>
<Box marginTop={1}>
<TextInput
value={customModelIds}
onChange={(value) => {
setCustomModelIds(value);
if (customModelIdsError) {
setCustomModelIdsError(null);
}
}}
onSubmit={handleCustomModelIdSubmit}
placeholder="qwen/qwen3-coder,openai/gpt-4.1"
/>
</Box>
{customModelIdsError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{customModelIdsError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit, Esc to go back')}
</Text>
</Box>
</Box>
);
// Render custom advanced config
const renderCustomAdvancedConfigView = () => {
const checkmark = (v: boolean) => (v ? '◉' : '○');
const cursor = (index: number) =>
focusedConfigIndex === index ? '' : ' ';
return (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.primary}>
{t('Optional: configure advanced generation settings.')}
</Text>
</Box>
<Box marginTop={1} marginLeft={2}>
<Text
color={focusedConfigIndex === 0 ? theme.status.success : undefined}
>
{cursor(0)} {checkmark(advancedThinkingEnabled)}{' '}
{t('Enable thinking')}
</Text>
</Box>
<Box marginTop={0} marginLeft={4}>
<Text color={theme.text.secondary}>
{t(
'Allows the model to perform extended reasoning before responding.',
)}
</Text>
</Box>
<Box marginTop={1} marginLeft={2}>
<Text
color={focusedConfigIndex === 1 ? theme.status.success : undefined}
>
{cursor(1)} {checkmark(advancedModalityEnabled)}{' '}
{t('Enable modality')}
</Text>
</Box>
<Box marginTop={0} marginLeft={4}>
<Text color={theme.text.secondary}>
{t('Enables image, video, and audio input/output capabilities.')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'\u2191\u2193 to navigate, Space to toggle, Enter to continue, Esc to go back',
)}
</Text>
</Box>
</Box>
);
};
// Render custom review JSON
const renderCustomReviewJsonView = () => {
const generatedEnvKey = generateCustomApiKeyEnvKey(
customProtocol,
customBaseUrl.trim(),
);
const normalizedIds = normalizeCustomModelIds(customModelIds);
const maskedKey = maskApiKey(customApiKey);
// Build generationConfig preview lines
const hasThinking = advancedThinkingEnabled;
const hasModality = advancedModalityEnabled;
const hasGenConfig = hasThinking || hasModality;
let genConfig: Record<string, unknown> | undefined;
if (hasGenConfig) {
genConfig = {};
if (hasModality) {
genConfig['modalities'] = {
image: true,
video: true,
audio: true,
};
}
if (hasThinking) {
genConfig['extra_body'] = {
enable_thinking: true,
};
}
}
const modelEntries = normalizedIds.map((id) => {
const entry: Record<string, unknown> = {
id,
name: id,
baseUrl: customBaseUrl.trim(),
envKey: generatedEnvKey,
};
if (genConfig) {
entry['generationConfig'] = genConfig;
}
return entry;
});
const preview = {
env: { [generatedEnvKey]: maskedKey },
modelProviders: {
[customProtocol]: modelEntries,
},
security: {
auth: {
selectedType: customProtocol,
},
},
model: {
name: normalizedIds[0],
},
};
const jsonPreview = JSON.stringify(preview, null, 2);
return (
<Box marginTop={1} flexDirection="column">
<Box marginTop={1}>
<Text color={theme.text.primary}>
{t('The following JSON will be saved to settings.json:')}
</Text>
</Box>
<Box marginTop={1}>
<Text>{jsonPreview}</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to save, Esc to go back')}
</Text>
</Box>
</Box>
);
};
const renderOAuthProviderSelectView = () => (
<>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={oauthProviderItems}
initialIndex={oauthProviderIndex}
onSelect={handleOAuthProviderSelect}
onHighlight={(value) => {
const index = oauthProviderItems.findIndex(
(item) => item.value === value,
);
setOAuthProviderIndex(index);
}}
itemGap={1}
/>
</Box>
<Box marginTop={1}>
<Text color={theme?.text?.secondary}>
{t('Enter to select, ↑↓ to navigate, Esc to go back')}
</Text>
</Box>
</>
);
@ -659,8 +1226,18 @@ export function AuthDialog(): React.JSX.Element {
return t('Enter Coding Plan API Key');
case 'api-key-type-select':
return t('Select API Key Type');
case 'custom-info':
return t('Custom Configuration');
case 'custom-protocol-select':
return t('Step 1/6 \u00B7 Protocol');
case 'custom-base-url-input':
return t('Step 2/6 \u00B7 Base URL');
case 'custom-api-key-input':
return t('Step 3/6 \u00B7 API Key');
case 'custom-model-id-input':
return t('Step 4/6 \u00B7 Model IDs');
case 'custom-advanced-config':
return t('Step 5/6 \u00B7 Advanced Config');
case 'custom-review-json':
return t('Step 6/6 \u00B7 Review');
case 'alibaba-standard-region-select':
return t(
'Select Region for Alibaba Cloud ModelStudio Standard API Key',
@ -669,6 +1246,8 @@ export function AuthDialog(): React.JSX.Element {
return t('Enter Alibaba Cloud ModelStudio Standard API Key');
case 'alibaba-standard-model-id-input':
return t('Enter Model IDs');
case 'oauth-provider-select':
return t('Select OAuth Provider');
default:
return t('Select Authentication Method');
}
@ -694,7 +1273,15 @@ export function AuthDialog(): React.JSX.Element {
renderAlibabaStandardApiKeyInputView()}
{viewLevel === 'alibaba-standard-model-id-input' &&
renderAlibabaStandardModelIdInputView()}
{viewLevel === 'custom-info' && renderCustomInfoView()}
{viewLevel === 'custom-protocol-select' &&
renderCustomProtocolSelectView()}
{viewLevel === 'custom-base-url-input' && renderCustomBaseUrlInputView()}
{viewLevel === 'custom-api-key-input' && renderCustomApiKeyInputView()}
{viewLevel === 'custom-model-id-input' && renderCustomModelIdInputView()}
{viewLevel === 'custom-advanced-config' &&
renderCustomAdvancedConfigView()}
{viewLevel === 'custom-review-json' && renderCustomReviewJsonView()}
{viewLevel === 'oauth-provider-select' && renderOAuthProviderSelectView()}
{(authError || errorMessage) && (
<Box marginTop={1}>

View file

@ -0,0 +1,351 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AuthType } from '@qwen-code/qwen-code-core';
import {
useAuthCommand,
generateCustomApiKeyEnvKey,
normalizeCustomModelIds,
maskApiKey,
} from './useAuth.js';
import {
OPENROUTER_OAUTH_CALLBACK_URL,
applyOpenRouterModelsConfiguration,
createOpenRouterOAuthSession,
runOpenRouterOAuthLogin,
} from '../../commands/auth/openrouterOAuth.js';
vi.mock('../hooks/useQwenAuth.js', () => ({
useQwenAuth: vi.fn(() => ({
qwenAuthState: {},
cancelQwenAuth: vi.fn(),
})),
}));
vi.mock('../../utils/settingsUtils.js', () => ({
backupSettingsFile: vi.fn(),
}));
vi.mock('../../config/modelProvidersScope.js', () => ({
getPersistScopeForModelSelection: vi.fn(() => 'user'),
}));
vi.mock('../../commands/auth/openrouterOAuth.js', () => ({
OPENROUTER_OAUTH_CALLBACK_URL: 'http://localhost:3000/openrouter/callback',
createOpenRouterOAuthSession: vi.fn(() => ({
callbackUrl: 'http://localhost:3000/openrouter/callback',
codeVerifier: 'test-verifier',
state: 'test-state',
authorizationUrl:
'https://openrouter.ai/auth?callback_url=http%3A%2F%2Flocalhost%3A3000%2Fopenrouter%2Fcallback&code_challenge=test-challenge&state=test-state',
})),
applyOpenRouterModelsConfiguration: vi.fn(async () => ({
updatedConfigs: [
{
id: 'openai/gpt-4o-mini:free',
name: 'OpenRouter · GPT-4o mini',
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY',
},
],
activeModelId: 'openai/gpt-4o-mini:free',
persistScope: 'user',
})),
runOpenRouterOAuthLogin: vi.fn(
() => new Promise(() => undefined) as Promise<{ apiKey: string }>,
),
}));
const createSettings = () => ({
merged: {
modelProviders: {},
},
setValue: vi.fn(),
forScope: vi.fn(() => ({
path: '/tmp/settings.json',
})),
});
const createConfig = () => ({
getAuthType: vi.fn(() => AuthType.USE_OPENAI),
getUsageStatisticsEnabled: vi.fn(() => false),
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn(async () => undefined),
});
describe('useAuthCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('closes auth dialog immediately when starting OpenRouter OAuth', async () => {
const settings = createSettings();
const config = createConfig();
const addItem = vi.fn();
const { result } = renderHook(() =>
useAuthCommand(settings as never, config as never, addItem),
);
act(() => {
result.current.openAuthDialog();
});
expect(result.current.isAuthDialogOpen).toBe(true);
await act(async () => {
void result.current.handleOpenRouterSubmit();
await Promise.resolve();
});
expect(result.current.pendingAuthType).toBe(AuthType.USE_OPENAI);
expect(result.current.isAuthenticating).toBe(true);
expect(result.current.externalAuthState).toEqual({
title: 'OpenRouter Authentication',
message:
'Open the authorization page if your browser does not launch automatically.',
detail: expect.stringContaining('https://openrouter.ai/auth'),
});
expect(result.current.isAuthDialogOpen).toBe(false);
expect(addItem).not.toHaveBeenCalled();
});
it('cancels OpenRouter OAuth wait and reopens the auth dialog', async () => {
const settings = createSettings();
const config = createConfig();
const addItem = vi.fn();
const { result } = renderHook(() =>
useAuthCommand(settings as never, config as never, addItem),
);
act(() => {
result.current.openAuthDialog();
});
await act(async () => {
void result.current.handleOpenRouterSubmit();
await Promise.resolve();
});
expect(result.current.isAuthenticating).toBe(true);
expect(createOpenRouterOAuthSession).toHaveBeenCalledWith(
OPENROUTER_OAUTH_CALLBACK_URL,
);
expect(runOpenRouterOAuthLogin).toHaveBeenCalledWith(
OPENROUTER_OAUTH_CALLBACK_URL,
expect.objectContaining({
abortSignal: expect.any(AbortSignal),
session: expect.objectContaining({
authorizationUrl: expect.stringContaining(
'https://openrouter.ai/auth',
),
}),
}),
);
act(() => {
result.current.cancelAuthentication();
});
const abortSignal = vi.mocked(runOpenRouterOAuthLogin).mock.calls[0]?.[1]
?.abortSignal;
expect(abortSignal?.aborted).toBe(true);
expect(result.current.isAuthenticating).toBe(false);
expect(result.current.externalAuthState).toBe(null);
expect(result.current.pendingAuthType).toBe(AuthType.USE_OPENAI);
expect(result.current.isAuthDialogOpen).toBe(true);
});
it('cleans up UI state when OpenRouter OAuth rejects with AbortError', async () => {
const settings = createSettings();
const config = createConfig();
const addItem = vi.fn();
vi.mocked(runOpenRouterOAuthLogin).mockRejectedValueOnce(
new DOMException('OpenRouter OAuth cancelled.', 'AbortError'),
);
const { result } = renderHook(() =>
useAuthCommand(settings as never, config as never, addItem),
);
await act(async () => {
await result.current.handleOpenRouterSubmit();
});
expect(result.current.isAuthenticating).toBe(false);
expect(result.current.externalAuthState).toBe(null);
expect(result.current.pendingAuthType).toBeUndefined();
expect(result.current.isAuthDialogOpen).toBe(true);
expect(addItem).not.toHaveBeenCalled();
});
it('adds /model and /manage-models guidance after OpenRouter auth succeeds', async () => {
const settings = createSettings();
const config = createConfig();
const addItem = vi.fn();
vi.mocked(runOpenRouterOAuthLogin).mockResolvedValueOnce({
apiKey: 'oauth-key-123',
userId: 'user-1',
});
const { result } = renderHook(() =>
useAuthCommand(settings as never, config as never, addItem),
);
await act(async () => {
await result.current.handleOpenRouterSubmit();
});
expect(applyOpenRouterModelsConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.anything(),
config: expect.anything(),
apiKey: 'oauth-key-123',
reloadConfig: true,
}),
);
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Successfully configured OpenRouter.' }),
expect.any(Number),
);
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Use /model to switch models.' }),
expect.any(Number),
);
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: 'Want more OpenRouter models? Use /manage-models to browse and enable them.',
}),
expect.any(Number),
);
});
});
describe('generateCustomApiKeyEnvKey', () => {
it('generates env key from openai protocol and base URL', () => {
const key = generateCustomApiKeyEnvKey(
'openai',
'https://api.openai.com/v1',
);
expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_OPENAI_COM_V1');
});
it('generates env key from anthropic protocol and base URL', () => {
const key = generateCustomApiKeyEnvKey(
'anthropic',
'https://api.anthropic.com/v1',
);
expect(key).toBe(
'QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1',
);
});
it('generates env key from gemini protocol and base URL', () => {
const key = generateCustomApiKeyEnvKey(
'gemini',
'https://generativelanguage.googleapis.com',
);
expect(key).toBe(
'QWEN_CUSTOM_API_KEY_GEMINI_HTTPS_GENERATIVELANGUAGE_GOOGLEAPIS_COM',
);
});
it('handles localhost URLs', () => {
const key = generateCustomApiKeyEnvKey(
'openai',
'http://localhost:11434/v1',
);
expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_11434_V1');
});
it('normalizes trailing slashes and special chars', () => {
const key = generateCustomApiKeyEnvKey(
'openai',
'https://openrouter.ai/api/v1/',
);
expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1');
});
it('different protocols with same base URL produce different keys', () => {
const baseUrl = 'https://api.example.com/v1';
const openaiKey = generateCustomApiKeyEnvKey('openai', baseUrl);
const anthropicKey = generateCustomApiKeyEnvKey('anthropic', baseUrl);
expect(openaiKey).not.toBe(anthropicKey);
expect(openaiKey).toContain('OPENAI');
expect(anthropicKey).toContain('ANTHROPIC');
});
});
describe('normalizeCustomModelIds', () => {
it('splits comma-separated model IDs', () => {
const result = normalizeCustomModelIds('qwen/qwen3-coder,openai/gpt-4.1');
expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']);
});
it('trims whitespace from each model ID', () => {
const result = normalizeCustomModelIds(
' qwen/qwen3-coder , openai/gpt-4.1 ',
);
expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']);
});
it('deduplicates while preserving order', () => {
const result = normalizeCustomModelIds(
'qwen/qwen3-coder,openai/gpt-4.1,qwen/qwen3-coder',
);
expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']);
});
it('removes empty entries', () => {
const result = normalizeCustomModelIds('qwen/qwen3-coder,,openai/gpt-4.1');
expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']);
});
it('returns empty array for empty input', () => {
const result = normalizeCustomModelIds('');
expect(result).toEqual([]);
});
it('returns empty array for whitespace-only input', () => {
const result = normalizeCustomModelIds(' , , ');
expect(result).toEqual([]);
});
it('handles single model ID', () => {
const result = normalizeCustomModelIds('qwen/qwen3-coder');
expect(result).toEqual(['qwen/qwen3-coder']);
});
});
describe('maskApiKey', () => {
it('masks a standard API key showing first 3 and last 4 chars', () => {
const result = maskApiKey('sk-or-v1-1234567890abcdef');
expect(result).toBe('sk-...cdef');
});
it('shows placeholder for empty string', () => {
const result = maskApiKey('');
expect(result).toBe('(not set)');
});
it('masks short keys with asterisks', () => {
const result = maskApiKey('abc');
expect(result).toBe('***');
});
it('masks 6-char keys with asterisks', () => {
const result = maskApiKey('abcdef');
expect(result).toBe('***');
});
it('trims whitespace before masking', () => {
const result = maskApiKey(' sk-or-v1-1234567890abcdef ');
expect(result).toBe('sk-...cdef');
});
});

View file

@ -39,6 +39,53 @@ import {
DASHSCOPE_STANDARD_API_KEY_ENV_KEY,
type AlibabaStandardRegion,
} from '../../constants/alibabaStandardApiKey.js';
import {
applyOpenRouterModelsConfiguration,
createOpenRouterOAuthSession,
OPENROUTER_OAUTH_CALLBACK_URL,
runOpenRouterOAuthLogin,
} from '../../commands/auth/openrouterOAuth.js';
/**
* Generate a Qwen-managed env key from protocol and base URL.
* Format: QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL}
*/
export function generateCustomApiKeyEnvKey(
protocol: string,
baseUrl: string,
): string {
const normalize = (value: string) =>
value
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
return `QWEN_CUSTOM_API_KEY_${normalize(protocol)}_${normalize(baseUrl)}`;
}
/**
* Normalize model IDs: split by comma, trim, deduplicate, remove empty.
*/
export function normalizeCustomModelIds(modelIdsInput: string): string[] {
return modelIdsInput
.split(',')
.map((id) => id.trim())
.filter((id, index, array) => id.length > 0 && array.indexOf(id) === index);
}
/**
* Mask an API key for display: show first 3 and last 4 chars.
*/
export function maskApiKey(apiKey: string): string {
const trimmed = apiKey.trim();
if (trimmed.length === 0) return '(not set)';
if (trimmed.length <= 6) return '***';
const head = trimmed.slice(0, 3);
const tail = trimmed.slice(-4);
return `${head}...${tail}`;
}
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
@ -61,6 +108,13 @@ export const useAuthCommand = (
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const [externalAuthState, setExternalAuthState] = useState<{
title: string;
message: string;
detail?: string;
} | null>(null);
const [openRouterAuthAbortController, setOpenRouterAuthAbortController] =
useState<AbortController | null>(null);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
@ -81,6 +135,7 @@ export const useAuthCommand = (
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
setExternalAuthState(null);
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
message: getErrorMessage(error),
});
@ -276,6 +331,11 @@ export const useAuthCommand = (
cancelQwenAuth();
}
if (isAuthenticating && pendingAuthType === AuthType.USE_OPENAI) {
openRouterAuthAbortController?.abort();
setOpenRouterAuthAbortController(null);
}
// Log authentication cancellation
if (isAuthenticating && pendingAuthType) {
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
@ -284,9 +344,16 @@ export const useAuthCommand = (
// Do not reset pendingAuthType here, persist the previously selected type.
setIsAuthenticating(false);
setExternalAuthState(null);
setIsAuthDialogOpen(true);
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
}, [
isAuthenticating,
pendingAuthType,
cancelQwenAuth,
config,
openRouterAuthAbortController,
]);
/**
* Handle coding plan submission - generates configs from template and stores api-key
@ -552,6 +619,284 @@ export const useAuthCommand = (
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
const handleOpenRouterSubmit = useCallback(async () => {
try {
setPendingAuthType(AuthType.USE_OPENAI);
setIsAuthenticating(true);
setAuthError(null);
setIsAuthDialogOpen(false);
const oauthSession = createOpenRouterOAuthSession(
OPENROUTER_OAUTH_CALLBACK_URL,
);
setExternalAuthState({
title: t('OpenRouter Authentication'),
message: t(
'Open the authorization page if your browser does not launch automatically.',
),
detail: oauthSession.authorizationUrl,
});
const abortController = new AbortController();
setOpenRouterAuthAbortController(abortController);
const oauthResult = await runOpenRouterOAuthLogin(
OPENROUTER_OAUTH_CALLBACK_URL,
{
abortSignal: abortController.signal,
session: oauthSession,
},
);
setOpenRouterAuthAbortController(null);
setExternalAuthState({
title: t('OpenRouter Authentication'),
message: t('Finalizing OpenRouter setup...'),
detail: t(
'Syncing OpenRouter models and updating your local configuration.',
),
});
const selectedKey = oauthResult.apiKey;
if (!selectedKey) {
throw new Error(
t('OpenRouter authentication completed without an API key.'),
);
}
const persistScope = getPersistScopeForModelSelection(settings);
const settingsFile = settings.forScope(persistScope);
backupSettingsFile(settingsFile.path);
await applyOpenRouterModelsConfiguration({
settings,
config,
apiKey: selectedKey,
reloadConfig: true,
});
await config.refreshAuth(AuthType.USE_OPENAI);
setAuthError(null);
setExternalAuthState(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
onAuthChange?.();
addItem(
{
type: MessageType.INFO,
text: t('Successfully configured OpenRouter.'),
},
Date.now(),
);
addItem(
{
type: MessageType.INFO,
text: t('Use /model to switch models.'),
},
Date.now(),
);
addItem(
{
type: MessageType.INFO,
text: t(
'Want more OpenRouter models? Use /manage-models to browse and enable them.',
),
},
Date.now(),
);
const authEvent = new AuthEvent(AuthType.USE_OPENAI, 'manual', 'success');
logAuth(config, authEvent);
} catch (error) {
setOpenRouterAuthAbortController(null);
if (error instanceof DOMException && error.name === 'AbortError') {
setExternalAuthState(null);
setPendingAuthType(undefined);
setIsAuthenticating(false);
setIsAuthDialogOpen(true);
return;
}
handleAuthFailure(error);
}
}, [
settings,
config,
handleAuthFailure,
addItem,
onAuthChange,
setOpenRouterAuthAbortController,
]);
/**
* Handle custom API key setup wizard submission.
* Persists key to env[generatedEnvKey] and creates modelProviders entries.
*/
const handleCustomApiKeySubmit = useCallback(
async (
protocol:
| AuthType.USE_OPENAI
| AuthType.USE_ANTHROPIC
| AuthType.USE_GEMINI,
baseUrl: string,
apiKey: string,
modelIdsInput: string,
generationConfig?: {
enableThinking?: boolean;
multimodal?: {
image?: boolean;
video?: boolean;
audio?: boolean;
};
maxTokens?: number;
},
) => {
try {
setIsAuthenticating(true);
setAuthError(null);
const trimmedApiKey = apiKey.trim();
const trimmedBaseUrl = baseUrl.trim();
const modelIds = normalizeCustomModelIds(modelIdsInput);
if (!trimmedApiKey) {
throw new Error(t('API key cannot be empty.'));
}
if (!trimmedBaseUrl) {
throw new Error(t('Base URL cannot be empty.'));
}
if (!/^https?:\/\//i.test(trimmedBaseUrl)) {
throw new Error(t('Base URL must start with http:// or https://.'));
}
if (modelIds.length === 0) {
throw new Error(t('Model IDs cannot be empty.'));
}
const generatedEnvKey = generateCustomApiKeyEnvKey(
protocol,
trimmedBaseUrl,
);
const persistScope = getPersistScopeForModelSelection(settings);
const settingsFile = settings.forScope(persistScope);
backupSettingsFile(settingsFile.path);
// Persist API key to env
settings.setValue(
persistScope,
`env.${generatedEnvKey}`,
trimmedApiKey,
);
process.env[generatedEnvKey] = trimmedApiKey;
// Build generationConfig if any option is set
let genConfig: ProviderModelConfig['generationConfig'] | undefined;
if (generationConfig) {
const hasThinking = generationConfig.enableThinking === true;
const hasMultimodal =
generationConfig.multimodal &&
(generationConfig.multimodal.image === true ||
generationConfig.multimodal.video === true ||
generationConfig.multimodal.audio === true);
const hasMaxTokens =
generationConfig.maxTokens !== undefined &&
generationConfig.maxTokens > 0;
if (hasThinking || hasMultimodal || hasMaxTokens) {
genConfig = {};
if (hasMultimodal) {
genConfig.modalities = {
image: generationConfig.multimodal!.image ?? false,
video: generationConfig.multimodal!.video ?? false,
audio: generationConfig.multimodal!.audio ?? false,
};
}
if (hasThinking) {
genConfig.extra_body = { enable_thinking: true };
}
if (hasMaxTokens) {
genConfig.samplingParams = {
max_tokens: generationConfig.maxTokens,
};
}
}
}
// Build new model configs
const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({
id: modelId,
name: modelId,
baseUrl: trimmedBaseUrl,
envKey: generatedEnvKey,
...(genConfig ? { generationConfig: genConfig } : {}),
}));
// Merge with existing configs: replace same generatedEnvKey, preserve rest
const existingConfigs =
(
settings.merged.modelProviders as ModelProvidersConfig | undefined
)?.[protocol] || [];
const preservedConfigs = existingConfigs.filter(
(existing) => existing.envKey !== generatedEnvKey,
);
const updatedConfigs = [...newConfigs, ...preservedConfigs];
// Persist modelProviders, security, model
settings.setValue(
persistScope,
`modelProviders.${protocol}`,
updatedConfigs,
);
settings.setValue(persistScope, 'security.auth.selectedType', protocol);
settings.setValue(persistScope, 'model.name', modelIds[0]);
// Hot-reload before refreshAuth
const updatedModelProviders: ModelProvidersConfig = {
...(settings.merged.modelProviders as
| ModelProvidersConfig
| undefined),
[protocol]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
await config.refreshAuth(protocol);
setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
onAuthChange?.();
addItem(
{
type: MessageType.INFO,
text: t(
'Custom API Key authenticated successfully. Settings updated with generated env key and model provider config.',
),
},
Date.now(),
);
addItem(
{
type: MessageType.INFO,
text: t('Tip: Use /model to switch between configured models.'),
},
Date.now(),
);
const authEvent = new AuthEvent(protocol, 'manual', 'success');
logAuth(config, authEvent);
} catch (error) {
handleAuthFailure(error);
}
},
[settings, config, handleAuthFailure, addItem, onAuthChange],
);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
@ -600,10 +945,13 @@ export const useAuthCommand = (
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
externalAuthState,
qwenAuthState,
handleAuthSelect,
handleCodingPlanSubmit,
handleAlibabaStandardSubmit,
handleOpenRouterSubmit,
handleCustomApiKeySubmit,
openAuthDialog,
cancelAuthentication,
};

View file

@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { manageModelsCommand } from './manageModelsCommand.js';
import type { CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('manageModelsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the manage-models dialog', () => {
if (!manageModelsCommand.action) {
throw new Error('The manage-models command must have an action.');
}
const result = manageModelsCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'manage-models',
});
});
it('should have the correct name and description', () => {
expect(manageModelsCommand.name).toBe('manage-models');
expect(manageModelsCommand.description).toBe(
'Browse dynamic model catalogs and choose which models stay enabled locally',
);
});
});

View file

@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const manageModelsCommand: SlashCommand = {
name: 'manage-models',
get description() {
return t(
'Browse dynamic model catalogs and choose which models stay enabled locally',
);
},
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive'] as const,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'manage-models',
}),
};

View file

@ -16,7 +16,7 @@ export const rewindCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<SlashCommandActionReturn> => ({
type: 'dialog',
dialog: 'rewind',
}),
type: 'dialog',
dialog: 'rewind',
}),
};

View file

@ -173,6 +173,7 @@ export interface OpenDialogActionReturn {
| 'memory'
| 'model'
| 'fast-model'
| 'manage-models'
| 'subagent_create'
| 'subagent_list'
| 'trust'

View file

@ -16,11 +16,13 @@ import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { ExternalAuthProgress } from './ExternalAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { TrustDialog } from './TrustDialog.js';
import { PermissionsDialog } from './PermissionsDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ManageModelsDialog } from './ManageModelsDialog.js';
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
@ -213,6 +215,14 @@ export const DialogManager = ({
/>
);
}
if (uiState.isManageModelsDialogOpen) {
return (
<ManageModelsDialog
config={config}
onClose={uiActions.closeManageModelsDialog}
/>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
@ -310,6 +320,23 @@ export const DialogManager = ({
}
if (uiState.isAuthenticating) {
if (
uiState.pendingAuthType === AuthType.USE_OPENAI &&
uiState.externalAuthState
) {
return (
<ExternalAuthProgress
title={uiState.externalAuthState.title}
message={uiState.externalAuthState.message}
detail={uiState.externalAuthState.detail}
onCancel={() => {
uiActions.cancelAuthentication();
uiActions.setAuthState(AuthState.Updating);
}}
/>
);
}
// OpenAI authentication now handled through AuthDialog with coding-plan/custom sub-modes
// Qwen OAuth remains as a separate flow
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {

View file

@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { ExternalAuthProgress } from './ExternalAuthProgress.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
describe('ExternalAuthProgress', () => {
it('shows cancel hint when cancel is available', () => {
const onCancel = vi.fn();
const { lastFrame } = render(
<ExternalAuthProgress
title="OpenRouter Authentication"
message="Open the authorization page if your browser does not launch automatically."
detail="https://openrouter.ai/auth?example=1"
onCancel={onCancel}
/>,
);
expect(lastFrame()).toContain('Esc to cancel');
});
});

View file

@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ExternalAuthProgressProps {
title: string;
message: string;
detail?: string;
onCancel?: () => void;
}
export function ExternalAuthProgress({
title,
message,
detail,
onCancel,
}: ExternalAuthProgressProps): React.JSX.Element {
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel?.();
}
},
{ isActive: Boolean(onCancel) },
);
return (
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{title}</Text>
<Box marginTop={1} flexDirection="column">
<Text>{message}</Text>
{detail ? <Text color={theme.text.secondary}>{detail}</Text> : null}
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Please wait while authentication completes...')}
</Text>
{onCancel ? (
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
) : null}
</Box>
</Box>
);
}

View file

@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import {
applyCatalogFilters,
buildModelLabel,
getNextEnabledTabSource,
getNextFocusMode,
type FilterMode,
} from './ManageModelsDialog.js';
import type { ManageModelsCatalogEntry } from '../manageModels/manageModels.js';
function makeEntry(
id: string,
options: {
badges?: string[];
supportsVision?: boolean;
contextWindowSize?: number;
} = {},
): ManageModelsCatalogEntry {
return {
id,
label: id,
searchText: `${id} ${(options.badges || []).join(' ')}`,
supportsVision: options.supportsVision ?? false,
contextWindowSize: options.contextWindowSize,
badges: options.badges || [],
model: {
id,
name: id,
baseUrl: 'https://openrouter.ai/api/v1',
},
};
}
describe('ManageModelsDialog helpers', () => {
it('buildModelLabel uses the short display label only', () => {
expect(
buildModelLabel(
makeEntry('qwen/qwen3-coder:free', {
badges: ['free', 'vision'],
contextWindowSize: 1_000_000,
}),
),
).toBe('qwen/qwen3-coder:free');
});
it.each<[FilterMode, string[]]>([
['all', ['qwen/qwen3-coder:free', 'openai/gpt-4o-mini']],
['enabled', ['openai/gpt-4o-mini']],
['free', ['qwen/qwen3-coder:free']],
['vision', ['qwen/qwen3-coder:free']],
])('applyCatalogFilters supports %s filter', (filterMode, expectedIds) => {
const entries = [
makeEntry('qwen/qwen3-coder:free', {
badges: ['free', 'vision'],
supportsVision: true,
}),
makeEntry('openai/gpt-4o-mini'),
];
expect(
applyCatalogFilters({
entries,
query: '',
selectedIds: ['openai/gpt-4o-mini'],
filterMode,
}).map((entry) => entry.id),
).toEqual(expectedIds);
});
it('applyCatalogFilters combines query and filter mode', () => {
const entries = [
makeEntry('qwen/qwen3-coder:free', {
badges: ['free'],
}),
makeEntry('glm/glm-4.5-air:free', {
badges: ['free'],
}),
];
expect(
applyCatalogFilters({
entries,
query: 'qwen',
selectedIds: [],
filterMode: 'free',
}).map((entry) => entry.id),
).toEqual(['qwen/qwen3-coder:free']);
});
it('applyCatalogFilters supports enabled quick filter in search', () => {
const entries = [
makeEntry('qwen/qwen3-coder:free'),
makeEntry('openai/gpt-4o-mini'),
];
expect(
applyCatalogFilters({
entries,
query: 'enabled',
selectedIds: ['openai/gpt-4o-mini'],
filterMode: 'all',
}).map((entry) => entry.id),
).toEqual(['openai/gpt-4o-mini']);
expect(
applyCatalogFilters({
entries,
query: 'is:enabled gpt',
selectedIds: ['openai/gpt-4o-mini'],
filterMode: 'all',
}).map((entry) => entry.id),
).toEqual(['openai/gpt-4o-mini']);
});
it('cycles focus across tabs, search, and list', () => {
expect(getNextFocusMode('tabs', 'forward', true)).toBe('search');
expect(getNextFocusMode('search', 'forward', true)).toBe('list');
expect(getNextFocusMode('list', 'forward', true)).toBe('tabs');
expect(getNextFocusMode('search', 'backward', false)).toBe('tabs');
});
it('keeps provider tab on the only enabled source', () => {
expect(getNextEnabledTabSource('openrouter', 'left')).toBe('openrouter');
expect(getNextEnabledTabSource('openrouter', 'right')).toBe('openrouter');
});
});

View file

@ -0,0 +1,648 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useCallback, useEffect, useMemo, useState } from 'react';
import process from 'node:process';
import {
type Config,
type ProviderModelConfig as ModelConfig,
} from '@qwen-code/qwen-code-core';
import { useSettings } from '../contexts/SettingsContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { TextInput } from './shared/TextInput.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
type ManageModelsCatalog,
type ManageModelsCatalogEntry,
type ManageModelsSource,
fetchManageModelsCatalog,
getEnabledModelIdsForSource,
saveManageModelsSelection,
} from '../manageModels/manageModels.js';
interface ManageModelsDialogProps {
config: Config;
onClose: () => void;
}
type DialogStatus = 'loading' | 'ready' | 'saving' | 'error';
type FocusMode = 'tabs' | 'search' | 'list';
export type FilterMode = 'all' | 'enabled' | 'free' | 'vision';
const MAX_VISIBLE_MODELS = 12;
const MANAGE_MODELS_TABS = [
{ source: 'openrouter', label: 'OpenRouter', enabled: true },
{ source: 'modelstudio', label: 'ModelStudio', enabled: false },
] as const;
type ManageModelsTabSource = (typeof MANAGE_MODELS_TABS)[number]['source'];
export function buildModelLabel(entry: ManageModelsCatalogEntry): string {
return entry.label;
}
export function applyCatalogFilters(params: {
entries: ManageModelsCatalogEntry[];
query: string;
selectedIds: string[];
filterMode: FilterMode;
}): ManageModelsCatalogEntry[] {
const { entries, query, selectedIds, filterMode } = params;
const normalized = query.trim().toLowerCase();
const rawTokens = normalized ? normalized.split(/\s+/).filter(Boolean) : [];
const quickFilterEnabled = rawTokens.some(
(token) => token === 'enabled' || token === 'is:enabled',
);
const tokens = rawTokens.filter(
(token) => token !== 'enabled' && token !== 'is:enabled',
);
const selectedSet = new Set(selectedIds);
return entries.filter((entry) => {
if (
(filterMode === 'enabled' || quickFilterEnabled) &&
!selectedSet.has(entry.id)
) {
return false;
}
if (filterMode === 'free' && !entry.badges.includes('free')) {
return false;
}
if (filterMode === 'vision' && !entry.supportsVision) {
return false;
}
if (tokens.length === 0) {
return true;
}
const haystack = `${entry.searchText} ${entry.id}`.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}
function getFilterLabel(filterMode: FilterMode): string {
switch (filterMode) {
case 'enabled':
return 'Enabled';
case 'free':
return 'Free';
case 'vision':
return 'Vision';
case 'all':
default:
return 'All';
}
}
function cycleFilter(
current: FilterMode,
direction: 'left' | 'right',
): FilterMode {
const modes: FilterMode[] = ['all', 'enabled', 'free', 'vision'];
const currentIndex = modes.indexOf(current);
const nextIndex =
direction === 'right'
? (currentIndex + 1) % modes.length
: (currentIndex - 1 + modes.length) % modes.length;
return modes[nextIndex] || 'all';
}
function formatContextWindowSize(value?: number): string {
return typeof value === 'number' ? value.toLocaleString('en-US') : 'unknown';
}
export function getNextFocusMode(
current: FocusMode,
direction: 'forward' | 'backward',
hasList: boolean,
): FocusMode {
const order: FocusMode[] = hasList
? ['tabs', 'search', 'list']
: ['tabs', 'search'];
const currentIndex = order.indexOf(current);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
direction === 'forward'
? (safeIndex + 1) % order.length
: (safeIndex - 1 + order.length) % order.length;
return order[nextIndex] || 'tabs';
}
export function getNextEnabledTabSource(
current: ManageModelsTabSource,
direction: 'left' | 'right',
): ManageModelsTabSource {
const currentIndex = MANAGE_MODELS_TABS.findIndex(
(tab) => tab.source === current,
);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
for (let offset = 1; offset <= MANAGE_MODELS_TABS.length; offset += 1) {
const candidateIndex =
direction === 'right'
? (safeIndex + offset) % MANAGE_MODELS_TABS.length
: (safeIndex - offset + MANAGE_MODELS_TABS.length) %
MANAGE_MODELS_TABS.length;
const candidate = MANAGE_MODELS_TABS[candidateIndex];
if (candidate?.enabled) {
return candidate.source;
}
}
return current;
}
export function ManageModelsDialog({
config,
onClose,
}: ManageModelsDialogProps): React.JSX.Element {
const settings = useSettings();
const [activeTabSource, setActiveTabSource] =
useState<ManageModelsTabSource>('openrouter');
const source: ManageModelsSource = 'openrouter';
const [status, setStatus] = useState<DialogStatus>('loading');
const [error, setError] = useState<string | null>(null);
const [catalog, setCatalog] = useState<ManageModelsCatalog | null>(null);
const [query, setQuery] = useState('');
const [focusMode, setFocusMode] = useState<FocusMode>('tabs');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const loadCatalog = useCallback(async () => {
setStatus('loading');
setError(null);
setStatusMessage(null);
try {
const nextCatalog = await fetchManageModelsCatalog(source);
const enabledIds = getEnabledModelIdsForSource(source, settings);
setCatalog(nextCatalog);
setSelectedIds(enabledIds);
setHighlightedId(nextCatalog.entries[0]?.id || null);
setStatus('ready');
} catch (loadError) {
setError(
loadError instanceof Error ? loadError.message : String(loadError),
);
setStatus('error');
}
}, [settings, source]);
useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
const filteredEntries = useMemo(
() =>
applyCatalogFilters({
entries: catalog?.entries || [],
query,
selectedIds,
filterMode,
}),
[catalog?.entries, query, selectedIds, filterMode],
);
useEffect(() => {
if (filteredEntries.length === 0) {
setHighlightedId(null);
if (focusMode === 'list') {
setFocusMode('search');
}
return;
}
if (
highlightedId &&
filteredEntries.some((entry) => entry.id === highlightedId)
) {
return;
}
setHighlightedId(filteredEntries[0]?.id || null);
}, [filteredEntries, focusMode, highlightedId]);
const highlightedIndex = useMemo(() => {
if (!highlightedId) {
return 0;
}
const index = filteredEntries.findIndex(
(entry) => entry.id === highlightedId,
);
return index >= 0 ? index : 0;
}, [filteredEntries, highlightedId]);
const highlightedEntry = useMemo(() => {
if (!highlightedId) {
return null;
}
return catalog?.entries.find((entry) => entry.id === highlightedId) || null;
}, [catalog?.entries, highlightedId]);
const visibleWindow = useMemo(() => {
if (filteredEntries.length <= MAX_VISIBLE_MODELS) {
return {
start: 0,
entries: filteredEntries,
};
}
const centeredStart = Math.max(
0,
Math.min(
highlightedIndex - Math.floor(MAX_VISIBLE_MODELS / 2),
filteredEntries.length - MAX_VISIBLE_MODELS,
),
);
return {
start: centeredStart,
entries: filteredEntries.slice(
centeredStart,
centeredStart + MAX_VISIBLE_MODELS,
),
};
}, [filteredEntries, highlightedIndex]);
const moveHighlight = useCallback(
(direction: 'up' | 'down') => {
if (filteredEntries.length === 0) {
return;
}
if (direction === 'up') {
if (highlightedIndex <= 0) {
setFocusMode('search');
return;
}
setHighlightedId(filteredEntries[highlightedIndex - 1]?.id || null);
return;
}
const nextIndex = Math.min(
highlightedIndex + 1,
filteredEntries.length - 1,
);
setHighlightedId(filteredEntries[nextIndex]?.id || null);
},
[filteredEntries, highlightedIndex],
);
const toggleHighlightedSelection = useCallback(() => {
const currentEntry = filteredEntries[highlightedIndex];
if (!currentEntry) {
return;
}
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(currentEntry.id)) {
next.delete(currentEntry.id);
} else {
next.add(currentEntry.id);
}
return Array.from(next);
});
}, [filteredEntries, highlightedIndex]);
const handleSave = useCallback(async () => {
if (!catalog) {
return;
}
const selectedEntries = catalog.entries.filter((entry) =>
selectedIds.includes(entry.id),
);
if (selectedEntries.length === 0) {
setError('Select at least one model to keep enabled.');
return;
}
setStatus('saving');
setError(null);
setStatusMessage(null);
try {
const selectedModels: ModelConfig[] = selectedEntries.map(
(entry) => entry.model,
);
const result = await saveManageModelsSelection({
source,
selectedModels,
settings: settings as LoadedSettings,
config,
});
setSelectedIds(result.selectedIds);
setStatus('ready');
setStatusMessage(
result.activeModelId
? `Saved ${result.selectedIds.length} enabled models · active model: ${result.activeModelId} · use /model to switch models`
: `Saved ${result.selectedIds.length} enabled models · use /model to switch models`,
);
} catch (saveError) {
setError(
saveError instanceof Error ? saveError.message : String(saveError),
);
setStatus('error');
}
}, [catalog, config, selectedIds, settings, source]);
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
return;
}
if (key.ctrl && key.name === 'r' && status !== 'saving') {
void loadCatalog();
return;
}
if (status === 'saving') {
return;
}
if (key.name === 'tab') {
setFocusMode((current) =>
getNextFocusMode(
current,
key.shift ? 'backward' : 'forward',
filteredEntries.length > 0,
),
);
return;
}
if (focusMode === 'tabs') {
if (key.name === 'left') {
setActiveTabSource((current) =>
getNextEnabledTabSource(current, 'left'),
);
return;
}
if (key.name === 'right') {
setActiveTabSource((current) =>
getNextEnabledTabSource(current, 'right'),
);
return;
}
if (key.name === 'down') {
setFocusMode('search');
}
return;
}
if (focusMode === 'search') {
if (key.name === 'left') {
setFilterMode((current) => cycleFilter(current, 'left'));
return;
}
if (key.name === 'right') {
setFilterMode((current) => cycleFilter(current, 'right'));
return;
}
if (key.name === 'up') {
setFocusMode('tabs');
return;
}
if (key.name === 'down' && filteredEntries.length > 0) {
setFocusMode('list');
}
return;
}
if (focusMode === 'list') {
if (key.name === 'up') {
moveHighlight('up');
return;
}
if (key.name === 'down') {
moveHighlight('down');
return;
}
if (key.name === 'space' || key.sequence === ' ') {
toggleHighlightedSelection();
return;
}
if (key.name === 'return') {
void handleSave();
}
}
},
{ isActive: true },
);
const terminalWidth = process.stdout.columns || 120;
const searchInputWidth = Math.max(40, Math.min(100, terminalWidth - 16));
const enabledSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const hiddenAboveCount = visibleWindow.start;
const hiddenBelowCount = Math.max(
0,
filteredEntries.length -
(visibleWindow.start + visibleWindow.entries.length),
);
return (
<Box flexDirection="column" width="100%">
<Box width="100%">
<Text color={theme.border.default} wrap="truncate">
{'─'.repeat(200)}
</Text>
</Box>
<Box flexDirection="column">
<Box>
<Text color={theme.text.accent} bold>
Manage Models:{' '}
</Text>
{MANAGE_MODELS_TABS.map((tab) => {
const isActive = activeTabSource === tab.source;
const isFocused = focusMode === 'tabs' && isActive;
return (
<Box key={tab.source} marginRight={2}>
{isActive ? (
<Text
bold
backgroundColor={
isFocused ? theme.text.accent : theme.border.default
}
color={theme.background.primary}
>
{` ${tab.label} `}
</Text>
) : (
<Text color={theme.text.secondary}>
{` ${tab.label}${tab.enabled ? '' : ' (soon)'} `}
</Text>
)}
</Box>
);
})}
</Box>
</Box>
{(status === 'loading' || status === 'saving') && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{status === 'loading'
? 'Loading OpenRouter catalog…'
: 'Saving enabled models…'}
</Text>
</Box>
)}
{error && (
<Box marginTop={1}>
<Text color={theme.status.error}>{error}</Text>
</Box>
)}
{statusMessage && (
<Box marginTop={1}>
<Text color={theme.status.success}>{statusMessage}</Text>
</Box>
)}
<Box
borderStyle="round"
borderColor={
focusMode === 'search' ? theme.text.accent : theme.border.default
}
paddingLeft={1}
paddingRight={1}
>
<TextInput
value={query}
onChange={setQuery}
onTab={() => {
if (filteredEntries.length > 0) {
setFocusMode('list');
}
}}
onDown={() => {
if (filteredEntries.length > 0) {
setFocusMode('list');
}
}}
placeholder="Search models… (type enabled to filter)"
height={1}
isActive={status !== 'saving' && focusMode === 'search'}
inputWidth={searchInputWidth}
/>
</Box>
<Box flexDirection="row" gap={2}>
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={1}
paddingY={0}
width="56%"
>
<Text color={theme.text.secondary}>
{getFilterLabel(filterMode)} · {catalog?.entries.length || 0} total
· {filteredEntries.length} shown · {selectedIds.length} enabled
</Text>
{filteredEntries.length === 0 ? (
<Text color={theme.text.secondary}>
No models match the current search and filter.
</Text>
) : (
<Box flexDirection="column">
{hiddenAboveCount > 0 && (
<Text color={theme.text.secondary}>
{hiddenAboveCount} more above
</Text>
)}
{visibleWindow.entries.map((entry, index) => {
const absoluteIndex = visibleWindow.start + index;
const isActive =
focusMode === 'list' && absoluteIndex === highlightedIndex;
const isEnabled = enabledSet.has(entry.id);
const prefix = isActive ? '' : ' ';
const checkbox = isEnabled ? '[✓]' : '[ ]';
const rowColor = isActive
? theme.status.success
: isEnabled
? theme.text.accent
: theme.text.primary;
return (
<Text key={entry.id} color={rowColor} wrap="truncate-end">
{prefix} {checkbox} {buildModelLabel(entry)}
</Text>
);
})}
{hiddenBelowCount > 0 && (
<Text color={theme.text.secondary}>
{hiddenBelowCount} more below
</Text>
)}
</Box>
)}
</Box>
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={1}
paddingY={0}
width="44%"
>
<Text bold>Details</Text>
{highlightedEntry ? (
<Box flexDirection="column">
<Text>{highlightedEntry.label}</Text>
<Text color={theme.text.secondary}>
Model ID: {highlightedEntry.id}
</Text>
<Text color={theme.text.secondary}>
Enabled: {enabledSet.has(highlightedEntry.id) ? 'yes' : 'no'}
</Text>
<Text color={theme.text.secondary}>
Vision: {highlightedEntry.supportsVision ? 'yes' : 'no'}
</Text>
<Text color={theme.text.secondary}>
Context:{' '}
{formatContextWindowSize(highlightedEntry.contextWindowSize)}
</Text>
<Text color={theme.text.secondary}>
Tags:{' '}
{highlightedEntry.badges.length > 0
? highlightedEntry.badges.join(', ')
: 'none'}
</Text>
</Box>
) : (
<Text color={theme.text.secondary}>
Move to the model list to inspect a model.
</Text>
)}
</Box>
</Box>
<Box>
<Text color={theme.text.secondary}>
/ tab switch · enter list · Space toggle · Enter save · Esc cancel
</Text>
</Box>
</Box>
);
}

View file

@ -19,9 +19,10 @@ export interface MultiSelectItem<T> extends SelectionListItem<T> {
export interface MultiSelectProps<T> {
items: Array<MultiSelectItem<T>>;
initialIndex?: number;
initialSelectedKeys?: string[];
selectedKeys?: string[];
onConfirm: (selectedValues: T[]) => void;
onChange?: (selectedValues: T[]) => void;
onSelectedKeysChange?: (selectedKeys: string[]) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
@ -43,32 +44,18 @@ function getSelectedValues<T>(
export function MultiSelect<T>({
items,
initialIndex = 0,
initialSelectedKeys = EMPTY_SELECTED_KEYS,
selectedKeys = EMPTY_SELECTED_KEYS,
onConfirm,
onChange,
onSelectedKeysChange,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
}: MultiSelectProps<T>): React.JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(initialSelectedKeys),
);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
setSelectedKeys((prev) => {
const next = new Set(initialSelectedKeys);
if (
prev.size === next.size &&
Array.from(next).every((key) => prev.has(key))
) {
return prev;
}
return next;
});
}, [initialSelectedKeys]);
const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
const { activeIndex } = useSelectionList({
items,
@ -81,7 +68,7 @@ export function MultiSelect<T>({
showNumbers: false,
onHighlight,
onSelect: () => {
onConfirm(getSelectedValues(items, selectedKeys));
onConfirm(getSelectedValues(items, selectedKeySet));
},
});
@ -92,23 +79,19 @@ export function MultiSelect<T>({
return;
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
return next;
});
const next = new Set(selectedKeySet);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
const nextKeys = Array.from(next);
onSelectedKeysChange?.(nextKeys);
onChange?.(getSelectedValues(items, next));
},
[items],
[items, onChange, onSelectedKeysChange, selectedKeySet],
);
useEffect(() => {
onChange?.(getSelectedValues(items, selectedKeys));
}, [items, selectedKeys, onChange]);
useKeypress(
(key) => {
if (key.name === 'space' || key.sequence === ' ') {
@ -152,7 +135,7 @@ export function MultiSelect<T>({
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isActive = activeIndex === itemIndex;
const isChecked = selectedKeys.has(item.key);
const isChecked = selectedKeySet.has(item.key);
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,

View file

@ -22,7 +22,7 @@ export interface TextInputProps {
onChange: (text: string) => void;
onSubmit?: () => void;
/** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */
onTab?: () => void;
onTab?: (key: Key) => void;
/** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */
onUp?: () => void;
/** Called when ↓ is pressed; if provided, prevents cursor-down in the buffer. */
@ -80,7 +80,7 @@ export function TextInput({
// Tab completion: delegate to caller instead of inserting a tab character
// During paste, let tab through as literal content (e.g. Excel tab-separated data)
if (key.name === 'tab' && !key.paste) {
onTab?.();
onTab?.(key);
return;
}

View file

@ -52,6 +52,25 @@ export interface UIActions {
region: AlibabaStandardRegion,
modelIdsInput: string,
) => Promise<void>;
handleOpenRouterSubmit: () => Promise<void>;
handleCustomApiKeySubmit: (
protocol:
| AuthType.USE_OPENAI
| AuthType.USE_ANTHROPIC
| AuthType.USE_GEMINI,
baseUrl: string,
apiKey: string,
modelIdsInput: string,
generationConfig?: {
enableThinking?: boolean;
multimodal?: {
image?: boolean;
video?: boolean;
audio?: boolean;
};
maxTokens?: number;
},
) => Promise<void>;
setAuthState: (state: AuthState) => void;
onAuthError: (error: string | null) => void;
cancelAuthentication: () => void;
@ -64,6 +83,8 @@ export interface UIActions {
closeMemoryDialog: () => void;
closeModelDialog: () => void;
openModelDialog: (options?: { fastModelMode?: boolean }) => void;
openManageModelsDialog: () => void;
closeManageModelsDialog: () => void;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
handleArenaModelsSelected?: (models: string[]) => void;

View file

@ -18,7 +18,7 @@ import type {
PluginChoiceRequest,
} from '../types.js';
import type { TodoItem } from '../components/TodoDisplay.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { ExternalAuthState, QwenAuthState } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
@ -48,6 +48,7 @@ export interface UIState {
authError: string | null;
isAuthDialogOpen: boolean;
pendingAuthType: AuthType | undefined;
externalAuthState: ExternalAuthState | null;
// Qwen OAuth state
qwenAuthState: QwenAuthState;
editorError: string | null;
@ -58,6 +59,7 @@ export interface UIState {
isMemoryDialogOpen: boolean;
isModelDialogOpen: boolean;
isFastModelMode: boolean;
isManageModelsDialogOpen: boolean;
isTrustDialogOpen: boolean;
activeArenaDialog: ArenaDialogType;
isPermissionsDialogOpen: boolean;

View file

@ -86,6 +86,7 @@ interface SlashCommandProcessorActions {
openMemoryDialog: () => void;
openSettingsDialog: () => void;
openModelDialog: (options?: { fastModelMode?: boolean }) => void;
openManageModelsDialog: () => void;
openTrustDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
@ -595,6 +596,9 @@ export const useSlashCommandProcessor = (
case 'fast-model':
actions.openModelDialog({ fastModelMode: true });
return { type: 'handled' };
case 'manage-models':
actions.openManageModelsDialog();
return { type: 'handled' };
case 'trust':
actions.openTrustDialog();
return { type: 'handled' };

View file

@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useManageModelsCommand } from './useManageModelsCommand.js';
describe('useManageModelsCommand', () => {
it('should initialize with the dialog closed', () => {
const { result } = renderHook(() => useManageModelsCommand());
expect(result.current.isManageModelsDialogOpen).toBe(false);
});
it('should open the dialog when openManageModelsDialog is called', () => {
const { result } = renderHook(() => useManageModelsCommand());
act(() => {
result.current.openManageModelsDialog();
});
expect(result.current.isManageModelsDialogOpen).toBe(true);
});
it('should close the dialog when closeManageModelsDialog is called', () => {
const { result } = renderHook(() => useManageModelsCommand());
act(() => {
result.current.openManageModelsDialog();
});
expect(result.current.isManageModelsDialogOpen).toBe(true);
act(() => {
result.current.closeManageModelsDialog();
});
expect(result.current.isManageModelsDialogOpen).toBe(false);
});
});

View file

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useState } from 'react';
interface UseManageModelsCommandReturn {
isManageModelsDialogOpen: boolean;
openManageModelsDialog: () => void;
closeManageModelsDialog: () => void;
}
export function useManageModelsCommand(): UseManageModelsCommandReturn {
const [isManageModelsDialogOpen, setIsManageModelsDialogOpen] =
useState(false);
const openManageModelsDialog = useCallback(() => {
setIsManageModelsDialogOpen(true);
}, []);
const closeManageModelsDialog = useCallback(() => {
setIsManageModelsDialogOpen(false);
}, []);
return {
isManageModelsDialogOpen,
openManageModelsDialog,
closeManageModelsDialog,
};
}

View file

@ -24,6 +24,12 @@ export interface QwenAuthState {
authMessage: string | null;
}
export interface ExternalAuthState {
title: string;
message: string;
detail?: string;
}
export const useQwenAuth = (
pendingAuthType: AuthType | undefined,
isAuthenticating: boolean,

View file

@ -0,0 +1,140 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AuthType,
type Config,
type ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import {
fetchManageModelsCatalog,
getEnabledModelIdsForSource,
saveManageModelsSelection,
} from './manageModels.js';
const {
mockFetchOpenRouterModels,
mockMergeOpenRouterConfigs,
mockIsOpenRouterConfig,
} = vi.hoisted(() => ({
mockFetchOpenRouterModels: vi.fn(),
mockMergeOpenRouterConfigs: vi.fn(),
mockIsOpenRouterConfig: vi.fn(),
}));
vi.mock('../../commands/auth/openrouterOAuth.js', () => ({
OPENROUTER_DEFAULT_MODEL: 'openai/gpt-4o-mini',
fetchOpenRouterModels: mockFetchOpenRouterModels,
mergeOpenRouterConfigs: mockMergeOpenRouterConfigs,
isOpenRouterConfig: mockIsOpenRouterConfig,
}));
describe('manageModels', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetchManageModelsCatalog maps OpenRouter models into catalog entries', async () => {
mockFetchOpenRouterModels.mockResolvedValue([
{
id: 'qwen/qwen3-coder:free',
name: 'OpenRouter · Qwen3 Coder',
capabilities: { vision: true },
generationConfig: { contextWindowSize: 1_000_000 },
},
]);
const catalog = await fetchManageModelsCatalog('openrouter');
expect(catalog.source).toBe('openrouter');
expect(catalog.entries).toHaveLength(1);
expect(catalog.entries[0]?.label).toBe('Qwen3 Coder');
expect(catalog.entries[0]?.badges).toEqual(
expect.arrayContaining(['free', 'vision', 'long-context']),
);
});
it('getEnabledModelIdsForSource only returns OpenRouter-enabled ids', () => {
mockIsOpenRouterConfig.mockImplementation(
(config: { baseUrl?: string }) =>
config.baseUrl?.includes('openrouter') ?? false,
);
const settings = {
merged: {
modelProviders: {
[AuthType.USE_OPENAI]: [
{
id: 'openai/gpt-4o-mini',
baseUrl: 'https://openrouter.ai/api/v1',
},
{ id: 'custom/model', baseUrl: 'https://example.com/v1' },
],
},
},
} as unknown as LoadedSettings;
expect(getEnabledModelIdsForSource('openrouter', settings)).toEqual([
'openai/gpt-4o-mini',
]);
});
it('saveManageModelsSelection merges selected OpenRouter models and reloads config', async () => {
const settings = {
isTrusted: false,
user: { settings: { modelProviders: {} } },
workspace: { settings: {} },
merged: {
modelProviders: {
[AuthType.USE_OPENAI]: [
{ id: 'old-openrouter', baseUrl: 'https://openrouter.ai/api/v1' },
{ id: 'custom/model', baseUrl: 'https://example.com/v1' },
],
} satisfies ModelProvidersConfig,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const config = {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: AuthType.USE_OPENAI }),
getModel: vi.fn().mockReturnValue('old-openrouter'),
reloadModelProvidersConfig: vi.fn(),
refreshAuth: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
mockMergeOpenRouterConfigs.mockReturnValue([
{ id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' },
{ id: 'custom/model', baseUrl: 'https://example.com/v1' },
]);
const result = await saveManageModelsSelection({
source: 'openrouter',
selectedModels: [
{ id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' },
],
settings,
config,
});
expect(mockMergeOpenRouterConfigs).toHaveBeenCalled();
expect(settings.setValue).toHaveBeenCalledWith(
expect.anything(),
`modelProviders.${AuthType.USE_OPENAI}`,
[
{ id: 'openai/gpt-4o-mini', baseUrl: 'https://openrouter.ai/api/v1' },
{ id: 'custom/model', baseUrl: 'https://example.com/v1' },
],
);
expect(config.reloadModelProvidersConfig).toHaveBeenCalled();
expect(config.refreshAuth).toHaveBeenCalledWith(AuthType.USE_OPENAI);
expect(result.selectedIds).toEqual(['openai/gpt-4o-mini']);
expect(result.activeModelId).toBe('openai/gpt-4o-mini');
});
});

View file

@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
type Config,
type ProviderModelConfig as ModelConfig,
type ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import {
OPENROUTER_DEFAULT_MODEL,
fetchOpenRouterModels,
isOpenRouterConfig,
mergeOpenRouterConfigs,
} from '../../commands/auth/openrouterOAuth.js';
export const MANAGE_MODELS_SOURCES = ['openrouter'] as const;
export type ManageModelsSource = (typeof MANAGE_MODELS_SOURCES)[number];
export interface ManageModelsCatalogEntry {
id: string;
label: string;
searchText: string;
supportsVision: boolean;
contextWindowSize?: number;
badges: string[];
model: ModelConfig;
}
export interface ManageModelsCatalog {
source: ManageModelsSource;
title: string;
description: string;
authType: AuthType;
entries: ManageModelsCatalogEntry[];
}
export interface ManageModelsSaveResult {
updatedConfigs: ModelConfig[];
selectedIds: string[];
activeModelId?: string;
}
function isFreeOpenRouterModel(modelId: string): boolean {
const normalizedId = modelId.toLowerCase();
return normalizedId.includes(':free') || normalizedId === 'openrouter/free';
}
function getManageModelsDisplayLabel(
source: ManageModelsSource,
model: ModelConfig,
): string {
const rawLabel = model.name || model.id;
switch (source) {
case 'openrouter':
return rawLabel.replace(/^OpenRouter\s*·\s*/i, '').trim() || model.id;
default:
return rawLabel;
}
}
function createEntry(
source: ManageModelsSource,
model: ModelConfig,
): ManageModelsCatalogEntry {
const contextWindowSize = model.generationConfig?.contextWindowSize;
const supportsVision = model.capabilities?.vision === true;
const badges: string[] = [];
if (isFreeOpenRouterModel(model.id)) {
badges.push('free');
}
if (supportsVision) {
badges.push('vision');
}
if (typeof contextWindowSize === 'number' && contextWindowSize >= 1_000_000) {
badges.push('long-context');
}
const displayLabel = getManageModelsDisplayLabel(source, model);
return {
id: model.id,
label: displayLabel,
searchText: [model.id, model.name, displayLabel, ...badges]
.filter(Boolean)
.join(' '),
supportsVision,
contextWindowSize,
badges,
model,
};
}
export async function fetchManageModelsCatalog(
source: ManageModelsSource,
): Promise<ManageModelsCatalog> {
switch (source) {
case 'openrouter': {
const models = await fetchOpenRouterModels();
return {
source,
title: 'OpenRouter',
description:
'Browse the latest OpenRouter model catalog and choose which models are enabled locally.',
authType: AuthType.USE_OPENAI,
entries: models.map((model) => createEntry(source, model)),
};
}
default:
throw new Error(`Unsupported manage models source: ${source}`);
}
}
export function getEnabledModelIdsForSource(
source: ManageModelsSource,
settings: LoadedSettings,
): string[] {
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
const openaiConfigs = modelProviders?.[AuthType.USE_OPENAI] || [];
switch (source) {
case 'openrouter':
return openaiConfigs
.filter((config) => isOpenRouterConfig(config))
.map((config) => config.id);
default:
return [];
}
}
export async function saveManageModelsSelection(params: {
source: ManageModelsSource;
selectedModels: ModelConfig[];
settings: LoadedSettings;
config: Config;
}): Promise<ManageModelsSaveResult> {
const { source, selectedModels, settings, config } = params;
const persistScope = getPersistScopeForModelSelection(settings);
const mergedModelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
const existingOpenAIConfigs =
mergedModelProviders?.[AuthType.USE_OPENAI] || [];
switch (source) {
case 'openrouter': {
const updatedConfigs = mergeOpenRouterConfigs(
existingOpenAIConfigs,
selectedModels,
);
if (updatedConfigs.length === 0) {
throw new Error(
'At least one OpenAI-compatible model must remain enabled.',
);
}
settings.setValue(
persistScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
const selectedIds = selectedModels.map((model) => model.id);
const currentAuthType = config.getContentGeneratorConfig()?.authType;
const currentModelId = config.getModel();
const currentModelStillAvailable = currentModelId
? updatedConfigs.some((model) => model.id === currentModelId)
: false;
let activeModelId = currentModelId;
if (!currentModelStillAvailable) {
const preferredDefault = updatedConfigs.find(
(model) => model.id === OPENROUTER_DEFAULT_MODEL,
);
activeModelId = preferredDefault?.id || updatedConfigs[0]?.id;
if (activeModelId) {
settings.setValue(persistScope, 'model.name', activeModelId);
}
}
const updatedModelProviders: ModelProvidersConfig = {
...(mergedModelProviders || {}),
[AuthType.USE_OPENAI]: updatedConfigs,
};
config.reloadModelProvidersConfig(updatedModelProviders);
if (currentAuthType === AuthType.USE_OPENAI) {
await config.refreshAuth(AuthType.USE_OPENAI);
}
return {
updatedConfigs,
selectedIds,
activeModelId,
};
}
default:
throw new Error(`Unsupported manage models source: ${source}`);
}
}

View file

@ -663,10 +663,10 @@ export class ShellExecutionService {
const finalOutput = shellExecutionConfig.disableDynamicLineTrimming
? newOutput
: trimmedOutput;
const finalOutputComparison = shellExecutionConfig
.disableDynamicLineTrimming
? newOutputComparison
: trimmedOutputComparison;
const finalOutputComparison =
shellExecutionConfig.disableDynamicLineTrimming
? newOutputComparison
: trimmedOutputComparison;
if (!areAnsiOutputsEqual(outputComparison, finalOutputComparison)) {
outputComparison = finalOutputComparison;

View file

@ -40,6 +40,40 @@ vi.mock('mime/lite', () => ({
getType: vi.fn(),
}));
// Mock execFile so isPdftotextAvailable does not spawn a real process.
// On platforms where pdftotext is not installed (e.g. Windows CI),
// the 5-second execFile timeout can exceed the default 5s test timeout.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
execFile: vi.fn(
(
_command: string,
_args: string[],
_optionsOrCallback: unknown,
_callback?: unknown,
) => {
// Resolve the callback (supports both signatures of execFile)
const cb =
typeof _optionsOrCallback === 'function'
? _optionsOrCallback
: _callback;
const error = Object.assign(new Error('Command not found'), {
code: 'ENOENT',
});
if (typeof cb === 'function') {
setImmediate(() => cb(error, '', ''));
}
return {
kill: vi.fn(),
on: vi.fn(),
} as unknown as import('node:child_process').ChildProcess;
},
),
};
});
const mockMimeGetType = mime.getType as Mock;
describe('fileUtils', () => {

View file

@ -188,7 +188,12 @@ describe('terminalSerializer', () => {
unwrapWrappedLines: true,
});
const visibleText = result
.map((line) => line.map((token) => token.text).join('').trimEnd())
.map((line) =>
line
.map((token) => token.text)
.join('')
.trimEnd(),
)
.filter(Boolean);
expect(visibleText).toEqual(['abcdefghijkl', 'short']);