Skip to content
Closed
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
79 changes: 47 additions & 32 deletions src/client/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,41 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv

const decoder = new TextDecoder();
let buffer = '';
let event: Partial<ServerSentEvent> = {};

const processLine = (rawLine: string): ServerSentEvent | undefined => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;

if (line === '') {
const completed = event.data !== undefined
? { data: event.data, event: event.event, id: event.id }
: undefined;
event = {};
return completed;
}

if (line.startsWith(':')) return undefined;

const colonIndex = line.indexOf(':');
if (colonIndex === -1) return undefined;

const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).trimStart();

switch (field) {
case 'data':
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
break;
case 'event':
event.event = value;
break;
case 'id':
event.id = value;
break;
}

return undefined;
};

try {
while (true) {
Expand All @@ -21,45 +56,25 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
const lines = buffer.split('\n');
buffer = lines.pop() || '';

let event: Partial<ServerSentEvent> = {};

for (const line of lines) {
if (line === '') {
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
event = {};
continue;
const completed = processLine(line);
if (completed) {
yield completed;
}
}
}

if (line.startsWith(':')) continue; // comment

const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;

const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).trimStart();
buffer += decoder.decode();

switch (field) {
case 'data':
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
break;
case 'event':
event.event = value;
break;
case 'id':
event.id = value;
break;
}
if (buffer.length > 0) {
const completed = processLine(buffer);
if (completed) {
yield completed;
}
}

// Flush remaining
if (buffer.trim() && buffer.includes('data:')) {
const colonIndex = buffer.indexOf(':');
if (colonIndex !== -1) {
yield { data: buffer.slice(colonIndex + 1).trimStart() };
}
if (event.data !== undefined) {
yield { data: event.data, event: event.event, id: event.id };
}
} finally {
reader.releaseLock();
Expand Down
2 changes: 1 addition & 1 deletion src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { request, requestJson } from '../../client/http';
import { musicEndpoint } from '../../client/endpoints';
import { formatOutput, detectOutputFormat, dryRun } from '../../output/formatter';
import { detectOutputFormat, dryRun } from '../../output/formatter';
import { saveAudioOutput } from '../../output/audio';
import { readTextFromPathOrStdin } from '../../utils/fs';
import { MUSIC_FORMATS, formatList, validateAudioFormat } from '../../utils/audio-formats';
Expand Down
9 changes: 9 additions & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema';
import { ensureConfigDir, getConfigPath } from './paths';
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import type { GlobalFlags } from '../types/flags';

export function readConfigFile(): ConfigFile {
Expand Down Expand Up @@ -33,6 +35,13 @@ export function loadConfig(flags: GlobalFlags): Config {
const fileApiKey = file.api_key;

const explicitRegion = (flags.region as string) || process.env.MINIMAX_REGION || undefined;
if (explicitRegion && !(explicitRegion in REGIONS)) {
throw new CLIError(
`Invalid region "${explicitRegion}". Valid values: ${Object.keys(REGIONS).join(', ')}`,
ExitCode.USAGE,
);
}

const cachedRegion = file.region;
const region = (explicitRegion || cachedRegion || 'global') as Region;

Expand Down
7 changes: 7 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import quotaShow from './commands/quota/show';
import configShow from './commands/config/show';
import configSet from './commands/config/set';
import configExportSchema from './commands/config/export-schema';
import fileUpload from './commands/file/upload';
import fileList from './commands/file/list';
import fileDelete from './commands/file/delete';
import update from './commands/update';
import help from './commands/help';

Expand Down Expand Up @@ -197,6 +200,7 @@ ${b('Resources:')}
${a('vision')} ${d('Image understanding (describe)')}
${a('quota')} ${d('Usage quotas (show)')}
${a('config')} ${d('CLI configuration (show, set, export-schema)')}
${a('file')} ${d('File storage (upload, list, delete)')}
${a('update')} ${d('Update mmx to a newer version')}

${b('Global Flags:')}
Expand Down Expand Up @@ -284,6 +288,9 @@ export const registry = new CommandRegistry({
'config show': configShow,
'config set': configSet,
'config export-schema': configExportSchema,
'file upload': fileUpload,
'file list': fileList,
'file delete': fileDelete,
'update': update,
'help': help,
});
39 changes: 39 additions & 0 deletions test/client/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,45 @@ describe('parseSSE', () => {
expect(events2[1].data).toBe('[DONE]');
});

it('preserves event fields split across network chunks', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('id: 7\nevent: mes'));
controller.enqueue(encoder.encode('sage\ndata: hel'));
controller.enqueue(encoder.encode('lo\n\n'));
controller.close();
},
});

const events = await collectEvents(new Response(body));

expect(events).toEqual([{ id: '7', event: 'message', data: 'hello' }]);
});

