diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 260c7ec..0ea2bae 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -6,14 +6,22 @@ import { readConfigFile, writeConfigFile } from '../../config/loader'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; -const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key']; +const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key', 'default_text_model', 'default_speech_model', 'default_video_model', 'default_music_model']; + +// Allow hyphen-style keys (e.g. default-text-model → default_text_model) +const KEY_ALIASES: Record = { + 'default-text-model': 'default_text_model', + 'default-speech-model': 'default_speech_model', + 'default-video-model': 'default_video_model', + 'default-music-model': 'default_music_model', +}; export default defineCommand({ name: 'config set', description: 'Set a config value', usage: 'mmx config set --key --value ', options: [ - { flag: '--key ', description: 'Config key (region, base_url, output, timeout, api_key)' }, + { flag: '--key ', description: 'Config key (region, base_url, output, timeout, api_key, default_text_model, default_speech_model, default_video_model, default_music_model)' }, { flag: '--value ', description: 'Value to set' }, ], examples: [ @@ -33,7 +41,10 @@ export default defineCommand({ ); } - if (!VALID_KEYS.includes(key)) { + // Resolve hyphen aliases to underscore keys + const resolvedKey: string = KEY_ALIASES[key] || key; + + if (!VALID_KEYS.includes(resolvedKey)) { throw new CLIError( `Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(', ')}`, ExitCode.USAGE, @@ -41,21 +52,21 @@ export default defineCommand({ } // Validate specific values - if (key === 'region' && !['global', 'cn'].includes(value)) { + if (resolvedKey === 'region' && !['global', 'cn'].includes(value)) { throw new CLIError( `Invalid region "${value}". Valid values: global, cn`, ExitCode.USAGE, ); } - if (key === 'output' && !['text', 'json'].includes(value)) { + if (resolvedKey === 'output' && !['text', 'json'].includes(value)) { throw new CLIError( `Invalid output format "${value}". Valid values: text, json`, ExitCode.USAGE, ); } - if (key === 'timeout') { + if (resolvedKey === 'timeout') { const num = Number(value); if (isNaN(num) || num <= 0) { throw new CLIError( @@ -68,16 +79,16 @@ export default defineCommand({ const format = detectOutputFormat(config.output); if (config.dryRun) { - console.log(formatOutput({ would_set: { [key]: value } }, format)); + console.log(formatOutput({ would_set: { [resolvedKey]: value } }, format)); return; } const existing = readConfigFile() as Record; - existing[key] = key === 'timeout' ? Number(value) : value; + existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value; await writeConfigFile(existing); if (!config.quiet) { - console.log(formatOutput({ [key]: existing[key] }, format)); + console.log(formatOutput({ [resolvedKey]: existing[resolvedKey] }, format)); } }, }); diff --git a/src/commands/config/show.ts b/src/commands/config/show.ts index 1f69640..c431f09 100644 --- a/src/commands/config/show.ts +++ b/src/commands/config/show.ts @@ -31,6 +31,12 @@ export default defineCommand({ result.api_key = maskToken(file.api_key); } + // Default models + if (file.default_text_model) result.default_text_model = file.default_text_model; + if (file.default_speech_model) result.default_speech_model = file.default_speech_model; + if (file.default_video_model) result.default_video_model = file.default_video_model; + if (file.default_music_model) result.default_music_model = file.default_music_model; + console.log(formatOutput(result, format)); }, }); diff --git a/src/commands/music/models.ts b/src/commands/music/models.ts index 47fbda0..30351df 100644 --- a/src/commands/music/models.ts +++ b/src/commands/music/models.ts @@ -10,9 +10,17 @@ export function isCodingPlan(config: Config): boolean { } export function musicGenerateModel(config: Config): string { + // Config default > key-type-based default + if (config.defaultMusicModel) return config.defaultMusicModel; return isCodingPlan(config) ? 'music-2.6' : 'music-2.6-free'; } +const VALID_COVER_MODELS = new Set(['music-cover', 'music-cover-free']); + export function musicCoverModel(config: Config): string { + // Config default (only if it's a valid cover model) > key-type-based default + if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) { + return config.defaultMusicModel; + } return isCodingPlan(config) ? 'music-cover' : 'music-cover-free'; } diff --git a/src/commands/speech/synthesize.ts b/src/commands/speech/synthesize.ts index d97939a..f5f3630 100644 --- a/src/commands/speech/synthesize.ts +++ b/src/commands/speech/synthesize.ts @@ -55,7 +55,9 @@ export default defineCommand({ ); } - const model = (flags.model as string) || 'speech-2.8-hd'; + const model = (flags.model as string) + || config.defaultSpeechModel + || 'speech-2.8-hd'; const voice = (flags.voice as string) || 'English_expressive_narrator'; const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); const ext = (flags.format as string) || 'mp3'; diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index b0be8b3..31801d5 100644 --- a/src/commands/text/chat.ts +++ b/src/commands/text/chat.ts @@ -117,7 +117,9 @@ export default defineCommand({ } } - const model = (flags.model as string) || 'MiniMax-M2.7'; + const model = (flags.model as string) + || config.defaultTextModel + || 'MiniMax-M2.7'; const shouldStream = flags.stream === true || (flags.stream === undefined && process.stdout.isTTY); const format = detectOutputFormat(config.output); diff --git a/src/commands/video/generate.ts b/src/commands/video/generate.ts index 26f984b..78387ab 100644 --- a/src/commands/video/generate.ts +++ b/src/commands/video/generate.ts @@ -71,13 +71,17 @@ export default defineCommand({ ); } - // Determine model: explicit --model overrides auto-switch + // Determine model: explicit --model > auto-switch > config default > hardcoded const explicitModel = flags.model as string | undefined; - let model = explicitModel || 'MiniMax-Hailuo-2.3'; - if (!explicitModel && flags.lastFrame) { + let model: string; + if (explicitModel) { + model = explicitModel; + } else if (flags.lastFrame) { model = 'MiniMax-Hailuo-02'; - } else if (!explicitModel && flags.subjectImage) { + } else if (flags.subjectImage) { model = 'S2V-01'; + } else { + model = config.defaultVideoModel || 'MiniMax-Hailuo-2.3'; } const format = detectOutputFormat(config.output); diff --git a/src/config/loader.ts b/src/config/loader.ts index 9eace02..a29790d 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -64,6 +64,10 @@ export function loadConfig(flags: GlobalFlags): Config { baseUrl, output, timeout, + defaultTextModel: file.default_text_model, + defaultSpeechModel: file.default_speech_model, + defaultVideoModel: file.default_video_model, + defaultMusicModel: file.default_music_model, verbose: flags.verbose || process.env.MINIMAX_VERBOSE === '1', quiet: flags.quiet || false, noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY, diff --git a/src/config/schema.ts b/src/config/schema.ts index 240ab5f..eb075be 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -16,6 +16,10 @@ export interface ConfigFile { base_url?: string; output?: 'text' | 'json'; timeout?: number; + default_text_model?: string; + default_speech_model?: string; + default_video_model?: string; + default_music_model?: string; } const VALID_REGIONS = new Set(['global', 'cn']); @@ -31,6 +35,10 @@ export function parseConfigFile(raw: unknown): ConfigFile { if (typeof obj.base_url === 'string' && obj.base_url.startsWith('http')) out.base_url = obj.base_url; if (typeof obj.output === 'string' && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile['output']; if (typeof obj.timeout === 'number' && obj.timeout > 0) out.timeout = obj.timeout; + if (typeof obj.default_text_model === 'string' && obj.default_text_model.length > 0) out.default_text_model = obj.default_text_model; + if (typeof obj.default_speech_model === 'string' && obj.default_speech_model.length > 0) out.default_speech_model = obj.default_speech_model; + if (typeof obj.default_video_model === 'string' && obj.default_video_model.length > 0) out.default_video_model = obj.default_video_model; + if (typeof obj.default_music_model === 'string' && obj.default_music_model.length > 0) out.default_music_model = obj.default_music_model; return out; } @@ -44,6 +52,10 @@ export interface Config { baseUrl: string; output: 'text' | 'json'; timeout: number; + defaultTextModel?: string; + defaultSpeechModel?: string; + defaultVideoModel?: string; + defaultMusicModel?: string; verbose: boolean; quiet: boolean; noColor: boolean; diff --git a/test/commands/config/set.test.ts b/test/commands/config/set.test.ts index b436aee..53f085e 100644 --- a/test/commands/config/set.test.ts +++ b/test/commands/config/set.test.ts @@ -1,6 +1,12 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, mock } from 'bun:test'; import { default as setCommand } from '../../../src/commands/config/set'; +// Mock file I/O +mock.module('../../../src/config/loader', () => ({ + readConfigFile: () => ({}), + writeConfigFile: mock(() => Promise.resolve()), +})); + describe('config set command', () => { it('has correct name', () => { expect(setCommand.name).toBe('config set'); @@ -65,4 +71,68 @@ describe('config set command', () => { }), ).rejects.toThrow('Invalid config key'); }); + + it('accepts default_text_model key', async () => { + const config = { + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + // Should not throw — key is valid + await expect( + setCommand.execute(config, { + key: 'default_text_model', + value: 'MiniMax-M2.7-highspeed', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }), + ).resolves.toBeUndefined(); + }); + + it('accepts hyphen alias default-text-model', async () => { + const config = { + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'text' as const, + timeout: 10, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + // Hyphen alias should resolve to default_text_model + await expect( + setCommand.execute(config, { + key: 'default-text-model', + value: 'MiniMax-M2.7-highspeed', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }), + ).resolves.toBeUndefined(); + }); }); diff --git a/test/commands/config/show.test.ts b/test/commands/config/show.test.ts index b3dd5bf..e063b7c 100644 --- a/test/commands/config/show.test.ts +++ b/test/commands/config/show.test.ts @@ -1,6 +1,17 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, mock } from 'bun:test'; import { default as showCommand } from '../../../src/commands/config/show'; +// Mock file I/O +mock.module('../../../src/config/loader', () => ({ + readConfigFile: () => ({ + api_key: 'sk-cp-test-key', + default_text_model: 'MiniMax-M2.7-highspeed', + default_speech_model: 'speech-2.8-hd', + default_video_model: 'MiniMax-Hailuo-2.3-6s-768p', + default_music_model: 'music-2.6', + }), +})); + describe('config show command', () => { it('has correct name', () => { expect(showCommand.name).toBe('config show'); @@ -45,4 +56,45 @@ describe('config show command', () => { console.log = originalLog; } }); + + it('includes default models in output', async () => { + const config = { + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 300, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await showCommand.execute(config, { + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: false, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.default_text_model).toBe('MiniMax-M2.7-highspeed'); + expect(parsed.default_speech_model).toBe('speech-2.8-hd'); + expect(parsed.default_video_model).toBe('MiniMax-Hailuo-2.3-6s-768p'); + expect(parsed.default_music_model).toBe('music-2.6'); + } finally { + console.log = originalLog; + } + }); }); diff --git a/test/commands/music/generate.test.ts b/test/commands/music/generate.test.ts index 9734399..a347bd3 100644 --- a/test/commands/music/generate.test.ts +++ b/test/commands/music/generate.test.ts @@ -142,4 +142,42 @@ describe('music generate command', () => { } expect(resolved).toBe(true); }); + + it('uses defaultMusicModel when config is set', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const, defaultMusicModel: 'music-2.6' }, + { ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'no lyrics' }, + ); + } catch { + // dry-run may resolve or reject + } + + console.log = origLog; + const parsed = JSON.parse(captured); + expect(parsed.request.model).toBe('music-2.6'); + }); + + it('--model flag overrides defaultMusicModel', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + + try { + await generateCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const, defaultMusicModel: 'music-2.6' }, + { ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'no lyrics', model: 'music-2.5' }, + ); + } catch { + // dry-run may resolve or reject + } + + console.log = origLog; + const parsed = JSON.parse(captured); + expect(parsed.request.model).toBe('music-2.5'); + }); }); diff --git a/test/commands/music/models.test.ts b/test/commands/music/models.test.ts new file mode 100644 index 0000000..ac9b9e7 --- /dev/null +++ b/test/commands/music/models.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'bun:test'; +import { musicGenerateModel, musicCoverModel, isCodingPlan } from '../../../src/commands/music/models'; + +describe('music models', () => { + it('isCodingPlan returns true for sk-cp- key', () => { + expect(isCodingPlan({ apiKey: 'sk-cp-abc' } as any)).toBe(true); + }); + + it('isCodingPlan returns false for sk-api- key', () => { + expect(isCodingPlan({ apiKey: 'sk-api-xyz' } as any)).toBe(false); + }); + + it('musicGenerateModel uses defaultMusicModel when set', () => { + const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as any; + expect(musicGenerateModel(config)).toBe('music-2.6'); + }); + + it('musicGenerateModel falls back to key-type default when no defaultMusicModel', () => { + const cpConfig = { apiKey: 'sk-cp-abc' } as any; + expect(musicGenerateModel(cpConfig)).toBe('music-2.6'); + + const apiConfig = { apiKey: 'sk-api-xyz' } as any; + expect(musicGenerateModel(apiConfig)).toBe('music-2.6-free'); + }); + + it('musicCoverModel ignores defaultMusicModel for non-cover models', () => { + // defaultMusicModel is 'music-2.6' (a generate model, not a cover model) + // cover should still use key-type default + const config = { apiKey: 'sk-api-xyz', defaultMusicModel: 'music-2.6' } as any; + expect(musicCoverModel(config)).toBe('music-cover-free'); + }); + + it('musicCoverModel uses key-type default when no defaultMusicModel', () => { + const cpConfig = { apiKey: 'sk-cp-abc' } as any; + expect(musicCoverModel(cpConfig)).toBe('music-cover'); + + const apiConfig = { apiKey: 'sk-api-xyz' } as any; + expect(musicCoverModel(apiConfig)).toBe('music-cover-free'); + }); +}); diff --git a/test/commands/speech/synthesize.test.ts b/test/commands/speech/synthesize.test.ts index b3e6f79..360e8ac 100644 --- a/test/commands/speech/synthesize.test.ts +++ b/test/commands/speech/synthesize.test.ts @@ -76,4 +76,87 @@ describe('speech synthesize command', () => { console.log = originalLog; } }); + + it('uses defaultSpeechModel when --model flag is not provided', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + defaultSpeechModel: 'speech-hd', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await synthesizeCommand.execute(config, { + text: 'Hello', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('speech-hd'); + } finally { + console.log = originalLog; + } + }); + + it('--model flag overrides defaultSpeechModel', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + defaultSpeechModel: 'speech-hd', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await synthesizeCommand.execute(config, { + text: 'Hello', + model: 'speech-01-hd', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('speech-01-hd'); + } finally { + console.log = originalLog; + } + }); }); diff --git a/test/commands/text/chat.test.ts b/test/commands/text/chat.test.ts index f7eac7b..8748f9b 100644 --- a/test/commands/text/chat.test.ts +++ b/test/commands/text/chat.test.ts @@ -102,4 +102,91 @@ describe('text chat command', () => { console.log = originalLog; } }); + + it('uses defaultTextModel when --model flag is not provided', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + defaultTextModel: 'MiniMax-M2.7-highspeed', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-M2.7-highspeed'); + } finally { + console.log = originalLog; + } + }); + + it('--model flag overrides defaultTextModel', async () => { + const { default: chatCommand } = await import('../../../src/commands/text/chat'); + + const config: Config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json', + timeout: 10, + defaultTextModel: 'MiniMax-M2.7-highspeed', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await chatCommand.execute(config, { + message: ['Hello'], + model: 'MiniMax-M2.7', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-M2.7'); + } finally { + console.log = originalLog; + } + }); }); diff --git a/test/commands/video/generate.test.ts b/test/commands/video/generate.test.ts index 21b0d58..5e8cb07 100644 --- a/test/commands/video/generate.test.ts +++ b/test/commands/video/generate.test.ts @@ -35,4 +35,131 @@ describe('video generate command', () => { }), ).rejects.toThrow('Missing required argument: --prompt'); }); + + it('uses defaultVideoModel when --model flag is not provided', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + defaultVideoModel: 'MiniMax-Hailuo-2.3-6s-768p', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await generateCommand.execute(config, { + prompt: 'A cat', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-Hailuo-2.3-6s-768p'); + } finally { + console.log = originalLog; + } + }); + + it('auto-switch (--lastFrame) overrides defaultVideoModel', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + defaultVideoModel: 'MiniMax-Hailuo-2.3-6s-768p', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + // Use HTTP URLs to avoid file system read + await generateCommand.execute(config, { + prompt: 'A cat', + firstFrame: 'https://example.com/first.png', + lastFrame: 'https://example.com/last.png', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-Hailuo-02'); + } finally { + console.log = originalLog; + } + }); + + it('--model flag overrides everything', async () => { + const config = { + apiKey: 'test-key', + region: 'global' as const, + baseUrl: 'https://api.mmx.io', + output: 'json' as const, + timeout: 10, + defaultVideoModel: 'MiniMax-Hailuo-2.3-6s-768p', + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + }; + + const originalLog = console.log; + let output = ''; + console.log = (msg: string) => { output += msg; }; + + try { + await generateCommand.execute(config, { + prompt: 'A cat', + model: 'MiniMax-Hailuo-2.3', + quiet: false, + verbose: false, + noColor: true, + yes: false, + dryRun: true, + help: false, + nonInteractive: true, + async: false, + }); + + const parsed = JSON.parse(output); + expect(parsed.request.model).toBe('MiniMax-Hailuo-2.3'); + } finally { + console.log = originalLog; + } + }); }); diff --git a/test/utils/model-defaults.test.ts b/test/utils/model-defaults.test.ts new file mode 100644 index 0000000..6ed34e0 --- /dev/null +++ b/test/utils/model-defaults.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'bun:test'; +import type { Config } from '../../src/config/schema'; + +const baseConfig: Config = { + region: 'global', + baseUrl: 'https://api.minimax.io', + output: 'text', + timeout: 300, + verbose: false, + quiet: false, + noColor: true, + yes: false, + dryRun: false, + nonInteractive: true, + async: false, +}; + +/** + * Helper: resolve model with priority flag > config default > fallback. + * Each command implements this inline; this mirrors the logic for testing. + */ +function resolveModel( + configKey: 'defaultTextModel' | 'defaultSpeechModel' | 'defaultVideoModel' | 'defaultMusicModel', + fallback: string, + config: Partial, + flags: Record, +): string { + if (typeof flags.model === 'string' && flags.model.length > 0) return flags.model; + const cfg = (config as Record)[configKey] as string | undefined; + if (cfg) return cfg; + return fallback; +} + +describe('model resolution (flag > config default > fallback)', () => { + it('uses flag when provided', () => { + const model = resolveModel('defaultTextModel', 'MiniMax-M2.7', baseConfig, { model: 'MiniMax-M2.7-highspeed' }); + expect(model).toBe('MiniMax-M2.7-highspeed'); + }); + + it('falls back to config default when flag is absent', () => { + const model = resolveModel('defaultTextModel', 'MiniMax-M2.7', { ...baseConfig, defaultTextModel: 'MiniMax-M2.7-highspeed' }, {}); + expect(model).toBe('MiniMax-M2.7-highspeed'); + }); + + it('falls back to hardcoded when neither flag nor config', () => { + const model = resolveModel('defaultTextModel', 'MiniMax-M2.7', baseConfig, {}); + expect(model).toBe('MiniMax-M2.7'); + }); + + it('flag overrides config default', () => { + const model = resolveModel('defaultSpeechModel', 'speech-2.8-hd', { ...baseConfig, defaultSpeechModel: 'speech-01-hd' }, { model: 'speech-hd' }); + expect(model).toBe('speech-hd'); + }); + + it('config default overrides hardcoded', () => { + const model = resolveModel('defaultVideoModel', 'MiniMax-Hailuo-2.3', { ...baseConfig, defaultVideoModel: 'MiniMax-Hailuo-2.3-6s-768p' }, {}); + expect(model).toBe('MiniMax-Hailuo-2.3-6s-768p'); + }); + + it('handles music model default', () => { + const model = resolveModel('defaultMusicModel', 'music-2.6-free', { ...baseConfig, defaultMusicModel: 'music-2.6' }, {}); + expect(model).toBe('music-2.6'); + }); +});