Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ export function fileUploadEndpoint(baseUrl: string): string {
}

export function fileListEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/files`;
return `${baseUrl}/v1/files/list`;
}

export function fileDeleteEndpoint(baseUrl: string, fileId: string): string {
return `${baseUrl}/v1/files?file_id=${encodeURIComponent(fileId)}`;
export function fileDeleteEndpoint(baseUrl: string): string {
return `${baseUrl}/v1/files/delete`;
}
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
11 changes: 6 additions & 5 deletions src/commands/file/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,21 @@ export default defineCommand({
return;
}

const url = fileDeleteEndpoint(config.baseUrl, fileId);
const url = fileDeleteEndpoint(config.baseUrl);
const response = await requestJson<FileDeleteResponse>(config, {
url,
method: 'DELETE',
method: 'POST',
body: { file_id: Number(fileId) },
});

if (config.quiet) {
process.stdout.write(response.deleted ? 'deleted\n' : 'failed\n');
process.stdout.write('deleted\n');
return;
}

process.stdout.write(formatOutput({
id: response.id,
deleted: response.deleted,
file_id: response.file_id,
deleted: true,
}, format) + '\n');
},
});
4 changes: 2 additions & 2 deletions src/commands/file/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export default defineCommand({
return;
}

if (!response.data || response.data.length === 0) {
if (!response.files || response.files.length === 0) {
process.stdout.write('No files found.\n');
return;
}

const tableData = response.data.map((f) => ({
const tableData = response.files.map((f) => ({
ID: f.file_id,
FILENAME: f.filename,
PURPOSE: f.purpose,
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,
});
6 changes: 2 additions & 4 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export interface FileUploadResponse {

export interface FileListResponse {
base_resp: BaseResp;
data: Array<{
files: Array<{
file_id: string;
bytes: number;
created_at: number;
Expand All @@ -285,9 +285,7 @@ export interface FileListResponse {

export interface FileDeleteResponse {
base_resp: BaseResp;
id: string;
object: string;
deleted: boolean;
file_id: number;
}

export interface FileRetrieveResponse {
Expand Down
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
Loading
Loading