it('preserves multi-line data split across network chunks', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('data: first\n'));
controller.enqueue(encoder.encode('data: sec'));
controller.enqueue(encoder.encode('ond\n\n'));
controller.close();
},
});

const events = await collectEvents(new Response(body));

expect(events).toEqual([{ data: 'first\nsecond' }]);
});

it('handles CRLF line endings', async () => {
const body = 'event: message\r\ndata: hello\r\n\r\n';
const events = await collectEvents(new Response(body));

expect(events).toEqual([{ event: 'message', data: 'hello' }]);
});

// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions test/commands/aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ describe('command aliases', () => {
const synthesize = registry.resolve(['speech', 'synthesize']);
expect(generate.command).toBe(synthesize.command);
});

it('resolves file storage commands', () => {
expect(registry.resolve(['file', 'upload']).command.name).toBe('file upload');
expect(registry.resolve(['file', 'list']).command.name).toBe('file list');
expect(registry.resolve(['file', 'delete']).command.name).toBe('file delete');
});
});

describe('text chat --prompt alias', () => {
Expand Down
135 changes: 135 additions & 0 deletions test/commands/file/delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect, afterEach } from 'bun:test';
import { default as deleteCommand } from '../../../src/commands/file/delete';
import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server';
import type { Config } from '../../../src/config/schema';
import type { GlobalFlags } from '../../../src/types/flags';

function makeConfig(overrides: Partial<Config> = {}): Config {
return {
apiKey: 'test-key',
region: 'global',
baseUrl: 'https://api.mmx.io',
output: 'text',
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: false,
nonInteractive: true,
async: false,
...overrides,
};
}

const baseFlags: GlobalFlags = {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: false,
help: false,
nonInteractive: true,
async: false,
};

async function captureStdout(fn: () => Promise<void>): Promise<string> {
const originalWrite = process.stdout.write;
let output = '';
process.stdout.write = ((chunk: string | Uint8Array) => {
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
return true;
}) as typeof process.stdout.write;

try {
await fn();
return output;
} finally {
process.stdout.write = originalWrite;
}
}

describe('file delete command', () => {
let server: MockServer;

afterEach(() => {
server?.close();
});

it('has correct name', () => {
expect(deleteCommand.name).toBe('file delete');
});

it('requires file-id argument', async () => {
await expect(
deleteCommand.execute(makeConfig({ dryRun: true }), baseFlags),
).rejects.toThrow('Missing required argument: --file-id');
});

it('handles dry run', async () => {
const output = await captureStdout(async () => {
await deleteCommand.execute(makeConfig({ dryRun: true, output: 'json' }), {
...baseFlags,
dryRun: true,
fileId: 'file-123',
});
});

const parsed = JSON.parse(output);
expect(parsed.request.delete_file).toBe('file-123');
});

it('sends DELETE request and prints result', async () => {
let method = '';
let fileId = '';
server = createMockServer({
routes: {
'/v1/files': (req) => {
method = req.method;
fileId = new URL(req.url).searchParams.get('file_id') ?? '';
return jsonResponse({
base_resp: { status_code: 0, status_msg: '' },
id: fileId,
object: 'file',
deleted: true,
});
},
},
});

const output = await captureStdout(async () => {
await deleteCommand.execute(makeConfig({ baseUrl: server.url, output: 'json' }), {
...baseFlags,
fileId: 'file-123',
});
});

const parsed = JSON.parse(output);
expect(method).toBe('DELETE');
expect(fileId).toBe('file-123');
expect(parsed).toEqual({ id: 'file-123', deleted: true });
});

it('prints compact status in quiet mode', async () => {
server = createMockServer({
routes: {
'/v1/files': () => jsonResponse({
base_resp: { status_code: 0, status_msg: '' },
id: 'file-123',
object: 'file',
deleted: true,
}),
},
});

const output = await captureStdout(async () => {
await deleteCommand.execute(makeConfig({ baseUrl: server.url, quiet: true }), {
...baseFlags,
quiet: true,
fileId: 'file-123',
});
});

expect(output).toBe('deleted\n');
});
});
Loading
Loading