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

* 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:
callmeYe 2026-06-23 11:13:38 +08:00 committed by GitHub
parent 896e6c59e0
commit 2ddf9d2758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 292 additions and 15 deletions

View file

@ -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'),
});
});
});
});

View file

@ -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',

View file

@ -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(
() =>

View file

@ -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);
});
});

View file

@ -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.`,
);
}
}
/**

View file

@ -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;