mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat: add modelProviders in settings to support custom model switching
This commit is contained in:
@@ -135,6 +135,69 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||
|
||||
#### `modelProviders`
|
||||
|
||||
The `modelProviders` configuration allows you to define multiple models for a specific authentication type. Currently we support only `openai` authentication type.
|
||||
|
||||
| Field | Type | Required | Description | Default |
|
||||
| -------------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| `id` | string | Yes | Unique identifier for the model within the authentication type. | - |
|
||||
| `name` | string | No | Display name for the model. | Same as `id` |
|
||||
| `description` | string | No | A brief description of the model. | `undefined` |
|
||||
| `envKey` | string | No | The name of the environment variable containing the API key for this model. For example, if set to `"OPENAI_API_KEY"`, the system will read the API key from `process.env.OPENAI_API_KEY`. This keeps API keys secure in environment variables. | `undefined` |
|
||||
| `baseUrl` | string | No | Custom API endpoint URL. If not specified, uses the default URL for the authentication type. | `undefined` |
|
||||
| `capabilities.vision` | boolean | No | Whether the model supports vision/image inputs. | `false` |
|
||||
| `generationConfig.temperature` | number | No | Sampling temperature. Refer to your providers' document. | `undefined` |
|
||||
| `generationConfig.top_p` | number | No | Nucleus sampling parameter. Refer to your providers' document. | `undefined` |
|
||||
| `generationConfig.top_k` | number | No | Top-k sampling parameter. Refer to your providers' document. | `undefined` |
|
||||
| `generationConfig.max_tokens` | number | No | Maximum output tokens. | `undefined` |
|
||||
| `generationConfig.timeout` | number | No | Request timeout in milliseconds. | `undefined` |
|
||||
| `generationConfig.maxRetries` | number | No | Maximum retry attempts. | `undefined` |
|
||||
| `generationConfig.disableCacheControl` | boolean | No | Disable cache control for DashScope providers. | `false` |
|
||||
|
||||
**Example Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "gpt-4-turbo",
|
||||
"name": "GPT-4 Turbo",
|
||||
"description": "Most capable GPT-4 model",
|
||||
"envKey": "OPENAI_API_KEY",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"capabilities": {
|
||||
"vision": true
|
||||
},
|
||||
"generationConfig": {
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-coder",
|
||||
"name": "DeepSeek Coder",
|
||||
"description": "DeepSeek coding model",
|
||||
"envKey": "DEEPSEEK_API_KEY",
|
||||
"baseUrl": "https://api.deepseek.com/v1",
|
||||
"generationConfig": {
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 8192
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Note:** API keys should never be stored directly in configuration files. Always use the `envKey` field to reference environment variables where your API keys are stored. Set these environment variables in your shell profile or `.env` files:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
export DEEPSEEK_API_KEY="your-deepseek-key-here"
|
||||
```
|
||||
|
||||
#### context
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
@@ -358,7 +421,7 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
### Command-Line Arguments Table
|
||||
|
||||
| Argument | Alias | Description | Possible Values | Notes |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { getModelProvidersConfigFromSettings } from './settings.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -864,11 +865,16 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedModel =
|
||||
argv.model ||
|
||||
let resolvedModel: string | undefined;
|
||||
|
||||
if (argv.model) {
|
||||
resolvedModel = argv.model;
|
||||
} else {
|
||||
resolvedModel =
|
||||
process.env['OPENAI_MODEL'] ||
|
||||
process.env['QWEN_MODEL'] ||
|
||||
settings.model?.name;
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
const screenReader =
|
||||
@@ -902,6 +908,8 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const modelProvidersConfig = getModelProvidersConfigFromSettings(settings);
|
||||
|
||||
return new Config({
|
||||
sessionId,
|
||||
sessionData,
|
||||
@@ -960,6 +968,7 @@ export async function loadCliConfig(
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
type AuthType,
|
||||
type ProviderModelConfig as ModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ModelCapabilities,
|
||||
type ModelGenerationConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
@@ -47,7 +52,14 @@ function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
return current?.mergeStrategy;
|
||||
}
|
||||
|
||||
export type { Settings, MemoryImportFormat };
|
||||
export type {
|
||||
Settings,
|
||||
MemoryImportFormat,
|
||||
ModelConfig,
|
||||
ModelProvidersConfig,
|
||||
ModelCapabilities,
|
||||
ModelGenerationConfig,
|
||||
};
|
||||
|
||||
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
||||
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
||||
@@ -862,3 +874,31 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models configuration from settings, grouped by authType.
|
||||
* Returns the models config from the merged settings without mutating files.
|
||||
*
|
||||
* @param settings - The merged settings object
|
||||
* @returns ModelProvidersConfig object (keyed by authType) or empty object if not configured
|
||||
*/
|
||||
export function getModelProvidersConfigFromSettings(
|
||||
settings: Settings,
|
||||
): ModelProvidersConfig {
|
||||
return (settings.modelProviders as ModelProvidersConfig) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific authType from settings.
|
||||
*
|
||||
* @param settings - The merged settings object
|
||||
* @param authType - The authType to get models for
|
||||
* @returns Array of ModelConfig for the authType, or empty array if not configured
|
||||
*/
|
||||
export function getModelsForAuthType(
|
||||
settings: Settings,
|
||||
authType: string,
|
||||
): ModelConfig[] {
|
||||
const modelProvidersConfig = getModelProvidersConfigFromSettings(settings);
|
||||
return modelProvidersConfig[authType as AuthType] || [];
|
||||
}
|
||||
|
||||
@@ -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.SHALLOW_MERGE,
|
||||
},
|
||||
|
||||
general: {
|
||||
type: 'object',
|
||||
label: 'General',
|
||||
|
||||
@@ -52,7 +52,7 @@ export const modelCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
const availableModels = getAvailableModelsForAuthType(authType);
|
||||
const availableModels = getAvailableModelsForAuthType(authType, config);
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,8 @@ const renderComponent = (
|
||||
? ({
|
||||
// --- 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 ---
|
||||
@@ -139,16 +140,19 @@ describe('<ModelDialog />', () => {
|
||||
expect(mockedSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => {
|
||||
it('calls config.switchModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', async () => {
|
||||
const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue
|
||||
|
||||
const childOnSelect = mockedSelect.mock.calls[0][0].onSelect;
|
||||
expect(childOnSelect).toBeDefined();
|
||||
|
||||
childOnSelect(MAINLINE_CODER);
|
||||
await childOnSelect(MAINLINE_CODER);
|
||||
|
||||
// Assert against the default mock provided by renderComponent
|
||||
expect(mockConfig?.setModel).toHaveBeenCalledWith(MAINLINE_CODER);
|
||||
// Assert that switchModel is called with the model and metadata
|
||||
expect(mockConfig?.switchModel).toHaveBeenCalledWith(MAINLINE_CODER, {
|
||||
reason: 'user_manual',
|
||||
context: 'Model switched via /model dialog',
|
||||
});
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,13 +29,11 @@ interface ModelDialogProps {
|
||||
export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
const config = useContext(ConfigContext);
|
||||
|
||||
// Get auth type from config, default to QWEN_OAUTH if not available
|
||||
const authType = config?.getAuthType() ?? AuthType.QWEN_OAUTH;
|
||||
|
||||
// Get available models based on auth type
|
||||
const availableModels = useMemo(
|
||||
() => getAvailableModelsForAuthType(authType),
|
||||
[authType],
|
||||
() => getAvailableModelsForAuthType(authType, config ?? undefined),
|
||||
[authType, config],
|
||||
);
|
||||
|
||||
const MODEL_OPTIONS = useMemo(
|
||||
@@ -49,7 +47,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[availableModels],
|
||||
);
|
||||
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || MAINLINE_CODER;
|
||||
|
||||
useKeypress(
|
||||
@@ -61,17 +58,18 @@ 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],
|
||||
);
|
||||
|
||||
// Handle selection internally (Autonomous Dialog).
|
||||
const handleSelect = useCallback(
|
||||
(model: string) => {
|
||||
async (model: string) => {
|
||||
if (config) {
|
||||
config.setModel(model);
|
||||
await config.switchModel(model, {
|
||||
reason: 'user_manual',
|
||||
context: 'Model switched via /model dialog',
|
||||
});
|
||||
const event = new ModelSlashCommandEvent(model);
|
||||
logModelSlashCommand(config, event);
|
||||
}
|
||||
|
||||
203
packages/cli/src/ui/models/availableModels.test.ts
Normal file
203
packages/cli/src/ui/models/availableModels.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @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 mockConfig = {
|
||||
getAvailableModels: vi.fn().mockReturnValue(mockModels),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(mockConfig.getAvailableModels).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 = {
|
||||
getAvailableModels: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models[0].id).toBe('fallback-model');
|
||||
});
|
||||
|
||||
it('should fallback to env var for openai when config throws', () => {
|
||||
process.env['OPENAI_MODEL'] = 'fallback-model';
|
||||
const mockConfig = {
|
||||
getAvailableModels: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Registry not initialized');
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const models = getAvailableModelsForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(models[0].id).toBe('fallback-model');
|
||||
});
|
||||
|
||||
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 = {
|
||||
@@ -60,24 +65,56 @@ export function getOpenAIAvailableModelFromEnv(): AvailableModel | null {
|
||||
return id ? { id, label: id } : null;
|
||||
}
|
||||
|
||||
export function getAvailableModelsForAuthType(
|
||||
authType: AuthType,
|
||||
): AvailableModel[] {
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
case AuthType.USE_OPENAI: {
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
}
|
||||
default:
|
||||
// For other auth types, return empty array for now
|
||||
// This can be expanded later according to the design doc
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* 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 the model registry to get models.
|
||||
* For qwen-oauth, always returns the hard-coded models.
|
||||
* For openai authType, falls back to environment variable if no config 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;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const models = config.getAvailableModels();
|
||||
if (models.length > 0) {
|
||||
return models.map(convertCoreModelToCliModel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get models from model registry', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
}
|
||||
|
||||
// For other auth types, return empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard code the default vision model as a string literal,
|
||||
* until our coding model supports multimodal.
|
||||
|
||||
@@ -102,6 +102,15 @@ import {
|
||||
} from '../services/sessionService.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Models
|
||||
import {
|
||||
ModelSelectionManager,
|
||||
type ModelProvidersConfig,
|
||||
type AvailableModel,
|
||||
type ResolvedModelConfig,
|
||||
SelectionSource,
|
||||
} from '../models/index.js';
|
||||
|
||||
// Re-export types
|
||||
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
|
||||
export {
|
||||
@@ -351,6 +360,8 @@ export interface ConfigParameters {
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
channel?: string;
|
||||
/** Model providers configuration grouped by authType */
|
||||
modelProvidersConfig?: ModelProvidersConfig;
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -490,6 +501,10 @@ export class Config {
|
||||
private readonly useSmartEdit: boolean;
|
||||
private readonly channel: string | undefined;
|
||||
|
||||
// Model selection manager (ModelRegistry is internal to it)
|
||||
private modelSelectionManager?: ModelSelectionManager;
|
||||
private readonly modelProvidersConfig?: ModelProvidersConfig;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId ?? randomUUID();
|
||||
this.sessionData = params.sessionData;
|
||||
@@ -609,6 +624,7 @@ export class Config {
|
||||
this.vlmSwitchMode = params.vlmSwitchMode;
|
||||
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.modelProvidersConfig = params.modelProvidersConfig;
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
@@ -777,13 +793,111 @@ export class Config {
|
||||
|
||||
async setModel(
|
||||
newModel: string,
|
||||
_metadata?: { reason?: string; context?: string },
|
||||
metadata?: { reason?: string; context?: string },
|
||||
): Promise<void> {
|
||||
if (this.contentGeneratorConfig) {
|
||||
this.contentGeneratorConfig.model = newModel;
|
||||
const manager = this.getModelSelectionManager();
|
||||
await manager.switchModel(
|
||||
newModel,
|
||||
SelectionSource.PROGRAMMATIC_OVERRIDE,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
// TODO: Log _metadata for telemetry if needed
|
||||
// This _metadata can be used for tracking model switches (reason, context)
|
||||
|
||||
/**
|
||||
* Get or lazily initialize the ModelSelectionManager.
|
||||
* This is the single entry point for all model-related operations.
|
||||
*/
|
||||
getModelSelectionManager(): ModelSelectionManager {
|
||||
if (!this.modelSelectionManager) {
|
||||
const currentAuthType = this.contentGeneratorConfig?.authType;
|
||||
const currentModelId = this.contentGeneratorConfig?.model;
|
||||
|
||||
this.modelSelectionManager = new ModelSelectionManager({
|
||||
initialAuthType: currentAuthType,
|
||||
initialModelId: currentModelId,
|
||||
onModelChange: this.handleModelChange.bind(this),
|
||||
modelProvidersConfig: this.modelProvidersConfig,
|
||||
});
|
||||
}
|
||||
return this.modelSelectionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle model change from the selection manager.
|
||||
* This updates the content generator config with the new model settings.
|
||||
*/
|
||||
private async handleModelChange(
|
||||
authType: AuthType,
|
||||
model: ResolvedModelConfig,
|
||||
): Promise<void> {
|
||||
if (!this.contentGeneratorConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._generationConfig.model = model.id;
|
||||
|
||||
// Read API key from environment variable if envKey is specified
|
||||
if (model.envKey !== undefined) {
|
||||
const apiKey = process.env[model.envKey];
|
||||
if (apiKey) {
|
||||
this._generationConfig.apiKey = apiKey;
|
||||
} else {
|
||||
console.warn(
|
||||
`[Config] Environment variable '${model.envKey}' is not set for model '${model.id}'. ` +
|
||||
`API key will not be available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.baseUrl !== undefined) {
|
||||
this._generationConfig.baseUrl = model.baseUrl;
|
||||
}
|
||||
|
||||
if (model.generationConfig) {
|
||||
this._generationConfig.samplingParams = {
|
||||
temperature: model.generationConfig.temperature,
|
||||
top_p: model.generationConfig.top_p,
|
||||
top_k: model.generationConfig.top_k,
|
||||
max_tokens: model.generationConfig.max_tokens,
|
||||
presence_penalty: model.generationConfig.presence_penalty,
|
||||
frequency_penalty: model.generationConfig.frequency_penalty,
|
||||
repetition_penalty: model.generationConfig.repetition_penalty,
|
||||
};
|
||||
|
||||
if (model.generationConfig.timeout !== undefined) {
|
||||
this._generationConfig.timeout = model.generationConfig.timeout;
|
||||
}
|
||||
if (model.generationConfig.maxRetries !== undefined) {
|
||||
this._generationConfig.maxRetries = model.generationConfig.maxRetries;
|
||||
}
|
||||
if (model.generationConfig.disableCacheControl !== undefined) {
|
||||
this._generationConfig.disableCacheControl =
|
||||
model.generationConfig.disableCacheControl;
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshAuth(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for the current authType.
|
||||
* This is used by the /model command and ModelDialog.
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this.getModelSelectionManager().getAvailableModels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different model within the current authType.
|
||||
* @param modelId - The model ID to switch to
|
||||
* @param metadata - Optional metadata for telemetry
|
||||
*/
|
||||
async switchModel(
|
||||
modelId: string,
|
||||
metadata?: { reason?: string; context?: string },
|
||||
): Promise<void> {
|
||||
const manager = this.getModelSelectionManager();
|
||||
await manager.switchModel(modelId, SelectionSource.USER_MANUAL, metadata);
|
||||
}
|
||||
|
||||
isInFallbackMode(): boolean {
|
||||
|
||||
@@ -11,7 +11,8 @@ import fs from 'node:fs';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Flash Model Fallback Configuration', () => {
|
||||
// Skip this test because we do not have fall back mechanism.
|
||||
describe.skip('Flash Model Fallback Configuration', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -9,6 +9,25 @@ export * from './config/config.js';
|
||||
export * from './output/types.js';
|
||||
export * from './output/json-formatter.js';
|
||||
|
||||
// Export models
|
||||
export {
|
||||
type ModelCapabilities,
|
||||
type ModelGenerationConfig,
|
||||
type ModelConfig as ProviderModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
type CurrentModelInfo,
|
||||
SelectionSource,
|
||||
DEFAULT_GENERATION_CONFIG,
|
||||
DEFAULT_BASE_URLS,
|
||||
QWEN_OAUTH_MODELS,
|
||||
ModelSelectionManager,
|
||||
type ModelChangeCallback,
|
||||
type ModelSelectionManagerOptions,
|
||||
} from './models/index.js';
|
||||
|
||||
// Export Core Logic
|
||||
export * from './core/client.js';
|
||||
export * from './core/contentGenerator.js';
|
||||
|
||||
27
packages/core/src/models/index.ts
Normal file
27
packages/core/src/models/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export {
|
||||
type ModelCapabilities,
|
||||
type ModelGenerationConfig,
|
||||
type ModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
type CurrentModelInfo,
|
||||
SelectionSource,
|
||||
DEFAULT_GENERATION_CONFIG,
|
||||
DEFAULT_BASE_URLS,
|
||||
} from './types.js';
|
||||
|
||||
export { QWEN_OAUTH_MODELS } from './modelRegistry.js';
|
||||
|
||||
export {
|
||||
ModelSelectionManager,
|
||||
type ModelChangeCallback,
|
||||
type ModelSelectionManagerOptions,
|
||||
} from './modelSelectionManager.js';
|
||||
336
packages/core/src/models/modelRegistry.test.ts
Normal file
336
packages/core/src/models/modelRegistry.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ModelRegistry, QWEN_OAUTH_MODELS } from './modelRegistry.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import type { ModelProvidersConfig } from './types.js';
|
||||
|
||||
describe('ModelRegistry', () => {
|
||||
describe('initialization', () => {
|
||||
it('should always include hard-coded qwen-oauth models', () => {
|
||||
const registry = new ModelRegistry();
|
||||
|
||||
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
|
||||
expect(qwenModels[0].id).toBe('coder-model');
|
||||
expect(qwenModels[1].id).toBe('vision-model');
|
||||
});
|
||||
|
||||
it('should initialize with empty config', () => {
|
||||
const registry = new ModelRegistry();
|
||||
expect(registry.hasAuthType(AuthType.QWEN_OAUTH)).toBe(true);
|
||||
expect(registry.hasAuthType(AuthType.USE_OPENAI)).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with custom models config', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const registry = new ModelRegistry(modelProvidersConfig);
|
||||
|
||||
expect(registry.hasAuthType(AuthType.USE_OPENAI)).toBe(true);
|
||||
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(openaiModels.length).toBe(1);
|
||||
expect(openaiModels[0].id).toBe('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should ignore qwen-oauth models in config (hard-coded)', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
'qwen-oauth': [
|
||||
{
|
||||
id: 'custom-qwen',
|
||||
name: 'Custom Qwen',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const registry = new ModelRegistry(modelProvidersConfig);
|
||||
|
||||
// Should still use hard-coded qwen-oauth models
|
||||
const qwenModels = registry.getModelsForAuthType(AuthType.QWEN_OAUTH);
|
||||
expect(qwenModels.length).toBe(QWEN_OAUTH_MODELS.length);
|
||||
expect(qwenModels.find((m) => m.id === 'custom-qwen')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelsForAuthType', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
description: 'Most capable GPT-4',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
capabilities: { vision: true },
|
||||
},
|
||||
{
|
||||
id: 'gpt-3.5-turbo',
|
||||
name: 'GPT-3.5 Turbo',
|
||||
capabilities: { vision: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
registry = new ModelRegistry(modelProvidersConfig);
|
||||
});
|
||||
|
||||
it('should return models for existing authType', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
expect(models.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent authType', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_VERTEX_AI);
|
||||
expect(models.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return AvailableModel format with correct fields', () => {
|
||||
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
|
||||
const gpt4 = models.find((m) => m.id === 'gpt-4-turbo');
|
||||
|
||||
expect(gpt4).toBeDefined();
|
||||
expect(gpt4?.label).toBe('GPT-4 Turbo');
|
||||
expect(gpt4?.description).toBe('Most capable GPT-4');
|
||||
expect(gpt4?.isVision).toBe(true);
|
||||
expect(gpt4?.authType).toBe(AuthType.USE_OPENAI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModel', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
generationConfig: {
|
||||
temperature: 0.8,
|
||||
max_tokens: 4096,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
registry = new ModelRegistry(modelProvidersConfig);
|
||||
});
|
||||
|
||||
it('should return resolved model config', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(model?.id).toBe('gpt-4-turbo');
|
||||
expect(model?.name).toBe('GPT-4 Turbo');
|
||||
expect(model?.authType).toBe(AuthType.USE_OPENAI);
|
||||
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('should merge generationConfig with defaults', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
|
||||
|
||||
expect(model?.generationConfig.temperature).toBe(0.8);
|
||||
expect(model?.generationConfig.max_tokens).toBe(4096);
|
||||
// Default values should be applied
|
||||
expect(model?.generationConfig.top_p).toBe(0.9);
|
||||
expect(model?.generationConfig.timeout).toBe(60000);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent model', () => {
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'non-existent');
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent authType', () => {
|
||||
const model = registry.getModel(AuthType.USE_VERTEX_AI, 'some-model');
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasModel', () => {
|
||||
let registry: ModelRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for existing model', () => {
|
||||
expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent model', () => {
|
||||
expect(registry.hasModel(AuthType.USE_OPENAI, 'non-existent')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for non-existent authType', () => {
|
||||
expect(registry.hasModel(AuthType.USE_VERTEX_AI, 'gpt-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstModelForAuthType', () => {
|
||||
it('should return first model for authType', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{ id: 'first', name: 'First' },
|
||||
{ id: 'second', name: 'Second' },
|
||||
],
|
||||
});
|
||||
|
||||
const firstModel = registry.getFirstModelForAuthType(AuthType.USE_OPENAI);
|
||||
expect(firstModel?.id).toBe('first');
|
||||
});
|
||||
|
||||
it('should return undefined for empty authType', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const firstModel = registry.getFirstModelForAuthType(AuthType.USE_OPENAI);
|
||||
expect(firstModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultModelForAuthType', () => {
|
||||
it('should return coder-model for qwen-oauth', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const defaultModel = registry.getDefaultModelForAuthType(
|
||||
AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(defaultModel?.id).toBe('coder-model');
|
||||
});
|
||||
|
||||
it('should return first model for other authTypes', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{ id: 'gpt-4', name: 'GPT-4' },
|
||||
{ id: 'gpt-3.5', name: 'GPT-3.5' },
|
||||
],
|
||||
});
|
||||
|
||||
const defaultModel = registry.getDefaultModelForAuthType(
|
||||
AuthType.USE_OPENAI,
|
||||
);
|
||||
expect(defaultModel?.id).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableAuthTypes', () => {
|
||||
it('should return all configured authTypes', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
|
||||
const authTypes = registry.getAvailableAuthTypes();
|
||||
expect(authTypes).toContain(AuthType.QWEN_OAUTH);
|
||||
expect(authTypes).toContain(AuthType.USE_OPENAI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should throw error for model without id', () => {
|
||||
expect(
|
||||
() =>
|
||||
new ModelRegistry({
|
||||
openai: [{ id: '', name: 'No ID' }],
|
||||
}),
|
||||
).toThrow('missing required field: id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default base URLs', () => {
|
||||
it('should apply default dashscope URL for qwen-oauth', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const model = registry.getModel(AuthType.QWEN_OAUTH, 'coder-model');
|
||||
expect(model?.baseUrl).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply default openai URL when not specified', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
|
||||
expect(model?.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
it('should use custom baseUrl when specified', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const model = registry.getModel(AuthType.USE_OPENAI, 'deepseek');
|
||||
expect(model?.baseUrl).toBe('https://api.deepseek.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAuthTypesForModel', () => {
|
||||
it('should return empty array for non-existent model', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const authTypes = registry.findAuthTypesForModel('non-existent');
|
||||
expect(authTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return authTypes that have the model', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'gpt-4', name: 'GPT-4' }],
|
||||
});
|
||||
|
||||
const authTypes = registry.findAuthTypesForModel('gpt-4');
|
||||
expect(authTypes).toContain(AuthType.USE_OPENAI);
|
||||
expect(authTypes.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return multiple authTypes if model exists in multiple', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'shared-model', name: 'Shared' }],
|
||||
'gemini-api-key': [{ id: 'shared-model', name: 'Shared Gemini' }],
|
||||
});
|
||||
|
||||
const authTypes = registry.findAuthTypesForModel('shared-model');
|
||||
expect(authTypes.length).toBe(2);
|
||||
expect(authTypes).toContain(AuthType.USE_OPENAI);
|
||||
expect(authTypes).toContain(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('should prioritize preferred authType in results', () => {
|
||||
const registry = new ModelRegistry({
|
||||
openai: [{ id: 'shared-model', name: 'Shared' }],
|
||||
'gemini-api-key': [{ id: 'shared-model', name: 'Shared Gemini' }],
|
||||
});
|
||||
|
||||
const authTypes = registry.findAuthTypesForModel(
|
||||
'shared-model',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(authTypes[0]).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('should handle qwen-oauth models', () => {
|
||||
const registry = new ModelRegistry();
|
||||
const authTypes = registry.findAuthTypesForModel('coder-model');
|
||||
expect(authTypes).toContain(AuthType.QWEN_OAUTH);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
packages/core/src/models/modelRegistry.ts
Normal file
268
packages/core/src/models/modelRegistry.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import {
|
||||
type ModelConfig,
|
||||
type ModelProvidersConfig,
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelGenerationConfig,
|
||||
DEFAULT_GENERATION_CONFIG,
|
||||
DEFAULT_BASE_URLS,
|
||||
} from './types.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
|
||||
/**
|
||||
* Hard-coded Qwen OAuth models that are always available.
|
||||
* These cannot be overridden by user configuration.
|
||||
*/
|
||||
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
|
||||
{
|
||||
id: 'coder-model',
|
||||
name: 'Qwen Coder',
|
||||
description:
|
||||
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
|
||||
capabilities: { vision: false },
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 8192,
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vision-model',
|
||||
name: 'Qwen Vision',
|
||||
description:
|
||||
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
|
||||
capabilities: { vision: true },
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 8192,
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Central registry for managing model configurations.
|
||||
* Models are organized by authType.
|
||||
*/
|
||||
export class ModelRegistry {
|
||||
private modelsByAuthType: Map<AuthType, Map<string, ResolvedModelConfig>>;
|
||||
// Reverse index for O(1) model lookups: modelId -> authTypes[]
|
||||
private modelIdToAuthTypes: Map<string, AuthType[]>;
|
||||
|
||||
constructor(modelProvidersConfig?: ModelProvidersConfig) {
|
||||
this.modelsByAuthType = new Map();
|
||||
this.modelIdToAuthTypes = new Map();
|
||||
|
||||
// Always register qwen-oauth models (hard-coded, cannot be overridden)
|
||||
this.registerAuthTypeModels(AuthType.QWEN_OAUTH, QWEN_OAUTH_MODELS);
|
||||
|
||||
// Register user-configured models for other authTypes
|
||||
if (modelProvidersConfig) {
|
||||
for (const [authType, models] of Object.entries(modelProvidersConfig)) {
|
||||
// Skip qwen-oauth as it uses hard-coded models
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authTypeEnum = authType as AuthType;
|
||||
this.registerAuthTypeModels(authTypeEnum, models);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register models for an authType
|
||||
*/
|
||||
private registerAuthTypeModels(
|
||||
authType: AuthType,
|
||||
models: ModelConfig[],
|
||||
): void {
|
||||
const modelMap = new Map<string, ResolvedModelConfig>();
|
||||
|
||||
for (const config of models) {
|
||||
const resolved = this.resolveModelConfig(config, authType);
|
||||
modelMap.set(config.id, resolved);
|
||||
|
||||
// Update reverse index
|
||||
const existingAuthTypes = this.modelIdToAuthTypes.get(config.id) || [];
|
||||
existingAuthTypes.push(authType);
|
||||
this.modelIdToAuthTypes.set(config.id, existingAuthTypes);
|
||||
}
|
||||
|
||||
this.modelsByAuthType.set(authType, modelMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models for a specific authType.
|
||||
* This is used by /model command to show only relevant models.
|
||||
*/
|
||||
getModelsForAuthType(authType: AuthType): AvailableModel[] {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
if (!models) return [];
|
||||
|
||||
return Array.from(models.values()).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
capabilities: model.capabilities,
|
||||
authType: model.authType,
|
||||
isVision: model.capabilities?.vision ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available authTypes that have models configured
|
||||
*/
|
||||
getAvailableAuthTypes(): AuthType[] {
|
||||
return Array.from(this.modelsByAuthType.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model configuration by authType and modelId
|
||||
*/
|
||||
getModel(
|
||||
authType: AuthType,
|
||||
modelId: string,
|
||||
): ResolvedModelConfig | undefined {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
return models?.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model exists for given authType
|
||||
*/
|
||||
hasModel(authType: AuthType, modelId: string): boolean {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
return models?.has(modelId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first model for an authType (used as default)
|
||||
*/
|
||||
getFirstModelForAuthType(
|
||||
authType: AuthType,
|
||||
): ResolvedModelConfig | undefined {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
if (!models || models.size === 0) return undefined;
|
||||
return Array.from(models.values())[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for an authType.
|
||||
* For qwen-oauth, returns the coder model.
|
||||
* For others, returns the first configured model.
|
||||
*/
|
||||
getDefaultModelForAuthType(
|
||||
authType: AuthType,
|
||||
): ResolvedModelConfig | undefined {
|
||||
if (authType === AuthType.QWEN_OAUTH) {
|
||||
return this.getModel(authType, DEFAULT_QWEN_MODEL);
|
||||
}
|
||||
return this.getFirstModelForAuthType(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model config by applying defaults
|
||||
*/
|
||||
private resolveModelConfig(
|
||||
config: ModelConfig,
|
||||
authType: AuthType,
|
||||
): ResolvedModelConfig {
|
||||
this.validateModelConfig(config, authType);
|
||||
|
||||
const defaultBaseUrl = DEFAULT_BASE_URLS[authType] || '';
|
||||
|
||||
return {
|
||||
...config,
|
||||
authType,
|
||||
name: config.name || config.id,
|
||||
baseUrl: config.baseUrl || defaultBaseUrl,
|
||||
generationConfig: this.mergeGenerationConfig(config.generationConfig),
|
||||
capabilities: config.capabilities || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge generation config with defaults
|
||||
*/
|
||||
private mergeGenerationConfig(
|
||||
config?: ModelGenerationConfig,
|
||||
): ModelGenerationConfig {
|
||||
if (!config) {
|
||||
return { ...DEFAULT_GENERATION_CONFIG };
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_GENERATION_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model configuration
|
||||
*/
|
||||
private validateModelConfig(config: ModelConfig, authType: AuthType): void {
|
||||
if (!config.id) {
|
||||
throw new Error(
|
||||
`Model config in authType '${authType}' missing required field: id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the registry has any models for a given authType
|
||||
*/
|
||||
hasAuthType(authType: AuthType): boolean {
|
||||
const models = this.modelsByAuthType.get(authType);
|
||||
return models !== undefined && models.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of models across all authTypes
|
||||
*/
|
||||
getTotalModelCount(): number {
|
||||
let count = 0;
|
||||
for (const models of this.modelsByAuthType.values()) {
|
||||
count += models.size;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all authTypes that have a model with the given modelId.
|
||||
* Uses reverse index for O(1) lookup.
|
||||
* Returns empty array if model doesn't exist.
|
||||
*
|
||||
* @param modelId - The model ID to search for
|
||||
* @param preferredAuthType - Optional authType to prioritize in results
|
||||
* @returns Array of authTypes that have this model (preferred authType first if found)
|
||||
*/
|
||||
findAuthTypesForModel(
|
||||
modelId: string,
|
||||
preferredAuthType?: AuthType,
|
||||
): AuthType[] {
|
||||
const authTypes = this.modelIdToAuthTypes.get(modelId) || [];
|
||||
|
||||
// If no preferred authType or it's not in the list, return as-is
|
||||
if (!preferredAuthType || !authTypes.includes(preferredAuthType)) {
|
||||
return authTypes;
|
||||
}
|
||||
|
||||
// Move preferred authType to front
|
||||
return [
|
||||
preferredAuthType,
|
||||
...authTypes.filter((at) => at !== preferredAuthType),
|
||||
];
|
||||
}
|
||||
}
|
||||
235
packages/core/src/models/modelSelectionManager.test.ts
Normal file
235
packages/core/src/models/modelSelectionManager.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ModelSelectionManager } from './modelSelectionManager.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { SelectionSource } from './types.js';
|
||||
import type { ModelProvidersConfig } from './types.js';
|
||||
|
||||
describe('ModelSelectionManager', () => {
|
||||
let manager: ModelSelectionManager;
|
||||
|
||||
const defaultConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
name: 'GPT-4 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
id: 'gpt-3.5-turbo',
|
||||
name: 'GPT-3.5 Turbo',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
},
|
||||
{
|
||||
id: 'deepseek-coder',
|
||||
name: 'DeepSeek Coder',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default qwen-oauth authType and coder-model', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
});
|
||||
|
||||
expect(manager.getCurrentAuthType()).toBe(AuthType.QWEN_OAUTH);
|
||||
expect(manager.getCurrentModelId()).toBe('coder-model');
|
||||
expect(manager.getSelectionSource()).toBe(SelectionSource.DEFAULT);
|
||||
});
|
||||
|
||||
it('should initialize with specified authType and model', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'gpt-4-turbo',
|
||||
});
|
||||
|
||||
expect(manager.getCurrentAuthType()).toBe(AuthType.USE_OPENAI);
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-4-turbo');
|
||||
expect(manager.getSelectionSource()).toBe(SelectionSource.SETTINGS);
|
||||
});
|
||||
|
||||
it('should fallback to default model if specified model not found', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'non-existent',
|
||||
});
|
||||
|
||||
expect(manager.getCurrentAuthType()).toBe(AuthType.USE_OPENAI);
|
||||
// Should fallback to first model
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-4-turbo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switchModel', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'gpt-4-turbo',
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch model within same authType', async () => {
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.USER_MANUAL);
|
||||
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-3.5-turbo');
|
||||
expect(manager.getCurrentAuthType()).toBe(AuthType.USE_OPENAI);
|
||||
});
|
||||
|
||||
it('should update selection source on switch', async () => {
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.USER_MANUAL);
|
||||
|
||||
expect(manager.getSelectionSource()).toBe(SelectionSource.USER_MANUAL);
|
||||
});
|
||||
|
||||
it('should call onModelChange callback', async () => {
|
||||
const onModelChange = vi.fn();
|
||||
manager.setOnModelChange(onModelChange);
|
||||
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.USER_MANUAL);
|
||||
|
||||
expect(onModelChange).toHaveBeenCalledTimes(1);
|
||||
expect(onModelChange).toHaveBeenCalledWith(
|
||||
AuthType.USE_OPENAI,
|
||||
expect.objectContaining({ id: 'gpt-3.5-turbo' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent model', async () => {
|
||||
await expect(
|
||||
manager.switchModel('non-existent', SelectionSource.USER_MANUAL),
|
||||
).rejects.toThrow('not found for authType');
|
||||
});
|
||||
|
||||
it('should allow any source to override previous selection', async () => {
|
||||
// First set to USER_MANUAL
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.USER_MANUAL);
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-3.5-turbo');
|
||||
|
||||
// Should allow PROGRAMMATIC_OVERRIDE to override USER_MANUAL
|
||||
await manager.switchModel(
|
||||
'gpt-4-turbo',
|
||||
SelectionSource.PROGRAMMATIC_OVERRIDE,
|
||||
);
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-4-turbo');
|
||||
|
||||
// Should allow SETTINGS to override PROGRAMMATIC_OVERRIDE
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.SETTINGS);
|
||||
expect(manager.getCurrentModelId()).toBe('gpt-3.5-turbo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return models for current authType', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
});
|
||||
|
||||
const models = manager.getAvailableModels();
|
||||
expect(models.length).toBe(3);
|
||||
expect(models.map((m) => m.id)).toContain('gpt-4-turbo');
|
||||
});
|
||||
|
||||
it('should return qwen-oauth models by default', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
});
|
||||
|
||||
const models = manager.getAvailableModels();
|
||||
expect(models.some((m) => m.id === 'coder-model')).toBe(true);
|
||||
expect(models.some((m) => m.id === 'vision-model')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableAuthTypes', () => {
|
||||
it('should return all available authTypes', () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
});
|
||||
|
||||
const authTypes = manager.getAvailableAuthTypes();
|
||||
expect(authTypes).toContain(AuthType.QWEN_OAUTH);
|
||||
expect(authTypes).toContain(AuthType.USE_OPENAI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentModel', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'gpt-4-turbo',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return current model info', () => {
|
||||
const modelInfo = manager.getCurrentModel();
|
||||
|
||||
expect(modelInfo.authType).toBe(AuthType.USE_OPENAI);
|
||||
expect(modelInfo.modelId).toBe('gpt-4-turbo');
|
||||
expect(modelInfo.model.id).toBe('gpt-4-turbo');
|
||||
expect(modelInfo.selectionSource).toBe(SelectionSource.SETTINGS);
|
||||
});
|
||||
|
||||
it('should throw error if no model selected', () => {
|
||||
// Create manager with invalid initial state
|
||||
const mgr = new ModelSelectionManager({
|
||||
modelProvidersConfig: { openai: [] },
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
});
|
||||
|
||||
expect(() => mgr.getCurrentModel()).toThrow('No model selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection timestamp', () => {
|
||||
it('should update timestamp on model switch', async () => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
initialModelId: 'gpt-4-turbo',
|
||||
});
|
||||
|
||||
const initialTimestamp = manager.getSelectionTimestamp();
|
||||
|
||||
// Wait a small amount to ensure timestamp changes
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await manager.switchModel('gpt-3.5-turbo', SelectionSource.USER_MANUAL);
|
||||
|
||||
expect(manager.getSelectionTimestamp()).toBeGreaterThan(initialTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegation methods', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ModelSelectionManager({
|
||||
modelProvidersConfig: defaultConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delegate hasModel to registry', () => {
|
||||
expect(manager.hasModel(AuthType.QWEN_OAUTH, 'coder-model')).toBe(true);
|
||||
expect(manager.hasModel(AuthType.QWEN_OAUTH, 'non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should delegate getModel to registry', () => {
|
||||
const model = manager.getModel(AuthType.QWEN_OAUTH, 'coder-model');
|
||||
expect(model).toBeDefined();
|
||||
expect(model?.id).toBe('coder-model');
|
||||
|
||||
const nonExistent = manager.getModel(AuthType.QWEN_OAUTH, 'non-existent');
|
||||
expect(nonExistent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
251
packages/core/src/models/modelSelectionManager.ts
Normal file
251
packages/core/src/models/modelSelectionManager.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { ModelRegistry } from './modelRegistry.js';
|
||||
import {
|
||||
type ResolvedModelConfig,
|
||||
type AvailableModel,
|
||||
type ModelSwitchMetadata,
|
||||
type CurrentModelInfo,
|
||||
type ModelProvidersConfig,
|
||||
SelectionSource,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Callback type for when the model changes.
|
||||
* This is used to notify Config to update the ContentGenerator.
|
||||
*/
|
||||
export type ModelChangeCallback = (
|
||||
authType: AuthType,
|
||||
model: ResolvedModelConfig,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Options for initializing the ModelSelectionManager
|
||||
*/
|
||||
export interface ModelSelectionManagerOptions {
|
||||
/** Initial authType from persisted settings */
|
||||
initialAuthType?: AuthType;
|
||||
/** Initial model ID from persisted settings */
|
||||
initialModelId?: string;
|
||||
/** Callback when model changes */
|
||||
onModelChange?: ModelChangeCallback;
|
||||
/** Model providers configuration for creating ModelRegistry */
|
||||
modelProvidersConfig?: ModelProvidersConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages model and auth selection with persistence.
|
||||
* Two-level selection: authType → model
|
||||
*/
|
||||
export class ModelSelectionManager {
|
||||
private modelRegistry: ModelRegistry;
|
||||
|
||||
// Current selection state
|
||||
private currentAuthType: AuthType;
|
||||
private currentModelId: string;
|
||||
|
||||
// Selection metadata for tracking and observability
|
||||
private selectionSource: SelectionSource = SelectionSource.DEFAULT;
|
||||
private selectionTimestamp: number = Date.now();
|
||||
|
||||
// Callback for model changes
|
||||
private onModelChange?: ModelChangeCallback;
|
||||
|
||||
constructor(options: ModelSelectionManagerOptions = {}) {
|
||||
// Create ModelRegistry internally - it's an implementation detail
|
||||
this.modelRegistry = new ModelRegistry(options.modelProvidersConfig);
|
||||
this.onModelChange = options.onModelChange;
|
||||
|
||||
// Initialize from options or use defaults
|
||||
this.currentAuthType = options.initialAuthType || AuthType.QWEN_OAUTH;
|
||||
this.currentModelId = options.initialModelId || '';
|
||||
|
||||
// Validate and initialize selection
|
||||
this.initializeDefaultSelection(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default selection
|
||||
*/
|
||||
private initializeDefaultSelection(
|
||||
_options: ModelSelectionManagerOptions,
|
||||
): void {
|
||||
// Check if persisted model selection is valid
|
||||
if (
|
||||
this.currentModelId &&
|
||||
this.modelRegistry.hasModel(this.currentAuthType, this.currentModelId)
|
||||
) {
|
||||
this.selectionSource = SelectionSource.SETTINGS;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check environment variables (backward compatibility)
|
||||
const envModel = this.getModelFromEnvironment();
|
||||
if (
|
||||
envModel &&
|
||||
this.modelRegistry.hasModel(this.currentAuthType, envModel)
|
||||
) {
|
||||
this.currentModelId = envModel;
|
||||
this.selectionSource = SelectionSource.ENVIRONMENT;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use registry default (first model for current authType)
|
||||
const defaultModel = this.modelRegistry.getDefaultModelForAuthType(
|
||||
this.currentAuthType,
|
||||
);
|
||||
if (defaultModel) {
|
||||
this.currentModelId = defaultModel.id;
|
||||
this.selectionSource = SelectionSource.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model from environment variables (backward compatibility)
|
||||
*/
|
||||
private getModelFromEnvironment(): string | undefined {
|
||||
// Support legacy OPENAI_MODEL env var for openai authType
|
||||
if (this.currentAuthType === AuthType.USE_OPENAI) {
|
||||
return process.env['OPENAI_MODEL'];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch model within current authType.
|
||||
* This updates model name and generation config.
|
||||
*/
|
||||
async switchModel(
|
||||
modelId: string,
|
||||
source: SelectionSource,
|
||||
_metadata?: ModelSwitchMetadata,
|
||||
): Promise<void> {
|
||||
// Validate model exists for current authType
|
||||
const model = this.modelRegistry.getModel(this.currentAuthType, modelId);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Model '${modelId}' not found for authType '${this.currentAuthType}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// Store previous model for rollback if needed
|
||||
const previousModelId = this.currentModelId;
|
||||
|
||||
try {
|
||||
// Update selection state
|
||||
this.currentModelId = modelId;
|
||||
this.selectionSource = source;
|
||||
this.selectionTimestamp = Date.now();
|
||||
|
||||
// Notify about the change
|
||||
if (this.onModelChange) {
|
||||
await this.onModelChange(this.currentAuthType, model);
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
this.currentModelId = previousModelId;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for current authType.
|
||||
* Used by /model command to show only relevant models.
|
||||
*/
|
||||
getAvailableModels(): AvailableModel[] {
|
||||
return this.modelRegistry.getModelsForAuthType(this.currentAuthType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available authTypes.
|
||||
* Used by /auth command.
|
||||
*/
|
||||
getAvailableAuthTypes(): AuthType[] {
|
||||
return this.modelRegistry.getAvailableAuthTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authType
|
||||
*/
|
||||
getCurrentAuthType(): AuthType {
|
||||
return this.currentAuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current model ID
|
||||
*/
|
||||
getCurrentModelId(): string {
|
||||
return this.currentModelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current model information
|
||||
*/
|
||||
getCurrentModel(): CurrentModelInfo {
|
||||
if (!this.currentModelId) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
const model = this.modelRegistry.getModel(
|
||||
this.currentAuthType,
|
||||
this.currentModelId,
|
||||
);
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`Current model '${this.currentModelId}' not found for authType '${this.currentAuthType}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
authType: this.currentAuthType,
|
||||
modelId: this.currentModelId,
|
||||
model,
|
||||
selectionSource: this.selectionSource,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists for the given authType.
|
||||
* Delegates to ModelRegistry.
|
||||
*/
|
||||
hasModel(authType: AuthType, modelId: string): boolean {
|
||||
return this.modelRegistry.hasModel(authType, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model configuration by authType and modelId.
|
||||
* Delegates to ModelRegistry.
|
||||
*/
|
||||
getModel(
|
||||
authType: AuthType,
|
||||
modelId: string,
|
||||
): ResolvedModelConfig | undefined {
|
||||
return this.modelRegistry.getModel(authType, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current selection source
|
||||
*/
|
||||
getSelectionSource(): SelectionSource {
|
||||
return this.selectionSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp of when the current selection was made
|
||||
*/
|
||||
getSelectionTimestamp(): number {
|
||||
return this.selectionTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the onModelChange callback
|
||||
*/
|
||||
setOnModelChange(callback: ModelChangeCallback): void {
|
||||
this.onModelChange = callback;
|
||||
}
|
||||
}
|
||||
154
packages/core/src/models/types.ts
Normal file
154
packages/core/src/models/types.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AuthType } from '../core/contentGenerator.js';
|
||||
|
||||
/**
|
||||
* Model capabilities configuration
|
||||
*/
|
||||
export interface ModelCapabilities {
|
||||
/** Supports image/vision inputs */
|
||||
vision?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generation configuration for model sampling parameters
|
||||
*/
|
||||
export interface ModelGenerationConfig {
|
||||
/** Temperature for sampling (0.0 - 2.0) */
|
||||
temperature?: number;
|
||||
/** Top-p for nucleus sampling (0.0 - 1.0) */
|
||||
top_p?: number;
|
||||
/** Top-k for sampling */
|
||||
top_k?: number;
|
||||
/** Maximum output tokens */
|
||||
max_tokens?: number;
|
||||
/** Presence penalty (-2.0 - 2.0) */
|
||||
presence_penalty?: number;
|
||||
/** Frequency penalty (-2.0 - 2.0) */
|
||||
frequency_penalty?: number;
|
||||
/** Repetition penalty (provider-specific) */
|
||||
repetition_penalty?: number;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
/** Disable cache control for DashScope providers */
|
||||
disableCacheControl?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model configuration for a single model within an authType
|
||||
*/
|
||||
export interface ModelConfig {
|
||||
/** Unique model ID within authType (e.g., "qwen-coder", "gpt-4-turbo") */
|
||||
id: string;
|
||||
/** Display name (defaults to id) */
|
||||
name?: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Environment variable name to read API key from (e.g., "OPENAI_API_KEY") */
|
||||
envKey?: string;
|
||||
/** API endpoint override */
|
||||
baseUrl?: string;
|
||||
/** Model capabilities */
|
||||
capabilities?: ModelCapabilities;
|
||||
/** Generation configuration (sampling parameters) */
|
||||
generationConfig?: ModelGenerationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model providers configuration grouped by authType
|
||||
*/
|
||||
export type ModelProvidersConfig = {
|
||||
[authType: string]: ModelConfig[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved model config with all defaults applied
|
||||
*/
|
||||
export interface ResolvedModelConfig extends ModelConfig {
|
||||
/** AuthType this model belongs to (always present from map key) */
|
||||
authType: AuthType;
|
||||
/** Display name (always present, defaults to id) */
|
||||
name: string;
|
||||
/** Environment variable name to read API key from (optional, provider-specific) */
|
||||
envKey?: string;
|
||||
/** API base URL (always present, has default per authType) */
|
||||
baseUrl: string;
|
||||
/** Generation config (always present, merged with defaults) */
|
||||
generationConfig: ModelGenerationConfig;
|
||||
/** Capabilities (always present, defaults to {}) */
|
||||
capabilities: ModelCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model info for UI display
|
||||
*/
|
||||
export interface AvailableModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
capabilities?: ModelCapabilities;
|
||||
authType: AuthType;
|
||||
isVision?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection source for tracking and observability.
|
||||
* This tracks how a model was selected but does not enforce any priority rules.
|
||||
*/
|
||||
export enum SelectionSource {
|
||||
/** Default selection (first model in registry) */
|
||||
DEFAULT = 'default',
|
||||
/** From environment variables */
|
||||
ENVIRONMENT = 'environment',
|
||||
/** From settings.json */
|
||||
SETTINGS = 'settings',
|
||||
/** Programmatic override (e.g., VLM auto-switch, control requests) */
|
||||
PROGRAMMATIC_OVERRIDE = 'programmatic_override',
|
||||
/** User explicitly switched via /model command */
|
||||
USER_MANUAL = 'user_manual',
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for model switch operations
|
||||
*/
|
||||
export interface ModelSwitchMetadata {
|
||||
/** Reason for the switch */
|
||||
reason?: string;
|
||||
/** Additional context */
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current model information
|
||||
*/
|
||||
export interface CurrentModelInfo {
|
||||
authType: AuthType;
|
||||
modelId: string;
|
||||
model: ResolvedModelConfig;
|
||||
selectionSource: SelectionSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default generation configuration values
|
||||
*/
|
||||
export const DEFAULT_GENERATION_CONFIG: ModelGenerationConfig = {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
max_tokens: 4096,
|
||||
timeout: 60000,
|
||||
maxRetries: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default base URLs per authType
|
||||
*/
|
||||
export const DEFAULT_BASE_URLS: Partial<Record<AuthType, string>> = {
|
||||
'qwen-oauth': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
};
|
||||
Reference in New Issue
Block a user