From 618b245c1f1f60b3448a70aba1acd6a15865df35 Mon Sep 17 00:00:00 2001 From: MeloMei Date: Fri, 26 Jun 2026 15:47:23 +0800 Subject: [PATCH] fix: accept music-cover-free model, differentiate network errors in music cover --- src/commands/music/cover.ts | 4 +- src/commands/music/models.ts | 2 +- src/errors/handler.ts | 17 ++++++++- test/auth/timeout-fix.test.ts | 59 ++++++++++++++++++++++++++++++ test/commands/music/cover.test.ts | 25 +++++++++++++ test/commands/music/models.test.ts | 5 +++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/commands/music/cover.ts b/src/commands/music/cover.ts index 451d66d5..1cba1b5b 100644 --- a/src/commands/music/cover.ts +++ b/src/commands/music/cover.ts @@ -19,7 +19,7 @@ export default defineCommand({ apiDocs: '/docs/api-reference/music-generation', usage: 'mmx music cover --prompt (--audio | --audio-file ) [--lyrics ] [--out ] [flags]', options: [ - { flag: '--model ', description: 'Model: music-cover (default).' }, + { flag: '--model ', description: 'Model: music-cover (default, paid), music-cover-free (free tier).' }, { flag: '--prompt ', description: 'Target cover style, e.g. "Indie folk, acoustic guitar, warm male vocal"' }, { flag: '--audio ', description: 'URL of the reference audio (mp3, wav, flac, etc. — 6s to 6min, max 50MB)' }, { flag: '--audio-file ', description: 'Local reference audio file (auto base64-encoded)' }, @@ -72,7 +72,7 @@ export default defineCommand({ const format = detectOutputFormat(config.output); const model = (flags.model as string) || musicCoverModel(config); - const VALID_MODELS = ['music-cover']; + const VALID_MODELS = ['music-cover', 'music-cover-free']; if (flags.model && !VALID_MODELS.includes(model)) { throw new CLIError( `Invalid model "${model}". Valid models: ${VALID_MODELS.join(', ')}`, diff --git a/src/commands/music/models.ts b/src/commands/music/models.ts index 73b25b2b..db8848a0 100644 --- a/src/commands/music/models.ts +++ b/src/commands/music/models.ts @@ -4,7 +4,7 @@ export function musicGenerateModel(config: Config): string { return config.defaultMusicModel ?? 'music-2.6'; } -const VALID_COVER_MODELS = new Set(['music-cover']); +const VALID_COVER_MODELS = new Set(['music-cover', 'music-cover-free']); export function musicCoverModel(config: Config): string { if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) { diff --git a/src/errors/handler.ts b/src/errors/handler.ts index 871e553c..5843692b 100644 --- a/src/errors/handler.ts +++ b/src/errors/handler.ts @@ -64,6 +64,21 @@ export function handleError(err: unknown): never { msg.includes("eai_AGAIN"); if (isNetworkError) { + // Network-level timeouts (ETIMEDOUT) should use TIMEOUT exit code, not NETWORK + if (msg.includes("etimedout") || (msg.includes("timeout") && !msg.includes("timed out"))) { + const timeout = new CLIError( + "Connection timed out.", + ExitCode.TIMEOUT, + "Try increasing --timeout (e.g. --timeout 120).\n" + + "If this happens on every request with a valid API key, you may be hitting the wrong region.\n" + + "Run: mmx auth status — to check your credentials and region.\n" + + "Run: mmx config set region global (or cn) — to override the region.", + ); + return handleError(timeout); + } + + const causeCode = (err as any).cause?.code; + const detail = causeCode ? ` (${causeCode})` : ""; let hint = "Check your network connection.\n" + "To use a proxy: set HTTPS_PROXY env var, or run: mmx config set --key proxy --value http://HOST:PORT"; @@ -73,7 +88,7 @@ export function handleError(err: unknown): never { "Check: HTTPS_PROXY / HTTP_PROXY env vars, or mmx config show for configured proxy."; } const networkErr = new CLIError( - "Network request failed.", + `Network request failed${detail}.`, ExitCode.NETWORK, hint, ); diff --git a/test/auth/timeout-fix.test.ts b/test/auth/timeout-fix.test.ts index b26c08ab..10ffffe0 100644 --- a/test/auth/timeout-fix.test.ts +++ b/test/auth/timeout-fix.test.ts @@ -231,4 +231,63 @@ describe('handleError: timeout message includes region/auth hint', () => { expect(err.hint).toContain('mmx auth status'); expect(err.hint).toContain('mmx config set region'); }); + + it('network-level ETIMEDOUT error routes to TIMEOUT exit code', async () => { + const { handleError } = await import('../../src/errors/handler'); + + const etimedoutErr = new Error('connect ETIMEDOUT 1.2.3.4:443'); + + let captured = ''; + let exitCode = -1; + const origWrite = process.stderr.write.bind(process.stderr); + const origExit = process.exit; + (process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => { + captured += String(chunk); + return true; + }; + (process as unknown as Record).exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + + try { + handleError(etimedoutErr); + } catch { + // mocked process.exit throws — expected + } finally { + (process.stderr as NodeJS.WriteStream).write = origWrite; + (process as unknown as Record).exit = origExit; + } + + expect(captured).toContain('timed out'); + expect(exitCode).toBe(ExitCode.TIMEOUT); + }); + + it('generic network error includes cause code when available', async () => { + const { handleError } = await import('../../src/errors/handler'); + + const networkErr = new Error('failed to fetch'); + (networkErr as any).cause = { code: 'ENOTFOUND' }; + + let captured = ''; + const origWrite = process.stderr.write.bind(process.stderr); + const origExit = process.exit; + (process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => { + captured += String(chunk); + return true; + }; + (process as unknown as Record).exit = () => { throw new Error('exit'); }; + + try { + handleError(networkErr); + } catch { + // mocked process.exit throws — expected + } finally { + (process.stderr as NodeJS.WriteStream).write = origWrite; + (process as unknown as Record).exit = origExit; + } + + expect(captured).toContain('Network request failed'); + expect(captured).toContain('ENOTFOUND'); + }); }); diff --git a/test/commands/music/cover.test.ts b/test/commands/music/cover.test.ts index d8126830..31823579 100644 --- a/test/commands/music/cover.test.ts +++ b/test/commands/music/cover.test.ts @@ -147,6 +147,31 @@ describe('music cover command', () => { ).rejects.toThrow('Invalid model'); }); + it('accepts music-cover-free model in dry-run', async () => { + let captured = ''; + const origLog = console.log; + console.log = (msg: string) => { captured += msg; }; + + try { + await coverCommand.execute( + { ...baseConfig, dryRun: true, output: 'json' as const }, + { + ...baseFlags, + dryRun: true, + prompt: 'Folk cover', + audio: 'https://example.com/ref.mp3', + model: 'music-cover-free', + }, + ); + } catch { + // dry-run may resolve or reject + } + + console.log = origLog; + const parsed = JSON.parse(captured); + expect(parsed.request.model).toBe('music-cover-free'); + }); + it('rejects invalid audio format', async () => { await expect( coverCommand.execute( diff --git a/test/commands/music/models.test.ts b/test/commands/music/models.test.ts index 9273ad26..3600e7f4 100644 --- a/test/commands/music/models.test.ts +++ b/test/commands/music/models.test.ts @@ -22,6 +22,11 @@ describe('music models', () => { expect(musicCoverModel(config)).toBe('music-cover'); }); + it('musicCoverModel accepts music-cover-free as default', () => { + const config = { defaultMusicModel: 'music-cover-free' } as Config; + expect(musicCoverModel(config)).toBe('music-cover-free'); + }); + it('musicCoverModel defaults to music-cover', () => { expect(musicCoverModel({} as Config)).toBe('music-cover'); });