From 2ddf9d275864fd134fc268388a81cfc08b97f889 Mon Sep 17 00:00:00 2001 From: callmeYe <512217680@qq.com> Date: Tue, 23 Jun 2026 11:13:38 +0800 Subject: [PATCH] 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. --- .../cli/src/ui/commands/modelCommand.test.ts | 184 ++++++++++++++++++ packages/cli/src/ui/commands/modelCommand.ts | 47 +++-- .../cli/src/ui/components/ModelDialog.tsx | 6 +- .../core/src/models/modelRegistry.test.ts | 54 +++++ packages/core/src/models/modelRegistry.ts | 7 + packages/core/src/models/types.ts | 9 + 6 files changed, 292 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index b17151809c..c0787add35 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -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'), + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 771d4affa9..25008fb65a 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -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', diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index fbb1223b56..1996c9e715 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -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( () => diff --git a/packages/core/src/models/modelRegistry.test.ts b/packages/core/src/models/modelRegistry.test.ts index 816473d602..a175977f45 100644 --- a/packages/core/src/models/modelRegistry.test.ts +++ b/packages/core/src/models/modelRegistry.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/models/modelRegistry.ts b/packages/core/src/models/modelRegistry.ts index 1a15113779..4714c86898 100644 --- a/packages/core/src/models/modelRegistry.ts +++ b/packages/core/src/models/modelRegistry.ts @@ -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.`, + ); + } } /** diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index 1e74217e03..3ef34642f6 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -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;