Merge branch 'main' into feat/extension

This commit is contained in:
LaZzyMan 2026-01-14 17:56:01 +08:00
commit 4c7605d900
246 changed files with 20903 additions and 2406 deletions

View file

@ -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(
'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.',
"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,115 @@ 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('modelProviders[].baseUrl');
});
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();
});
it('should use config.modelsConfig.getModel() when Config is provided', () => {
// Settings has a different model
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
// Mock Config object that returns a different model (e.g., from CLI args)
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Set the env key for the CLI model, not the settings model
process.env['CLI_API_KEY'] = 'cli-key';
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).toBeNull();
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
});
it('should fail validation when Config provides different model without matching env key', () => {
// Clean up any existing env keys first
delete process.env['CLI_API_KEY'];
delete process.env['SETTINGS_API_KEY'];
delete process.env['OPENAI_API_KEY'];
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
model: { name: 'settings-model' },
modelProviders: {
openai: [
{ id: 'settings-model', envKey: 'SETTINGS_API_KEY' },
{ id: 'cli-model', envKey: 'CLI_API_KEY' },
],
},
},
} as unknown as ReturnType<typeof settings.loadSettings>);
const mockConfig = {
modelsConfig: {
getModel: vi.fn().mockReturnValue('cli-model'),
},
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Don't set CLI_API_KEY - validation should fail
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).not.toBeNull();
expect(result).toContain('CLI_API_KEY');
});
});

View file

@ -1,21 +1,169 @@
/**
* @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 {
AuthType,
type Config,
type ModelProvidersConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import { loadEnvironment, loadSettings, type Settings } from './settings.js';
import { t } from '../i18n/index.js';
export function validateAuthMethod(authMethod: string): string | null {
/**
* 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,
config?: Config,
): {
hasKey: boolean;
checkedEnvKey: string | undefined;
isExplicitEnvKey: boolean;
} {
const modelProviders = settings.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
// Try to find model-specific envKey from modelProviders
const modelConfig = findModelConfig(modelProviders, authType, modelId);
if (modelConfig?.envKey) {
// Explicit envKey configured - only check this env var, no apiKey fallback
const hasKey = !!process.env[modelConfig.envKey];
return {
hasKey,
checkedEnvKey: modelConfig.envKey,
isExplicitEnvKey: true,
};
}
// Using default environment variable - apiKey fallback is allowed
const defaultEnvKey = DEFAULT_ENV_KEYS[authType];
if (defaultEnvKey) {
const hasKey = !!process.env[defaultEnvKey];
if (hasKey) {
return { hasKey, checkedEnvKey: defaultEnvKey, isExplicitEnvKey: false };
}
}
// Also check settings.security.auth.apiKey as fallback (only for default env key)
if (settings.security?.auth?.apiKey) {
return {
hasKey: true,
checkedEnvKey: defaultEnvKey || undefined,
isExplicitEnvKey: false,
};
}
return {
hasKey: false,
checkedEnvKey: defaultEnvKey,
isExplicitEnvKey: false,
};
}
/**
* Generate API key error message based on auth check result.
* Returns null if API key is present, otherwise returns the appropriate error message.
*/
function getApiKeyError(
authMethod: string,
settings: Settings,
config?: Config,
): string | null {
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
authMethod,
settings,
config,
);
if (hasKey) {
return null;
}
const envKeyHint = checkedEnvKey || DEFAULT_ENV_KEYS[authMethod];
if (isExplicitEnvKey) {
return t(
'{{envKeyHint}} environment variable not found. Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
return t(
'{{envKeyHint}} environment variable not found (or set settings.security.auth.apiKey). Please set it in your .env file or environment variables.',
{ envKeyHint },
);
}
/**
* Validate that the required credentials and configuration exist for the given auth method.
*/
export function validateAuthMethod(
authMethod: string,
config?: Config,
): 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) {
return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.';
const { hasKey, checkedEnvKey, isExplicitEnvKey } = hasApiKeyForAuth(
authMethod,
settings.merged,
config,
);
if (!hasKey) {
const envKeyHint = checkedEnvKey
? `'${checkedEnvKey}'`
: "'OPENAI_API_KEY'";
if (isExplicitEnvKey) {
// Explicit envKey configured - only suggest setting the env var
return t(
'Missing API key for OpenAI-compatible auth. Set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
}
// Default env key - can use either apiKey or env var
return t(
'Missing API key for OpenAI-compatible auth. Set settings.security.auth.apiKey, or set the {{envKeyHint}} environment variable.',
{ envKeyHint },
);
}
return null;
}
@ -27,36 +175,49 @@ 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 apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
const hasBaseUrl = process.env['ANTHROPIC_BASE_URL'];
if (!hasBaseUrl) {
return 'ANTHROPIC_BASE_URL environment variable not found.';
// Check baseUrl - can come from modelProviders or environment
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID
const modelId =
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
if (modelConfig && !modelConfig.baseUrl) {
return t(
'Anthropic provider missing required baseUrl in modelProviders[].baseUrl.',
);
}
if (!modelConfig && !process.env['ANTHROPIC_BASE_URL']) {
return t('ANTHROPIC_BASE_URL environment variable not found.');
}
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 apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
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 apiKeyError = getApiKeyError(authMethod, settings.merged, config);
if (apiKeyError) {
return apiKeyError;
}
process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true';
return null;
}
return 'Invalid auth method selected.';
return t('Invalid auth method selected.');
}

