Skip to content

Commit d44356f

Browse files
committed
fix: address multiple bugs from issue #79
- text/chat: warn instead of silent skip on SSE parse errors - text/chat: add SSE content-type validation before parsing - text/chat: proper error handling for tool JSON parsing - poll: include API failure context in task failed error - auth/refresh: add retry with exponential backoff (2 retries) - output/audio: validate audio hex before Buffer.from, handle ENOSPC - vision/describe: add 50MB size limit before base64 encoding - speech/voices: fix greedy language filter (substring → prefix match) - image/generate: warn on file overwrite, respect --output json in quiet - config/detect-region: improve fallback error message Fixes #79
1 parent 813ff4c commit d44356f

8 files changed

Lines changed: 155 additions & 42 deletions

File tree

src/auth/refresh.ts

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,68 @@ import { ExitCode } from "../errors/codes";
66
// OAuth config — endpoints TBD pending MiniMax OAuth documentation
77
const TOKEN_URL = "https://api.minimax.io/v1/oauth/token";
88

9+
const MAX_REFRESH_RETRIES = 2;
10+
const RETRY_DELAY_MS = 500;
11+
912
export async function refreshAccessToken(
1013
refreshToken: string,
1114
): Promise<OAuthTokens> {
12-
let res: Response;
13-
try {
14-
res = await fetch(TOKEN_URL, {
15-
method: "POST",
16-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
17-
body: new URLSearchParams({
18-
grant_type: "refresh_token",
19-
refresh_token: refreshToken,
20-
}),
21-
signal: AbortSignal.timeout(10_000),
22-
});
23-
} catch (err) {
24-
const isTimeout =
25-
err instanceof Error &&
26-
(err.name === "AbortError" ||
27-
err.name === "TimeoutError" ||
28-
err.message.includes("timed out"));
29-
throw new CLIError(
30-
isTimeout
31-
? "Token refresh timed out — auth server did not respond within 10 s."
32-
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
33-
ExitCode.AUTH,
34-
"Check your network connection.\nRe-authenticate: mmx auth login",
35-
);
36-
}
15+
let lastErr: Error | null = null;
16+
17+
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
18+
if (attempt > 0) {
19+
// Exponential backoff before retry
20+
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt));
21+
}
22+
23+
let res: Response;
24+
try {
25+
res = await fetch(TOKEN_URL, {
26+
method: "POST",
27+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
28+
body: new URLSearchParams({
29+
grant_type: "refresh_token",
30+
refresh_token: refreshToken,
31+
}),
32+
signal: AbortSignal.timeout(10_000),
33+
});
34+
} catch (err) {
35+
const isTimeout =
36+
err instanceof Error &&
37+
(err.name === "AbortError" ||
38+
err.name === "TimeoutError" ||
39+
err.message.includes("timed out"));
40+
lastErr = new Error(
41+
isTimeout
42+
? "Token refresh timed out — auth server did not respond within 10 s."
43+
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
44+
);
45+
continue; // retry
46+
}
47+
48+
if (!res.ok) {
49+
// 4xx errors won't recover with retry
50+
if (res.status >= 400 && res.status < 500) {
51+
throw new CLIError(
52+
"OAuth session expired and could not be refreshed.",
53+
ExitCode.AUTH,
54+
"Re-authenticate: mmx auth login",
55+
);
56+
}
57+
lastErr = new Error(`Token refresh failed: HTTP ${res.status}`);
58+
continue; // retry 5xx errors
59+
}
3760

38-
if (!res.ok) {
39-
throw new CLIError(
40-
"OAuth session expired and could not be refreshed.",
41-
ExitCode.AUTH,
42-
"Re-authenticate: mmx auth login",
43-
);
61+
const data = (await res.json()) as OAuthTokens;
62+
return data;
4463
}
4564

46-
const data = (await res.json()) as OAuthTokens;
47-
return data;
65+
// All retries exhausted
66+
throw new CLIError(
67+
`Token refresh failed after ${MAX_REFRESH_RETRIES + 1} attempts: ${lastErr?.message}`,
68+
ExitCode.AUTH,
69+
"Check your network connection.\nRe-authenticate: mmx auth login",
70+
);
4871
}
4972

