Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/commands/music/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineCommand({
apiDocs: '/docs/api-reference/music-generation',
usage: 'mmx music cover --prompt <text> (--audio <url> | --audio-file <path>) [--lyrics <text>] [--out <path>] [flags]',
options: [
{ flag: '--model <model>', description: 'Model: music-cover (default).' },
{ flag: '--model <model>', description: 'Model: music-cover (default, paid), music-cover-free (free tier).' },
{ flag: '--prompt <text>', description: 'Target cover style, e.g. "Indie folk, acoustic guitar, warm male vocal"' },
{ flag: '--audio <url>', description: 'URL of the reference audio (mp3, wav, flac, etc. — 6s to 6min, max 50MB)' },
{ flag: '--audio-file <path>', description: 'Local reference audio file (auto base64-encoded)' },
Expand Down Expand Up @@ -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(', ')}`,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/music/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
17 changes: 16 additions & 1 deletion src/errors/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
);
Expand Down
59 changes: 59 additions & 0 deletions test/auth/timeout-fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).exit = origExit;
}

expect(captured).toContain('Network request failed');
expect(captured).toContain('ENOTFOUND');
});
});
25 changes: 25 additions & 0 deletions test/commands/music/cover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions test/commands/music/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down