mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
refactor: update authentication handling and model configuration
- Enhanced authentication method validation in `auth.ts` and `auth.test.ts`. - Introduced new model provider configuration logic - Updated environment variable handling for various auth types. - Removed deprecated utility functions and tests related to fallback mechanisms.
This commit is contained in:
parent
aa9cdf2a3c
commit
db12796df5
53 changed files with 5183 additions and 942 deletions
|
|
@ -1,41 +1,112 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
import * as settings from './settings.js';
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
merged: vi.fn().mockReturnValue({}),
|
||||
merged: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('validateAuthMethod', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Reset mock to default
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {},
|
||||
} as ReturnType<typeof settings.loadSettings>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
delete process.env['CUSTOM_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['GEMINI_API_KEY_ALTERED'];
|
||||
delete process.env['ANTHROPIC_API_KEY'];
|
||||
delete process.env['ANTHROPIC_BASE_URL'];
|
||||
delete process.env['GOOGLE_API_KEY'];
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI', () => {
|
||||
it('should return null for USE_OPENAI with default env key', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error message for USE_OPENAI if OPENAI_API_KEY is not set', () => {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
it('should return an error message for USE_OPENAI if no API key is available', () => {
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBe(
|
||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. If you configured a model in settings.modelProviders with an envKey, set that env var as well.",
|
||||
"Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_OPENAI with custom envKey from modelProviders', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_API_KEY'] = 'custom-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_OPENAI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey hint when modelProviders envKey is set but env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'custom-model' },
|
||||
modelProviders: {
|
||||
openai: [{ id: 'custom-model', envKey: 'CUSTOM_API_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_OPENAI);
|
||||
expect(result).toContain('CUSTOM_API_KEY');
|
||||
});
|
||||
|
||||
it('should return null for USE_GEMINI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GEMINI_API_KEY_ALTERED'] = 'altered-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error with custom envKey for USE_GEMINI when env var is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'gemini-1.5-flash' },
|
||||
modelProviders: {
|
||||
gemini: [
|
||||
{ id: 'gemini-1.5-flash', envKey: 'GEMINI_API_KEY_ALTERED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_GEMINI);
|
||||
expect(result).toContain('GEMINI_API_KEY_ALTERED');
|
||||
});
|
||||
|
||||
it('should return null for QWEN_OAUTH', () => {
|
||||
expect(validateAuthMethod(AuthType.QWEN_OAUTH)).toBeNull();
|
||||
});
|
||||
|
|
@ -45,4 +116,55 @@ describe('validateAuthMethod', () => {
|
|||
'Invalid auth method selected.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null for USE_ANTHROPIC with custom envKey and baseUrl', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3',
|
||||
envKey: 'CUSTOM_ANTHROPIC_KEY',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-anthropic-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_ANTHROPIC)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for USE_ANTHROPIC when baseUrl is missing', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'claude-3' },
|
||||
modelProviders: {
|
||||
anthropic: [{ id: 'claude-3', envKey: 'CUSTOM_ANTHROPIC_KEY' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['CUSTOM_ANTHROPIC_KEY'] = 'custom-key';
|
||||
|
||||
const result = validateAuthMethod(AuthType.USE_ANTHROPIC);
|
||||
expect(result).toContain('ANTHROPIC_BASE_URL');
|
||||
});
|
||||
|
||||
it('should return null for USE_VERTEX_AI with custom envKey', () => {
|
||||
vi.mocked(settings.loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
model: { name: 'vertex-model' },
|
||||
modelProviders: {
|
||||
'vertex-ai': [
|
||||
{ id: 'vertex-model', envKey: 'GOOGLE_API_KEY_VERTEX' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof settings.loadSettings>);
|
||||
process.env['GOOGLE_API_KEY_VERTEX'] = 'vertex-key';
|
||||
|
||||
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,97 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
import type {
|
||||
ModelProvidersConfig,
|
||||
ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
|
||||
|
||||
/**
|
||||
* Default environment variable names for each auth type
|
||||
*/
|
||||
const DEFAULT_ENV_KEYS: Record<string, string> = {
|
||||
[AuthType.USE_OPENAI]: 'OPENAI_API_KEY',
|
||||
[AuthType.USE_ANTHROPIC]: 'ANTHROPIC_API_KEY',
|
||||
[AuthType.USE_GEMINI]: 'GEMINI_API_KEY',
|
||||
[AuthType.USE_VERTEX_AI]: 'GOOGLE_API_KEY',
|
||||
};
|
||||
|
||||
/**
|
||||
* Find model configuration from modelProviders by authType and modelId
|
||||
*/
|
||||
function findModelConfig(
|
||||
modelProviders: ModelProvidersConfig | undefined,
|
||||
authType: string,
|
||||
modelId: string | undefined,
|
||||
): ProviderModelConfig | undefined {
|
||||
if (!modelProviders || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const models = modelProviders[authType];
|
||||
if (!Array.isArray(models)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return models.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key is available for the given auth type and model configuration.
|
||||
* Prioritizes custom envKey from modelProviders over default environment variables.
|
||||
*/
|
||||
function hasApiKeyForAuth(
|
||||
authType: string,
|
||||
settings: Settings,
|
||||
): { hasKey: boolean; checkedEnvKey: string | undefined } {
|
||||
const modelProviders = settings.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
const modelId = settings.model?.name;
|
||||
|
||||
// Try to find model-specific envKey from modelProviders
|
||||
const modelConfig = findModelConfig(modelProviders, authType, modelId);
|
||||
if (modelConfig?.envKey) {
|
||||
const hasKey = !!process.env[modelConfig.envKey];
|
||||
return { hasKey, checkedEnvKey: modelConfig.envKey };
|
||||
}
|
||||
|
||||
// Fallback to default environment variable
|
||||
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
|
||||
if (defaultEnvKey) {
|
||||
const hasKey = !!process.env[defaultEnvKey];
|
||||
return { hasKey, checkedEnvKey: defaultEnvKey };
|
||||
}
|
||||
|
||||
// Also check settings.security.auth.apiKey as fallback
|
||||
if (settings.security?.auth?.apiKey) {
|
||||
return { hasKey: true, checkedEnvKey: undefined };
|
||||
}
|
||||
|
||||
return { hasKey: false, checkedEnvKey: undefined };
|
||||
}
|
||||
|
||||
export function validateAuthMethod(authMethod: string): string | null {
|
||||
const settings = loadSettings();
|
||||
loadEnvironment(settings.merged);
|
||||
|
||||
if (authMethod === AuthType.USE_OPENAI) {
|
||||
const hasApiKey =
|
||||
process.env['OPENAI_API_KEY'] || settings.merged.security?.auth?.apiKey;
|
||||
if (!hasApiKey) {
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey
|
||||
? `'${checkedEnvKey}'`
|
||||
: "'OPENAI_API_KEY' (or configure modelProviders[].envKey)";
|
||||
return (
|
||||
'Missing API key for OpenAI-compatible auth. ' +
|
||||
"Set settings.security.auth.apiKey, or set the 'OPENAI_API_KEY' environment variable. " +
|
||||
'If you configured a model in settings.modelProviders with an envKey, set that env var as well.'
|
||||
`Set settings.security.auth.apiKey, or set the ${envKeyHint} environment variable.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -31,31 +104,50 @@ export function validateAuthMethod(authMethod: string): string | null {
|
|||
}
|
||||
|
||||
if (authMethod === AuthType.USE_ANTHROPIC) {
|
||||
const hasApiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'ANTHROPIC_API_KEY environment variable not found.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'ANTHROPIC_API_KEY';
|
||||
return `${envKeyHint} environment variable not found.`;
|
||||
}
|
||||
|
||||
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
|
||||
// Check baseUrl - can come from modelProviders or environment
|
||||
const modelProviders = settings.merged.modelProviders as
|
||||
| ModelProvidersConfig
|
||||
| undefined;
|
||||
const modelId = settings.merged.model?.name;
|
||||
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
|
||||
const hasBaseUrl =
|
||||
modelConfig?.baseUrl || process.env['ANTHROPIC_BASE_URL'];
|
||||
if (!hasBaseUrl) {
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found.';
|
||||
return 'ANTHROPIC_BASE_URL environment variable not found (or configure modelProviders[].baseUrl).';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
const hasApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GEMINI_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'GEMINI_API_KEY';
|
||||
return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_VERTEX_AI) {
|
||||
const hasApiKey = process.env['GOOGLE_API_KEY'];
|
||||
if (!hasApiKey) {
|
||||
return 'GOOGLE_API_KEY environment variable not found. Please set it in your .env file or environment variables.';
|
||||
const { hasKey, checkedEnvKey } = hasApiKeyForAuth(
|
||||
authMethod,
|
||||
settings.merged,
|
||||
);
|
||||
if (!hasKey) {
|
||||
const envKeyHint = checkedEnvKey || 'GOOGLE_API_KEY';
|
||||
return `${envKeyHint} environment variable not found. Please set it in your .env file or environment variables.`;
|
||||
}
|
||||
|
||||
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
|
||||
|
|
|
|||
|
|
@ -77,10 +77,8 @@ vi.mock('read-package-up', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actualServer = await vi.importActual<typeof ServerConfig>(
|
||||
'@qwen-code/qwen-code-core',
|
||||
);
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actualServer = await importOriginal<typeof ServerConfig>();
|
||||
return {
|
||||
...actualServer,
|
||||
IdeClient: {
|
||||
|
|
|
|||
|
|
@ -31,10 +31,7 @@ import {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
buildGenerationConfigSources,
|
||||
getModelProvidersConfigFromSettings,
|
||||
} from '../utils/modelProviderUtils.js';
|
||||
import { resolveCliGenerationConfig } from '../utils/modelConfigUtils.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import * as fs from 'node:fs';
|
||||
|
|
@ -930,26 +927,21 @@ export async function loadCliConfig(
|
|||
(argv.authType as AuthType | undefined) ||
|
||||
settings.security?.auth?.selectedType;
|
||||
|
||||
const apiKey =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiApiKey ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
settings.security?.auth?.apiKey
|
||||
: '') || '';
|
||||
const baseUrl =
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? argv.openaiBaseUrl ||
|
||||
process.env['OPENAI_BASE_URL'] ||
|
||||
settings.security?.auth?.baseUrl
|
||||
: '') || '';
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
(selectedAuthType === AuthType.USE_OPENAI
|
||||
? process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name
|
||||
: '') ||
|
||||
'';
|
||||
// Unified resolution of generation config with source attribution
|
||||
const resolvedCliConfig = resolveCliGenerationConfig({
|
||||
argv: {
|
||||
model: argv.model,
|
||||
openaiApiKey: argv.openaiApiKey,
|
||||
openaiBaseUrl: argv.openaiBaseUrl,
|
||||
openaiLogging: argv.openaiLogging,
|
||||
openaiLoggingDir: argv.openaiLoggingDir,
|
||||
},
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
const { model: resolvedModel } = resolvedCliConfig;
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
|
|
@ -983,17 +975,7 @@ export async function loadCliConfig(
|
|||
}
|
||||
}
|
||||
|
||||
const modelProvidersConfig = getModelProvidersConfigFromSettings(settings);
|
||||
const generationConfigSources = buildGenerationConfigSources({
|
||||
argv: {
|
||||
model: argv.model,
|
||||
openaiApiKey: argv.openaiApiKey,
|
||||
openaiBaseUrl: argv.openaiBaseUrl,
|
||||
},
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: process.env as Record<string, string | undefined>,
|
||||
});
|
||||
const modelProvidersConfig = settings.modelProviders;
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
|
|
@ -1053,25 +1035,10 @@ export async function loadCliConfig(
|
|||
outputFormat,
|
||||
includePartialMessages,
|
||||
modelProvidersConfig,
|
||||
generationConfigSources,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false,
|
||||
openAILoggingDir:
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||
},
|
||||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(
|
||||
argv,
|
||||
settings,
|
||||
settings.security?.auth?.selectedType,
|
||||
),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
|
|
|
|||
89
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
89
packages/cli/src/config/modelProvidersScope.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { getPersistScopeForModelSelection } from './modelProvidersScope.js';
|
||||
|
||||
function makeSettings({
|
||||
isTrusted,
|
||||
userModelProviders,
|
||||
workspaceModelProviders,
|
||||
}: {
|
||||
isTrusted: boolean;
|
||||
userModelProviders?: unknown;
|
||||
workspaceModelProviders?: unknown;
|
||||
}) {
|
||||
const userSettings: Record<string, unknown> = {};
|
||||
const workspaceSettings: Record<string, unknown> = {};
|
||||
|
||||
// When undefined, treat as "not present in this scope" (the key is omitted),
|
||||
// matching how LoadedSettings is shaped when a settings file doesn't define it.
|
||||
if (userModelProviders !== undefined) {
|
||||
userSettings['modelProviders'] = userModelProviders;
|
||||
}
|
||||
if (workspaceModelProviders !== undefined) {
|
||||
workspaceSettings['modelProviders'] = workspaceModelProviders;
|
||||
}
|
||||
|
||||
return {
|
||||
isTrusted,
|
||||
user: { settings: userSettings },
|
||||
workspace: { settings: workspaceSettings },
|
||||
} as unknown as import('./settings.js').LoadedSettings;
|
||||
}
|
||||
|
||||
describe('getPersistScopeForModelSelection', () => {
|
||||
it('prefers workspace when trusted and workspace defines modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: { anything: true },
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to user when workspace does not define modelProviders', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: true,
|
||||
workspaceModelProviders: undefined,
|
||||
userModelProviders: {},
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('ignores workspace modelProviders when workspace is untrusted', () => {
|
||||
const settings = makeSettings({
|
||||
isTrusted: false,
|
||||
workspaceModelProviders: {},
|
||||
userModelProviders: undefined,
|
||||
});
|
||||
|
||||
expect(getPersistScopeForModelSelection(settings)).toBe(SettingScope.User);
|
||||
});
|
||||
|
||||
it('falls back to legacy trust heuristic when neither scope defines modelProviders', () => {
|
||||
const trusted = makeSettings({
|
||||
isTrusted: true,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(trusted)).toBe(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
|
||||
const untrusted = makeSettings({
|
||||
isTrusted: false,
|
||||
userModelProviders: undefined,
|
||||
workspaceModelProviders: undefined,
|
||||
});
|
||||
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
48
packages/cli/src/config/modelProvidersScope.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SettingScope, type LoadedSettings } from './settings.js';
|
||||
|
||||
function hasOwnModelProviders(settingsObj: unknown): boolean {
|
||||
if (!settingsObj || typeof settingsObj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = settingsObj as Record<string, unknown>;
|
||||
// Treat an explicitly configured empty object (modelProviders: {}) as "owned"
|
||||
// by this scope, which is important when mergeStrategy is REPLACE.
|
||||
return Object.prototype.hasOwnProperty.call(obj, 'modelProviders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns which writable scope (Workspace/User) owns the effective modelProviders
|
||||
* configuration.
|
||||
*
|
||||
* Note: Workspace scope is only considered when the workspace is trusted.
|
||||
*/
|
||||
export function getModelProvidersOwnerScope(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope | undefined {
|
||||
if (settings.isTrusted && hasOwnModelProviders(settings.workspace.settings)) {
|
||||
return SettingScope.Workspace;
|
||||
}
|
||||
|
||||
if (hasOwnModelProviders(settings.user.settings)) {
|
||||
return SettingScope.User;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the settings scope to persist a model selection.
|
||||
* Prefer persisting back to the scope that contains the effective modelProviders
|
||||
* config, otherwise fall back to the legacy trust-based heuristic.
|
||||
*/
|
||||
export function getPersistScopeForModelSelection(
|
||||
settings: LoadedSettings,
|
||||
): SettingScope {
|
||||
return getModelProvidersOwnerScope(settings) ?? SettingScope.User;
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ const SETTINGS_SCHEMA = {
|
|||
description:
|
||||
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
|
||||
mergeStrategy: MergeStrategy.REPLACE,
|
||||
},
|
||||
|
||||
general: {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,9 @@ export async function initializeApp(
|
|||
// Auto-detect and set LLM output language on first use
|
||||
initializeLlmOutputLanguage();
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
// Use authType from modelsConfig which respects CLI --auth-type argument
|
||||
// over settings.security.auth.selectedType
|
||||
const authType = config.modelsConfig.getCurrentAuthType();
|
||||
const authError = await performInitialAuth(config, authType);
|
||||
|
||||
// Fallback to user select when initial authentication fails
|
||||
|
|
@ -58,8 +60,13 @@ export async function initializeApp(
|
|||
}
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
// Open auth dialog if:
|
||||
// 1. No authType was explicitly selected (neither from CLI --auth-type nor settings), OR
|
||||
// 2. Authentication failed
|
||||
// wasAuthTypeExplicitlyProvided() returns true if CLI or settings specified authType,
|
||||
// false if using the default QWEN_OAUTH
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import {
|
|||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
IdeClient,
|
||||
ideContextStore,
|
||||
getErrorMessage,
|
||||
|
|
@ -180,15 +179,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
[],
|
||||
);
|
||||
|
||||
// Helper to determine the effective model, considering the fallback state.
|
||||
const getEffectiveModel = useCallback(() => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return DEFAULT_GEMINI_FLASH_MODEL;
|
||||
}
|
||||
return config.getModel();
|
||||
}, [config]);
|
||||
// Helper to determine the current model (polled, since Config has no model-change event).
|
||||
const getCurrentModel = useCallback(() => config.getModel(), [config]);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState(getEffectiveModel());
|
||||
const [currentModel, setCurrentModel] = useState(getCurrentModel());
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
|
|
@ -241,12 +235,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
[historyManager.addItem],
|
||||
);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
// Watch for model changes (e.g., user switches model via /model)
|
||||
useEffect(() => {
|
||||
const checkModelChange = () => {
|
||||
const effectiveModel = getEffectiveModel();
|
||||
if (effectiveModel !== currentModel) {
|
||||
setCurrentModel(effectiveModel);
|
||||
const model = getCurrentModel();
|
||||
if (model !== currentModel) {
|
||||
setCurrentModel(model);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -254,7 +248,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config, currentModel, getEffectiveModel]);
|
||||
}, [config, currentModel, getCurrentModel]);
|
||||
|
||||
const {
|
||||
consoleMessages,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
|||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -109,7 +108,6 @@ export const useAuthCommand = (
|
|||
if (credentials?.model != null) {
|
||||
settings.setValue(scope, 'model.name', credentials.model);
|
||||
}
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
} catch (error) {
|
||||
handleAuthFailure(error);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,6 @@ import {
|
|||
type ContentGeneratorConfig,
|
||||
type Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as availableModelsModule from '../models/availableModels.js';
|
||||
|
||||
// Mock the availableModels module
|
||||
vi.mock('../models/availableModels.js', () => ({
|
||||
getAvailableModelsForAuthType: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper function to create a mock config
|
||||
function createMockConfig(
|
||||
|
|
@ -31,9 +25,6 @@ function createMockConfig(
|
|||
|
||||
describe('modelCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockGetAvailableModelsForAuthType = vi.mocked(
|
||||
availableModelsModule.getAvailableModelsForAuthType,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
|
|
@ -87,10 +78,6 @@ describe('modelCommand', () => {
|
|||
});
|
||||
|
||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||
]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
|
|
@ -105,11 +92,7 @@ describe('modelCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'gpt-4', label: 'gpt-4' },
|
||||
]);
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
|
|
@ -124,28 +107,7 @@ describe('modelCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should return error for USE_OPENAI auth type when no model is available', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
});
|
||||
mockContext.services.config = mockConfig as Config;
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No models available for the current authentication type (openai).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for unsupported auth types', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
it('should return dialog action for unsupported auth types', async () => {
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||
|
|
@ -155,10 +117,8 @@ describe('modelCommand', () => {
|
|||
const result = await modelCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No models available for the current authentication type (UNSUPPORTED_AUTH_TYPE).',
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
|
|
@ -52,22 +51,6 @@ export const modelCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
const availableModels = getAvailableModelsForAuthType(authType);
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger model selection dialog
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'model',
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import { ModelDialog } from './ModelDialog.js';
|
|||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_CODER,
|
||||
|
|
@ -36,18 +40,29 @@ const renderComponent = (
|
|||
};
|
||||
const combinedProps = { ...defaultProps, ...props };
|
||||
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const mockConfig = contextValue
|
||||
? ({
|
||||
// --- Functions used by ModelDialog ---
|
||||
getModel: vi.fn(() => MAINLINE_CODER),
|
||||
setModel: vi.fn(),
|
||||
setModel: vi.fn().mockResolvedValue(undefined),
|
||||
switchModel: vi.fn().mockResolvedValue(undefined),
|
||||
getAuthType: vi.fn(() => 'qwen-oauth'),
|
||||
|
||||
// --- Functions used by ClearcutLogger ---
|
||||
getUsageStatisticsEnabled: vi.fn(() => true),
|
||||
getSessionId: vi.fn(() => 'mock-session-id'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
getUseSmartEdit: vi.fn(() => false),
|
||||
getUseModelRouter: vi.fn(() => false),
|
||||
getProxy: vi.fn(() => undefined),
|
||||
|
|
@ -58,21 +73,27 @@ const renderComponent = (
|
|||
: undefined;
|
||||
|
||||
const renderResult = render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<ModelDialog {...combinedProps} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
props: combinedProps,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
};
|
||||
};
|
||||
|
||||
describe('<ModelDialog />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Ensure env-based fallback models don't leak into this suite from the developer environment.
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
delete process.env['ANTHROPIC_MODEL'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -91,8 +112,12 @@ describe('<ModelDialog />', () => {
|
|||
|
||||
const props = mockedSelect.mock.calls[0][0];
|
||||
expect(props.items).toHaveLength(AVAILABLE_MODELS_QWEN.length);
|
||||
expect(props.items[0].value).toBe(MAINLINE_CODER);
|
||||
expect(props.items[1].value).toBe(MAINLINE_VLM);
|
||||
expect(props.items[0].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`,
|
||||
);
|
||||
expect(props.items[1].value).toBe(
|
||||
`${AuthType.QWEN_OAUTH}::${MAINLINE_VLM}`,
|
||||
);
|
||||
expect(props.showNumbers).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -139,16 +164,93 @@ describe('<ModelDialog />', () => {
|
|||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||
const { props, mockConfig, mockSettings } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(MAINLINE_CODER);
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
undefined,
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context: 'Model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'security.auth.selectedType',
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.switchModel and persists authType+model when selecting a different authType', async () => {
|
||||
const switchModel = vi.fn().mockResolvedValue(undefined);
|
||||
const getAuthType = vi.fn(() => AuthType.USE_OPENAI);
|
||||
const getAvailableModelsForAuthType = vi.fn((t: AuthType) => {
|
||||
if (t === AuthType.USE_OPENAI) {
|
||||
return [{ id: 'gpt-4', label: 'GPT-4', authType: t }];
|
||||
}
|
||||
if (t === AuthType.QWEN_OAUTH) {
|
||||
return AVAILABLE_MODELS_QWEN.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const mockConfigWithSwitchAuthType = {
|
||||
getAuthType,
|
||||
getModel: vi.fn(() => 'gpt-4'),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: MAINLINE_CODER,
|
||||
})),
|
||||
// Add switchModel to the mock object (not the type)
|
||||
switchModel,
|
||||
getAvailableModelsForAuthType,
|
||||
};
|
||||
|
||||
const { props, mockSettings } = renderComponent(
|
||||
{},
|
||||
// Cast to Config to bypass type checking, matching the runtime behavior
|
||||
mockConfigWithSwitchAuthType as unknown as Partial<Config>,
|
||||
);
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
await childOnSelect(`${AuthType.QWEN_OAUTH}::${MAINLINE_CODER}`);
|
||||
|
||||
expect(switchModel).toHaveBeenCalledWith(
|
||||
AuthType.QWEN_OAUTH,
|
||||
MAINLINE_CODER,
|
||||
{ requireCachedCredentials: true },
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context: 'AuthType+model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'model.name',
|
||||
MAINLINE_CODER,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'security.auth.selectedType',
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -193,17 +295,25 @@ describe('<ModelDialog />', () => {
|
|||
it('updates initialIndex when config context changes', () => {
|
||||
const mockGetModel = vi.fn(() => MAINLINE_CODER);
|
||||
const mockGetAuthType = vi.fn(() => 'qwen-oauth');
|
||||
const mockSettings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
const { rerender } = render(
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider
|
||||
value={
|
||||
{
|
||||
getModel: mockGetModel,
|
||||
getAuthType: mockGetAuthType,
|
||||
} as unknown as Config
|
||||
}
|
||||
>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockedSelect.mock.calls[0][0].initialIndex).toBe(0);
|
||||
|
|
@ -215,9 +325,11 @@ describe('<ModelDialog />', () => {
|
|||
} as unknown as Config;
|
||||
|
||||
rerender(
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<ConfigContext.Provider value={newMockConfig}>
|
||||
<ModelDialog onClose={vi.fn()} />
|
||||
</ConfigContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Should be called at least twice: initial render + re-render after context change
|
||||
|
|
|
|||
|
|
@ -5,52 +5,210 @@
|
|||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
AuthType,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSource,
|
||||
type ContentGeneratorConfigSources,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import {
|
||||
getAvailableModelsForAuthType,
|
||||
MAINLINE_CODER,
|
||||
} from '../models/availableModels.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSourceBadge(
|
||||
source: ContentGeneratorConfigSource | undefined,
|
||||
): string | undefined {
|
||||
if (!source) return undefined;
|
||||
|
||||
switch (source.kind) {
|
||||
case 'cli':
|
||||
return source.detail ? `CLI ${source.detail}` : 'CLI';
|
||||
case 'env':
|
||||
return source.envKey ? `ENV ${source.envKey}` : 'ENV';
|
||||
case 'settings':
|
||||
return source.settingsPath
|
||||
? `Settings ${source.settingsPath}`
|
||||
: 'Settings';
|
||||
case 'modelProviders': {
|
||||
const suffix =
|
||||
source.authType && source.modelId
|
||||
? `${source.authType}:${source.modelId}`
|
||||
: source.authType
|
||||
? `${source.authType}`
|
||||
: source.modelId
|
||||
? `${source.modelId}`
|
||||
: '';
|
||||
return suffix ? `ModelProviders ${suffix}` : 'ModelProviders';
|
||||
}
|
||||
case 'default':
|
||||
return source.detail ? `Default ${source.detail}` : 'Default';
|
||||
case 'computed':
|
||||
return source.detail ? `Computed ${source.detail}` : 'Computed';
|
||||
case 'programmatic':
|
||||
return source.detail ? `Programmatic ${source.detail}` : 'Programmatic';
|
||||
case 'unknown':
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readSourcesFromConfig(config: unknown): ContentGeneratorConfigSources {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const maybe = config as {
|
||||
getContentGeneratorConfigSources?: () => ContentGeneratorConfigSources;
|
||||
};
|
||||
return maybe.getContentGeneratorConfigSources?.() ?? {};
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey: string | undefined): string {
|
||||
if (!apiKey) return '(not set)';
|
||||
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}`;
|
||||
}
|
||||
|
||||
function persistModelSelection(
|
||||
settings: ReturnType<typeof useSettings>,
|
||||
modelId: string,
|
||||
): void {
|
||||
const scope = getPersistScopeForModelSelection(settings);
|
||||
settings.setValue(scope, 'model.name', modelId);
|
||||
}
|
||||
|
||||
function persistAuthTypeSelection(
|
||||
settings: ReturnType<typeof useSettings>,
|
||||
authType: AuthType,
|
||||
): void {
|
||||
const scope = getPersistScopeForModelSelection(settings);
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
label,
|
||||
value,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
badge?: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box minWidth={12} flexShrink={0}>
|
||||
<Text color={theme.text.secondary}>{label}:</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="row" flexWrap="wrap">
|
||||
<Text>{value}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{badge ? (
|
||||
<Box>
|
||||
<Box minWidth={12} flexShrink={0}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.text.secondary}>{badge}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
const uiState = useContext(UIStateContext);
|
||||
const settings = useSettings();
|
||||
|
||||
// Local error state for displaying errors within the dialog
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Get auth type from config, default to QWEN_OAUTH if not available
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
const effectiveConfig =
|
||||
(config?.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
| undefined) ?? undefined;
|
||||
const sources = readSourcesFromConfig(config);
|
||||
|
||||
// Get available models based on auth type
|
||||
const availableModels = useMemo(
|
||||
() => getAvailableModelsForAuthType(authType),
|
||||
[authType],
|
||||
);
|
||||
const availableModelEntries = useMemo(() => {
|
||||
const allAuthTypes = Object.values(AuthType) as AuthType[];
|
||||
const modelsByAuthType = allAuthTypes
|
||||
.map((t) => ({
|
||||
authType: t,
|
||||
models: getAvailableModelsForAuthType(t, config ?? undefined),
|
||||
}))
|
||||
.filter((x) => x.models.length > 0);
|
||||
|
||||
// Fixed order: qwen-oauth first, then others in a stable order
|
||||
const authTypeOrder: AuthType[] = [
|
||||
AuthType.QWEN_OAUTH,
|
||||
AuthType.USE_OPENAI,
|
||||
AuthType.USE_ANTHROPIC,
|
||||
AuthType.USE_GEMINI,
|
||||
AuthType.USE_VERTEX_AI,
|
||||
];
|
||||
|
||||
// Filter to only include authTypes that have models
|
||||
const availableAuthTypes = new Set(modelsByAuthType.map((x) => x.authType));
|
||||
const orderedAuthTypes = authTypeOrder.filter((t) =>
|
||||
availableAuthTypes.has(t),
|
||||
);
|
||||
|
||||
return orderedAuthTypes.flatMap((t) => {
|
||||
const models =
|
||||
modelsByAuthType.find((x) => x.authType === t)?.models ?? [];
|
||||
return models.map((m) => ({ authType: t, model: m }));
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const MODEL_OPTIONS = useMemo(
|
||||
() =>
|
||||
availableModels.map((model) => ({
|
||||
value: model.id,
|
||||
title: model.label,
|
||||
description: model.description || '',
|
||||
key: model.id,
|
||||
})),
|
||||
[availableModels],
|
||||
availableModelEntries.map(({ authType: t2, model }) => {
|
||||
const value = `${t2}::${model.id}`;
|
||||
const title = (
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
[{t2}]
|
||||
</Text>
|
||||
<Text>{` ${model.label}`}</Text>
|
||||
</Text>
|
||||
);
|
||||
const description = model.description || '';
|
||||
return {
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
key: value,
|
||||
};
|
||||
}),
|
||||
[availableModelEntries],
|
||||
);
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredModelId = config?.getModel() || MAINLINE_CODER;
|
||||
const preferredKey = `${authType}::${preferredModelId}`;
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -61,25 +219,97 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Calculate the initial index based on the preferred model.
|
||||
const initialIndex = useMemo(
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredModel),
|
||||
[MODEL_OPTIONS, preferredModel],
|
||||
() => MODEL_OPTIONS.findIndex((option) => option.value === preferredKey),
|
||||
[MODEL_OPTIONS, preferredKey],
|
||||
);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
async (selected: string) => {
|
||||
// Clear any previous error
|
||||
setErrorMessage(null);
|
||||
|
||||
const sep = '::';
|
||||
const idx = selected.indexOf(sep);
|
||||
const selectedAuthType = (
|
||||
idx >= 0 ? selected.slice(0, idx) : authType
|
||||
) as AuthType;
|
||||
const modelId = idx >= 0 ? selected.slice(idx + sep.length) : selected;
|
||||
|
||||
if (config) {
|
||||
config.setModel(model);
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
try {
|
||||
await config.switchModel(
|
||||
selectedAuthType,
|
||||
modelId,
|
||||
selectedAuthType !== authType &&
|
||||
selectedAuthType === AuthType.QWEN_OAUTH
|
||||
? { requireCachedCredentials: true }
|
||||
: undefined,
|
||||
{
|
||||
reason: 'user_manual',
|
||||
context:
|
||||
selectedAuthType === authType
|
||||
? 'Model switched via /model dialog'
|
||||
: 'AuthType+model switched via /model dialog',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
const baseErrorMessage = e instanceof Error ? e.message : String(e);
|
||||
|
||||
// Some auth types (notably openai without modelProviders configured) can present
|
||||
// env-based "raw" model IDs in the list. These are not registry-backed and will
|
||||
// fail switchModel(). Fall back to setModel() to keep UX functional.
|
||||
const isNotFound =
|
||||
baseErrorMessage.includes('not found for authType') ||
|
||||
(baseErrorMessage.includes('Model') &&
|
||||
baseErrorMessage.includes('not found'));
|
||||
if (!isNotFound) {
|
||||
setErrorMessage(
|
||||
`Failed to switch model to '${modelId}'.\n\n${baseErrorMessage}`,
|
||||
);
|
||||
|
||||
// Keep the dialog open so the user can choose another model.
|
||||
return;
|
||||
}
|
||||
await config.setModel(modelId, {
|
||||
reason: 'user_manual',
|
||||
context: 'Model set via /model dialog (raw)',
|
||||
});
|
||||
}
|
||||
const event = new ModelSlashCommandEvent(modelId);
|
||||
logModelSlashCommand(config, event);
|
||||
|
||||
const after = config.getContentGeneratorConfig?.() as
|
||||
| ContentGeneratorConfig
|
||||
| undefined;
|
||||
const effectiveAuthType =
|
||||
after?.authType ?? selectedAuthType ?? authType;
|
||||
const effectiveModelId = after?.model ?? modelId;
|
||||
|
||||
persistModelSelection(settings, effectiveModelId);
|
||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||
|
||||
const baseUrl = after?.baseUrl ?? '(default)';
|
||||
const maskedKey = maskApiKey(after?.apiKey);
|
||||
uiState?.historyManager.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text:
|
||||
`authType: ${effectiveAuthType}\n` +
|
||||
`Using model: ${effectiveModelId}\n` +
|
||||
`Base URL: ${baseUrl}\n` +
|
||||
`API key: ${maskedKey}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[config, onClose],
|
||||
[authType, config, onClose, settings, uiState, setErrorMessage],
|
||||
);
|
||||
|
||||
const hasModels = MODEL_OPTIONS.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
|
|
@ -89,14 +319,93 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Select Model')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Current (effective) configuration')}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<ConfigRow label="AuthType" value={authType} />
|
||||
<ConfigRow
|
||||
label="Model"
|
||||
value={effectiveConfig?.model ?? config?.getModel?.() ?? ''}
|
||||
badge={formatSourceBadge(sources['model'])}
|
||||
/>
|
||||
|
||||
{authType !== AuthType.QWEN_OAUTH && (
|
||||
<>
|
||||
<ConfigRow
|
||||
label="Base URL"
|
||||
value={effectiveConfig?.baseUrl ?? ''}
|
||||
badge={formatSourceBadge(sources['baseUrl'])}
|
||||
/>
|
||||
<ConfigRow
|
||||
label="API Key"
|
||||
value={effectiveConfig?.apiKey ? t('(set)') : t('(not set)')}
|
||||
badge={formatSourceBadge(sources['apiKey'])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{effectiveConfig?.samplingParams ? (
|
||||
<ConfigRow
|
||||
label="Sampling"
|
||||
value={
|
||||
`temperature=${effectiveConfig.samplingParams.temperature ?? t('unset')} ` +
|
||||
`top_p=${effectiveConfig.samplingParams.top_p ?? t('unset')} ` +
|
||||
`max_tokens=${effectiveConfig.samplingParams.max_tokens ?? t('unset')}`
|
||||
}
|
||||
badge={formatSourceBadge(sources['samplingParams'])}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{effectiveConfig?.timeout !== undefined ? (
|
||||
<ConfigRow
|
||||
label="Timeout"
|
||||
value={String(effectiveConfig.timeout)}
|
||||
badge={formatSourceBadge(sources['timeout'])}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!hasModels ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Please configure models in settings.modelProviders or use environment variables.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
onSelect={handleSelect}
|
||||
initialIndex={initialIndex}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Box marginTop={1} flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
✕ {errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { BaseSelectionList } from './BaseSelectionList.js';
|
|||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ export const useGeminiStream = (
|
|||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
config.setQuotaErrorOccurred(false);
|
||||
// No quota-error / fallback routing mechanism currently; keep state minimal.
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const mockConfig = {
|
|||
getAllowedTools: vi.fn(() => []),
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini-api-key',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
|
|
|
|||
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
205
packages/cli/src/ui/models/availableModels.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getAvailableModelsForAuthType,
|
||||
getFilteredQwenModels,
|
||||
getOpenAIAvailableModelFromEnv,
|
||||
isVisionModel,
|
||||
getDefaultVisionModel,
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
MAINLINE_VLM,
|
||||
MAINLINE_CODER,
|
||||
} from './availableModels.js';
|
||||
import { AuthType, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('availableModels', () => {
|
||||
describe('AVAILABLE_MODELS_QWEN', () => {
|
||||
it('should include coder model', () => {
|
||||
const coderModel = AVAILABLE_MODELS_QWEN.find(
|
||||
(m) => m.id === MAINLINE_CODER,
|
||||
);
|
||||
expect(coderModel).toBeDefined();
|
||||
expect(coderModel?.isVision).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should include vision model', () => {
|
||||
const visionModel = AVAILABLE_MODELS_QWEN.find(
|
||||
(m) => m.id === MAINLINE_VLM,
|
||||
);
|
||||
expect(visionModel).toBeDefined();
|
||||
expect(visionModel?.isVision).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredQwenModels', () => {
|
||||
it('should return all models when vision preview is enabled', () => {
|
||||
const models = getFilteredQwenModels(true);
|
||||
expect(models.length).toBe(AVAILABLE_MODELS_QWEN.length);
|
||||
});
|
||||
|
||||
it('should filter out vision models when preview is disabled', () => {
|
||||
const models = getFilteredQwenModels(false);
|
||||
expect(models.every((m) => !m.isVision)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenAIAvailableModelFromEnv', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return null when OPENAI_MODEL is not set', () => {
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
expect(getOpenAIAvailableModelFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return model from OPENAI_MODEL env var', () => {
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||
const model = getOpenAIAvailableModelFromEnv();
|
||||
expect(model?.id).toBe('gpt-4-turbo');
|
||||
expect(model?.label).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should trim whitespace from env var', () => {
|
||||
process.env['OPENAI_MODEL'] = ' gpt-4 ';
|
||||
const model = getOpenAIAvailableModelFromEnv();
|
||||
expect(model?.id).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModelsForAuthType', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return hard-coded qwen models for qwen-oauth', () => {
|
||||
const models = getAvailableModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||
});
|
||||
|
||||
it('should return hard-coded qwen models even when config is provided', () => {
|
||||
const mockConfig = {
|
||||
getAvailableModels: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ id: 'custom', label: 'Custom', authType: AuthType.QWEN_OAUTH },
|
||||
]),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.QWEN_OAUTH,
|
||||
mockConfig,
|
||||
);
|
||||
expect(models).toEqual(AVAILABLE_MODELS_QWEN);
|
||||
});
|
||||
|
||||
it('should use config.getAvailableModels for openai authType when available', () => {
|
||||
const mockModels = [
|
||||
{
|
||||
id: 'gpt-4',
|
||||
label: 'GPT-4',
|
||||
description: 'Test',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
isVision: false,
|
||||
},
|
||||
];
|
||||
const getAvailableModelsForAuthType = vi.fn().mockReturnValue(mockModels);
|
||||
const mockConfigWithMethod = {
|
||||
// Prefer the newer API when available.
|
||||
getAvailableModelsForAuthType,
|
||||
};
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfigWithMethod as unknown as Config,
|
||||
);
|
||||
|
||||
expect(getAvailableModelsForAuthType).toHaveBeenCalled();
|
||||
expect(models[0].id).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('should fallback to env var for openai when config returns empty', () => {
|
||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||
const mockConfig = {
|
||||
getAvailableModelsForAuthType: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fallback to env var for openai when config throws', () => {
|
||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||
const mockConfig = {
|
||||
getAvailableModelsForAuthType: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Registry not initialized');
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return env model for openai without config', () => {
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4-turbo';
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models[0].id).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should return empty array for openai without config or env', () => {
|
||||
delete process.env['OPENAI_MODEL'];
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for other auth types', () => {
|
||||
const models = getAvailableModelsForAuthType(AuthType.USE_GEMINI);
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVisionModel', () => {
|
||||
it('should return true for vision model', () => {
|
||||
expect(isVisionModel(MAINLINE_VLM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-vision model', () => {
|
||||
expect(isVisionModel(MAINLINE_CODER)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown model', () => {
|
||||
expect(isVisionModel('unknown-model')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultVisionModel', () => {
|
||||
it('should return the vision model ID', () => {
|
||||
expect(getDefaultVisionModel()).toBe(MAINLINE_VLM);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,12 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
DEFAULT_QWEN_MODEL,
|
||||
type Config,
|
||||
type AvailableModel as CoreAvailableModel,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export type AvailableModel = {
|
||||
|
|
@ -57,20 +62,78 @@ export function getFilteredQwenModels(
|
|||
*/
|
||||
export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||
const id = process.env['OPENAI_MODEL']?.trim();
|
||||
return id ? { id, label: id } : null;
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
label: id,
|
||||
get description() {
|
||||
return t('Configured via OPENAI_MODEL environment variable');
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getAnthropicAvailableModelFromEnv(): AvailableModel | null {
|
||||
const id = process.env['ANTHROPIC_MODEL']?.trim();
|
||||
return id ? { id, label: id } : null;
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
label: id,
|
||||
get description() {
|
||||
return t('Configured via ANTHROPIC_MODEL environment variable');
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert core AvailableModel to CLI AvailableModel format
|
||||
*/
|
||||
function convertCoreModelToCliModel(
|
||||
coreModel: CoreAvailableModel,
|
||||
): AvailableModel {
|
||||
return {
|
||||
id: coreModel.id,
|
||||
label: coreModel.label,
|
||||
description: coreModel.description,
|
||||
isVision: coreModel.isVision ?? coreModel.capabilities?.vision ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for the given authType.
|
||||
*
|
||||
* If a Config object is provided, uses config.getAvailableModelsForAuthType().
|
||||
* For qwen-oauth, always returns the hard-coded models.
|
||||
* Falls back to environment variables only when no config is provided.
|
||||
*/
|
||||
export function getAvailableModelsForAuthType(
|
||||
authType: AuthType,
|
||||
config?: Config,
|
||||
): AvailableModel[] {
|
||||
// For qwen-oauth, always use hard-coded models, this aligns with the API gateway.
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
}
|
||||
|
||||
// Use config's model registry when available
|
||||
if (config) {
|
||||
try {
|
||||
const models = config.getAvailableModelsForAuthType(authType);
|
||||
if (models.length > 0) {
|
||||
return models.map(convertCoreModelToCliModel);
|
||||
}
|
||||
} catch {
|
||||
// If config throws (e.g., not initialized), return empty array
|
||||
}
|
||||
// When a Config object is provided, we intentionally do NOT fall back to env-based
|
||||
// "raw" models. These may reflect the currently effective config but should not be
|
||||
// presented as selectable options in /model.
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fall back to environment variables for specific auth types (no config provided)
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
case AuthType.USE_OPENAI: {
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
|
|
@ -80,13 +143,10 @@ export function getAvailableModelsForAuthType(
|
|||
return anthropicModel ? [anthropicModel] : [];
|
||||
}
|
||||
default:
|
||||
// For other auth types, return empty array for now
|
||||
// This can be expanded later according to the design doc
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Hard code the default vision model as a string literal,
|
||||
* until our coding model supports multimodal.
|
||||
|
|
|
|||
112
packages/cli/src/utils/modelConfigUtils.ts
Normal file
112
packages/cli/src/utils/modelConfigUtils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSources,
|
||||
resolveModelConfig,
|
||||
type ModelConfigSourcesInput,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
|
||||
export interface CliGenerationConfigInputs {
|
||||
argv: {
|
||||
model?: string | undefined;
|
||||
openaiApiKey?: string | undefined;
|
||||
openaiBaseUrl?: string | undefined;
|
||||
openaiLogging?: boolean | undefined;
|
||||
openaiLoggingDir?: string | undefined;
|
||||
};
|
||||
settings: Settings;
|
||||
selectedAuthType: AuthType | undefined;
|
||||
/**
|
||||
* Injectable env for testability. Defaults to process.env at callsites.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ResolvedCliGenerationConfig {
|
||||
/** The resolved model id (may be empty string if not resolvable at CLI layer) */
|
||||
model: string;
|
||||
/** API key for OpenAI-compatible auth */
|
||||
apiKey: string;
|
||||
/** Base URL for OpenAI-compatible auth */
|
||||
baseUrl: string;
|
||||
/** The full generation config to pass to core Config */
|
||||
generationConfig: Partial<ContentGeneratorConfig>;
|
||||
/** Source attribution for each resolved field */
|
||||
sources: ContentGeneratorConfigSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified resolver for CLI generation config.
|
||||
*
|
||||
* Precedence (for OpenAI auth):
|
||||
* - model: argv.model > OPENAI_MODEL > QWEN_MODEL > settings.model.name
|
||||
* - apiKey: argv.openaiApiKey > OPENAI_API_KEY > settings.security.auth.apiKey
|
||||
* - baseUrl: argv.openaiBaseUrl > OPENAI_BASE_URL > settings.security.auth.baseUrl
|
||||
*
|
||||
* For non-OpenAI auth, only argv.model override is respected at CLI layer.
|
||||
*/
|
||||
export function resolveCliGenerationConfig(
|
||||
inputs: CliGenerationConfigInputs,
|
||||
): ResolvedCliGenerationConfig {
|
||||
const { argv, settings, selectedAuthType } = inputs;
|
||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||
|
||||
const authType = selectedAuthType ?? AuthType.QWEN_OAUTH;
|
||||
|
||||
const configSources: ModelConfigSourcesInput = {
|
||||
authType,
|
||||
cli: {
|
||||
model: argv.model,
|
||||
apiKey: argv.openaiApiKey,
|
||||
baseUrl: argv.openaiBaseUrl,
|
||||
},
|
||||
settings: {
|
||||
model: settings.model?.name,
|
||||
apiKey: settings.security?.auth?.apiKey,
|
||||
baseUrl: settings.security?.auth?.baseUrl,
|
||||
generationConfig: settings.model?.generationConfig as
|
||||
| Partial<ContentGeneratorConfig>
|
||||
| undefined,
|
||||
},
|
||||
env,
|
||||
};
|
||||
|
||||
const resolved = resolveModelConfig(configSources);
|
||||
|
||||
// Log warnings if any
|
||||
for (const warning of resolved.warnings) {
|
||||
console.warn(`[modelProviderUtils] ${warning}`);
|
||||
}
|
||||
|
||||
// Resolve OpenAI logging config (CLI-specific, not part of core resolver)
|
||||
const enableOpenAILogging =
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false;
|
||||
|
||||
const openAILoggingDir =
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir;
|
||||
|
||||
// Build the full generation config
|
||||
// Note: we merge the resolved config with logging settings
|
||||
const generationConfig: Partial<ContentGeneratorConfig> = {
|
||||
...resolved.config,
|
||||
enableOpenAILogging,
|
||||
openAILoggingDir,
|
||||
};
|
||||
|
||||
return {
|
||||
model: resolved.config.model || '',
|
||||
apiKey: resolved.config.apiKey || '',
|
||||
baseUrl: resolved.config.baseUrl || '',
|
||||
generationConfig,
|
||||
sources: resolved.sources,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type ContentGeneratorConfig,
|
||||
type ContentGeneratorConfigSource,
|
||||
type ContentGeneratorConfigSources,
|
||||
type ModelProvidersConfig,
|
||||
type ProviderModelConfig as ModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
|
||||
export interface GenerationConfigSourceInputs {
|
||||
argv: {
|
||||
model?: string | undefined;
|
||||
openaiApiKey?: string | undefined;
|
||||
openaiBaseUrl?: string | undefined;
|
||||
};
|
||||
settings: Settings;
|
||||
selectedAuthType: AuthType | undefined;
|
||||
/**
|
||||
* Injectable env for testability. Defaults to process.env at callsites.
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models configuration from settings, grouped by authType.
|
||||
* Returns the models config from the merged settings without mutating files.
|
||||
*/
|
||||
export function getModelProvidersConfigFromSettings(
|
||||
settings: Settings,
|
||||
): ModelProvidersConfig {
|
||||
return (settings.modelProviders as ModelProvidersConfig) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific authType from settings.
|
||||
*/
|
||||
export function getModelsForAuthType(
|
||||
settings: Settings,
|
||||
authType: AuthType,
|
||||
): ModelConfig[] {
|
||||
const modelProvidersConfig = getModelProvidersConfigFromSettings(settings);
|
||||
return modelProvidersConfig[authType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort attribution for the seed generationConfig fields.
|
||||
*
|
||||
* NOTE:
|
||||
* - This does not attempt to distinguish user vs workspace settings; it reflects merged settings.
|
||||
* - This should stay consistent with the actual precedence used to compute the corresponding values.
|
||||
*/
|
||||
export function buildGenerationConfigSources(
|
||||
inputs: GenerationConfigSourceInputs,
|
||||
): ContentGeneratorConfigSources {
|
||||
const { argv, settings, selectedAuthType } = inputs;
|
||||
const env = inputs.env ?? (process.env as Record<string, string | undefined>);
|
||||
|
||||
const sources: ContentGeneratorConfigSources = {};
|
||||
|
||||
const setSource = (path: string, source: ContentGeneratorConfigSource) => {
|
||||
sources[path] = source;
|
||||
};
|
||||
|
||||
// Model/apiKey/baseUrl attribution mirrors current CLI precedence:
|
||||
// - model: argv.model > (OPENAI_MODEL|QWEN_MODEL|settings.model.name) only for OpenAI auth
|
||||
// - apiKey/baseUrl: only meaningful for OpenAI auth in current CLI wiring
|
||||
if (selectedAuthType === AuthType.USE_OPENAI) {
|
||||
if (argv.model) {
|
||||
setSource('model', { kind: 'cli', detail: '--model' });
|
||||
} else if (env['OPENAI_MODEL']) {
|
||||
setSource('model', { kind: 'env', envKey: 'OPENAI_MODEL' });
|
||||
} else if (env['QWEN_MODEL']) {
|
||||
setSource('model', { kind: 'env', envKey: 'QWEN_MODEL' });
|
||||
} else if (settings.model?.name) {
|
||||
setSource('model', { kind: 'settings', settingsPath: 'model.name' });
|
||||
}
|
||||
|
||||
if (argv.openaiApiKey) {
|
||||
setSource('apiKey', { kind: 'cli', detail: '--openaiApiKey' });
|
||||
} else if (env['OPENAI_API_KEY']) {
|
||||
setSource('apiKey', { kind: 'env', envKey: 'OPENAI_API_KEY' });
|
||||
} else if (settings.security?.auth?.apiKey) {
|
||||
setSource('apiKey', {
|
||||
kind: 'settings',
|
||||
settingsPath: 'security.auth.apiKey',
|
||||
});
|
||||
}
|
||||
|
||||
if (argv.openaiBaseUrl) {
|
||||
setSource('baseUrl', { kind: 'cli', detail: '--openaiBaseUrl' });
|
||||
} else if (env['OPENAI_BASE_URL']) {
|
||||
setSource('baseUrl', { kind: 'env', envKey: 'OPENAI_BASE_URL' });
|
||||
} else if (settings.security?.auth?.baseUrl) {
|
||||
setSource('baseUrl', {
|
||||
kind: 'settings',
|
||||
settingsPath: 'security.auth.baseUrl',
|
||||
});
|
||||
}
|
||||
} else if (argv.model) {
|
||||
// For non-openai auth types, the CLI only wires through an explicit raw model override.
|
||||
setSource('model', { kind: 'cli', detail: '--model' });
|
||||
}
|
||||
|
||||
const mergedGenerationConfig = settings.model?.generationConfig as
|
||||
| Partial<ContentGeneratorConfig>
|
||||
| undefined;
|
||||
if (mergedGenerationConfig) {
|
||||
setSource('generationConfig', {
|
||||
kind: 'settings',
|
||||
settingsPath: 'model.generationConfig',
|
||||
});
|
||||
// We also map the known top-level fields used by core.
|
||||
if (mergedGenerationConfig.samplingParams) {
|
||||
setSource('samplingParams', {
|
||||
kind: 'settings',
|
||||
settingsPath: 'model.generationConfig.samplingParams',
|
||||
});
|
||||
}
|
||||
for (const k of [
|
||||
'timeout',
|
||||
'maxRetries',
|
||||
'disableCacheControl',
|
||||
'schemaCompliance',
|
||||
] as const) {
|
||||
if (mergedGenerationConfig[k] !== undefined) {
|
||||
setSource(k, {
|
||||
kind: 'settings',
|
||||
settingsPath: `model.generationConfig.${k}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue