1
0
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:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
@@ -6,6 +6,7 @@ import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
@@ -393,6 +394,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -409,6 +411,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
@@ -424,6 +427,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -435,6 +439,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
}
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
@@ -19,6 +19,7 @@ async function clearAllBusinessData() {
await tx.delete(schema.modelAvailability).run();
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.proxyVideoTasks).run();
await tx.delete(schema.proxyFiles).run();
await tx.delete(schema.checkinLogs).run();
await tx.delete(schema.accountTokens).run();
await tx.delete(schema.accounts).run();
+115
View File
@@ -0,0 +1,115 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../db/index.js');
type StoreModule = typeof import('./proxyFileStore.js');
describe('proxyFileStore', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let store: StoreModule;
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-files-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const storeModule = await import('./proxyFileStore.js');
db = dbModule.db;
schema = dbModule.schema;
store = storeModule;
});
beforeEach(async () => {
await db.delete(schema.proxyFiles).run();
});
afterAll(async () => {
const dbModule = await import('../db/index.js');
await dbModule.closeDbConnections();
delete process.env.DATA_DIR;
});
it('creates, lists, and reads files within one owner namespace', async () => {
const created = await store.saveProxyFile({
ownerType: 'global_proxy_token',
ownerId: 'global',
purpose: 'assistants',
filename: 'sample.pdf',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.7 test file').toString('base64'),
});
expect(created.publicId).toMatch(/^file[_-]metapi[_-]/);
expect(created.byteSize).toBeGreaterThan(0);
expect(created.mimeType).toBe('application/pdf');
const listed = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
expect(listed).toHaveLength(1);
expect(listed[0]?.publicId).toBe(created.publicId);
const loaded = await store.getProxyFileByPublicIdForOwner(created.publicId, {
ownerType: 'global_proxy_token',
ownerId: 'global',
});
expect(loaded?.filename).toBe('sample.pdf');
expect(loaded?.contentBase64).toBe(Buffer.from('%PDF-1.7 test file').toString('base64'));
});
it('isolates files by owner namespace', async () => {
const managed = await store.saveProxyFile({
ownerType: 'managed_key',
ownerId: '12',
purpose: 'assistants',
filename: 'managed.json',
mimeType: 'application/json',
contentBase64: Buffer.from('{"ok":true}').toString('base64'),
});
await store.saveProxyFile({
ownerType: 'global_proxy_token',
ownerId: 'global',
purpose: 'assistants',
filename: 'global.json',
mimeType: 'application/json',
contentBase64: Buffer.from('{"scope":"global"}').toString('base64'),
});
const managedFiles = await store.listProxyFilesByOwner({ ownerType: 'managed_key', ownerId: '12' });
const globalFiles = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
expect(managedFiles.map((item) => item.publicId)).toEqual([managed.publicId]);
expect(globalFiles).toHaveLength(1);
expect(globalFiles[0]?.publicId).not.toBe(managed.publicId);
});
it('soft deletes files and hides them from active lookups', async () => {
const created = await store.saveProxyFile({
ownerType: 'managed_key',
ownerId: '99',
purpose: 'assistants',
filename: 'to-delete.txt',
mimeType: 'text/plain',
contentBase64: Buffer.from('delete me').toString('base64'),
});
const deleted = await store.softDeleteProxyFileByPublicIdForOwner(created.publicId, {
ownerType: 'managed_key',
ownerId: '99',
});
expect(deleted).toBe(true);
const listed = await store.listProxyFilesByOwner({ ownerType: 'managed_key', ownerId: '99' });
expect(listed).toEqual([]);
const loaded = await store.getProxyFileByPublicIdForOwner(created.publicId, {
ownerType: 'managed_key',
ownerId: '99',
});
expect(loaded).toBeNull();
});
});
+206
View File
@@ -0,0 +1,206 @@
import { and, desc, eq, isNull, or } from 'drizzle-orm';
import { createHash } from 'node:crypto';
import { db, schema } from '../db/index.js';
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
export const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
export type ProxyFileRecord = {
publicId: string;
ownerType: ProxyResourceOwner['ownerType'];
ownerId: string;
filename: string;
mimeType: string;
purpose: string | null;
byteSize: number;
sha256: string;
contentBase64: string;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
};
export type SaveProxyFileInput = ProxyResourceOwner & {
filename: string;
mimeType: string;
purpose?: string | null;
contentBase64: string;
byteSize?: number | null;
sha256?: string | null;
};
export type CreateProxyFileInput = {
owner: ProxyResourceOwner;
filename: string;
mimeType: string;
purpose?: string | null;
buffer: Buffer;
};
function buildPublicFileId(): string {
const timePart = Date.now().toString(36);
const randomPart = Math.random().toString(36).slice(2, 10);
return `${LOCAL_PROXY_FILE_ID_PREFIX}${timePart}-${randomPart}`;
}
function normalizeFilename(value: string): string {
const trimmed = value.trim();
return trimmed || 'upload.bin';
}
function toByteSize(contentBase64: string): number {
return Buffer.from(contentBase64, 'base64').byteLength;
}
function rowToRecord(row: typeof schema.proxyFiles.$inferSelect): ProxyFileRecord {
return {
publicId: row.publicId,
ownerType: row.ownerType as ProxyResourceOwner['ownerType'],
ownerId: row.ownerId,
filename: row.filename,
mimeType: row.mimeType,
purpose: row.purpose,
byteSize: row.byteSize,
sha256: row.sha256,
contentBase64: row.contentBase64,
createdAt: row.createdAt ?? null,
updatedAt: row.updatedAt ?? null,
deletedAt: row.deletedAt ?? null,
};
}
function ownerWhere(owner: ProxyResourceOwner) {
return and(
eq(schema.proxyFiles.ownerType, owner.ownerType),
eq(schema.proxyFiles.ownerId, owner.ownerId),
);
}
export async function saveProxyFile(input: SaveProxyFileInput): Promise<ProxyFileRecord> {
const publicId = buildPublicFileId();
const now = formatUtcSqlDateTime(new Date());
const filename = normalizeFilename(input.filename);
const byteSize = typeof input.byteSize === 'number' && Number.isFinite(input.byteSize)
? Math.max(0, Math.trunc(input.byteSize))
: toByteSize(input.contentBase64);
const sha256 = typeof input.sha256 === 'string' && input.sha256.trim().length > 0
? input.sha256.trim()
: createHash('sha256').update(Buffer.from(input.contentBase64, 'base64')).digest('hex');
await db.insert(schema.proxyFiles).values({
publicId,
ownerType: input.ownerType,
ownerId: input.ownerId,
filename,
mimeType: input.mimeType,
purpose: input.purpose?.trim() || null,
byteSize,
sha256,
contentBase64: input.contentBase64,
createdAt: now,
updatedAt: now,
deletedAt: null,
}).run();
return (await getProxyFileByPublicIdForOwner(publicId, input))!;
}
export async function createProxyFile(input: CreateProxyFileInput): Promise<ProxyFileRecord> {
return saveProxyFile({
ownerType: input.owner.ownerType,
ownerId: input.owner.ownerId,
filename: input.filename,
mimeType: input.mimeType,
purpose: input.purpose,
byteSize: input.buffer.byteLength,
sha256: createHash('sha256').update(input.buffer).digest('hex'),
contentBase64: input.buffer.toString('base64'),
});
}
export async function listProxyFilesByOwner(owner: ProxyResourceOwner): Promise<ProxyFileRecord[]> {
const rows = await db.select().from(schema.proxyFiles)
.where(and(ownerWhere(owner), isNull(schema.proxyFiles.deletedAt)))
.orderBy(desc(schema.proxyFiles.createdAt))
.all();
return rows.map(rowToRecord);
}
export async function getProxyFileByPublicId(publicId: string): Promise<ProxyFileRecord | null> {
const row = await db.select().from(schema.proxyFiles)
.where(and(
eq(schema.proxyFiles.publicId, publicId),
isNull(schema.proxyFiles.deletedAt),
))
.get();
return row ? rowToRecord(row) : null;
}
export async function getProxyFileByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<ProxyFileRecord | null> {
const row = await db.select().from(schema.proxyFiles)
.where(and(
eq(schema.proxyFiles.publicId, publicId),
ownerWhere(owner),
isNull(schema.proxyFiles.deletedAt),
))
.get();
return row ? rowToRecord(row) : null;
}
export async function getProxyFileContentByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<{ filename: string; mimeType: string; buffer: Buffer } | null> {
const record = await getProxyFileByPublicIdForOwner(publicId, owner);
if (!record) return null;
return {
filename: record.filename,
mimeType: record.mimeType,
buffer: Buffer.from(record.contentBase64, 'base64'),
};
}
export async function softDeleteProxyFileByPublicIdForOwner(
publicId: string,
owner: ProxyResourceOwner,
): Promise<boolean> {
const now = formatUtcSqlDateTime(new Date());
const result = await db.update(schema.proxyFiles)
.set({
deletedAt: now,
updatedAt: now,
})
.where(and(
eq(schema.proxyFiles.publicId, publicId),
ownerWhere(owner),
or(isNull(schema.proxyFiles.deletedAt), eq(schema.proxyFiles.deletedAt, '')),
))
.run();
return Number(result?.changes || 0) > 0;
}
export async function softDeleteProxyFileByPublicId(
publicId: string,
owner: ProxyResourceOwner,
): Promise<boolean> {
return softDeleteProxyFileByPublicIdForOwner(publicId, owner);
}
export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
const now = formatUtcSqlDateTime(new Date());
const result = await db.update(schema.proxyFiles)
.set({
deletedAt: now,
updatedAt: now,
})
.where(and(
eq(schema.proxyFiles.publicId, publicId),
or(isNull(schema.proxyFiles.deletedAt), eq(schema.proxyFiles.deletedAt, '')),
))
.run();
return Number(result?.changes || 0) > 0;
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const getProxyFileByPublicIdForOwnerMock = vi.fn();
vi.mock('./proxyFileStore.js', () => ({
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
LOCAL_PROXY_FILE_ID_PREFIX: 'file-metapi-',
}));
describe('proxyInputFileResolver', () => {
beforeEach(() => {
getProxyFileByPublicIdForOwnerMock.mockReset();
});
it('preserves non-local file ids without resolving them from the local store', async () => {
const { resolveResponsesBodyInputFiles } = await import('./proxyInputFileResolver.js');
const body = {
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
content: [
{
type: 'input_file',
file_id: 'file_external_123',
},
],
},
],
};
await expect(resolveResponsesBodyInputFiles(
body,
{ ownerType: 'global_proxy_token', ownerId: 'global' },
)).resolves.toEqual(body);
expect(getProxyFileByPublicIdForOwnerMock).not.toHaveBeenCalled();
});
it('resolves object-form responses input payloads with local file ids', async () => {
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
publicId: 'file-metapi-123',
filename: 'brief.pdf',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-local').toString('base64'),
});
const { resolveResponsesBodyInputFiles } = await import('./proxyInputFileResolver.js');
await expect(resolveResponsesBodyInputFiles(
{
model: 'gpt-5',
input: {
type: 'message',
role: 'user',
content: [
{
type: 'input_file',
file_id: 'file-metapi-123',
},
],
},
},
{ ownerType: 'managed_key', ownerId: '9' },
)).resolves.toEqual({
model: 'gpt-5',
input: {
type: 'message',
role: 'user',
content: [
{
type: 'input_file',
file_id: 'file-metapi-123',
filename: 'brief.pdf',
file_data: Buffer.from('%PDF-local').toString('base64'),
mime_type: 'application/pdf',
},
],
},
});
});
});
@@ -0,0 +1,320 @@
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
function inferMimeTypeFromFilename(filename: string): string {
const normalized = filename.toLowerCase();
if (normalized.endsWith('.pdf')) return 'application/pdf';
if (normalized.endsWith('.txt')) return 'text/plain';
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
if (normalized.endsWith('.json')) return 'application/json';
if (normalized.endsWith('.png')) return 'image/png';
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
if (normalized.endsWith('.gif')) return 'image/gif';
if (normalized.endsWith('.webp')) return 'image/webp';
if (normalized.endsWith('.wav')) return 'audio/wav';
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
if (normalized.endsWith('.m4a')) return 'audio/mp4';
if (normalized.endsWith('.ogg')) return 'audio/ogg';
if (normalized.endsWith('.webm')) return 'audio/webm';
return 'application/octet-stream';
}
function isSupportedMimeType(mimeType: string): boolean {
return mimeType === 'application/pdf'
|| mimeType === 'text/plain'
|| mimeType === 'text/markdown'
|| mimeType === 'application/json'
|| mimeType.startsWith('image/')
|| mimeType.startsWith('audio/');
}
function audioFormatFromMimeType(mimeType: string, filename: string): string {
const normalized = mimeType.toLowerCase();
if (normalized === 'audio/mpeg') return 'mp3';
if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return 'wav';
if (normalized === 'audio/mp4' || normalized === 'audio/x-m4a') return 'm4a';
if (normalized === 'audio/ogg') return 'ogg';
if (normalized === 'audio/webm') return 'webm';
const extension = filename.split('.').pop()?.trim().toLowerCase();
return extension || 'mp3';
}
function isLocalProxyFileId(fileId: string): boolean {
return fileId.startsWith(LOCAL_PROXY_FILE_ID_PREFIX);
}
type InputFileLike = {
fileId?: string;
filename?: string;
fileData?: string;
mimeType?: string;
};
export class ProxyInputFileResolutionError extends Error {
statusCode: number;
payload: unknown;
constructor(statusCode: number, message: string, type = 'invalid_request_error') {
super(message);
this.name = 'ProxyInputFileResolutionError';
this.statusCode = statusCode;
this.payload = {
error: {
message,
type,
},
};
}
}
function normalizeInputFileLike(item: Record<string, unknown>): InputFileLike | null {
const type = asTrimmedString(item.type).toLowerCase();
if (type !== 'file' && type !== 'input_file') return null;
const source = type === 'file' && isRecord(item.file)
? item.file
: item;
const fileId = asTrimmedString(source.file_id ?? item.file_id);
const filename = asTrimmedString(source.filename ?? item.filename);
const fileData = asTrimmedString(source.file_data ?? item.file_data);
const mimeType = asTrimmedString(source.mime_type ?? source.mimeType ?? item.mime_type ?? item.mimeType);
if (!fileId && !fileData) return null;
return {
...(fileId ? { fileId } : {}),
...(filename ? { filename } : {}),
...(fileData ? { fileData } : {}),
...(mimeType ? { mimeType } : {}),
};
}
async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourceOwner): Promise<{
fileId?: string;
filename: string;
fileData: string;
mimeType: string;
}> {
if (fileLike.fileData) {
const filename = fileLike.filename || 'upload.bin';
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
if (!isSupportedMimeType(mimeType)) {
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
}
return {
...(fileLike.fileId ? { fileId: fileLike.fileId } : {}),
filename,
fileData: fileLike.fileData,
mimeType,
};
}
if (fileLike.fileId) {
const stored = await getProxyFileByPublicIdForOwner(fileLike.fileId, owner);
if (!stored) {
throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error');
}
const mimeType = fileLike.mimeType || stored.mimeType || inferMimeTypeFromFilename(stored.filename);
if (!isSupportedMimeType(mimeType)) {
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
}
return {
fileId: fileLike.fileId,
filename: fileLike.filename || stored.filename,
fileData: stored.contentBase64,
mimeType,
};
}
const filename = fileLike.filename || 'upload.bin';
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
if (!fileLike.fileData) {
throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`);
}
if (!isSupportedMimeType(mimeType)) {
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
}
return {
filename,
fileData: fileLike.fileData,
mimeType,
};
}
function shouldResolveInlineFileLike(fileLike: InputFileLike): boolean {
if (fileLike.fileData) return true;
if (!fileLike.fileId) return false;
return isLocalProxyFileId(fileLike.fileId);
}
function toOpenAiResolvedBlock(file: { fileId?: string; filename: string; fileData: string; mimeType: string }): Record<string, unknown> {
if (file.mimeType.startsWith('image/')) {
return {
type: 'image_url',
image_url: `data:${file.mimeType};base64,${file.fileData}`,
};
}
if (file.mimeType.startsWith('audio/')) {
return {
type: 'input_audio',
input_audio: {
data: file.fileData,
format: audioFormatFromMimeType(file.mimeType, file.filename),
},
};
}
return {
type: 'file',
file: {
...(file.fileId ? { file_id: file.fileId } : {}),
filename: file.filename,
file_data: file.fileData,
mime_type: file.mimeType,
},
};
}
function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fileData: string; mimeType: string }): Record<string, unknown> {
if (file.mimeType.startsWith('image/')) {
return {
type: 'input_image',
image_url: `data:${file.mimeType};base64,${file.fileData}`,
};
}
if (file.mimeType.startsWith('audio/')) {
return {
type: 'input_audio',
input_audio: {
data: file.fileData,
format: audioFormatFromMimeType(file.mimeType, file.filename),
},
};
}
return {
type: 'input_file',
...(file.fileId ? { file_id: file.fileId } : {}),
filename: file.filename,
file_data: file.fileData,
mime_type: file.mimeType,
};
}
async function resolveOpenAiMessageContent(content: unknown, owner: ProxyResourceOwner): Promise<unknown> {
if (!Array.isArray(content)) return content;
return Promise.all(content.map(async (item) => {
if (!isRecord(item)) return cloneJsonValue(item);
const fileLike = normalizeInputFileLike(item);
if (!fileLike || !shouldResolveInlineFileLike(fileLike)) return cloneJsonValue(item);
return toOpenAiResolvedBlock(await resolveInputFileLike(fileLike, owner));
}));
}
async function resolveResponsesMessageContent(content: unknown, owner: ProxyResourceOwner): Promise<unknown> {
if (!Array.isArray(content)) return content;
return Promise.all(content.map(async (item) => {
if (!isRecord(item)) return cloneJsonValue(item);
const fileLike = normalizeInputFileLike(item);
if (!fileLike || !shouldResolveInlineFileLike(fileLike)) return cloneJsonValue(item);
return toResponsesResolvedBlock(await resolveInputFileLike(fileLike, owner));
}));
}
export async function resolveOpenAiBodyInputFiles(
body: Record<string, unknown>,
owner: ProxyResourceOwner,
): Promise<Record<string, unknown>> {
const next = cloneJsonValue(body);
if (!Array.isArray(next.messages)) return next;
next.messages = await Promise.all(next.messages.map(async (message) => {
if (!isRecord(message)) return cloneJsonValue(message);
if (Array.isArray(message.content)) {
return {
...message,
content: await resolveOpenAiMessageContent(message.content, owner),
};
}
if (isRecord(message.content)) {
const fileLike = normalizeInputFileLike(message.content);
if (!fileLike) return cloneJsonValue(message);
return {
...message,
content: [toOpenAiResolvedBlock(await resolveInputFileLike(fileLike, owner))],
};
}
return cloneJsonValue(message);
}));
return next;
}
export async function resolveResponsesBodyInputFiles(
body: Record<string, unknown>,
owner: ProxyResourceOwner,
): Promise<Record<string, unknown>> {
const next = cloneJsonValue(body);
const resolveResponsesInputItem = async (item: unknown): Promise<unknown> => {
if (!isRecord(item)) return cloneJsonValue(item);
const fileLike = normalizeInputFileLike(item);
if (fileLike && shouldResolveInlineFileLike(fileLike)) {
return toResponsesResolvedBlock(await resolveInputFileLike(fileLike, owner));
}
if (Array.isArray(item.content)) {
return {
...item,
content: await resolveResponsesMessageContent(item.content, owner),
};
}
if (isRecord(item.content)) {
const nestedFileLike = normalizeInputFileLike(item.content);
if (!nestedFileLike) return cloneJsonValue(item);
return {
...item,
content: [toResponsesResolvedBlock(await resolveInputFileLike(nestedFileLike, owner))],
};
}
return cloneJsonValue(item);
};
if (Array.isArray(next.input)) {
next.input = await Promise.all(next.input.map((item) => resolveResponsesInputItem(item)));
return next;
}
if (isRecord(next.input)) {
next.input = await resolveResponsesInputItem(next.input);
}
return next;
}
export function hasNonImageFileInputInOpenAiBody(body: Record<string, unknown>): boolean {
const messages = Array.isArray(body.messages) ? body.messages : [];
return messages.some((message) => {
if (!isRecord(message)) return false;
const content = message.content;
if (isRecord(content)) {
const type = asTrimmedString(content.type).toLowerCase();
return type === 'file' || type === 'input_file';
}
if (!Array.isArray(content)) return false;
return content.some((item) => isRecord(item) && ['file', 'input_file'].includes(asTrimmedString(item.type).toLowerCase()));
});
}