qwen-code/packages/core/src/models/modelRegistry.test.ts
callmeYe 2ddf9d2758
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
Qwen Code CI / Integration Tests (No-AK Smoke) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
feat(core): add fastOnly/voiceOnly flags to hide models from main model list (#5632)
* feat(core): add fastOnly flag to hide models from main model list

Models configured with `fastOnly: true` in modelProviders now only
appear in the fast model selector (/model --fast) and are hidden from
the main model list and /model command.

* feat(core): add voiceOnly flag to hide models from main model list

Mirrors the fastOnly flag: models configured with `voiceOnly: true` in
modelProviders only appear in the voice model selector (/model --voice)
and are hidden from the main model list.

* fix(core): address review feedback for fastOnly/voiceOnly

- Cross-filter: --fast path excludes voiceOnly, --voice path excludes
  fastOnly models
- Remove redundant explicit assignments in resolveModelConfig (spread
  already copies them)
- Filter fastOnly/voiceOnly from tab completion suggestions
- Warn when both fastOnly and voiceOnly are set on the same model

* test(core): add unit tests for fastOnly/voiceOnly filtering

Cover: flag propagation through ModelRegistry, cross-filtering in
--fast/--voice CLI paths, and rejection of specialized models from
normal /model selection.

* test(cli): strengthen fastOnly/voiceOnly test assertions and add --voice tests

- Error assertions now check content includes model name
- Add test: voiceOnly model visible in --voice selection
- Add test: fastOnly model rejected from --voice selection

* fix(cli): make tab completion context-aware for fastOnly/voiceOnly

Tab completion for /model --fast now includes fastOnly models, and
/model --voice includes voiceOnly models. The filter is mode-aware:
main mode excludes both, fast mode excludes voiceOnly, voice mode
excludes fastOnly.
2026-06-23 11:13:38 +08:00

1176 lines
36 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
ModelRegistry,
QWEN_OAUTH_MODELS,
modelRegistryKey,
} from './modelRegistry.js';
import { AuthType, Protocol } 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');
});
it('should initialize with empty config', () => {
const registry = new ModelRegistry();
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
});
it('should initialize with custom models config', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
},
],
},
};
const registry = new ModelRegistry(modelProvidersConfig);
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': {
protocol: Protocol.QWEN_OAUTH,
models: [
{
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: {
protocol: Protocol.OPENAI,
models: [
{
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: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
generationConfig: {
samplingParams: {
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 preserve generationConfig without applying defaults', () => {
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
expect(model?.generationConfig.samplingParams?.temperature).toBe(0.8);
expect(model?.generationConfig.samplingParams?.max_tokens).toBe(4096);
// No defaults are applied - only the configured values are present
expect(model?.generationConfig.samplingParams?.top_p).toBeUndefined();
expect(model?.generationConfig.timeout).toBeUndefined();
});
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('modalities auto-fill', () => {
// Sub-agents that read straight from the registry (e.g. via
// getResolvedModel) need the registry to populate modalities for them;
// otherwise they inherit the parent session's modalities and fail the
// image/pdf/video gates set on tools like ReadFile.
it('populates modalities from the model name when not provided', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
generationConfig: {},
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
expect(model?.generationConfig.modalities).toEqual({ image: true });
});
it('preserves caller-provided modalities verbatim', () => {
const explicitModalities = { image: true, pdf: true };
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4-turbo',
name: 'GPT-4 Turbo',
baseUrl: 'https://api.openai.com/v1',
generationConfig: { modalities: explicitModalities },
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4-turbo');
expect(model?.generationConfig.modalities).toEqual(explicitModalities);
});
it('returns text-only ({}) for models with no multimodal default', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
baseUrl: 'https://example.invalid',
generationConfig: {},
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'qwen3-coder-plus');
expect(model?.generationConfig.modalities).toEqual({});
});
it('populates MiniMax-M3 metadata when provider entries omit generationConfig', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'MiniMax-M3',
name: '[MiniMax] MiniMax-M3',
baseUrl: 'https://api.minimaxi.com/v1',
envKey: 'MINIMAX_API_KEY',
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'MiniMax-M3');
expect(model?.generationConfig.modalities).toEqual({
image: true,
video: true,
});
const available = registry
.getModelsForAuthType(AuthType.USE_OPENAI)
.find((m) => m.id === 'MiniMax-M3');
expect(available?.contextWindowSize).toBe(1000000);
expect(available?.modalities).toEqual({
image: true,
video: true,
});
});
it('normalizes stale MiniMax-M3 provider modalities to image + video', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'MiniMax-M3',
name: '[MiniMax] MiniMax-M3',
baseUrl: 'https://api.minimaxi.com/v1',
envKey: 'MINIMAX_API_KEY',
generationConfig: {
modalities: { image: true },
},
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'MiniMax-M3');
expect(model?.generationConfig.modalities).toEqual({
image: true,
video: true,
});
});
});
describe('hasModel', () => {
let registry: ModelRegistry;
beforeEach(() => {
registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ 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('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: {
protocol: Protocol.OPENAI,
models: [
{ 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('validation', () => {
it('should throw error for model without id', () => {
expect(
() =>
new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ 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('DYNAMIC_QWEN_OAUTH_BASE_URL');
});
it('should apply default openai URL when not specified', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ 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: {
protocol: Protocol.OPENAI,
models: [
{
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('authType key validation', () => {
it('should accept valid authType keys', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
gemini: {
protocol: Protocol.GEMINI,
models: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
});
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
expect(openaiModels[0].id).toBe('gpt-4');
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
expect(geminiModels[0].id).toBe('gemini-pro');
});
it('should skip invalid authType keys', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
'': {
protocol: Protocol.OPENAI,
models: [{ id: 'some-model', name: 'Some Model' }],
},
} as unknown as ModelProvidersConfig);
// Valid key should be registered
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
// Invalid key should be skipped (no crash)
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
});
it('should handle mixed valid and invalid keys', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
' ': {
protocol: Protocol.OPENAI,
models: [{ id: 'model-1', name: 'Model 1' }],
},
gemini: {
protocol: Protocol.GEMINI,
models: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
' ': {
protocol: Protocol.OPENAI,
models: [{ id: 'model-2', name: 'Model 2' }],
},
} as unknown as ModelProvidersConfig);
// Valid keys should be registered
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1);
// Invalid keys should be skipped
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
});
it('should work correctly with getModelsForAuthType after validation', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
},
'': {
protocol: Protocol.OPENAI,
models: [{ id: 'invalid-model', name: 'Invalid Model' }],
},
} as unknown as ModelProvidersConfig);
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
expect(models.find((m) => m.id === 'gpt-4')).toBeDefined();
expect(models.find((m) => m.id === 'gpt-3.5')).toBeDefined();
expect(models.find((m) => m.id === 'invalid-model')).toBeUndefined();
});
});
describe('duplicate model id handling', () => {
it('should skip duplicate model ids (same id, no baseUrl) and use first registered config', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'gpt-4', name: 'GPT-4 First', description: 'First config' },
{ id: 'gpt-4', name: 'GPT-4 Second', description: 'Second config' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
const gpt4 = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
expect(gpt4).toBeDefined();
expect(gpt4?.name).toBe('GPT-4 First');
expect(gpt4?.description).toBe('First config');
});
it('should skip duplicate when both id and baseUrl match', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'First',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'Second',
baseUrl: 'https://api.openai.com/v1',
},
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(1);
expect(models[0].label).toBe('First');
});
it('should allow same id with different baseUrls as distinct models', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
expect(models[0].label).toBe('GPT-4 Direct');
expect(models[1].label).toBe('GPT-4 Proxy');
});
it('should retrieve model by id and baseUrl precisely', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
const direct = registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://api.openai.com/v1',
);
expect(direct?.name).toBe('GPT-4 Direct');
const proxy = registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://proxy.example.com/v1',
);
expect(proxy?.name).toBe('GPT-4 Proxy');
});
it('should return first match when getModel is called without baseUrl', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
const model = registry.getModel(AuthType.USE_OPENAI, 'gpt-4');
expect(model).toBeDefined();
expect(model?.name).toBe('GPT-4 Direct');
});
it('should handle hasModel with and without baseUrl', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
expect(registry.hasModel(AuthType.USE_OPENAI, 'gpt-4')).toBe(true);
expect(
registry.hasModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://api.openai.com/v1',
),
).toBe(true);
expect(
registry.hasModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://proxy.example.com/v1',
),
).toBe(true);
expect(
registry.hasModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://unknown.example.com/v1',
),
).toBe(false);
});
it('should handle multiple duplicate ids in same authType', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'model-a', name: 'Model A First' },
{ id: 'model-a', name: 'Model A Second' },
{ id: 'model-b', name: 'Model B First' },
{ id: 'model-b', name: 'Model B Second' },
{ id: 'model-c', name: 'Model C' },
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(3);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe(
'Model A First',
);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-b')?.name).toBe(
'Model B First',
);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-c')?.name).toBe(
'Model C',
);
});
it('should treat same id in different authTypes as different models', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'shared-model', name: 'OpenAI Shared' }],
},
gemini: {
protocol: Protocol.GEMINI,
models: [{ id: 'shared-model', name: 'Gemini Shared' }],
},
});
const openaiModel = registry.getModel(
AuthType.USE_OPENAI,
'shared-model',
);
const geminiModel = registry.getModel(
AuthType.USE_GEMINI,
'shared-model',
);
expect(openaiModel?.name).toBe('OpenAI Shared');
expect(geminiModel?.name).toBe('Gemini Shared');
});
});
describe('reloadModels', () => {
it('should reload models from new config', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeDefined();
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeUndefined();
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
},
});
// After reload, only new models should exist
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')).toBeUndefined();
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined();
});
it('should preserve hard-coded qwen-oauth models after reload', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
},
});
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
expect(
registry.getModel(AuthType.QWEN_OAUTH, 'coder-model'),
).toBeDefined();
});
it('should clear user-configured models when reload with empty config', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
gemini: {
protocol: Protocol.GEMINI,
models: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
});
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(1);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(1);
registry.reloadModels({});
// All user-configured models should be cleared
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
expect(registry.getModelsForAuthType(AuthType.USE_GEMINI).length).toBe(0);
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
});
it('should ignore qwen-oauth models in reload config', () => {
const registry = new ModelRegistry();
registry.reloadModels({
'qwen-oauth': {
protocol: Protocol.QWEN_OAUTH,
models: [{ id: 'custom-qwen', name: 'Custom Qwen' }],
},
});
// qwen-oauth should still use hard-coded 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();
});
it('should handle reload with multiple authTypes', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'gpt-4', name: 'GPT-4 Updated' },
{ id: 'gpt-3.5', name: 'GPT-3.5' },
],
},
gemini: {
protocol: Protocol.GEMINI,
models: [{ id: 'gemini-pro', name: 'Gemini Pro' }],
},
});
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(2);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-4')?.name).toBe(
'GPT-4 Updated',
);
const geminiModels = registry.getModelsForAuthType(AuthType.USE_GEMINI);
expect(geminiModels.length).toBe(1);
});
it('should skip invalid authType keys during reload', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-3.5', name: 'GPT-3.5' }],
},
'': {
protocol: Protocol.OPENAI,
models: [{ id: 'invalid-model', name: 'Invalid Model' }],
},
} as unknown as ModelProvidersConfig);
const openaiModels = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(openaiModels.length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'gpt-3.5')).toBeDefined();
});
it('should correctly reload same-id different-baseUrl models', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'Old Direct',
baseUrl: 'https://api.openai.com/v1',
},
],
},
});
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'New Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'New Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
expect(
registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://api.openai.com/v1',
)?.name,
).toBe('New Direct');
expect(
registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://proxy.example.com/v1',
)?.name,
).toBe('New Proxy');
});
it('should handle reload with undefined config', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
registry.reloadModels(undefined);
// All user-configured models should be cleared
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(0);
// qwen-oauth models should still exist
expect(registry.getModelsForAuthType(AuthType.QWEN_OAUTH).length).toBe(
QWEN_OAUTH_MODELS.length,
);
});
it('should handle reload replacing same-id entries when baseUrls change', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 v1',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://old-proxy.example.com/v1',
},
],
},
});
expect(registry.getModelsForAuthType(AuthType.USE_OPENAI).length).toBe(2);
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 v1 updated',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 New Proxy',
baseUrl: 'https://new-proxy.example.com/v1',
},
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
expect(
registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://old-proxy.example.com/v1',
),
).toBeUndefined();
expect(
registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://new-proxy.example.com/v1',
)?.name,
).toBe('GPT-4 New Proxy');
});
it('should apply duplicate model id handling during reload', () => {
const registry = new ModelRegistry();
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'model-a', name: 'Model A First' },
{ id: 'model-a', name: 'Model A Second' },
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(1);
expect(registry.getModel(AuthType.USE_OPENAI, 'model-a')?.name).toBe(
'Model A First',
);
});
it('should preserve models with same id but different baseUrls during reload', () => {
const registry = new ModelRegistry();
registry.reloadModels({
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'gpt-4',
name: 'GPT-4 Direct',
baseUrl: 'https://api.openai.com/v1',
},
{
id: 'gpt-4',
name: 'GPT-4 Proxy',
baseUrl: 'https://proxy.example.com/v1',
},
],
},
});
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.length).toBe(2);
const direct = registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://api.openai.com/v1',
);
expect(direct?.name).toBe('GPT-4 Direct');
const proxy = registry.getModel(
AuthType.USE_OPENAI,
'gpt-4',
'https://proxy.example.com/v1',
);
expect(proxy?.name).toBe('GPT-4 Proxy');
});
});
});
describe('modelRegistryKey', () => {
it('should return id when no baseUrl is provided', () => {
expect(modelRegistryKey('gpt-4')).toBe('gpt-4');
expect(modelRegistryKey('gpt-4', undefined)).toBe('gpt-4');
expect(modelRegistryKey('gpt-4', '')).toBe('gpt-4');
});
it('should return composite key when baseUrl is provided', () => {
const key = modelRegistryKey('gpt-4', 'https://api.openai.com/v1');
expect(key).toBe('gpt-4\0https://api.openai.com/v1');
expect(key).not.toBe('gpt-4');
});
it('should produce different keys for same id with different baseUrls', () => {
const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1');
const key2 = modelRegistryKey('gpt-4', 'https://proxy.example.com/v1');
expect(key1).not.toBe(key2);
});
it('should produce same key for identical id and baseUrl', () => {
const key1 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1');
const key2 = modelRegistryKey('gpt-4', 'https://api.openai.com/v1');
expect(key1).toBe(key2);
});
});
describe('getProtocolForAuthType', () => {
it('should return correct protocol for registered authType', () => {
const registry = new ModelRegistry({
openai: {
protocol: Protocol.OPENAI,
models: [{ id: 'gpt-4', name: 'GPT-4' }],
},
});
expect(registry.getProtocolForAuthType(AuthType.USE_OPENAI)).toBe(
Protocol.OPENAI,
);
});
it('should return undefined for unregistered authType', () => {
const registry = new ModelRegistry();
expect(
registry.getProtocolForAuthType(AuthType.USE_OPENAI),
).toBeUndefined();
});
it('should always have qwen-oauth registered', () => {
const registry = new ModelRegistry();
expect(registry.getProtocolForAuthType(AuthType.QWEN_OAUTH)).toBe(
Protocol.QWEN_OAUTH,
);
});
});
describe('fastOnly and voiceOnly flags', () => {
it('should propagate fastOnly flag to AvailableModel', () => {
const config: ModelProvidersConfig = {
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', fastOnly: true },
],
},
};
const registry = new ModelRegistry(config);
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.find((m) => m.id === 'gpt-4o')?.fastOnly).toBeUndefined();
expect(models.find((m) => m.id === 'gpt-4o-mini')?.fastOnly).toBe(true);
});
it('should propagate voiceOnly flag to AvailableModel', () => {
const config: ModelProvidersConfig = {
openai: {
protocol: Protocol.OPENAI,
models: [
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'whisper-1', name: 'Whisper', voiceOnly: true },
],
},
};
const registry = new ModelRegistry(config);
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models.find((m) => m.id === 'gpt-4o')?.voiceOnly).toBeUndefined();
expect(models.find((m) => m.id === 'whisper-1')?.voiceOnly).toBe(true);
});
it('should warn when both fastOnly and voiceOnly are set', () => {
const config: ModelProvidersConfig = {
openai: {
protocol: Protocol.OPENAI,
models: [
{
id: 'unreachable-model',
fastOnly: true,
voiceOnly: true,
},
],
},
};
const registry = new ModelRegistry(config);
const models = registry.getModelsForAuthType(AuthType.USE_OPENAI);
expect(models).toHaveLength(1);
expect(models[0].fastOnly).toBe(true);
expect(models[0].voiceOnly).toBe(true);
});
});