mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(core): add fastOnly/voiceOnly flags to hide models from main model list (#5632)
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
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 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.
This commit is contained in:
parent
896e6c59e0
commit
2ddf9d2758
6 changed files with 292 additions and 15 deletions
|
|
@ -1034,4 +1034,188 @@ describe('modelCommand', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fastOnly/voiceOnly filtering', () => {
|
||||
it('should reject fastOnly models from normal /model selection', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: { raw: '/model fast-model', name: 'model', args: 'fast-model' },
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAvailableModelsForAuthType: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main' },
|
||||
{ id: 'fast-model', label: 'Fast', fastOnly: true },
|
||||
]),
|
||||
},
|
||||
settings: createMockSettings(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, 'fast-model');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('fast-model'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject voiceOnly models from normal /model selection', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: { raw: '/model voice-model', name: 'model', args: 'voice-model' },
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAvailableModelsForAuthType: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main' },
|
||||
{ id: 'voice-model', label: 'Voice', voiceOnly: true },
|
||||
]),
|
||||
},
|
||||
settings: createMockSettings(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, 'voice-model');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('voice-model'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow fastOnly models in --fast selection', async () => {
|
||||
const setValue = vi.fn();
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model --fast fast-model',
|
||||
name: 'model',
|
||||
args: '--fast fast-model',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAllConfiguredModels: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main' },
|
||||
{ id: 'fast-model', label: 'Fast', fastOnly: true },
|
||||
]),
|
||||
setFastModel: vi.fn(),
|
||||
},
|
||||
settings: createMockSettings(setValue),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '--fast fast-model');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('fast-model'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject voiceOnly models from --fast selection', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model --fast voice-model',
|
||||
name: 'model',
|
||||
args: '--fast voice-model',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAllConfiguredModels: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main' },
|
||||
{ id: 'voice-model', label: 'Voice', voiceOnly: true },
|
||||
]),
|
||||
setFastModel: vi.fn(),
|
||||
},
|
||||
settings: createMockSettings(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '--fast voice-model');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('voice-model'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not filter out voiceOnly models from --voice selection', async () => {
|
||||
const setValue = vi.fn();
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model --voice qwen3-asr-flash',
|
||||
name: 'model',
|
||||
args: '--voice qwen3-asr-flash',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAllConfiguredModels: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main', authType: AuthType.USE_OPENAI },
|
||||
{
|
||||
id: 'qwen3-asr-flash',
|
||||
label: 'ASR',
|
||||
voiceOnly: true,
|
||||
authType: AuthType.USE_OPENAI,
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
},
|
||||
]),
|
||||
},
|
||||
settings: createMockSettings(setValue),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '--voice qwen3-asr-flash');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('qwen3-asr-flash'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject fastOnly models from --voice selection', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model --voice fast-model',
|
||||
name: 'model',
|
||||
args: '--voice fast-model',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
model: 'main-model',
|
||||
authType: AuthType.USE_OPENAI,
|
||||
}),
|
||||
getAllConfiguredModels: vi.fn().mockReturnValue([
|
||||
{ id: 'main-model', label: 'Main' },
|
||||
{ id: 'fast-model', label: 'Fast', fastOnly: true },
|
||||
]),
|
||||
},
|
||||
settings: createMockSettings(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelCommand.action!(mockContext, '--voice fast-model');
|
||||
expect(result).toMatchObject({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining('fast-model'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,15 +140,21 @@ function formatUnavailableVoiceModelMessage(
|
|||
);
|
||||
}
|
||||
|
||||
// Get an array of the available model IDs as strings
|
||||
function getAvailableModelIds(context: CommandContext) {
|
||||
// Get an array of the available model IDs as strings, filtered by mode
|
||||
function getAvailableModelIds(
|
||||
context: CommandContext,
|
||||
mode: 'main' | 'fast' | 'voice' = 'main',
|
||||
) {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
const availableModels = config.getAvailableModels();
|
||||
// Convert AvailableModel[] to string[] on AvailableModel.id
|
||||
const availableModels = config.getAvailableModels().filter((m) => {
|
||||
if (mode === 'fast') return !m.voiceOnly;
|
||||
if (mode === 'voice') return !m.fastOnly;
|
||||
return !m.fastOnly && !m.voiceOnly;
|
||||
});
|
||||
return availableModels.map((model) => model.id);
|
||||
}
|
||||
|
||||
|
|
@ -180,9 +186,19 @@ export const modelCommand: SlashCommand = {
|
|||
if (flagCompletions.length > 0) {
|
||||
return flagCompletions;
|
||||
}
|
||||
if (partialArg.trim()) {
|
||||
return getAvailableModelIds(context).filter((id) =>
|
||||
id.startsWith(partialArg.trim()),
|
||||
const trimmed = partialArg.trim();
|
||||
if (trimmed) {
|
||||
let mode: 'main' | 'fast' | 'voice' = 'main';
|
||||
let modelPrefix = trimmed;
|
||||
if (trimmed.startsWith('--fast ')) {
|
||||
mode = 'fast';
|
||||
modelPrefix = trimmed.slice('--fast '.length);
|
||||
} else if (trimmed.startsWith('--voice ')) {
|
||||
mode = 'voice';
|
||||
modelPrefix = trimmed.slice('--voice '.length);
|
||||
}
|
||||
return getAvailableModelIds(context, mode).filter((id) =>
|
||||
id.startsWith(modelPrefix),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -239,7 +255,9 @@ export const modelCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
const availableModels = config.getAllConfiguredModels();
|
||||
const availableModels = config
|
||||
.getAllConfiguredModels()
|
||||
.filter((m) => !m.fastOnly);
|
||||
const matches = availableModels.filter((model) => model.id === modelName);
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
|
|
@ -330,9 +348,11 @@ export const modelCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
const availableModels = selector.authType
|
||||
? config.getAvailableModelsForAuthType(selector.authType)
|
||||
: config.getAllConfiguredModels();
|
||||
const availableModels = (
|
||||
selector.authType
|
||||
? config.getAvailableModelsForAuthType(selector.authType)
|
||||
: config.getAllConfiguredModels()
|
||||
).filter((m) => !m.voiceOnly);
|
||||
if (!availableModels.some((model) => model.id === selector.modelId)) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
@ -388,8 +408,9 @@ export const modelCommand: SlashCommand = {
|
|||
}
|
||||
const parsed = parseAcpModelOption(modelName);
|
||||
const targetAuthType = parsed.authType ?? authType;
|
||||
const availableModels =
|
||||
config.getAvailableModelsForAuthType(targetAuthType);
|
||||
const availableModels = config
|
||||
.getAvailableModelsForAuthType(targetAuthType)
|
||||
.filter((m) => !m.fastOnly && !m.voiceOnly);
|
||||
if (!availableModels.some((model) => model.id === parsed.modelId)) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
|
|||
|
|
@ -228,7 +228,9 @@ export function ModelDialog({
|
|||
(m) =>
|
||||
!m.isRuntimeModel &&
|
||||
(m.authType !== AuthType.QWEN_OAUTH ||
|
||||
authType === AuthType.QWEN_OAUTH),
|
||||
authType === AuthType.QWEN_OAUTH) &&
|
||||
(isFastModelMode || !m.fastOnly) &&
|
||||
(isVoiceModelMode || !m.voiceOnly),
|
||||
);
|
||||
|
||||
// Group registry models by authType
|
||||
|
|
@ -293,7 +295,7 @@ export function ModelDialog({
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [authType, config]);
|
||||
}, [authType, config, isFastModelMode, isVoiceModelMode]);
|
||||
|
||||
const MODEL_OPTIONS = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1120,3 +1120,57 @@ describe('getProtocolForAuthType', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ export class ModelRegistry {
|
|||
modalities: model.generationConfig.modalities,
|
||||
baseUrl: model.baseUrl,
|
||||
envKey: model.envKey,
|
||||
fastOnly: model.fastOnly,
|
||||
voiceOnly: model.voiceOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +266,11 @@ export class ModelRegistry {
|
|||
`Model config in authType '${authType}' missing required field: id`,
|
||||
);
|
||||
}
|
||||
if (config.fastOnly && config.voiceOnly) {
|
||||
debugLogger.warn(
|
||||
`Model "${config.id}" in authType "${authType}" has both fastOnly and voiceOnly set. It will be unreachable in all model selectors.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ export interface ModelConfig {
|
|||
capabilities?: ModelCapabilities;
|
||||
/** Generation configuration (sampling parameters) */
|
||||
generationConfig?: ModelGenerationConfig;
|
||||
/** When true, this model only appears in the fast model selector, not the main model list */
|
||||
fastOnly?: boolean;
|
||||
/** When true, this model only appears in the voice model selector, not the main model list */
|
||||
voiceOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -117,6 +121,11 @@ export interface AvailableModel {
|
|||
baseUrl?: string;
|
||||
envKey?: string;
|
||||
|
||||
/** When true, this model only appears in the fast model selector */
|
||||
fastOnly?: boolean;
|
||||
/** When true, this model only appears in the voice model selector */
|
||||
voiceOnly?: boolean;
|
||||
|
||||
/** Whether this is a runtime model (not from modelProviders) */
|
||||
isRuntimeModel?: boolean;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue