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
+38
View File
@@ -7,6 +7,7 @@ import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
import * as schema from './schema.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { config } from '../config.js';
import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
@@ -25,6 +26,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'route_channels',
'proxy_logs',
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys',
'events',
]);
@@ -183,6 +185,35 @@ function ensureProxyVideoTaskSchema() {
`);
}
function ensureProxyFileSchema() {
const sqlite = requireSqliteConnection();
sqlite.exec(`
CREATE TABLE IF NOT EXISTS proxy_files (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
public_id text NOT NULL,
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 DEFAULT (datetime('now')),
updated_at text DEFAULT (datetime('now')),
deleted_at text
);
`);
sqlite.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS proxy_files_public_id_unique
ON proxy_files(public_id);
`);
sqlite.exec(`
CREATE INDEX IF NOT EXISTS proxy_files_owner_lookup_idx
ON proxy_files(owner_type, owner_id, deleted_at);
`);
}
function ensureSiteStatusSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
@@ -355,6 +386,12 @@ export async function ensureRouteGroupingCompatibilityColumns(): Promise<void> {
await ensureRouteGroupingSchemaCompatibility(inspector);
}
export async function ensureProxyFileCompatibilityColumns(): Promise<void> {
const inspector = createRuntimeSchemaInspector();
if (!inspector) return;
await ensureProxyFileSchemaCompatibility(inspector);
}
function ensureRouteGroupingSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('token_routes') || !tableExists('route_channels')) {
@@ -770,6 +807,7 @@ function initSqliteDb() {
ensureDownstreamApiKeySchema();
ensureProxyLogBillingDetailsSchema();
ensureProxyVideoTaskSchema();
ensureProxyFileSchema();
const rawDb = drizzleSqliteProxy(
(sqlText, params, method) => sqliteProxyQuery(sqlText, params, method as SqlMethod),
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
ensureProxyFileSchemaCompatibility,
type ProxyFileSchemaInspector,
} from './proxyFileSchemaCompatibility.js';
function createInspector(
dialect: ProxyFileSchemaInspector['dialect'],
options?: {
hasTable?: boolean;
existingColumns?: string[];
},
) {
const executedSql: string[] = [];
const hasTable = options?.hasTable ?? false;
const existingColumns = new Set(options?.existingColumns ?? []);
const inspector: ProxyFileSchemaInspector = {
dialect,
async tableExists(table) {
return table === 'proxy_files' && hasTable;
},
async columnExists(table, column) {
return table === 'proxy_files' && existingColumns.has(column);
},
async execute(sqlText) {
executedSql.push(sqlText);
},
};
return { inspector, executedSql };
}
describe('ensureProxyFileSchemaCompatibility', () => {
it.each([
{
dialect: 'postgres' as const,
createPattern: /create table/i,
uniqueIndexPattern: /create unique index/i,
ownerIndexPattern: /owner_lookup_idx/i,
},
{
dialect: 'mysql' as const,
createPattern: /create table/i,
uniqueIndexPattern: /create unique index/i,
ownerIndexPattern: /owner_lookup_idx/i,
},
{
dialect: 'sqlite' as const,
createPattern: /create table/i,
uniqueIndexPattern: /create unique index/i,
ownerIndexPattern: /owner_lookup_idx/i,
},
])('creates proxy_files table and indexes for $dialect', async ({ dialect, createPattern, uniqueIndexPattern, ownerIndexPattern }) => {
const { inspector, executedSql } = createInspector(dialect);
await ensureProxyFileSchemaCompatibility(inspector);
expect(executedSql.some((sqlText) => createPattern.test(sqlText) && /proxy_files/i.test(sqlText))).toBe(true);
expect(executedSql.some((sqlText) => uniqueIndexPattern.test(sqlText) && /proxy_files_public_id_unique/i.test(sqlText))).toBe(true);
expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true);
});
it('adds missing columns on existing table before ensuring indexes', async () => {
const { inspector, executedSql } = createInspector('postgres', {
hasTable: true,
existingColumns: ['public_id', 'owner_type', 'owner_id'],
});
await ensureProxyFileSchemaCompatibility(inspector);
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "purpose"'))).toBe(true);
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "filename"'))).toBe(true);
expect(executedSql.some((sqlText) => sqlText.includes('ADD COLUMN "deleted_at"'))).toBe(true);
expect(executedSql.some((sqlText) => sqlText.includes('proxy_files_public_id_unique'))).toBe(true);
});
});
@@ -0,0 +1,170 @@
export type ProxyFileSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface ProxyFileSchemaInspector {
dialect: ProxyFileSchemaDialect;
tableExists(table: string): Promise<boolean>;
columnExists(table: string, column: string): Promise<boolean>;
execute(sqlText: string): Promise<void>;
}
type ProxyFileColumnCompatibilitySpec = {
column: string;
addSql: Record<ProxyFileSchemaDialect, string>;
};
const CREATE_TABLE_SQL: Record<ProxyFileSchemaDialect, string> = {
sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "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)',
postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "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)',
mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` TEXT NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)',
};
const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
{
column: 'public_id',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `public_id` TEXT NULL',
},
},
{
column: 'owner_type',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_type` TEXT NULL',
},
},
{
column: 'owner_id',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `owner_id` TEXT NULL',
},
},
{
column: 'filename',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `filename` TEXT NULL',
},
},
{
column: 'mime_type',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `mime_type` TEXT NULL',
},
},
{
column: 'purpose',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `purpose` TEXT NULL',
},
},
{
column: 'byte_size',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `byte_size` INTEGER NULL',
},
},
{
column: 'sha256',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `sha256` TEXT NULL',
},
},
{
column: 'content_base64',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `content_base64` LONGTEXT NULL',
},
},
{
column: 'created_at',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `created_at` TEXT NULL',
},
},
{
column: 'updated_at',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `updated_at` TEXT NULL',
},
},
{
column: 'deleted_at',
addSql: {
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
postgres: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
mysql: 'ALTER TABLE `proxy_files` ADD COLUMN `deleted_at` TEXT NULL',
},
},
];
const CREATE_INDEX_SQL: Record<ProxyFileSchemaDialect, string[]> = {
sqlite: [
'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
],
postgres: [
'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
],
mysql: [
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`(191))',
],
};
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
}
return String(error || '');
}
function isDuplicateSchemaError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('already exists') || lowered.includes('duplicate') || lowered.includes('relation');
}
async function executeIgnoreDuplicate(inspector: ProxyFileSchemaInspector, sqlText: string): Promise<void> {
try {
await inspector.execute(sqlText);
} catch (error) {
if (!isDuplicateSchemaError(error)) {
throw error;
}
}
}
export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise<void> {
if (!await inspector.tableExists('proxy_files')) {
await executeIgnoreDuplicate(inspector, CREATE_TABLE_SQL[inspector.dialect]);
} else {
for (const spec of COLUMN_COMPATIBILITY_SPECS) {
if (await inspector.columnExists('proxy_files', spec.column)) continue;
await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]);
}
}
for (const sqlText of CREATE_INDEX_SQL[inspector.dialect]) {
await executeIgnoreDuplicate(inspector, sqlText);
}
}
+19
View File
@@ -191,6 +191,25 @@ export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
createdAtIdx: index('proxy_video_tasks_created_at_idx').on(table.createdAt),
}));
export const proxyFiles = sqliteTable('proxy_files', {
id: integer('id').primaryKey({ autoIncrement: true }),
publicId: text('public_id').notNull(),
ownerType: text('owner_type').notNull(),
ownerId: text('owner_id').notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type').notNull(),
purpose: text('purpose'),
byteSize: integer('byte_size').notNull(),
sha256: text('sha256').notNull(),
contentBase64: text('content_base64').notNull(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
deletedAt: text('deleted_at'),
}, (table) => ({
publicIdUnique: uniqueIndex('proxy_files_public_id_unique').on(table.publicId),
ownerLookupIdx: index('proxy_files_owner_lookup_idx').on(table.ownerType, table.ownerId, table.deletedAt),
}));
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value'), // JSON