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:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
+42
View File
@@ -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');
});
});
+40
View File
@@ -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',
+7 -4
View File
@@ -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,
};
}