5073
export async function ensureFreshToken(creds: CredentialFile): Promise<string> {

src/commands/image/generate.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,26 @@ export default defineCommand({
158158
for (let i = 0; i < imageUrls.length; i++) {
159159
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
160160
const destPath = join(outDir, filename);
161+
162+
// Warn if overwriting existing file (but don't block)
163+
if (existsSync(destPath)) {
164+
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
165+
}
166+
161167
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
162168
saved.push(destPath);
163169
}
164170

165-
if (config.quiet) {
171+
// --output json is respected even in --quiet mode (JSON is the actual output, not progress)
172+
if (format === 'json') {
173+
console.log(formatOutput({
174+
id: response.data.task_id,
175+
saved,
176+
success_count: response.data.success_count,
177+
failed_count: response.data.failed_count,
178+
}, format));
179+
} else if (config.quiet) {
180+
// Non-JSON quiet mode: just print file paths
166181
console.log(saved.join('\n'));
167182
} else {
168183
console.log(formatOutput({

src/commands/speech/voices.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ function extractLanguage(voiceId: string): string {
1515

1616
function filterByLanguage(voices: SystemVoiceInfo[], language: string): SystemVoiceInfo[] {
1717
const lang = language.toLowerCase();
18-
return voices.filter(v => extractLanguage(v.voice_id).toLowerCase().includes(lang));
18+
return voices.filter(v => {
19+
const voiceLang = extractLanguage(v.voice_id).toLowerCase();
20+
// Exact prefix match: "english" matches "English_*" but not "Korean_*"
21+
return voiceLang === lang || voiceLang.startsWith(lang + '_') || voiceLang.startsWith(lang + ' (');
22+
});
1923
}
2024

2125
export default defineCommand({

src/commands/text/chat.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { request, requestJson } from '../../client/http';
33
import { chatEndpoint } from '../../client/endpoints';
44
import { parseSSE } from '../../client/stream';
55
import { formatOutput, detectOutputFormat } from '../../output/formatter';
6+
import { CLIError } from '../../errors/base';
7+
import { ExitCode } from '../../errors/codes';
68
import type { Config } from '../../config/schema';
79
import type { GlobalFlags } from '../../types/flags';
810
import type {
@@ -139,8 +141,16 @@ export default defineCommand({
139141
try {
140142
return JSON.parse(t);
141143
} catch {
142-
const raw = readFileSync(t, 'utf-8');
143-
return JSON.parse(raw);
144+
// Not JSON — treat as file path
145+
try {
146+
const raw = readFileSync(t, 'utf-8');
147+
return JSON.parse(raw);
148+
} catch {
149+
throw new CLIError(
150+
`Invalid tool definition: "${t}" is neither valid JSON nor a readable file.`,
151+
ExitCode.USAGE,
152+
);
153+
}
144154
}
145155
});
146156
body.tools = tools;
@@ -162,6 +172,15 @@ export default defineCommand({
162172
authStyle: 'x-api-key',
163173
});
164174

175+
// Validate response is actually SSE before attempting to parse
176+
const contentType = res.headers.get('content-type') || '';
177+
if (!contentType.includes('text/event-stream') && !contentType.includes('stream')) {
178+
throw new CLIError(
179+
`Expected SSE stream but got content-type "${contentType}". Server may be experiencing issues.`,
180+
ExitCode.GENERAL,
181+
);
182+
}
183+
165184
let textContent = '';
166185
let inThinking = false;
167186
const dim = config.noColor ? '' : '\x1b[2m';
@@ -193,8 +212,9 @@ export default defineCommand({
193212
statusOut.write(parsed.delta.thinking);
194213
}
195214
}
196-
} catch {
197-
// Skip unparseable chunks
215+
} catch (err) {
216+
// Warn but don't crash — partial output is better than nothing
217+
process.stderr.write(`\n${dim}[warning] Failed to parse stream chunk: ${err instanceof Error ? err.message : String(err)}${reset}\n`);
198218
}
199219
}
200220
if (inThinking) statusOut.write(reset);

src/commands/vision/describe.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const MIME_TYPES: Record<string, string> = {
2222
'.webp': 'image/webp',
2323
};
2424

25+
const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB limit
26+
2527
async function toDataUri(image: string): Promise<string> {
2628
if (image.startsWith('data:')) return image;
2729

@@ -31,6 +33,12 @@ async function toDataUri(image: string): Promise<string> {
3133
const contentType = res.headers.get('content-type') || 'image/jpeg';
3234
const mime = contentType.split(';')[0]!.trim();
3335
const buf = await res.arrayBuffer();
36+
if (buf.byteLength > MAX_IMAGE_SIZE_BYTES) {
37+
throw new CLIError(
38+
`Image too large (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum is 50 MB.`,
39+
ExitCode.USAGE,
40+
);
41+
}
3442
const b64 = Buffer.from(buf).toString('base64');
3543
return `data:${mime};base64,${b64}`;
3644
}

src/config/detect-region.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export async function detectRegion(apiKey: string): Promise<Region> {
5252
if (!match) {
5353
process.stderr.write(" failed\n");
5454
process.stderr.write(
55-
"Warning: API key failed validation against all regions. Falling back to global.\n",
55+
`Warning: API key failed validation against all regions (global, cn).\n` +
56+
` This usually means the API key is invalid or the network is blocking requests.\n` +
57+
` Falling back to 'global'. Subsequent requests may fail.\n` +
58+
` Run 'mmx auth status' to verify your credentials.\n`,
5659
);
5760
return "global";
5861
}

src/output/audio.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { writeFileSync } from 'fs';
22
import type { OutputFormat } from './formatter';
33
import { formatOutput } from './formatter';
4+
import { CLIError } from '../errors/base';
5+
import { ExitCode } from '../errors/codes';
46

57
interface AudioExtraInfo {
68
audio_length?: number;
@@ -20,7 +22,39 @@ export function saveAudioOutput(
2022
quiet: boolean,
2123
): void {
2224
if (outPath) {
23-
writeFileSync(outPath, Buffer.from(response.data.audio!, 'hex'));
25+
const audioHex = response.data.audio;
26+
if (!audioHex) {
27+
throw new CLIError(
28+
'API response missing audio data (audio field is empty).',
29+
ExitCode.GENERAL,
30+
);
31+
}
32+
// Validate hex string before attempting conversion
33+
if (!/^[0-9a-fA-F]*$/.test(audioHex)) {
34+
throw new CLIError(
35+
'API returned invalid audio data (not valid hex).',
36+
ExitCode.GENERAL,
37+
);
38+
}
39+
if (audioHex.length % 2 !== 0) {
40+
throw new CLIError(
41+
'API returned truncated audio data (odd-length hex string).',
42+
ExitCode.GENERAL,
43+
);
44+
}
45+
try {
46+
writeFileSync(outPath, Buffer.from(audioHex, 'hex'));
47+
} catch (err) {
48+
const e = err as NodeJS.ErrnoException;
49+
if (e.code === 'ENOSPC') {
50+
throw new CLIError(
51+
'Disk full — cannot write audio file.',
52+
ExitCode.GENERAL,
53+
'Free up disk space and try again.',
54+
);
55+
}
56+
throw err;
57+
}
2458
if (quiet) {
2559
console.log(outPath);
2660
} else {

src/polling/poll.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ export async function poll<T>(config: Config, opts: PollOptions): Promise<T> {
3434

3535
if (opts.isFailed(data)) {
3636
spinner.stop('Failed.');
37+
// Include API status context to help users diagnose failures
38+
const status = opts.getStatus ? opts.getStatus(data) : 'failed';
39+
const extra = (data as Record<string, unknown>)?.base_resp
40+
? ` (${(data as { base_resp: { status_code?: number; status_msg?: string } }).base_resp.status_msg})`
41+
: '';
3742
throw new CLIError(
38-
'Task failed.',
43+
`Task ${status}.${extra}`,
3944
ExitCode.GENERAL,
45+
'Check the MiniMax dashboard or --verbose output for details.',
4046
);
4147
}
4248

0 commit comments

Comments
 (0)