Skip to content

Commit e3cb7b9

Browse files
authored
Merge pull request #229 from MiniMax-AI/codex/share-file-operations
refactor: share file upload operation
2 parents 73c2ef8 + f1b44cf commit e3cb7b9

5 files changed

Lines changed: 180 additions & 54 deletions

File tree

‎src/commands/file/upload.ts‎

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@ import { defineCommand } from '../../command';
22
import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
44
import { requestJson } from '../../client/http';
5-
import { fileUploadEndpoint } from '../../client/endpoints';
5+
import { resolveFileUploadPath, uploadFile } from '../../files/upload';
66
import { formatOutput, detectOutputFormat } from '../../output/formatter';
77
import { isInteractive } from '../../utils/env';
88
import { promptText, failIfMissing } from '../../utils/prompt';
99
import type { Config } from '../../config/schema';
1010
import type { GlobalFlags } from '../../types/flags';
1111
import type { FileUploadResponse } from '../../types/api';
12-
import { existsSync } from 'fs';
13-
import { readFile } from 'fs/promises';
14-
import { resolve, basename } from 'path';
1512

1613
export default defineCommand({
1714
name: 'file upload',
@@ -40,10 +37,9 @@ export default defineCommand({
4037
}
4138
}
4239

43-
const fullPath = resolve(filePath);
44-
if (!existsSync(fullPath)) {
45-
throw new CLIError(`File not found: ${fullPath}`, ExitCode.USAGE);
46-
}
40+
const createFileNotFoundError = (fullPath: string) =>
41+
new CLIError(`File not found: ${fullPath}`, ExitCode.USAGE);
42+
const fullPath = resolveFileUploadPath(filePath, createFileNotFoundError);
4743

4844
const purpose = (flags.purpose as string) || 'retrieval';
4945
const format = detectOutputFormat(config.output);
@@ -53,19 +49,12 @@ export default defineCommand({
5349
return;
5450
}
5551

56-
const formData = new FormData();
57-
// Read file using Node.js fs/promises (compatible with both Node and Bun)
58-
const fileData = await readFile(fullPath);
59-
const fileName = basename(fullPath);
60-
const fileBlob = new Blob([fileData]);
61-
formData.append('file', fileBlob, fileName);
62-
formData.append('purpose', purpose);
63-
64-
const url = fileUploadEndpoint(config.baseUrl);
65-
const response = await requestJson<FileUploadResponse>(config, {
66-
url,
67-
method: 'POST',
68-
body: formData,
52+
const response = await uploadFile({
53+
filePath: fullPath,
54+
purpose,
55+
baseUrl: config.baseUrl,
56+
requestJson: (opts) => requestJson<FileUploadResponse>(config, opts),
57+
createFileNotFoundError,
6958
});
7059

7160
if (config.quiet) {

‎src/files/upload.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { existsSync } from 'node:fs';
2+
import { readFile } from 'node:fs/promises';
3+
import { basename, resolve } from 'node:path';
4+
import { fileUploadEndpoint } from '../client/endpoints';
5+
import type { RequestOpts } from '../client/http';
6+
import type { FileUploadResponse } from '../types/api';
7+
8+
type RequestFileUpload = (opts: RequestOpts) => Promise<FileUploadResponse>;
9+
type CreateFileNotFoundError = (fullPath: string) => Error;
10+
11+
interface UploadFileOptions {
12+
filePath: string;
13+
purpose: string;
14+
baseUrl: string;
15+
requestJson: RequestFileUpload;
16+
createFileNotFoundError: CreateFileNotFoundError;
17+
}
18+
19+
export function resolveFileUploadPath(
20+
filePath: string,
21+
createFileNotFoundError: CreateFileNotFoundError,
22+
): string {
23+
const fullPath = resolve(filePath);
24+
if (!existsSync(fullPath)) {
25+
throw createFileNotFoundError(fullPath);
26+
}
27+
return fullPath;
28+
}
29+
30+
export async function uploadFile({
31+
filePath,
32+
purpose,
33+
baseUrl,
34+
requestJson,
35+
createFileNotFoundError,
36+
}: UploadFileOptions): Promise<FileUploadResponse> {
37+
const fullPath = resolveFileUploadPath(filePath, createFileNotFoundError);
38+
const fileData = await readFile(fullPath);
39+
40+
const formData = new FormData();
41+
formData.append('file', new Blob([fileData]), basename(fullPath));
42+
formData.append('purpose', purpose);
43+
44+
return requestJson({
45+
url: fileUploadEndpoint(baseUrl),
46+
method: 'POST',
47+
body: formData,
48+
});
49+
}

‎src/sdk/file/index.ts‎

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { existsSync } from 'node:fs';
2-
import { readFile } from 'node:fs/promises';
3-
import { resolve, basename } from 'node:path';
41
import { Client } from '../client';
52
import {
6-
fileUploadEndpoint,
73
fileListEndpoint,
84
fileDeleteEndpoint,
95
fileRetrieveEndpoint,
106
} from '../../client/endpoints';
7+
import { uploadFile } from '../../files/upload';
118
import type {
129
FileUploadResponse,
1310
FileListResponse,
@@ -25,23 +22,13 @@ export class FileSDK extends Client {
2522
* @param purpose - File purpose, defaults to `"retrieval"`.
2623
*/
2724
async upload(filePath: string, purpose = 'retrieval'): Promise<FileUploadResponse> {
28-
const fullPath = resolve(filePath);
29-
if (!existsSync(fullPath)) {
30-
throw new SDKError(`File not found: ${fullPath}`, ExitCode.USAGE);
31-
}
32-
33-
const fileData = await readFile(fullPath);
34-
const fileName = basename(fullPath);
35-
36-
const formData = new FormData();
37-
formData.append('file', new Blob([fileData]), fileName);
38-
formData.append('purpose', purpose);
39-
40-
const url = fileUploadEndpoint(this.config.baseUrl);
41-
return this.requestJson<FileUploadResponse>({
42-
url,
43-
method: 'POST',
44-
body: formData,
25+
return uploadFile({
26+
filePath,
27+
purpose,
28+
baseUrl: this.config.baseUrl,
29+
requestJson: (opts) => this.requestJson<FileUploadResponse>(opts),
30+
createFileNotFoundError: (fullPath) =>
31+
new SDKError(`File not found: ${fullPath}`, ExitCode.USAGE),
4532
});
4633
}
4734

‎test/commands/file/upload.test.ts‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,19 @@ describe('file upload command', () => {
5858
});
5959

6060
it('throws when file does not exist', async () => {
61-
await expect(
62-
uploadCommand.execute(baseConfig, { ...baseFlags, file: '/tmp/nonexistent-file-xxxxx.bin' }),
63-
).rejects.toThrow('File not found');
61+
try {
62+
await uploadCommand.execute(baseConfig, {
63+
...baseFlags,
64+
file: '/tmp/nonexistent-file-xxxxx.bin',
65+
});
66+
throw new Error('Expected upload to reject');
67+
} catch (error) {
68+
expect(error).toMatchObject({
69+
name: 'CLIError',
70+
message: expect.stringContaining('File not found'),
71+
exitCode: 2,
72+
});
73+
}
6474
});
6575

6676
it('shows dry-run output with file info', async () => {
@@ -82,4 +92,57 @@ describe('file upload command', () => {
8292
rmSync(tempDir, { recursive: true, force: true });
8393
}
8494
});
95+
96+
it('uploads through the shared multipart operation and formats the response', async () => {
97+
const tempDir = mkdtempSync(join(tmpdir(), 'mmx-upload-test-'));
98+
const filePath = join(tempDir, 'fixture.txt');
99+
writeFileSync(filePath, 'shared upload contents');
100+
const originalFetch = globalThis.fetch;
101+
let requestUrl = '';
102+
let requestInit: RequestInit | undefined;
103+
104+
globalThis.fetch = (async (input, init) => {
105+
requestUrl = String(input);
106+
requestInit = init;
107+
return new Response(JSON.stringify({
108+
base_resp: { status_code: 0, status_msg: 'success' },
109+
file: {
110+
file_id: 'cli-file-id',
111+
bytes: 22,
112+
created_at: 1,
113+
filename: 'fixture.txt',
114+
purpose: 'vision',
115+
},
116+
}), {
117+
status: 200,
118+
headers: { 'Content-Type': 'application/json' },
119+
});
120+
}) as typeof fetch;
121+
122+
try {
123+
const captured = await captureStdout(async () => {
124+
await uploadCommand.execute(baseConfig, {
125+
...baseFlags,
126+
file: filePath,
127+
purpose: 'vision',
128+
});
129+
});
130+
131+
expect(requestUrl).toBe('https://api.mmx.io/v1/files/upload');
132+
expect(requestInit?.method).toBe('POST');
133+
expect(requestInit?.headers).toMatchObject({ Authorization: 'Bearer test-key' });
134+
expect(requestInit?.body).toBeInstanceOf(FormData);
135+
136+
const body = requestInit?.body as FormData;
137+
const uploadedFile = body.get('file');
138+
expect(uploadedFile).toBeInstanceOf(Blob);
139+
expect((uploadedFile as File).name).toBe('fixture.txt');
140+
expect(await (uploadedFile as Blob).text()).toBe('shared upload contents');
141+
expect(body.get('purpose')).toBe('vision');
142+
expect(captured).toContain('cli-file-id');
143+
} finally {
144+
globalThis.fetch = originalFetch;
145+
rmSync(tempDir, { recursive: true, force: true });
146+
}
147+
});
85148
});

‎test/sdk/file.test.ts‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,61 @@ import { tmpdir } from 'node:os';
77
describe('FileSDK', () => {
88
it('throws SDKError when file does not exist', async () => {
99
const sdk = new FileSDK({ apiKey: 'sk-test', region: 'global' });
10-
await expect(sdk.upload('/tmp/nonexistent-file-xxxxx.bin', 'retrieval'))
11-
.rejects
12-
.toThrow('File not found');
10+
try {
11+
await sdk.upload('/tmp/nonexistent-file-xxxxx.bin', 'retrieval');
12+
throw new Error('Expected upload to reject');
13+
} catch (error) {
14+
expect(error).toMatchObject({
15+
name: 'SDKError',
16+
message: expect.stringContaining('File not found'),
17+
exitCode: 2,
18+
});
19+
}
1320
});
1421

15-
it('gets past file existence check for a valid file', async () => {
22+
it('uploads through the shared multipart operation and returns the response', async () => {
1623
const tmpFile = join(tmpdir(), 'mmx-sdk-test-upload.txt');
1724
writeFileSync(tmpFile, 'hello world');
25+
const originalFetch = globalThis.fetch;
26+
let requestUrl = '';
27+
let requestInit: RequestInit | undefined;
28+
29+
globalThis.fetch = (async (input, init) => {
30+
requestUrl = String(input);
31+
requestInit = init;
32+
return new Response(JSON.stringify({
33+
base_resp: { status_code: 0, status_msg: 'success' },
34+
file: {
35+
file_id: 'sdk-file-id',
36+
bytes: 11,
37+
created_at: 1,
38+
filename: 'mmx-sdk-test-upload.txt',
39+
purpose: 'retrieval',
40+
},
41+
}), {
42+
status: 200,
43+
headers: { 'Content-Type': 'application/json' },
44+
});
45+
}) as typeof fetch;
1846

1947
try {
2048
const sdk = new FileSDK({ apiKey: 'sk-test', region: 'global' });
21-
await sdk.upload(tmpFile, 'retrieval');
22-
// Should not reach here (no mock server), but if it does, fail informatively
23-
} catch (err) {
24-
// Must NOT be "File not found" — proves file existence check passed
25-
expect((err as Error).message).not.toContain('File not found');
49+
const response = await sdk.upload(tmpFile, 'retrieval');
50+
51+
expect(response.file.file_id).toBe('sdk-file-id');
52+
expect(requestUrl).toBe('https://api.minimax.io/v1/files/upload');
53+
expect(requestInit?.method).toBe('POST');
54+
expect(requestInit?.headers).toMatchObject({ Authorization: 'Bearer sk-test' });
55+
expect(requestInit?.body).toBeInstanceOf(FormData);
56+
57+
const body = requestInit?.body as FormData;
58+
const uploadedFile = body.get('file');
59+
expect(uploadedFile).toBeInstanceOf(Blob);
60+
expect((uploadedFile as File).name).toBe('mmx-sdk-test-upload.txt');
61+
expect(await (uploadedFile as Blob).text()).toBe('hello world');
62+
expect(body.get('purpose')).toBe('retrieval');
2663
} finally {
64+
globalThis.fetch = originalFetch;
2765
if (existsSync(tmpFile)) unlinkSync(tmpFile);
2866
}
2967
});

0 commit comments

Comments
 (0)