View file

@ -78,10 +78,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: {
@ -1598,6 +1596,58 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
allowed: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.core', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
core: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
process.argv = [
'node',

View file

@ -9,24 +9,30 @@ import {
AuthType,
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
EditTool,
FileDiscoveryService,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
ShellTool,
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
Storage,
InputFormat,
OutputFormat,
isToolEnabled,
SessionService,
type ResumedSessionData,
type FileFilteringOptions,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
} from '../utils/modelConfigUtils.js';
import yargs, { type Argv } from 'yargs';
import { hideBin } from 'yargs/helpers';
import * as fs from 'node:fs';
@ -106,6 +112,7 @@ export interface CliArgs {
telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
extensions: string[] | undefined;
@ -158,7 +165,17 @@ function normalizeOutputFormat(
}
export async function parseArguments(): Promise<CliArgs> {
const rawArgv = hideBin(process.argv);
let rawArgv = hideBin(process.argv);
// hack: if the first argument is the CLI entry point, remove it
if (
rawArgv.length > 0 &&
(rawArgv[0].endsWith('/dist/qwen-cli/cli.js') ||
rawArgv[0].endsWith('/dist/cli.js'))
) {
rawArgv = rawArgv.slice(1);
}
const yargsInstance = yargs(rawArgv)
.locale('en')
.scriptName('qwen')
@ -299,10 +316,16 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
hidden: true,
})
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
@ -582,8 +605,19 @@ export async function parseArguments(): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
// Handle deprecated --experimental-acp flag
if (result['experimentalAcp']) {
console.warn(
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
);
// Map experimental-acp to acp if acp is not explicitly set
if (!result['acp']) {
(result as Record<string, unknown>)['acp'] = true;
}
}
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
@ -774,6 +808,28 @@ export async function loadCliConfig(
// However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case.
const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
if (
!interactive &&
!argv.experimentalAcp &&
@ -782,12 +838,15 @@ export async function loadCliConfig(
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded.
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
// In default non-interactive mode, all tools that require approval are excluded,
// unless explicitly enabled via coreTools/allowedTools.
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
break;
case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
extraExcludes.push(ShellTool.Name);
excludeUnlessExplicit(ShellTool.Name as ToolName);
break;
case ApprovalMode.YOLO:
// No extra excludes for YOLO mode.
@ -815,28 +874,25 @@ export async function loadCliConfig(
const selectedAuthType =
(argv.authType as AuthType | undefined) ||
settings.security?.auth?.selectedType;
settings.security?.auth?.selectedType ||
/* getAuthTypeFromEnv means no authType was explicitly provided, we infer the authType from env vars */
getAuthTypeFromEnv();
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 =
@ -870,6 +926,8 @@ export async function loadCliConfig(
}
}
const modelProvidersConfig = settings.modelProviders;
return new Config({
sessionId,
sessionData,
@ -923,7 +981,7 @@ export async function loadCliConfig(
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false,
overrideExtensions: overrideExtensions || argv.extensions,
@ -932,24 +990,11 @@ export async function loadCliConfig(
inputFormat,
outputFormat,
includePartialMessages,
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,
},
modelProvidersConfig,
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,

View file

@ -0,0 +1,87 @@
/**
* @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.User);
const untrusted = makeSettings({
isTrusted: false,
userModelProviders: undefined,
workspaceModelProviders: undefined,
});
expect(getPersistScopeForModelSelection(untrusted)).toBe(SettingScope.User);
});
});

View 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;
}

View file

@ -54,6 +54,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import {
getSettingsWarnings,
loadSettings,
USER_SETTINGS_PATH, // This IS the mocked path.
getSystemSettingsPath,
@ -415,6 +416,86 @@ describe('Settings Loading and Merging', () => {
});
});
it('should warn about ignored legacy keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
usageStatisticsEnabled: false,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Legacy setting 'usageStatisticsEnabled' will be ignored",
),
]),
);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining("'privacy.usageStatisticsEnabled'"),
]),
);
});
it('should warn about unknown top-level keys in a v2 settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
someUnknownKey: 'value',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual(
expect.arrayContaining([
expect.stringContaining(
"Unknown setting 'someUnknownKey' will be ignored",
),
]),
);
});
it('should not warn for valid v2 container keys', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
model: { name: 'qwen-coder' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(getSettingsWarnings(settings)).toEqual([]);
});
it('should rewrite allowedTools to tools.allowed during migration', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,

View file

@ -342,6 +342,97 @@ const KNOWN_V2_CONTAINERS = new Set(
Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
);
function getSettingsFileKeyWarnings(
settings: Record<string, unknown>,
settingsFilePath: string,
): string[] {
const version = settings[SETTINGS_VERSION_KEY];
if (typeof version !== 'number' || version < SETTINGS_VERSION) {
return [];
}
const warnings: string[] = [];
const ignoredLegacyKeys = new Set<string>();
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
if (oldKey === newPath) {
continue;
}
if (!(oldKey in settings)) {
continue;
}
const oldValue = settings[oldKey];
// If this key is a V2 container (like 'model') and it's already an object,
// it's likely already in V2 format. Don't warn.
if (
KNOWN_V2_CONTAINERS.has(oldKey) &&
typeof oldValue === 'object' &&
oldValue !== null &&
!Array.isArray(oldValue)
) {
continue;
}
ignoredLegacyKeys.add(oldKey);
warnings.push(
`⚠️ Legacy setting '${oldKey}' will be ignored in ${settingsFilePath}. Please use '${newPath}' instead.`,
);
}
// Unknown top-level keys.
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
for (const key of Object.keys(settings)) {
if (key === SETTINGS_VERSION_KEY) {
continue;
}
if (ignoredLegacyKeys.has(key)) {
continue;
}
if (schemaKeys.has(key)) {
continue;
}
warnings.push(
`⚠️ Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
);
}
return warnings;
}
/**
* Collects warnings for ignored legacy and unknown settings keys.
*
* For `$version: 2` settings files, we do not apply implicit migrations.
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
*/
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
const warningSet = new Set<string>();
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const settingsFile = loadedSettings.forScope(scope);
if (settingsFile.rawJson === undefined) {
continue; // File not present / not loaded.
}
const settingsObject = settingsFile.originalSettings as unknown as Record<
string,
unknown
>;
for (const warning of getSettingsFileKeyWarnings(
settingsObject,
settingsFile.path,
)) {
warningSet.add(warning);
}
}
return [...warningSet];
}
export function migrateSettingsToV1(
v2Settings: Record<string, unknown>,
): Record<string, unknown> {

View file

@ -10,6 +10,7 @@ import type {
TelemetrySettings,
AuthType,
ChatCompressionSettings,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
@ -102,6 +103,19 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
// Model providers configuration grouped by authType
modelProviders: {
type: 'object',
label: 'Model Providers',
category: 'Model',
requiresRestart: false,
default: {} as ModelProvidersConfig,
description:
'Model providers configuration grouped by authType. Each authType contains an array of model configurations.',
showInDialog: false,
mergeStrategy: MergeStrategy.REPLACE,
},
general: {
type: 'object',
label: 'General',
@ -202,6 +216,7 @@ const SETTINGS_SCHEMA = {
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
],
},
terminalBell: {