forked from boos/BoosAPI
feat: implement proxy input file resolution and storage
- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types. - Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation. - Created tests for proxy file storage functionality in `proxyFileStore.test.ts`. - Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats. - Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests. - Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
@@ -149,4 +149,46 @@ describe('testRoutes proxy tester transport', () => {
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows multipart proxy uploads to /v1/files', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'file_123',
|
||||
object: 'file',
|
||||
bytes: 8,
|
||||
filename: 'notes.md',
|
||||
purpose: 'assistants',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose: 'assistants',
|
||||
},
|
||||
multipartFiles: [
|
||||
{
|
||||
field: 'file',
|
||||
name: 'notes.md',
|
||||
mimeType: 'text/markdown',
|
||||
dataUrl: 'data:text/markdown;base64,IyBoZWxsbw==',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit & { body?: { constructor?: { name?: string } } }];
|
||||
expect(url).toMatch(/\/v1\/files$/);
|
||||
expect(requestInit.body?.constructor?.name).toBe('FormData');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,46 @@ describe('/api/test proxy tester routes', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows multipart /v1/files uploads through the proxy tester', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'file_demo_123',
|
||||
object: 'file',
|
||||
filename: 'notes.txt',
|
||||
bytes: 11,
|
||||
purpose: 'user_data',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
multipartFields: {
|
||||
purpose: 'user_data',
|
||||
},
|
||||
multipartFiles: [
|
||||
{
|
||||
field: 'file',
|
||||
name: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
dataUrl: 'data:text/plain;base64,aGVsbG8gZmlsZXM=',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit & { body?: { constructor?: { name?: string } } }];
|
||||
expect(targetUrl).toBe(`http://127.0.0.1:${config.port}/v1/files`);
|
||||
expect(requestInit.method).toBe('POST');
|
||||
expect(requestInit.body?.constructor?.name).toBe('FormData');
|
||||
});
|
||||
|
||||
it('keeps legacy /api/test/chat wrapper working for responses payloads', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
object: 'response',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fetch, File, FormData } from 'undici';
|
||||
import { fetch, File as UndiciFile, FormData as UndiciFormData } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
type UndiciRequestInit = Parameters<typeof fetch>[1];
|
||||
@@ -90,6 +90,7 @@ const jobs = new Map<string, TestProxyJob>();
|
||||
|
||||
const ALLOWED_PROXY_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/v1\/chat\/completions(?:\?.*)?$/i,
|
||||
/^\/v1\/files(?:\/[^/?#]+(?:\/content)?)?(?:\?.*)?$/i,
|
||||
/^\/v1\/responses(?:\/compact)?(?:\?.*)?$/i,
|
||||
/^\/v1\/messages(?:\?.*)?$/i,
|
||||
/^\/v1\/embeddings(?:\?.*)?$/i,
|
||||
@@ -481,7 +482,9 @@ async function buildUpstreamRequestInit(
|
||||
}
|
||||
|
||||
if (envelope.requestKind === 'multipart') {
|
||||
const formData = new FormData();
|
||||
const FormDataCtor = globalThis.FormData ?? UndiciFormData;
|
||||
const FileCtor = globalThis.File ?? UndiciFile;
|
||||
const formData = new FormDataCtor();
|
||||
for (const [field, value] of Object.entries(envelope.multipartFields || {})) {
|
||||
formData.append(field, value);
|
||||
}
|
||||
@@ -490,14 +493,14 @@ async function buildUpstreamRequestInit(
|
||||
const bytes = Uint8Array.from(decoded.buffer);
|
||||
formData.append(
|
||||
file.field,
|
||||
new File([bytes], file.name, { type: file.mimeType || decoded.mimeType }),
|
||||
new FileCtor([bytes], file.name, { type: file.mimeType || decoded.mimeType }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
method: envelope.method,
|
||||
headers,
|
||||
body: formData,
|
||||
body: formData as any,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user