mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
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:
parent
96bc874197
commit
7fe853a782
45 changed files with 5666 additions and 129 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||
89
docs/design/openrouter-auth-and-models.md
Normal file
89
docs/design/openrouter-auth-and-models.md
Normal 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.
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
304
packages/cli/src/commands/auth/openrouter.test.ts
Normal file
304
packages/cli/src/commands/auth/openrouter.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
730
packages/cli/src/commands/auth/openrouterOAuth.test.ts
Normal file
730
packages/cli/src/commands/auth/openrouterOAuth.test.ts
Normal 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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
751
packages/cli/src/commands/auth/openrouterOAuth.ts
Normal file
751
packages/cli/src/commands/auth/openrouterOAuth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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キー/プロバイダーをご利用ください。',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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-ключи/провайдеры.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
351
packages/cli/src/ui/auth/useAuth.test.ts
Normal file
351
packages/cli/src/ui/auth/useAuth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
38
packages/cli/src/ui/commands/manageModelsCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/manageModelsCommand.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
24
packages/cli/src/ui/commands/manageModelsCommand.ts
Normal file
24
packages/cli/src/ui/commands/manageModelsCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
|
|
@ -16,7 +16,7 @@ export const rewindCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (): Promise<SlashCommandActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'rewind',
|
||||
}),
|
||||
type: 'dialog',
|
||||
dialog: 'rewind',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export interface OpenDialogActionReturn {
|
|||
| 'memory'
|
||||
| 'model'
|
||||
| 'fast-model'
|
||||
| 'manage-models'
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'trust'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
29
packages/cli/src/ui/components/ExternalAuthProgress.test.tsx
Normal file
29
packages/cli/src/ui/components/ExternalAuthProgress.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
60
packages/cli/src/ui/components/ExternalAuthProgress.tsx
Normal file
60
packages/cli/src/ui/components/ExternalAuthProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
packages/cli/src/ui/components/ManageModelsDialog.test.tsx
Normal file
132
packages/cli/src/ui/components/ManageModelsDialog.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
648
packages/cli/src/ui/components/ManageModelsDialog.tsx
Normal file
648
packages/cli/src/ui/components/ManageModelsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
40
packages/cli/src/ui/hooks/useManageModelsCommand.test.ts
Normal file
40
packages/cli/src/ui/hooks/useManageModelsCommand.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
packages/cli/src/ui/hooks/useManageModelsCommand.ts
Normal file
32
packages/cli/src/ui/hooks/useManageModelsCommand.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
140
packages/cli/src/ui/manageModels/manageModels.test.ts
Normal file
140
packages/cli/src/ui/manageModels/manageModels.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
211
packages/cli/src/ui/manageModels/manageModels.ts
Normal file
211
packages/cli/src/ui/manageModels/manageModels.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue