mirror of
https://github.com/cita-777/metapi.git
synced 2026-10-31 03:02:19 +08:00
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:
@@ -26,6 +26,7 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
| `/v1/messages` | POST | Claude Messages |
|
||||
| `/v1/completions` | POST | OpenAI Completions(Legacy) |
|
||||
| `/v1/embeddings` | POST | 向量嵌入 |
|
||||
| `/v1/files` | POST / GET / DELETE | OpenAI Files 上传、列举、读取与删除 |
|
||||
| `/v1/images/generations` | POST | 图像生成 |
|
||||
| `/v1/models` | GET | 模型列表 |
|
||||
|
||||
@@ -52,6 +53,10 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
| 模型提供商 → OpenAI → API 地址 | `https://your-domain.com` |
|
||||
| API Key | `PROXY_TOKEN` |
|
||||
|
||||
> 说明:Metapi 现在支持标准 OpenAI `/v1/files` 文件链路。
|
||||
> Cherry Studio、Open WebUI 等客户端如果通过标准 OpenAI Files + `Responses` / Chat 文件块发送 PDF、Markdown、JSON、图片、音频附件,即可经由 Metapi 转发。
|
||||
> 如果某个客户端版本仍使用私有附件协议而不是标准 `/v1/files`,则仍需单独适配。
|
||||
|
||||
### Cursor
|
||||
|
||||
| 配置项 | 值 |
|
||||
@@ -142,7 +147,13 @@ curl -sS https://your-domain.com/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
|
||||
|
||||
# 3. 测试流式
|
||||
# 3. 测试文件上传
|
||||
curl -sS https://your-domain.com/v1/files \
|
||||
-H "Authorization: Bearer <PROXY_TOKEN>" \
|
||||
-F "purpose=assistants" \
|
||||
-F "file=@./sample.pdf"
|
||||
|
||||
# 4. 测试流式
|
||||
curl -sS https://your-domain.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer <PROXY_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@ import { fileURLToPath } from 'url';
|
||||
import { dirname, normalize, resolve, sep } from 'path';
|
||||
import {
|
||||
db,
|
||||
ensureProxyFileCompatibilityColumns,
|
||||
ensureProxyLogBillingDetailsColumn,
|
||||
ensureRouteGroupingCompatibilityColumns,
|
||||
ensureSiteCompatibilityColumns,
|
||||
@@ -219,6 +220,7 @@ try {
|
||||
|
||||
await ensureSiteCompatibilityColumns();
|
||||
await ensureRouteGroupingCompatibilityColumns();
|
||||
await ensureProxyFileCompatibilityColumns();
|
||||
const finalRows = await db.select().from(schema.settings).all();
|
||||
const finalMap = toSettingsMap(finalRows);
|
||||
applyRuntimeSettings(finalMap);
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface ProxyAuthContext {
|
||||
policy: DownstreamRoutingPolicy;
|
||||
}
|
||||
|
||||
export interface ProxyResourceOwner {
|
||||
ownerType: 'managed_key' | 'global_proxy_token';
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
||||
|
||||
function normalizeIp(rawIp: string | null | undefined): string {
|
||||
@@ -107,3 +112,20 @@ export async function proxyAuthMiddleware(request: FastifyRequest, reply: Fastif
|
||||
export function getProxyAuthContext(request: FastifyRequest): ProxyAuthContext | null {
|
||||
return proxyAuthContextByRequest.get(request) || null;
|
||||
}
|
||||
|
||||
export function getProxyResourceOwner(request: FastifyRequest): ProxyResourceOwner | null {
|
||||
const auth = getProxyAuthContext(request);
|
||||
if (!auth) return null;
|
||||
|
||||
if (auth.source === 'managed') {
|
||||
return {
|
||||
ownerType: 'managed_key',
|
||||
ownerId: auth.keyId === null ? auth.token : String(auth.keyId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -576,6 +576,78 @@ describe('chat proxy stream behavior', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps native Claude file blocks when retrying /v1/messages with normalized body', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: {
|
||||
type: '<nil>',
|
||||
message: 'messages is required',
|
||||
},
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'msg_retry_file_ok',
|
||||
type: 'message',
|
||||
model: 'claude-opus-4-6',
|
||||
content: [{ type: 'text', text: 'ok after normalized fallback' }],
|
||||
stop_reason: 'end_turn',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'brief.pdf',
|
||||
file_data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock.mock.calls.length).toBe(2);
|
||||
|
||||
const [_firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||
const [_secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
const firstBody = JSON.parse(firstOptions.body);
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstBody.messages[0]?.content).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'document',
|
||||
title: 'brief.pdf',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(secondBody.messages[0]?.content).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'document',
|
||||
title: 'brief.pdf',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('downgrades to next endpoint when normalized Claude fallback still returns messages is required', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
@@ -970,6 +1042,7 @@ describe('chat proxy stream behavior', () => {
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.metadata).toEqual({ session_id: 'abc123' });
|
||||
expect(forwardedBody.reasoning).toEqual({ effort: 'high' });
|
||||
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
|
||||
});
|
||||
|
||||
it('retries /v1/responses without metadata when upstream returns empty upstream_error', async () => {
|
||||
@@ -1706,6 +1779,105 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(targetUrl).toContain('/v1/messages');
|
||||
});
|
||||
|
||||
it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_reasoning_1',
|
||||
object: 'response',
|
||||
model: 'upstream-gpt',
|
||||
output_text: 'hello from responses upstream',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_reasoning_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
});
|
||||
|
||||
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without an explicit include list', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_reasoning_2',
|
||||
object: 'response',
|
||||
model: 'upstream-gpt',
|
||||
output_text: 'hello from responses upstream',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_reasoning_2',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
input: 'hello',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toContain('/v1/responses');
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
|
||||
});
|
||||
|
||||
it('forces anyrouter platform to prefer /v1/messages even when catalog says openai', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
|
||||
@@ -30,6 +30,12 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
hasNonImageFileInputInOpenAiBody,
|
||||
resolveOpenAiBodyInputFiles,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -64,6 +70,19 @@ async function handleChatProxyRequest(
|
||||
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let resolvedOpenAiBody = upstreamBody;
|
||||
if (owner) {
|
||||
try {
|
||||
resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(upstreamBody, owner);
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyInputFileResolutionError) {
|
||||
return reply.code(error.statusCode).send(error.payload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
@@ -91,17 +110,20 @@ async function handleChatProxyRequest(
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const endpointCandidates = [
|
||||
...await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
modelName,
|
||||
downstreamFormat,
|
||||
requestedModel,
|
||||
),
|
||||
];
|
||||
const endpointCandidates = [
|
||||
...await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
modelName,
|
||||
downstreamFormat,
|
||||
requestedModel,
|
||||
{
|
||||
hasNonImageFileInput,
|
||||
},
|
||||
),
|
||||
];
|
||||
let startTime = Date.now();
|
||||
|
||||
try {
|
||||
@@ -117,7 +139,7 @@ async function handleChatProxyRequest(
|
||||
tokenValue: selected.tokenValue,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: upstreamBody,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
@@ -143,9 +165,10 @@ async function handleChatProxyRequest(
|
||||
tokenValue: selected.tokenValue,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: upstreamBody,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody: undefined,
|
||||
claudeOriginalBody,
|
||||
forceNormalizeClaudeBody: true,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getProxyResourceOwnerMock = vi.fn();
|
||||
const saveProxyFileMock = vi.fn();
|
||||
const listProxyFilesByOwnerMock = vi.fn();
|
||||
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
||||
const getProxyFileContentByPublicIdForOwnerMock = vi.fn();
|
||||
const softDeleteProxyFileByPublicIdForOwnerMock = vi.fn();
|
||||
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
getProxyResourceOwner: (...args: unknown[]) => getProxyResourceOwnerMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyFileStore.js', () => ({
|
||||
saveProxyFile: (...args: unknown[]) => saveProxyFileMock(...args),
|
||||
listProxyFilesByOwner: (...args: unknown[]) => listProxyFilesByOwnerMock(...args),
|
||||
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
||||
getProxyFileContentByPublicIdForOwner: (...args: unknown[]) => getProxyFileContentByPublicIdForOwnerMock(...args),
|
||||
softDeleteProxyFileByPublicIdForOwner: (...args: unknown[]) => softDeleteProxyFileByPublicIdForOwnerMock(...args),
|
||||
}));
|
||||
|
||||
function buildUploadBody(boundary: string) {
|
||||
return Buffer.from(
|
||||
`--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="purpose"\r\n\r\n`
|
||||
+ `assistants\r\n`
|
||||
+ `--${boundary}\r\n`
|
||||
+ `Content-Disposition: form-data; name="file"; filename="sample.pdf"\r\n`
|
||||
+ `Content-Type: application/pdf\r\n\r\n`
|
||||
+ `%PDF-1.7\r\n`
|
||||
+ `--${boundary}--\r\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe('/v1/files routes', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { filesProxyRoute } = await import('./files.js');
|
||||
app = Fastify();
|
||||
await app.register(filesProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
getProxyResourceOwnerMock.mockReset();
|
||||
saveProxyFileMock.mockReset();
|
||||
listProxyFilesByOwnerMock.mockReset();
|
||||
getProxyFileByPublicIdForOwnerMock.mockReset();
|
||||
getProxyFileContentByPublicIdForOwnerMock.mockReset();
|
||||
softDeleteProxyFileByPublicIdForOwnerMock.mockReset();
|
||||
|
||||
getProxyResourceOwnerMock.mockReturnValue({
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uploads multipart files and returns an OpenAI-style file object', async () => {
|
||||
saveProxyFileMock.mockResolvedValue({
|
||||
publicId: 'file-metapi-demo',
|
||||
purpose: 'assistants',
|
||||
filename: 'sample.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
byteSize: 8,
|
||||
sha256: 'abc',
|
||||
createdAt: '2026-03-08 10:00:00',
|
||||
updatedAt: '2026-03-08 10:00:00',
|
||||
deletedAt: null,
|
||||
contentBase64: Buffer.from('%PDF-1.7').toString('base64'),
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
});
|
||||
|
||||
const boundary = 'metapi-file-boundary';
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/files',
|
||||
headers: {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
payload: buildUploadBody(boundary),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(saveProxyFileMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
purpose: 'assistants',
|
||||
filename: 'sample.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: expect.any(String),
|
||||
}));
|
||||
expect(response.json()).toMatchObject({
|
||||
id: 'file-metapi-demo',
|
||||
object: 'file',
|
||||
filename: 'sample.pdf',
|
||||
purpose: 'assistants',
|
||||
mime_type: 'application/pdf',
|
||||
bytes: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists files only for the current owner', async () => {
|
||||
listProxyFilesByOwnerMock.mockResolvedValue([
|
||||
{
|
||||
publicId: 'file_abc',
|
||||
purpose: 'assistants',
|
||||
filename: 'a.json',
|
||||
mimeType: 'application/json',
|
||||
byteSize: 12,
|
||||
sha256: 'abc',
|
||||
createdAt: '2026-03-08 10:00:00',
|
||||
updatedAt: '2026-03-08 10:00:00',
|
||||
deletedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/files',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(listProxyFilesByOwnerMock).toHaveBeenCalledWith({ ownerType: 'global_proxy_token', ownerId: 'global' });
|
||||
expect(response.json()).toMatchObject({
|
||||
object: 'list',
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
id: 'file_abc',
|
||||
object: 'file',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns raw content for /v1/files/:id/content', async () => {
|
||||
getProxyFileContentByPublicIdForOwnerMock.mockResolvedValue({
|
||||
filename: 'sample.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('%PDF-1.7'),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/files/file_pdf/content',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('application/pdf');
|
||||
expect(response.body).toBe('%PDF-1.7');
|
||||
});
|
||||
|
||||
it('soft deletes files and returns deleted=true', async () => {
|
||||
softDeleteProxyFileByPublicIdForOwnerMock.mockResolvedValue(true);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/v1/files/file_pdf',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(softDeleteProxyFileByPublicIdForOwnerMock).toHaveBeenCalledWith('file_pdf', {
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
});
|
||||
expect(response.json()).toEqual({
|
||||
id: 'file_pdf',
|
||||
object: 'file',
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
getProxyFileByPublicIdForOwner,
|
||||
getProxyFileContentByPublicIdForOwner,
|
||||
listProxyFilesByOwner,
|
||||
saveProxyFile,
|
||||
softDeleteProxyFileByPublicIdForOwner,
|
||||
} from '../../services/proxyFileStore.js';
|
||||
import { ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
|
||||
function invalidRequest(reply: FastifyReply, message: string) {
|
||||
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
||||
}
|
||||
|
||||
function notFound(reply: FastifyReply, message = 'file not found') {
|
||||
return reply.code(404).send({ error: { message, type: 'not_found_error' } });
|
||||
}
|
||||
|
||||
function toUnixSeconds(sqlDateTime: string | null | undefined): number {
|
||||
if (!sqlDateTime) return Math.floor(Date.now() / 1000);
|
||||
const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z');
|
||||
if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000);
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
function toFileObject(record: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
purpose: string | null;
|
||||
byteSize: number;
|
||||
createdAt: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: record.publicId,
|
||||
object: 'file',
|
||||
bytes: record.byteSize,
|
||||
created_at: toUnixSeconds(record.createdAt),
|
||||
filename: record.filename,
|
||||
purpose: record.purpose || 'assistants',
|
||||
mime_type: record.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function inferMimeTypeFromFilename(filename: string): string {
|
||||
const normalized = filename.trim().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';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedProxyFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export async function filesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
|
||||
const formData = await parseMultipartFormData(request);
|
||||
if (!formData) {
|
||||
return invalidRequest(reply, 'multipart/form-data with a file field is required');
|
||||
}
|
||||
|
||||
const fileEntry = formData.get('file');
|
||||
if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') {
|
||||
return invalidRequest(reply, 'file field is required');
|
||||
}
|
||||
|
||||
const filename = fileEntry.name || 'upload.bin';
|
||||
const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
||||
if (!isSupportedProxyFileMimeType(mimeType)) {
|
||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
|
||||
const purposeValue = formData.get('purpose');
|
||||
const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0
|
||||
? purposeValue.trim()
|
||||
: 'assistants';
|
||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
||||
|
||||
const saved = await saveProxyFile({
|
||||
...owner,
|
||||
filename,
|
||||
mimeType,
|
||||
purpose,
|
||||
contentBase64: buffer.toString('base64'),
|
||||
});
|
||||
return reply.send(toFileObject(saved));
|
||||
});
|
||||
|
||||
app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const files = await listProxyFilesByOwner(owner);
|
||||
return reply.send({
|
||||
object: 'list',
|
||||
data: files.map((item) => toFileObject(item)),
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
return reply.send(toFileObject(file));
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
reply.type(file.mimeType);
|
||||
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`);
|
||||
return reply.send(file.buffer);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!deleted) return notFound(reply);
|
||||
return reply.send({
|
||||
id: request.params.fileId,
|
||||
object: 'file',
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
@@ -43,6 +45,30 @@ function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
||||
.map((data) => JSON.parse(data) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
describe('gemini transformer-owned path parsing', () => {
|
||||
it('keeps apiVersion and modelActionPath parsing in transformer helpers', async () => {
|
||||
const geminiRoute = readWorkspaceFile('src/server/routes/proxy/gemini.ts');
|
||||
|
||||
expect(geminiRoute).not.toContain('function resolveGeminiApiVersion(');
|
||||
expect(geminiRoute).not.toContain('function extractGeminiModelActionPath(');
|
||||
|
||||
const { geminiGenerateContentTransformer } = await import('../../transformers/gemini/generate-content/index.js');
|
||||
expect(geminiGenerateContentTransformer.parseProxyRequestPath({
|
||||
rawUrl: '/gemini/v1/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||
params: { geminiApiVersion: 'v1' },
|
||||
})).toEqual({
|
||||
apiVersion: 'v1',
|
||||
modelActionPath: 'models/gemini-2.5-flash:streamGenerateContent',
|
||||
requestedModel: 'gemini-2.5-flash',
|
||||
isStreamAction: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini native proxy routes', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
@@ -215,6 +241,54 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards explicit gemini version paths through transformer-owned parsing helpers', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'hello from v1 gemini' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/gemini/v1/models/gemini-2.5-flash:generateContent?alt=json',
|
||||
headers: {
|
||||
'x-goog-api-key': 'sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toContain('/v1/models/gemini-2.5-flash:generateContent');
|
||||
expect(targetUrl).toContain('alt=json');
|
||||
expect(targetUrl).toContain('key=gemini-key');
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.json().candidates?.[0]?.content?.parts?.[0]?.text).toBe('hello from v1 gemini');
|
||||
});
|
||||
|
||||
it('preserves structured Gemini-native fields instead of narrowing them to a bare passthrough shell', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
candidates: [
|
||||
|
||||
@@ -33,33 +33,11 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGeminiApiVersion(request: FastifyRequest): string {
|
||||
const params = request.params as { geminiApiVersion?: string } | undefined;
|
||||
return (params?.geminiApiVersion || 'v1beta').trim() || 'v1beta';
|
||||
}
|
||||
|
||||
function getRawRequestUrl(request: FastifyRequest): string {
|
||||
return request.raw.url || request.url || '';
|
||||
}
|
||||
|
||||
function extractGeminiModelActionPath(request: FastifyRequest, apiVersion: string): string {
|
||||
const rawUrl = getRawRequestUrl(request);
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const geminiPrefix = `/gemini/${normalizedVersion}/`;
|
||||
const aliasPrefix = `/${normalizedVersion}/`;
|
||||
if (withoutQuery.startsWith(geminiPrefix)) {
|
||||
return withoutQuery.slice(geminiPrefix.length);
|
||||
}
|
||||
if (withoutQuery.startsWith(aliasPrefix)) {
|
||||
return withoutQuery.slice(aliasPrefix.length);
|
||||
}
|
||||
return withoutQuery.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiVersion = resolveGeminiApiVersion(request);
|
||||
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
|
||||
request.params as { geminiApiVersion?: string } | undefined,
|
||||
);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
@@ -117,10 +95,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
};
|
||||
|
||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiVersion = resolveGeminiApiVersion(request);
|
||||
const modelActionPath = extractGeminiModelActionPath(request, apiVersion);
|
||||
const isStreamAction = modelActionPath.endsWith(':streamGenerateContent');
|
||||
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
||||
const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({
|
||||
rawUrl: request.raw.url || request.url || '',
|
||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
||||
});
|
||||
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
||||
|
||||
vi.mock('../../services/proxyFileStore.js', () => ({
|
||||
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
||||
}));
|
||||
|
||||
describe('inlineLocalInputFileReferences', () => {
|
||||
beforeEach(() => {
|
||||
getProxyFileByPublicIdForOwnerMock.mockReset();
|
||||
});
|
||||
|
||||
it('replaces local responses input_file ids with inline file_data payloads', async () => {
|
||||
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
|
||||
publicId: 'file-metapi-123',
|
||||
filename: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-demo').toString('base64'),
|
||||
});
|
||||
|
||||
const { inlineLocalInputFileReferences } = await import('./inputFiles.js');
|
||||
const result = await inlineLocalInputFileReferences(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file-metapi-123',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ownerType: 'global_proxy_token', ownerId: 'global' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: Buffer.from('%PDF-demo').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces local OpenAI file blocks with inline payloads', async () => {
|
||||
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
|
||||
publicId: 'file-metapi-abc',
|
||||
filename: 'notes.md',
|
||||
mimeType: 'text/markdown',
|
||||
contentBase64: Buffer.from('# hello').toString('base64'),
|
||||
});
|
||||
|
||||
const { inlineLocalInputFileReferences } = await import('./inputFiles.js');
|
||||
const result = await inlineLocalInputFileReferences(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file-metapi-abc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ownerType: 'managed_key', ownerId: '7' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_data: Buffer.from('# hello').toString('base64'),
|
||||
filename: 'notes.md',
|
||||
mime_type: 'text/markdown',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 404 when a local file id cannot be resolved for the owner', async () => {
|
||||
getProxyFileByPublicIdForOwnerMock.mockResolvedValue(null);
|
||||
|
||||
const { inlineLocalInputFileReferences, ProxyInputFileResolutionError } = await import('./inputFiles.js');
|
||||
await expect(inlineLocalInputFileReferences(
|
||||
{
|
||||
input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_id: 'file-metapi-missing' }] }],
|
||||
},
|
||||
{ ownerType: 'managed_key', ownerId: '9' },
|
||||
)).rejects.toBeInstanceOf(ProxyInputFileResolutionError);
|
||||
});
|
||||
|
||||
it('rejects unsupported inline file mime types with a clear 400 error', async () => {
|
||||
const { inlineLocalInputFileReferences, ProxyInputFileResolutionError } = await import('./inputFiles.js');
|
||||
await expect(inlineLocalInputFileReferences(
|
||||
{
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'archive.exe',
|
||||
file_data: Buffer.from('MZ').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ownerType: 'global_proxy_token', ownerId: 'global' },
|
||||
)).rejects.toBeInstanceOf(ProxyInputFileResolutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js';
|
||||
import {
|
||||
inferInputFileMimeType,
|
||||
normalizeInputFileBlock,
|
||||
toOpenAiChatFileBlock,
|
||||
toResponsesInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../../transformers/shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isSupportedFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export class ProxyInputFileResolutionError extends Error {
|
||||
statusCode: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(statusCode: number, payload: unknown) {
|
||||
super(typeof payload === 'object' && payload && 'error' in (payload as Record<string, unknown>)
|
||||
? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed')
|
||||
: 'input file resolution failed');
|
||||
this.name = 'ProxyInputFileResolutionError';
|
||||
this.statusCode = statusCode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvalidRequest(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(400, {
|
||||
error: {
|
||||
message,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildNotFound(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(404, {
|
||||
error: {
|
||||
message,
|
||||
type: 'not_found_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return item;
|
||||
|
||||
let resolved: NormalizedInputFile = { ...normalized };
|
||||
|
||||
if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) {
|
||||
const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner);
|
||||
if (!stored) {
|
||||
throw buildNotFound(`file not found: ${resolved.fileId}`);
|
||||
}
|
||||
resolved = {
|
||||
filename: stored.filename,
|
||||
mimeType: stored.mimeType,
|
||||
fileData: stored.contentBase64,
|
||||
};
|
||||
}
|
||||
|
||||
const inferredMimeType = inferInputFileMimeType(resolved);
|
||||
if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) {
|
||||
throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
if (inferredMimeType) {
|
||||
resolved.mimeType = inferredMimeType;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if (type === 'input_file') {
|
||||
return toResponsesInputFileBlock(resolved);
|
||||
}
|
||||
if (type === 'file') {
|
||||
return toOpenAiChatFileBlock(resolved);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function inlineLocalInputFileReferences(
|
||||
value: unknown,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner)));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type : '';
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
return resolveInputFileBlock(value, owner);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
@@ -22,6 +22,13 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
hasNonImageFileInputInOpenAiBody,
|
||||
resolveOpenAiBodyInputFiles,
|
||||
resolveResponsesBodyInputFiles,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -29,6 +36,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
||||
return relevantKeys.some((key) => {
|
||||
const entry = value[key];
|
||||
if (typeof entry === 'string') return entry.trim().length > 0;
|
||||
return entry !== undefined && entry !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function wantsNativeResponsesReasoning(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
|
||||
const include = Array.isArray(body.include)
|
||||
? body.include
|
||||
: (typeof body.include === 'string' ? [body.include] : []);
|
||||
|
||||
return include.some((item) => (
|
||||
typeof item === 'string'
|
||||
&& item.trim().toLowerCase() === 'reasoning.encrypted_content'
|
||||
)) || hasResponsesReasoningRequest(body.reasoning);
|
||||
}
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
@@ -74,6 +104,22 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(body, modelName, isStream);
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let resolvedOpenAiBody = openAiBody;
|
||||
let resolvedResponsesBody = body;
|
||||
if (owner) {
|
||||
try {
|
||||
resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(openAiBody, owner);
|
||||
resolvedResponsesBody = await resolveResponsesBodyInputFiles(body, owner);
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyInputFileResolutionError) {
|
||||
return reply.code(error.statusCode).send(error.payload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(resolvedResponsesBody);
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
@@ -82,6 +128,10 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
modelName,
|
||||
'responses',
|
||||
requestedModel,
|
||||
{
|
||||
hasNonImageFileInput,
|
||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||
},
|
||||
);
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
@@ -102,9 +152,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
tokenValue: selected.tokenValue,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: body,
|
||||
responsesOriginalBody: resolvedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
const upstreamPath = (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { imagesProxyRoute } from './images.js';
|
||||
import { searchProxyRoute } from './search.js';
|
||||
import { geminiProxyRoute } from './gemini.js';
|
||||
import { videosProxyRoute } from './videos.js';
|
||||
import { filesProxyRoute } from './files.js';
|
||||
|
||||
export async function proxyRoutes(app: FastifyInstance) {
|
||||
// Auth middleware for all /v1 routes
|
||||
@@ -23,6 +24,7 @@ export async function proxyRoutes(app: FastifyInstance) {
|
||||
await app.register(modelsProxyRoute);
|
||||
await app.register(embeddingsProxyRoute);
|
||||
await app.register(searchProxyRoute);
|
||||
await app.register(filesProxyRoute);
|
||||
await app.register(imagesProxyRoute);
|
||||
await app.register(videosProxyRoute);
|
||||
await app.register(geminiProxyRoute);
|
||||
|
||||
@@ -153,6 +153,23 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
||||
expect(claudeOrder).toEqual(['messages']);
|
||||
});
|
||||
|
||||
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'gpt-5.3',
|
||||
'openai',
|
||||
undefined,
|
||||
{
|
||||
hasNonImageFileInput: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
@@ -208,6 +225,23 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
||||
expect(responsesOrder).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('prefers native responses endpoints for claude-family models when encrypted reasoning is explicitly requested', async () => {
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
'responses',
|
||||
undefined,
|
||||
{
|
||||
wantsNativeResponsesReasoning: true,
|
||||
} as any,
|
||||
);
|
||||
|
||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('treats endpoint-not-found responses as downgrade candidates', () => {
|
||||
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
|
||||
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
|
||||
@@ -391,6 +425,90 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves input_file blocks when converting chat to responses', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-4.1',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{ type: 'input_file', filename: 'brief.pdf', file_data: 'JVBERi0xLjc=' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/responses');
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this file' },
|
||||
{ type: 'input_file', filename: 'brief.pdf', file_data: 'JVBERi0xLjc=' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes file uploads into Responses input_file blocks for upstream responses endpoints', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.2',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.2',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'read this' },
|
||||
{
|
||||
type: 'file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/responses');
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'read this' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies global responses standardization and drops non-standard fields', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
@@ -550,6 +668,100 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves structured input_file blocks on downstream responses bodies', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'upstream-gpt',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'openai',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {},
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: {
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'notes.txt',
|
||||
file_data: 'data:text/plain;base64,aGVsbG8=',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.body.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'notes.txt',
|
||||
file_data: 'aGVsbG8=',
|
||||
mime_type: 'text/plain',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps OpenAI file blocks to Anthropic document blocks', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'openai',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.2',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this pdf' },
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this pdf' },
|
||||
{
|
||||
type: 'document',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjQK',
|
||||
},
|
||||
title: 'brief.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multimodal OpenAI user content when converting to Anthropic messages', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
@@ -603,6 +815,100 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps input_file blocks to anthropic document content', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
openaiBody: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'read this document' },
|
||||
{ type: 'input_file', filename: 'brief.pdf', mime_type: 'application/pdf', file_data: 'JVBERi0xLjc=' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'read this document' },
|
||||
{
|
||||
type: 'document',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
title: 'brief.pdf',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps file uploads into Anthropic document blocks for messages endpoints', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'openai',
|
||||
openaiBody: {
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize the PDF' },
|
||||
{
|
||||
type: 'file',
|
||||
file_id: 'file_local_789',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize the PDF' },
|
||||
{
|
||||
type: 'document',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjQK',
|
||||
},
|
||||
title: 'report.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves assistant reasoning blocks for Anthropic and strips cache_control from thinking blocks', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
@@ -653,6 +959,61 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves native Claude request bodies instead of re-optimizing cache anchors', () => {
|
||||
const claudeOriginalBody = {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
};
|
||||
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
tokenValue: 'sk-test',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://example.com',
|
||||
downstreamFormat: 'claude',
|
||||
openaiBody: {
|
||||
model: 'ignored',
|
||||
messages: [{ role: 'user', content: 'ignored' }],
|
||||
},
|
||||
claudeOriginalBody,
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.body).toEqual({
|
||||
...claudeOriginalBody,
|
||||
model: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves multimodal OpenAI user content when converting to Responses input', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
|
||||
@@ -277,14 +277,25 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
modelName: string,
|
||||
downstreamFormat: EndpointPreference,
|
||||
requestedModelHint?: string,
|
||||
requestCapabilities?: {
|
||||
hasNonImageFileInput?: boolean;
|
||||
wantsNativeResponsesReasoning?: boolean;
|
||||
},
|
||||
): Promise<UpstreamEndpoint[]> {
|
||||
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||
const preferMessagesForClaudeModel = (
|
||||
isClaudeFamilyModel(modelName)
|
||||
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
|
||||
);
|
||||
const hasNonImageFileInput = requestCapabilities?.hasNonImageFileInput === true;
|
||||
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
|
||||
if (sitePlatform === 'anyrouter') {
|
||||
// anyrouter deployments are effectively anthropic-protocol first.
|
||||
if (hasNonImageFileInput) {
|
||||
return downstreamFormat === 'responses'
|
||||
? ['responses', 'messages', 'chat']
|
||||
: ['messages', 'responses', 'chat'];
|
||||
}
|
||||
if (downstreamFormat === 'responses') {
|
||||
return ['responses', 'messages', 'chat'];
|
||||
}
|
||||
@@ -296,6 +307,24 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
context.site.platform,
|
||||
preferMessagesForClaudeModel,
|
||||
);
|
||||
const preferredWithCapabilities = hasNonImageFileInput
|
||||
? (() => {
|
||||
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
|
||||
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
|
||||
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
|
||||
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
|
||||
})()
|
||||
: preferred;
|
||||
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
|
||||
wantsNativeResponsesReasoning
|
||||
&& preferMessagesForClaudeModel
|
||||
&& preferredWithCapabilities.includes('responses')
|
||||
)
|
||||
? [
|
||||
'responses',
|
||||
...preferredWithCapabilities.filter((endpoint): endpoint is UpstreamEndpoint => endpoint !== 'responses'),
|
||||
]
|
||||
: preferredWithCapabilities;
|
||||
const forceMessagesFirstForClaudeModel = (
|
||||
downstreamFormat === 'openai'
|
||||
&& preferMessagesForClaudeModel
|
||||
@@ -320,20 +349,20 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
});
|
||||
|
||||
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
|
||||
return preferred;
|
||||
return prioritizedPreferredEndpoints;
|
||||
}
|
||||
|
||||
const matched = catalog.models.find((item) =>
|
||||
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
|
||||
);
|
||||
if (!matched) return preferred;
|
||||
if (!matched) return prioritizedPreferredEndpoints;
|
||||
|
||||
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
||||
preferMessagesForClaudeModel
|
||||
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
||||
);
|
||||
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
||||
return preferred;
|
||||
return prioritizedPreferredEndpoints;
|
||||
}
|
||||
|
||||
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
||||
@@ -353,7 +382,7 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
||||
// Generic labels like openai/anthropic are too coarse for Claude models;
|
||||
// keep messages-first order in this case.
|
||||
return preferred;
|
||||
return prioritizedPreferredEndpoints;
|
||||
}
|
||||
|
||||
const supported = new Set<UpstreamEndpoint>();
|
||||
@@ -364,19 +393,19 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
}
|
||||
}
|
||||
|
||||
if (supported.size === 0) return preferred;
|
||||
if (supported.size === 0) return prioritizedPreferredEndpoints;
|
||||
|
||||
const firstSupported = preferred.find((endpoint) => supported.has(endpoint));
|
||||
if (!firstSupported) return preferred;
|
||||
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
|
||||
if (!firstSupported) return prioritizedPreferredEndpoints;
|
||||
|
||||
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
|
||||
// the first attempt. Keep downstream-driven fallback order unchanged.
|
||||
return [
|
||||
firstSupported,
|
||||
...preferred.filter((endpoint) => endpoint !== firstSupported),
|
||||
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
|
||||
];
|
||||
} catch {
|
||||
return preferred;
|
||||
return prioritizedPreferredEndpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +419,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
openaiBody: Record<string, unknown>;
|
||||
downstreamFormat: EndpointPreference;
|
||||
claudeOriginalBody?: Record<string, unknown>;
|
||||
forceNormalizeClaudeBody?: boolean;
|
||||
responsesOriginalBody?: Record<string, unknown>;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}): {
|
||||
@@ -451,16 +481,33 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
|| passthroughHeaders['anthropic-version']
|
||||
|| '2023-06-01'
|
||||
);
|
||||
const body = (
|
||||
input.downstreamFormat === 'claude' && input.claudeOriginalBody
|
||||
? {
|
||||
...input.claudeOriginalBody,
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}
|
||||
: convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream)
|
||||
);
|
||||
const sanitizedBody = sanitizeAnthropicMessagesBody(body);
|
||||
const nativeClaudeBody = (
|
||||
input.downstreamFormat === 'claude'
|
||||
&& input.claudeOriginalBody
|
||||
&& input.forceNormalizeClaudeBody !== true
|
||||
)
|
||||
? {
|
||||
...input.claudeOriginalBody,
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}
|
||||
: null;
|
||||
const normalizedClaudeBody = (
|
||||
input.downstreamFormat === 'claude'
|
||||
&& input.claudeOriginalBody
|
||||
&& input.forceNormalizeClaudeBody === true
|
||||
)
|
||||
? sanitizeAnthropicMessagesBody({
|
||||
...input.claudeOriginalBody,
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
})
|
||||
: null;
|
||||
const sanitizedBody = nativeClaudeBody
|
||||
?? normalizedClaudeBody
|
||||
?? sanitizeAnthropicMessagesBody(
|
||||
convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream),
|
||||
);
|
||||
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
@@ -151,6 +151,50 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
it('maps OpenAI file blocks into Anthropic document blocks', () => {
|
||||
const base64Pdf = Buffer.from('%PDF-hello').toString('base64');
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: base64Pdf,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: base64Pdf,
|
||||
},
|
||||
title: 'paper.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves image blocks, tool_use blocks, and tool_result chains', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -238,6 +282,82 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops thinking blocks whose signatures belong to another provider', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:openai-encrypted-reasoning:enc-foreign',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'final answer',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes anthropic-tagged thinking signatures before sending upstream', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'metapi:anthropic-signature:sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'claude-opus-4-6',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal only',
|
||||
signature: 'sig-anthropic-1',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips cache_control from thinking blocks and empty text blocks', () => {
|
||||
const body = convertOpenAiBodyToAnthropicMessagesBody(
|
||||
{
|
||||
@@ -410,7 +530,7 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
expect(body.output_config).toEqual({ effort: 'high' });
|
||||
});
|
||||
|
||||
it('rebuilds cache_control anchors at the anthropic inbound boundary', () => {
|
||||
it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => {
|
||||
const result = anthropicMessagesInbound.parse(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
@@ -431,36 +551,22 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody?.system).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: 'system prompt',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody?.tools).toEqual([
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody?.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
model: 'gpt-5',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
system: 'system prompt',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { normalizeInputFileBlock, toAnthropicDocumentBlock } from '../../shared/inputFile.js';
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from '../../shared/reasoningTransport.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -256,6 +261,19 @@ function toAnthropicTextBlock(text: string): Record<string, unknown> | null {
|
||||
return normalized ? { type: 'text', text: normalized } : null;
|
||||
}
|
||||
|
||||
function resolveAnthropicThinkingSignature(item: Record<string, unknown>): string | null | undefined {
|
||||
const rawSignature = asTrimmedString(item.signature ?? item.reasoning_signature);
|
||||
if (!rawSignature) return undefined;
|
||||
const decodedTaggedSignature = decodeAnthropicReasoningSignature(rawSignature);
|
||||
if (decodedTaggedSignature !== null) {
|
||||
return decodedTaggedSignature;
|
||||
}
|
||||
if (item.reasoning_signature !== undefined || rawSignature.startsWith('metapi:')) {
|
||||
return null;
|
||||
}
|
||||
return rawSignature;
|
||||
}
|
||||
|
||||
function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!type) {
|
||||
@@ -276,9 +294,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
return toAnthropicImageBlock(item);
|
||||
}
|
||||
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'thinking' || type === 'redacted_thinking' || type === 'reasoning') {
|
||||
const text = asTrimmedString(item.thinking ?? item.text ?? item.content ?? item.data);
|
||||
if (!text) return null;
|
||||
const signature = type === 'redacted_thinking' ? undefined : resolveAnthropicThinkingSignature(item);
|
||||
if (signature === null) return null;
|
||||
const next: Record<string, unknown> = { ...item };
|
||||
if (type === 'redacted_thinking') {
|
||||
next.type = 'redacted_thinking';
|
||||
@@ -287,10 +312,16 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
} else {
|
||||
next.type = type === 'reasoning' ? 'thinking' : type;
|
||||
next.thinking = text;
|
||||
if (signature) {
|
||||
next.signature = signature;
|
||||
} else {
|
||||
delete next.signature;
|
||||
}
|
||||
}
|
||||
delete next.cache_control;
|
||||
delete next.text;
|
||||
delete next.content;
|
||||
delete next.reasoning_signature;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -893,6 +924,7 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
|
||||
export function validateAnthropicMessagesBody(
|
||||
body: Record<string, unknown>,
|
||||
options: { autoOptimizeCacheControls?: boolean } = {},
|
||||
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||
const thinkingResult = sanitizeAnthropicThinkingConfig(body.thinking);
|
||||
if (thinkingResult.error) {
|
||||
@@ -928,5 +960,5 @@ export function validateAnthropicMessagesBody(
|
||||
};
|
||||
}
|
||||
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body) };
|
||||
return { sanitizedBody: sanitizeAnthropicMessagesBody(body, options) };
|
||||
}
|
||||
|
||||
@@ -60,4 +60,97 @@ describe('anthropicMessagesInbound', () => {
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves already-native anthropic block shapes and cache markers', () => {
|
||||
const result = anthropicMessagesInbound.parse({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
{ type: 'text', text: 'system prompt', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'high' },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps native cache_control placement for already-native anthropic bodies', () => {
|
||||
const nativeBody = {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool_1',
|
||||
name: 'lookup',
|
||||
input: { city: 'paris' },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'done',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
};
|
||||
|
||||
const result = anthropicMessagesInbound.parse(nativeBody);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual(nativeBody);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,9 @@ function sanitizeAnthropicInboundBody(
|
||||
const toolChoiceError = validateToolChoice(body);
|
||||
if (toolChoiceError) return { error: toolChoiceError };
|
||||
|
||||
const validation = validateAnthropicMessagesBody(body);
|
||||
const validation = validateAnthropicMessagesBody(body, {
|
||||
autoOptimizeCacheControls: false,
|
||||
});
|
||||
if (validation.error) {
|
||||
return { error: validation.error };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('extractAnthropicUsage', () => {
|
||||
completionTokens: 30,
|
||||
totalTokens: 130,
|
||||
cachedTokens: 12,
|
||||
cacheReadTokens: 12,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
@@ -256,6 +256,8 @@ export function extractAnthropicUsage(payload: unknown): NormalizedUsage {
|
||||
completionTokens: summary.completionTokens,
|
||||
totalTokens: summary.totalTokens,
|
||||
cachedTokens: summary.cacheReadInputTokens,
|
||||
cacheReadTokens: summary.cacheReadInputTokens,
|
||||
cacheCreationTokens: summary.cacheCreationInputTokens,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
|
||||
@@ -523,6 +523,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
cacheReadTokens: 4,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
expect(extractGeminiUsage(serialized)).toEqual({
|
||||
@@ -530,6 +532,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
cacheReadTokens: 4,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
});
|
||||
@@ -550,6 +554,8 @@ describe('Gemini aggregate/outbound/usage', () => {
|
||||
completionTokens: 6,
|
||||
totalTokens: 6,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -570,6 +570,8 @@ describe('extractGeminiUsage', () => {
|
||||
completionTokens: 10,
|
||||
totalTokens: 18,
|
||||
cachedTokens: 5,
|
||||
cacheReadTokens: 5,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ function baseIncludesVersion(baseUrl: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
@@ -37,6 +41,42 @@ export function resolveGeminiGenerateContentUrl(
|
||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||
}
|
||||
|
||||
export function parseGeminiProxyRequestPath(input: {
|
||||
rawUrl?: string | null;
|
||||
params?: { geminiApiVersion?: unknown } | null;
|
||||
}): {
|
||||
apiVersion: string;
|
||||
modelActionPath: string;
|
||||
requestedModel: string;
|
||||
isStreamAction: boolean;
|
||||
} {
|
||||
const apiVersion = resolveGeminiProxyApiVersion(input.params);
|
||||
const rawUrl = asTrimmedString(input.rawUrl);
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const geminiPrefix = `/gemini/${normalizedVersion}/`;
|
||||
const aliasPrefix = `/${normalizedVersion}/`;
|
||||
|
||||
let modelActionPath = withoutQuery.replace(/^\/+/, '');
|
||||
if (withoutQuery.startsWith(geminiPrefix)) {
|
||||
modelActionPath = withoutQuery.slice(geminiPrefix.length);
|
||||
} else if (withoutQuery.startsWith(aliasPrefix)) {
|
||||
modelActionPath = withoutQuery.slice(aliasPrefix.length);
|
||||
}
|
||||
|
||||
const requestedModel = modelActionPath.replace(/^models\//, '').split(':')[0].trim();
|
||||
return {
|
||||
apiVersion,
|
||||
modelActionPath,
|
||||
requestedModel,
|
||||
isStreamAction: modelActionPath.endsWith(':streamGenerateContent'),
|
||||
};
|
||||
}
|
||||
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
import { geminiGenerateContentStream } from './stream.js';
|
||||
@@ -58,6 +98,8 @@ export const geminiGenerateContentTransformer = {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
geminiThinkingConfigToReasoning,
|
||||
},
|
||||
parseProxyRequestPath: parseGeminiProxyRequestPath,
|
||||
resolveProxyApiVersion: resolveGeminiProxyApiVersion,
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
|
||||
@@ -37,6 +37,8 @@ export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
cachedTokens,
|
||||
cacheReadTokens: cachedTokens,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,6 +109,130 @@ describe('serializeConvertedResponsesEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps encrypted-only reasoning output items during stream aggregation', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.output_item.added',
|
||||
responsesPayload: {
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: 'reasoning',
|
||||
encrypted_content: 'enc-only',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_enc_only',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payloads = parseSsePayloads(completedLines);
|
||||
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||
expect(completed).toBeTruthy();
|
||||
expect(completed?.response?.output?.[0]).toMatchObject({
|
||||
type: 'reasoning',
|
||||
encrypted_content: 'enc-only',
|
||||
});
|
||||
expect((completed?.response?.output?.[0] as any)?.id).toMatch(/^rs_/);
|
||||
});
|
||||
|
||||
it('starts a synthetic reasoning item when fallback streams only carry encrypted reasoning signatures', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
const usage = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
};
|
||||
|
||||
const signatureLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
reasoningSignature: 'enc-stream-only',
|
||||
} as any,
|
||||
});
|
||||
|
||||
const signaturePayloads = parseSsePayloads(signatureLines);
|
||||
expect(signaturePayloads).toEqual([
|
||||
{
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_0',
|
||||
type: 'reasoning',
|
||||
status: 'in_progress',
|
||||
summary: [],
|
||||
encrypted_content: 'enc-stream-only',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const completedLines = serializeConvertedResponsesEvents({
|
||||
state,
|
||||
streamContext,
|
||||
usage,
|
||||
event: {
|
||||
responsesEventType: 'response.completed',
|
||||
responsesPayload: {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'resp_enc_stream_only',
|
||||
model: 'gpt-5',
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
total_tokens: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payloads = parseSsePayloads(completedLines);
|
||||
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||
expect(completed?.response?.output).toEqual([
|
||||
{
|
||||
id: 'rs_0',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [],
|
||||
encrypted_content: 'enc-stream-only',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves richer image_generation_call fields while aggregating progress events', () => {
|
||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||
const streamContext = createStreamTransformContext('gpt-5');
|
||||
|
||||
@@ -218,7 +218,9 @@ function ensureReasoningItem(
|
||||
indexHint?: unknown,
|
||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||
const itemId = asTrimmedString(itemIdRaw);
|
||||
const existingIndex = itemId ? state.reasoningIndexById[itemId] : undefined;
|
||||
const existingIndex = itemId
|
||||
? state.reasoningIndexById[itemId]
|
||||
: Object.values(state.reasoningIndexById)[0];
|
||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
||||
const created = !state.outputItems[index];
|
||||
const item = ensureOutputItem(state, index, () => ({
|
||||
@@ -236,7 +238,15 @@ function ensureReasoningSummaryPart(
|
||||
itemIdRaw: unknown,
|
||||
summaryIndexRaw: unknown,
|
||||
indexHint?: unknown,
|
||||
): { item: AggregateOutputItem; summary: AggregateOutputItem; index: number; summaryIndex: number; created: boolean } {
|
||||
): {
|
||||
item: AggregateOutputItem;
|
||||
summary: AggregateOutputItem;
|
||||
index: number;
|
||||
summaryIndex: number;
|
||||
created: boolean;
|
||||
itemCreated: boolean;
|
||||
partCreated: boolean;
|
||||
} {
|
||||
const reasoningState = ensureReasoningItem(state, itemIdRaw, indexHint);
|
||||
const summaryIndex = typeof summaryIndexRaw === 'number' && Number.isFinite(summaryIndexRaw)
|
||||
? Math.max(0, Math.trunc(summaryIndexRaw))
|
||||
@@ -244,12 +254,20 @@ function ensureReasoningSummaryPart(
|
||||
const summary = Array.isArray(reasoningState.item.summary) ? reasoningState.item.summary as AggregateOutputItem[] : [];
|
||||
if (!Array.isArray(reasoningState.item.summary)) reasoningState.item.summary = summary;
|
||||
let part = summary[summaryIndex];
|
||||
const created = !isRecord(part);
|
||||
const partCreated = !isRecord(part);
|
||||
if (!isRecord(part)) {
|
||||
part = { type: 'summary_text', text: '' };
|
||||
summary[summaryIndex] = part;
|
||||
}
|
||||
return { item: reasoningState.item, summary: part, index: reasoningState.index, summaryIndex, created: reasoningState.created || created };
|
||||
return {
|
||||
item: reasoningState.item,
|
||||
summary: part,
|
||||
index: reasoningState.index,
|
||||
summaryIndex,
|
||||
created: reasoningState.created || partCreated,
|
||||
itemCreated: reasoningState.created,
|
||||
partCreated,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureFunctionCallItem(
|
||||
@@ -458,6 +476,23 @@ function applyOriginalResponsesPayload(
|
||||
const outputIndex = resolveOutputIndex(state, payload.output_index, (payload.item as Record<string, unknown> | undefined)?.id);
|
||||
const item = cloneRecord(payload.item) || {};
|
||||
if (Object.keys(item).length > 0) {
|
||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||
if (itemType === 'reasoning') {
|
||||
const reasoningState = ensureReasoningItem(state, item.id, outputIndex);
|
||||
const next = {
|
||||
...reasoningState.item,
|
||||
...item,
|
||||
};
|
||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) {
|
||||
next.summary = reasoningState.item.summary;
|
||||
}
|
||||
setOutputItem(state, reasoningState.index, next);
|
||||
return serializeOriginalResponsesEvent(eventType, {
|
||||
...payload,
|
||||
output_index: reasoningState.index,
|
||||
item: state.outputItems[reasoningState.index] ?? payload.item,
|
||||
});
|
||||
}
|
||||
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
|
||||
const next = {
|
||||
...existing,
|
||||
@@ -641,19 +676,47 @@ function buildSyntheticMessageEvents(
|
||||
|
||||
function buildSyntheticReasoningEvents(
|
||||
state: OpenAiResponsesAggregateState,
|
||||
delta: string,
|
||||
delta?: string,
|
||||
reasoningSignature?: string,
|
||||
): string[] {
|
||||
const summaryState = ensureReasoningSummaryPart(state, '', 0, state.outputItems.length);
|
||||
const itemId = asTrimmedString(summaryState.item.id);
|
||||
const lines: string[] = [];
|
||||
const signature = asTrimmedString(reasoningSignature);
|
||||
const reasoningState = (signature || delta)
|
||||
? ensureReasoningItem(state, '', state.outputItems.length)
|
||||
: null;
|
||||
|
||||
if (reasoningState && signature) {
|
||||
reasoningState.item.encrypted_content = signature;
|
||||
if (reasoningState.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: reasoningState.index,
|
||||
item: reasoningState.item,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (!delta) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const summaryState = ensureReasoningSummaryPart(
|
||||
state,
|
||||
reasoningState?.item.id ?? '',
|
||||
0,
|
||||
reasoningState?.index ?? state.outputItems.length,
|
||||
);
|
||||
const itemId = asTrimmedString(summaryState.item.id);
|
||||
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
|
||||
const novelDelta = computeNovelDelta(currentText, delta);
|
||||
if (summaryState.created) {
|
||||
if (summaryState.itemCreated && !reasoningState?.created) {
|
||||
lines.push(serializeSse('response.output_item.added', {
|
||||
type: 'response.output_item.added',
|
||||
output_index: summaryState.index,
|
||||
item: summaryState.item,
|
||||
}));
|
||||
}
|
||||
if (summaryState.partCreated) {
|
||||
lines.push(serializeSse('response.reasoning_summary_part.added', {
|
||||
type: 'response.reasoning_summary_part.added',
|
||||
item_id: itemId,
|
||||
@@ -728,8 +791,8 @@ export function serializeConvertedResponsesEvents(input: {
|
||||
if (event.contentDelta) {
|
||||
lines.push(...buildSyntheticMessageEvents(state, event.contentDelta));
|
||||
}
|
||||
if (event.reasoningDelta) {
|
||||
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta));
|
||||
if (event.reasoningDelta || event.reasoningSignature) {
|
||||
lines.push(...buildSyntheticReasoningEvents(state, event.reasoningDelta, event.reasoningSignature));
|
||||
}
|
||||
lines.push(...buildSyntheticToolEvents(state, event));
|
||||
return lines;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -79,6 +81,11 @@ function normalizeResponsesContentItem(
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'file' || type === 'input_file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toResponsesInputFileBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return item;
|
||||
}
|
||||
@@ -191,7 +198,39 @@ export function buildResponsesCompatibilityBodies(
|
||||
};
|
||||
|
||||
push(stripResponsesMetadata(body));
|
||||
push(buildCoreResponsesBody(body));
|
||||
const coreModel = typeof body.model === 'string' ? body.model.trim() : '';
|
||||
if (coreModel && body.input !== undefined) {
|
||||
const richCandidate: Record<string, unknown> = {
|
||||
model: coreModel,
|
||||
input: body.input,
|
||||
stream: body.stream === true,
|
||||
};
|
||||
const maxOutputTokens = toFiniteNumber(body.max_output_tokens);
|
||||
if (maxOutputTokens !== null && maxOutputTokens > 0) {
|
||||
richCandidate.max_output_tokens = Math.trunc(maxOutputTokens);
|
||||
}
|
||||
const temperature = toFiniteNumber(body.temperature);
|
||||
if (temperature !== null) richCandidate.temperature = temperature;
|
||||
const topP = toFiniteNumber(body.top_p);
|
||||
if (topP !== null) richCandidate.top_p = topP;
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
if (instructions) richCandidate.instructions = instructions;
|
||||
|
||||
const passthroughFields = [
|
||||
'reasoning',
|
||||
'safety_identifier',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'top_logprobs',
|
||||
] as const;
|
||||
for (const key of passthroughFields) {
|
||||
if (body[key] === undefined) continue;
|
||||
richCandidate[key] = cloneJsonValue(body[key]);
|
||||
}
|
||||
push(richCandidate);
|
||||
}
|
||||
push(buildStrictResponsesBody(body));
|
||||
return candidates;
|
||||
}
|
||||
@@ -327,6 +366,18 @@ function toFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
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 parseUpstreamErrorShape(rawText: string): {
|
||||
type: string;
|
||||
code: string;
|
||||
@@ -383,6 +434,20 @@ function buildCoreResponsesBody(
|
||||
const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : '';
|
||||
if (instructions) core.instructions = instructions;
|
||||
|
||||
const passthroughFields = [
|
||||
'reasoning',
|
||||
'safety_identifier',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'top_logprobs',
|
||||
] as const;
|
||||
for (const key of passthroughFields) {
|
||||
if (body[key] === undefined) continue;
|
||||
core[key] = cloneJsonValue(body[key]);
|
||||
}
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
convertResponsesBodyToOpenAiBody,
|
||||
sanitizeResponsesBodyForProxy,
|
||||
} from './conversion.js';
|
||||
import { buildResponsesCompatibilityBodies } from './compatibility.js';
|
||||
|
||||
describe('sanitizeResponsesBodyForProxy', () => {
|
||||
it('preserves newer Responses request fields needed by the proxy', () => {
|
||||
@@ -79,9 +80,80 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
service_tier: 'priority',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
it('maps OpenAI chat file blocks into Responses input_file blocks', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file-metapi-demo',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'notes.md',
|
||||
file_data: Buffer.from('# hello').toString('base64'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file-metapi-demo',
|
||||
},
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'notes.md',
|
||||
file_data: Buffer.from('# hello').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps extra request fields and preserves custom/image_generation tools', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
@@ -223,9 +295,96 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
service_tier: 'flex',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
it('maps Responses input_file blocks back into OpenAI chat file blocks', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_file', file_id: 'file-metapi-demo' },
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'paper.pdf',
|
||||
file_data: Buffer.from('%PDF-demo').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file-metapi-demo',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: Buffer.from('%PDF-demo').toString('base64'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves richer Responses request fields back onto the OpenAI-compatible body', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
@@ -337,6 +496,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
type: 'reasoning',
|
||||
id: 'rs_1',
|
||||
status: 'completed',
|
||||
encrypted_content: 'enc_sig_1',
|
||||
summary: [
|
||||
{ type: 'summary_text', text: 'Think step by step' },
|
||||
],
|
||||
@@ -356,6 +516,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
text: 'Think step by step',
|
||||
},
|
||||
],
|
||||
reasoning_signature: 'enc_sig_1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -371,6 +532,10 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
@@ -385,6 +550,110 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
truncation: 'auto',
|
||||
service_tier: 'priority',
|
||||
top_logprobs: 4,
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('adds encrypted reasoning include for OpenAI-compatible fallback when reasoning options are present', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'analyze this upload' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_456',
|
||||
filename: 'notes.md',
|
||||
mime_type: 'text/markdown',
|
||||
file_data: 'IyBoZWxsbwo=',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'analyze this upload' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file_local_456',
|
||||
filename: 'notes.md',
|
||||
mime_type: 'text/markdown',
|
||||
file_data: 'IyBoZWxsbwo=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps richer field parity on compatibility retry bodies when metadata is absent', () => {
|
||||
const candidates = buildResponsesCompatibilityBodies({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
safety_identifier: 'safe-user-9',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key-9',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
background: true,
|
||||
top_logprobs: 2,
|
||||
});
|
||||
|
||||
expect(candidates).toContainEqual({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
summary: 'auto',
|
||||
},
|
||||
safety_identifier: 'safe-user-9',
|
||||
max_tool_calls: 3,
|
||||
prompt_cache_key: 'cache-key-9',
|
||||
prompt_cache_retention: { scope: 'workspace' },
|
||||
background: true,
|
||||
top_logprobs: 2,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
|
||||
normalizeResponsesMessageItem,
|
||||
} from './compatibility.js';
|
||||
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -81,6 +82,32 @@ function normalizeIncludeList(value: unknown): unknown {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function hasReasoningRequest(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
||||
return relevantKeys.some((key) => {
|
||||
const entry = value[key];
|
||||
if (typeof entry === 'string') return entry.trim().length > 0;
|
||||
return entry !== undefined && entry !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureEncryptedReasoningInclude(body: Record<string, unknown>): void {
|
||||
if (!hasReasoningRequest(body.reasoning)) return;
|
||||
|
||||
const normalizedInclude = normalizeIncludeList(body.include);
|
||||
const includeList = Array.isArray(normalizedInclude) ? normalizedInclude : [];
|
||||
const alreadyRequested = includeList.some((item) => (
|
||||
typeof item === 'string' && item.trim().toLowerCase() === 'reasoning.encrypted_content'
|
||||
));
|
||||
if (alreadyRequested) {
|
||||
body.include = includeList;
|
||||
return;
|
||||
}
|
||||
|
||||
body.include = [...includeList, 'reasoning.encrypted_content'];
|
||||
}
|
||||
|
||||
function normalizeTextConfig(
|
||||
rawText: unknown,
|
||||
fallbackVerbosity?: unknown,
|
||||
@@ -147,6 +174,7 @@ function normalizeResponsesRequestFieldParity(
|
||||
if (normalized.include !== undefined) {
|
||||
normalized.include = normalizeIncludeList(normalized.include);
|
||||
}
|
||||
ensureEncryptedReasoningInclude(normalized);
|
||||
|
||||
if (normalized.stream_options !== undefined) {
|
||||
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
|
||||
@@ -594,6 +622,11 @@ function normalizeOpenAiContentBlock(item: Record<string, unknown>): string | Re
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
const fileBlock = normalizeInputFileBlock(item);
|
||||
return fileBlock ? toOpenAiChatFileBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = extractTextContent(item).trim();
|
||||
return text ? { type: 'text', text } : null;
|
||||
@@ -738,15 +771,20 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (itemType === 'reasoning') {
|
||||
flushPendingToolCalls();
|
||||
const reasoningContent = toOpenAiMessageContent(item.summary ?? item.content ?? item);
|
||||
const reasoningSignature = asTrimmedString(item.encrypted_content);
|
||||
const hasReasoningContent = typeof reasoningContent === 'string'
|
||||
? reasoningContent.trim().length > 0
|
||||
: Array.isArray(reasoningContent) && reasoningContent.length > 0;
|
||||
if (!hasReasoningContent) return;
|
||||
if (!hasReasoningContent && !reasoningSignature) return;
|
||||
|
||||
messages.push({
|
||||
const message: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
content: reasoningContent,
|
||||
});
|
||||
};
|
||||
if (reasoningSignature) {
|
||||
message.reasoning_signature = reasoningSignature;
|
||||
}
|
||||
messages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -813,6 +851,7 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (normalizedBody.include !== undefined) payload.include = cloneJsonValue(normalizedBody.include);
|
||||
if (normalizedBody.previous_response_id !== undefined) payload.previous_response_id = normalizedBody.previous_response_id;
|
||||
if (normalizedBody.truncation !== undefined) payload.truncation = normalizedBody.truncation;
|
||||
if (normalizedBody.reasoning !== undefined) payload.reasoning = cloneJsonValue(normalizedBody.reasoning);
|
||||
if (normalizedBody.service_tier !== undefined) payload.service_tier = normalizedBody.service_tier;
|
||||
if (normalizedBody.top_logprobs !== undefined) payload.top_logprobs = normalizedBody.top_logprobs;
|
||||
if (normalizedBody.stream_options !== undefined) payload.stream_options = normalizedBody.stream_options;
|
||||
|
||||
@@ -248,4 +248,77 @@ describe('serializeResponsesFinalPayload', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('restores encrypted reasoning content from provider-tagged reasoning signatures', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'chatcmpl_reasoning',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
normalized: {
|
||||
id: 'chatcmpl_reasoning',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: 'Think step by step',
|
||||
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-sig-1',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
} as any,
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'msg_chatcmpl_reasoning_reasoning',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
encrypted_content: 'enc-sig-1',
|
||||
summary: [
|
||||
{
|
||||
type: 'summary_text',
|
||||
text: 'Think step by step',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits encrypted-only reasoning items when summary text is empty', () => {
|
||||
const payload = serializeResponsesFinalPayload({
|
||||
upstreamPayload: {
|
||||
id: 'chatcmpl_reasoning_only',
|
||||
model: 'gpt-5',
|
||||
},
|
||||
normalized: {
|
||||
id: 'chatcmpl_reasoning_only',
|
||||
model: 'gpt-5',
|
||||
created: 1700000000,
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-only-1',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
} as any,
|
||||
usage: {
|
||||
promptTokens: 1,
|
||||
completionTokens: 2,
|
||||
totalTokens: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.output).toEqual([
|
||||
{
|
||||
id: 'msg_chatcmpl_reasoning_only_reasoning',
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
encrypted_content: 'enc-only-1',
|
||||
summary: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeUpstreamFinalResponse,
|
||||
type NormalizedFinalResponse,
|
||||
} from '../../shared/normalized.js';
|
||||
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -301,6 +302,11 @@ export function serializeResponsesFinalPayload(input: {
|
||||
if (extracted.length > 0) return extracted;
|
||||
return extractToolCallsFromNormalized(normalized);
|
||||
})();
|
||||
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
|
||||
? normalized.reasoningSignature.trim()
|
||||
: '';
|
||||
const encryptedReasoning = decodeOpenAiEncryptedReasoning(rawReasoningSignature)
|
||||
?? (rawReasoningSignature && !rawReasoningSignature.startsWith('metapi:') ? rawReasoningSignature : null);
|
||||
const annotations = extractAnnotationsFromUpstream(upstreamPayload);
|
||||
|
||||
const output: Array<Record<string, unknown>> = syntheticOutput.map((item) => cloneJson(item));
|
||||
@@ -317,16 +323,22 @@ export function serializeResponsesFinalPayload(input: {
|
||||
);
|
||||
});
|
||||
|
||||
if (normalized.reasoningContent && !hasReasoningItem) {
|
||||
output.push({
|
||||
if ((normalized.reasoningContent || encryptedReasoning) && !hasReasoningItem) {
|
||||
const reasoningItem: Record<string, unknown> = {
|
||||
id: ensureMessageId(`${normalizedId}_reasoning`),
|
||||
type: 'reasoning',
|
||||
status: 'completed',
|
||||
summary: [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}],
|
||||
});
|
||||
summary: normalized.reasoningContent
|
||||
? [{
|
||||
type: 'summary_text',
|
||||
text: normalized.reasoningContent,
|
||||
}]
|
||||
: [],
|
||||
};
|
||||
if (encryptedReasoning) {
|
||||
reasoningItem.encrypted_content = encryptedReasoning;
|
||||
}
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
|
||||
if ((normalized.content || (!hasToolLikeItem && output.length === 0 && toolCalls.length === 0)) && !hasMessageItem) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from './reasoningTransport.js';
|
||||
|
||||
export type DownstreamFormat = 'openai' | 'claude';
|
||||
|
||||
export type ParsedSseEvent = {
|
||||
@@ -32,6 +36,8 @@ export type NormalizedStreamEvent = {
|
||||
role?: 'assistant';
|
||||
contentDelta?: string;
|
||||
reasoningDelta?: string;
|
||||
reasoningSignature?: string;
|
||||
redactedReasoningContent?: string;
|
||||
toolCallDeltas?: Array<{
|
||||
index: number;
|
||||
id?: string;
|
||||
@@ -48,6 +54,8 @@ export type NormalizedFinalResponse = {
|
||||
created: number;
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
reasoningSignature?: string;
|
||||
redactedReasoningContent?: string;
|
||||
finishReason: string;
|
||||
toolCalls: Array<{
|
||||
id: string;
|
||||
@@ -746,6 +754,9 @@ export function normalizeUpstreamStreamEvent(
|
||||
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|
||||
|| deltaParsed.reasoning
|
||||
|| '';
|
||||
const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature)
|
||||
? (delta as any).reasoning_signature
|
||||
: undefined;
|
||||
|
||||
// Some upstream providers (e.g. certain OpenAI-compatible aggregators) emit thinking
|
||||
// tokens with the same text duplicated in both delta.content and delta.reasoning_content.
|
||||
@@ -791,6 +802,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
role: (delta as any).role === 'assistant' ? 'assistant' : undefined,
|
||||
contentDelta: contentDelta || undefined,
|
||||
reasoningDelta: reasoningDelta || undefined,
|
||||
reasoningSignature,
|
||||
toolCallDeltas: toolCallDeltas.length > 0 ? toolCallDeltas : undefined,
|
||||
finishReason: normalizeStopReason(choice?.finish_reason),
|
||||
};
|
||||
@@ -830,6 +842,11 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
|
||||
const item = (payload as any).item as Record<string, unknown>;
|
||||
if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) {
|
||||
return {
|
||||
reasoningSignature: item.encrypted_content,
|
||||
};
|
||||
}
|
||||
if (item.type === 'function_call') {
|
||||
const outputIndex = (
|
||||
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
|
||||
@@ -1371,13 +1388,29 @@ export function serializeFinalResponse(
|
||||
usage: { promptTokens: number; completionTokens: number; totalTokens: number },
|
||||
): Record<string, unknown> {
|
||||
const toolCalls = Array.isArray(normalized.toolCalls) ? normalized.toolCalls : [];
|
||||
const rawReasoningSignature = typeof normalized.reasoningSignature === 'string'
|
||||
? normalized.reasoningSignature.trim()
|
||||
: '';
|
||||
const taggedReasoningSignature = rawReasoningSignature.startsWith('metapi:');
|
||||
const claudeReasoningSignature = decodeAnthropicReasoningSignature(rawReasoningSignature)
|
||||
?? (rawReasoningSignature && !taggedReasoningSignature ? rawReasoningSignature : null);
|
||||
|
||||
if (downstreamFormat === 'claude') {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
if (normalized.reasoningContent) {
|
||||
contentBlocks.push({
|
||||
const thinkingBlock: Record<string, unknown> = {
|
||||
type: 'thinking',
|
||||
thinking: normalized.reasoningContent,
|
||||
};
|
||||
if (claudeReasoningSignature) {
|
||||
thinkingBlock.signature = claudeReasoningSignature;
|
||||
}
|
||||
contentBlocks.push(thinkingBlock);
|
||||
}
|
||||
if (normalized.redactedReasoningContent) {
|
||||
contentBlocks.push({
|
||||
type: 'redacted_thinking',
|
||||
data: normalized.redactedReasoningContent,
|
||||
});
|
||||
}
|
||||
if (normalized.content) {
|
||||
@@ -1422,6 +1455,9 @@ export function serializeFinalResponse(
|
||||
if (normalized.reasoningContent) {
|
||||
message.reasoning_content = normalized.reasoningContent;
|
||||
}
|
||||
if (rawReasoningSignature) {
|
||||
message.reasoning_signature = rawReasoningSignature;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toOpenAiToolCalls(toolCalls);
|
||||
if (!normalized.content) message.content = '';
|
||||
@@ -1470,6 +1506,9 @@ export function buildSyntheticOpenAiChunks(normalized: NormalizedFinalResponse):
|
||||
if (normalized.reasoningContent) {
|
||||
startDelta.reasoning_content = normalized.reasoningContent;
|
||||
}
|
||||
if (typeof normalized.reasoningSignature === 'string' && normalized.reasoningSignature.trim()) {
|
||||
startDelta.reasoning_signature = normalized.reasoningSignature.trim();
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
startDelta.tool_calls = toOpenAiToolCalls(toolCalls);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function splitBase64DataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1].trim().toLowerCase(),
|
||||
data: match[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export type NormalizedInputFile = {
|
||||
sourceType?: 'file' | 'input_file';
|
||||
fileId?: string;
|
||||
fileData?: string;
|
||||
filename?: string;
|
||||
mimeType?: string | null;
|
||||
hadDataUrl?: boolean;
|
||||
};
|
||||
|
||||
export function inferInputFileMimeType(input: Pick<NormalizedInputFile, 'filename' | 'mimeType'>): string | null {
|
||||
const explicit = asTrimmedString(input.mimeType);
|
||||
if (explicit) return explicit.toLowerCase();
|
||||
|
||||
const filename = asTrimmedString(input.filename).toLowerCase();
|
||||
if (!filename) return null;
|
||||
if (filename.endsWith('.pdf')) return 'application/pdf';
|
||||
if (filename.endsWith('.txt')) return 'text/plain';
|
||||
if (filename.endsWith('.md') || filename.endsWith('.markdown')) return 'text/markdown';
|
||||
if (filename.endsWith('.json')) return 'application/json';
|
||||
if (filename.endsWith('.png')) return 'image/png';
|
||||
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (filename.endsWith('.gif')) return 'image/gif';
|
||||
if (filename.endsWith('.webp')) return 'image/webp';
|
||||
if (filename.endsWith('.wav')) return 'audio/wav';
|
||||
if (filename.endsWith('.mp3')) return 'audio/mpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeInputFileBlock(item: Record<string, unknown>): NormalizedInputFile | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'input_file') {
|
||||
const fileId = asTrimmedString(item.file_id);
|
||||
const fileData = asTrimmedString(item.file_data);
|
||||
const filename = asTrimmedString(item.filename);
|
||||
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData) return null;
|
||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||
if (parsedDataUrl) {
|
||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||
}
|
||||
return {
|
||||
sourceType: 'input_file',
|
||||
fileId: fileId || undefined,
|
||||
fileData: fileData || undefined,
|
||||
filename: filename || undefined,
|
||||
mimeType,
|
||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
const file = isRecord(item.file) ? item.file : item;
|
||||
const fileId = asTrimmedString(file.file_id ?? item.file_id);
|
||||
const fileData = asTrimmedString(file.file_data ?? item.file_data);
|
||||
const filename = asTrimmedString(file.filename ?? item.filename);
|
||||
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData) return null;
|
||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||
if (parsedDataUrl) {
|
||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||
}
|
||||
return {
|
||||
sourceType: 'file',
|
||||
fileId: fileId || undefined,
|
||||
fileData: fileData || undefined,
|
||||
filename: filename || undefined,
|
||||
mimeType,
|
||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||
const block: Record<string, unknown> = { type: 'input_file' };
|
||||
if (file.fileId) block.file_id = file.fileId;
|
||||
if (file.fileData) block.file_data = parsedDataUrl?.data || file.fileData;
|
||||
if (file.filename) block.filename = file.filename;
|
||||
if (file.mimeType) block.mime_type = file.mimeType;
|
||||
else if (parsedDataUrl?.mimeType) block.mime_type = parsedDataUrl.mimeType;
|
||||
return block;
|
||||
}
|
||||
|
||||
export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (file.fileId) payload.file_id = file.fileId;
|
||||
if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData;
|
||||
if (file.filename) payload.filename = file.filename;
|
||||
if (file.mimeType) payload.mime_type = file.mimeType;
|
||||
else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;
|
||||
const block: Record<string, unknown> = { type: 'file' };
|
||||
block.file = payload;
|
||||
return block;
|
||||
}
|
||||
|
||||
export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null {
|
||||
if (!file.fileData) return null;
|
||||
const parsedDataUrl = splitBase64DataUrl(file.fileData);
|
||||
const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file);
|
||||
if (!mimeType) return null;
|
||||
return {
|
||||
type: 'document',
|
||||
...(file.hadDataUrl ? { cache_control: { type: 'ephemeral' } } : {}),
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mimeType,
|
||||
data: parsedDataUrl?.data || file.fileData,
|
||||
},
|
||||
...(file.filename ? { title: file.filename } : {}),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,14 @@ describe('shared normalized helpers', () => {
|
||||
expect(source).not.toContain('chatFormats.js');
|
||||
});
|
||||
|
||||
it('exposes shared transformer metadata extensions', () => {
|
||||
const source = readFileSync(new URL('./normalized.ts', import.meta.url), 'utf8');
|
||||
expect(source).toContain('thoughtSignatures');
|
||||
expect(source).toContain('promptCacheKey');
|
||||
expect(source).toContain('truncation');
|
||||
expect(source).toContain('serviceTier');
|
||||
});
|
||||
|
||||
it('parses SSE events and keeps the trailing partial block', () => {
|
||||
const pulled = pullSseEventsWithDone([
|
||||
'event: message',
|
||||
@@ -68,14 +76,19 @@ describe('shared normalized helpers', () => {
|
||||
});
|
||||
|
||||
it('serializes normalized final responses for claude', () => {
|
||||
const normalized: NormalizedFinalResponse = {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'claude-test',
|
||||
created: 456,
|
||||
content: 'done',
|
||||
reasoningContent: 'thinking',
|
||||
reasoningSignature: 'metapi:anthropic-signature:sig-1',
|
||||
redactedReasoningContent: 'ciphertext',
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'tool_1', name: 'lookup', arguments: '{"q":"x"}' }],
|
||||
} as NormalizedFinalResponse & {
|
||||
reasoningSignature: string;
|
||||
redactedReasoningContent: string;
|
||||
};
|
||||
|
||||
expect(serializeFinalResponse('claude', normalized, {
|
||||
@@ -88,7 +101,8 @@ describe('shared normalized helpers', () => {
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'thinking' },
|
||||
{ type: 'thinking', thinking: 'thinking', signature: 'sig-1' },
|
||||
{ type: 'redacted_thinking', data: 'ciphertext' },
|
||||
{ type: 'text', text: 'done' },
|
||||
{ type: 'tool_use', id: 'tool_1', name: 'lookup', input: { q: 'x' } },
|
||||
],
|
||||
@@ -101,6 +115,34 @@ describe('shared normalized helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes provider-tagged reasoning signatures for openai-compatible downstreams', () => {
|
||||
const normalized = {
|
||||
id: 'chatcmpl-2',
|
||||
model: 'gpt-test',
|
||||
created: 789,
|
||||
content: 'final',
|
||||
reasoningContent: 'deliberation',
|
||||
reasoningSignature: 'metapi:openai-encrypted-reasoning:enc-1',
|
||||
finishReason: 'stop',
|
||||
toolCalls: [],
|
||||
} as NormalizedFinalResponse & { reasoningSignature: string };
|
||||
|
||||
expect(serializeFinalResponse('openai', normalized, {
|
||||
promptTokens: 3,
|
||||
completionTokens: 5,
|
||||
totalTokens: 8,
|
||||
})).toMatchObject({
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'final',
|
||||
reasoning_content: 'deliberation',
|
||||
reasoning_signature: 'metapi:openai-encrypted-reasoning:enc-1',
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes known stop reasons', () => {
|
||||
expect(normalizeStopReason('max_output_tokens')).toBe('length');
|
||||
expect(normalizeStopReason('tool_use')).toBe('tool_calls');
|
||||
|
||||
@@ -24,6 +24,7 @@ export type NormalizedContentBlockType =
|
||||
| 'text'
|
||||
| 'image_url'
|
||||
| 'image_inline'
|
||||
| 'input_file'
|
||||
| 'input_audio'
|
||||
| 'output_audio'
|
||||
| 'tool_call'
|
||||
@@ -39,6 +40,8 @@ export type NormalizedContentBlock = {
|
||||
mimeType?: string | null;
|
||||
url?: string | null;
|
||||
data?: string | null;
|
||||
fileId?: string | null;
|
||||
filename?: string | null;
|
||||
toolName?: string | null;
|
||||
toolCallId?: string | null;
|
||||
argumentsText?: string | null;
|
||||
@@ -51,6 +54,8 @@ export type NormalizedUsage = {
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
cachedTokens?: number | null;
|
||||
cacheReadTokens?: number | null;
|
||||
cacheCreationTokens?: number | null;
|
||||
reasoningTokens?: number | null;
|
||||
audioInputTokens?: number | null;
|
||||
audioOutputTokens?: number | null;
|
||||
@@ -66,13 +71,17 @@ export type ParsedDownstreamChatRequestResult = {
|
||||
export type TransformerMetadata = {
|
||||
include?: unknown;
|
||||
maxToolCalls?: number | null;
|
||||
promptCacheKey?: unknown;
|
||||
promptCacheRetention?: unknown;
|
||||
truncation?: unknown;
|
||||
serviceTier?: unknown;
|
||||
includeObfuscation?: boolean | null;
|
||||
citations?: unknown;
|
||||
annotations?: unknown;
|
||||
geminiSafetySettings?: unknown;
|
||||
geminiImageConfig?: unknown;
|
||||
thoughtSignature?: string | null;
|
||||
thoughtSignatures?: string[];
|
||||
passthrough?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -108,6 +117,8 @@ export function createEmptyNormalizedUsage(): NormalizedUsage {
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
audioInputTokens: 0,
|
||||
audioOutputTokens: 0,
|
||||
@@ -127,6 +138,8 @@ export function mergeNormalizedUsage(
|
||||
completionTokens: (merged.completionTokens || 0) + (incoming.completionTokens || 0),
|
||||
totalTokens: (merged.totalTokens || 0) + (incoming.totalTokens || 0),
|
||||
cachedTokens: (merged.cachedTokens || 0) + (incoming.cachedTokens || 0),
|
||||
cacheReadTokens: (merged.cacheReadTokens || 0) + (incoming.cacheReadTokens || 0),
|
||||
cacheCreationTokens: (merged.cacheCreationTokens || 0) + (incoming.cacheCreationTokens || 0),
|
||||
reasoningTokens: (merged.reasoningTokens || 0) + (incoming.reasoningTokens || 0),
|
||||
audioInputTokens: (merged.audioInputTokens || 0) + (incoming.audioInputTokens || 0),
|
||||
audioOutputTokens: (merged.audioOutputTokens || 0) + (incoming.audioOutputTokens || 0),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decodeAnthropicReasoningSignature,
|
||||
decodeGeminiThoughtSignature,
|
||||
decodeOpenAiEncryptedReasoning,
|
||||
encodeAnthropicReasoningSignature,
|
||||
encodeGeminiThoughtSignature,
|
||||
encodeOpenAiEncryptedReasoning,
|
||||
isAnthropicReasoningSignature,
|
||||
isGeminiThoughtSignature,
|
||||
isOpenAiEncryptedReasoning,
|
||||
isSafeAnthropicRedactedReasoningCarrier,
|
||||
} from './reasoningTransport.js';
|
||||
|
||||
describe('reasoning transport codecs', () => {
|
||||
it('round-trips provider-tagged reasoning signatures', () => {
|
||||
const anthropic = encodeAnthropicReasoningSignature('anthropic-sig');
|
||||
const gemini = encodeGeminiThoughtSignature('gemini-sig');
|
||||
const openai = encodeOpenAiEncryptedReasoning('openai-enc');
|
||||
|
||||
expect(isAnthropicReasoningSignature(anthropic)).toBe(true);
|
||||
expect(isGeminiThoughtSignature(gemini)).toBe(true);
|
||||
expect(isOpenAiEncryptedReasoning(openai)).toBe(true);
|
||||
|
||||
expect(decodeAnthropicReasoningSignature(anthropic)).toBe('anthropic-sig');
|
||||
expect(decodeGeminiThoughtSignature(gemini)).toBe('gemini-sig');
|
||||
expect(decodeOpenAiEncryptedReasoning(openai)).toBe('openai-enc');
|
||||
});
|
||||
|
||||
it('prevents prefixed signatures from being treated as anthropic redacted reasoning carriers', () => {
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier('ciphertext')).toBe(true);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeAnthropicReasoningSignature('sig'))).toBe(false);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeGeminiThoughtSignature('sig'))).toBe(false);
|
||||
expect(isSafeAnthropicRedactedReasoningCarrier(encodeOpenAiEncryptedReasoning('enc'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const PROVIDER_PREFIX = 'metapi';
|
||||
|
||||
export const ANTHROPIC_REASONING_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:anthropic-signature:`;
|
||||
export const GEMINI_THOUGHT_SIGNATURE_PREFIX = `${PROVIDER_PREFIX}:gemini-thought-signature:`;
|
||||
export const OPENAI_ENCRYPTED_REASONING_PREFIX = `${PROVIDER_PREFIX}:openai-encrypted-reasoning:`;
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string | null {
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
export function isAnthropicReasoningSignature(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(ANTHROPIC_REASONING_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return isAnthropicReasoningSignature(signature) ? signature : `${ANTHROPIC_REASONING_SIGNATURE_PREFIX}${signature}`;
|
||||
}
|
||||
|
||||
export function decodeAnthropicReasoningSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, ANTHROPIC_REASONING_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function isGeminiThoughtSignature(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(GEMINI_THOUGHT_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return isGeminiThoughtSignature(signature) ? signature : `${GEMINI_THOUGHT_SIGNATURE_PREFIX}${signature}`;
|
||||
}
|
||||
|
||||
export function decodeGeminiThoughtSignature(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, GEMINI_THOUGHT_SIGNATURE_PREFIX);
|
||||
}
|
||||
|
||||
export function isOpenAiEncryptedReasoning(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith(OPENAI_ENCRYPTED_REASONING_PREFIX);
|
||||
}
|
||||
|
||||
export function encodeOpenAiEncryptedReasoning(encryptedContent: string | null | undefined): string | null {
|
||||
if (!encryptedContent) return null;
|
||||
return isOpenAiEncryptedReasoning(encryptedContent)
|
||||
? encryptedContent
|
||||
: `${OPENAI_ENCRYPTED_REASONING_PREFIX}${encryptedContent}`;
|
||||
}
|
||||
|
||||
export function decodeOpenAiEncryptedReasoning(signature: string | null | undefined): string | null {
|
||||
if (!signature) return null;
|
||||
return stripPrefix(signature, OPENAI_ENCRYPTED_REASONING_PREFIX);
|
||||
}
|
||||
|
||||
export function isSafeAnthropicRedactedReasoningCarrier(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& !isAnthropicReasoningSignature(value)
|
||||
&& !isGeminiThoughtSignature(value)
|
||||
&& !isOpenAiEncryptedReasoning(value);
|
||||
}
|
||||
+311
-35
@@ -10,6 +10,7 @@ import {
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildImagesEditRequestEnvelope,
|
||||
buildImagesGenerationsRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
collectModelTesterModelNames,
|
||||
createLoadingAssistantMessage,
|
||||
createMessage,
|
||||
createConversationUserMessage,
|
||||
filterModelTesterModelNames,
|
||||
finalizeIncompleteMessage,
|
||||
findLastLoadingAssistantIndex,
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
syncCustomRequestBodyToMessages,
|
||||
syncMessagesToCustomRequestBody,
|
||||
type ChatMessage,
|
||||
type ConversationContentPart,
|
||||
type ConversationUploadedFile,
|
||||
type DebugTab,
|
||||
type ModelTesterInputs,
|
||||
type ModelTesterModeState,
|
||||
@@ -62,16 +66,19 @@ type UploadState = {
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type ConversationFileState = UploadState & {
|
||||
localId: string;
|
||||
fileId?: string | null;
|
||||
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1200;
|
||||
const CONVERSATION_FILE_ACCEPT = '.pdf,.txt,.md,.markdown,.json,image/*,audio/*';
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const fileToDataUrl = async (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error || new Error('read file failed'));
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const createConversationFileLocalId = () =>
|
||||
`draft-file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const summarizeModeRequest = (
|
||||
mode: PlaygroundMode,
|
||||
@@ -671,10 +678,12 @@ export default function ModelTester() {
|
||||
const [videoInspectAction, setVideoInspectAction] = useState<'GET' | 'DELETE'>('GET');
|
||||
const [imageSourceFile, setImageSourceFile] = useState<UploadState | null>(null);
|
||||
const [imageMaskFile, setImageMaskFile] = useState<UploadState | null>(null);
|
||||
const [conversationFiles, setConversationFiles] = useState<ConversationFileState[]>([]);
|
||||
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const conversationFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const restoredSessionRef = useRef<ReturnType<typeof parseModelTesterSession>>(null);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
const streamStopRequestedRef = useRef(false);
|
||||
@@ -859,6 +868,107 @@ export default function ModelTester() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConversationFilesChange = useCallback(async (fileList: FileList | null) => {
|
||||
const files = Array.from(fileList || []);
|
||||
if (files.length <= 0) return;
|
||||
|
||||
try {
|
||||
const nextFiles = await Promise.all(files.map(async (file) => ({
|
||||
localId: createConversationFileLocalId(),
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
dataUrl: await readFileAsDataUrl(file),
|
||||
fileId: null,
|
||||
status: 'pending' as const,
|
||||
errorMessage: null,
|
||||
})));
|
||||
setConversationFiles((prev) => [...prev, ...nextFiles]);
|
||||
pushDebug('info', `已添加 ${nextFiles.length} 个会话附件。`);
|
||||
} catch (readError: any) {
|
||||
const message = readError?.message || '读取附件失败';
|
||||
setError(message);
|
||||
pushDebug('error', message);
|
||||
}
|
||||
}, [pushDebug]);
|
||||
|
||||
const removeConversationFile = useCallback((localId: string) => {
|
||||
if (sending) return;
|
||||
setConversationFiles((prev) => prev.filter((item) => item.localId !== localId));
|
||||
}, [sending]);
|
||||
|
||||
const extractUploadedFilesFromMessage = useCallback((message: ChatMessage): ConversationUploadedFile[] => {
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
return parts.flatMap((part) => {
|
||||
if (part.type !== 'input_file') return [];
|
||||
const fileId = typeof part.fileId === 'string' ? part.fileId.trim() : '';
|
||||
if (!fileId) return [];
|
||||
return [{
|
||||
fileId,
|
||||
filename: typeof part.filename === 'string' && part.filename.trim() ? part.filename.trim() : null,
|
||||
mimeType: typeof part.mimeType === 'string' && part.mimeType.trim() ? part.mimeType.trim() : null,
|
||||
}];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploadConversationFiles = useCallback(async (): Promise<ConversationUploadedFile[]> => {
|
||||
if (conversationFiles.length <= 0) return [];
|
||||
|
||||
const uploaded: ConversationUploadedFile[] = [];
|
||||
for (const item of conversationFiles) {
|
||||
if (item.fileId) {
|
||||
uploaded.push({
|
||||
fileId: item.fileId,
|
||||
filename: item.name,
|
||||
mimeType: item.mimeType,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, status: 'uploading', errorMessage: null }
|
||||
: entry
|
||||
)));
|
||||
pushDebug('info', `正在上传附件:${item.name}`);
|
||||
|
||||
try {
|
||||
const result = await api.proxyTest(buildFileUploadRequestEnvelope({
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
dataUrl: item.dataUrl,
|
||||
})) as { id?: unknown; filename?: unknown; mime_type?: unknown };
|
||||
const fileId = typeof result?.id === 'string' ? result.id.trim() : '';
|
||||
if (!fileId) {
|
||||
throw new Error('上传成功但未返回 file_id');
|
||||
}
|
||||
const filename = typeof result?.filename === 'string' && result.filename.trim()
|
||||
? result.filename.trim()
|
||||
: item.name;
|
||||
const mimeType = typeof result?.mime_type === 'string' && result.mime_type.trim()
|
||||
? result.mime_type.trim()
|
||||
: item.mimeType;
|
||||
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, fileId, name: filename, mimeType, status: 'uploaded', errorMessage: null }
|
||||
: entry
|
||||
)));
|
||||
uploaded.push({ fileId, filename, mimeType });
|
||||
pushDebug('info', `附件上传完成:${filename} -> ${fileId}`);
|
||||
} catch (uploadError: any) {
|
||||
const message = uploadError?.message || '附件上传失败';
|
||||
setConversationFiles((prev) => prev.map((entry) => (
|
||||
entry.localId === item.localId
|
||||
? { ...entry, status: 'error', errorMessage: message }
|
||||
: entry
|
||||
)));
|
||||
throw new Error(`${item.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
}, [conversationFiles, pushDebug]);
|
||||
|
||||
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
|
||||
if (!inputs.systemPrompt.trim()) return baseMessages;
|
||||
return [
|
||||
@@ -1260,6 +1370,7 @@ export default function ModelTester() {
|
||||
() => filteredModels.map((item) => ({ value: item, label: item })),
|
||||
[filteredModels],
|
||||
);
|
||||
const conversationFileSupported = inputs.protocol === 'openai' || inputs.protocol === 'responses';
|
||||
const canSend = useMemo(() => {
|
||||
if (sending || pendingJobId || !inputs.model) return false;
|
||||
if (inputs.mode !== 'conversation') {
|
||||
@@ -1272,9 +1383,9 @@ export default function ModelTester() {
|
||||
return false;
|
||||
}
|
||||
const hasPrompt = input.trim().length > 0;
|
||||
if (!customRequestMode) return hasPrompt;
|
||||
if (!customRequestMode) return hasPrompt || (conversationFileSupported && conversationFiles.length > 0);
|
||||
return hasPrompt || customRequestBody.trim().length > 0;
|
||||
}, [assetPrompt, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]);
|
||||
}, [assetPrompt, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]);
|
||||
|
||||
const startChatJob = useCallback(async (payload: TestChatPayload) => {
|
||||
try {
|
||||
@@ -1627,8 +1738,12 @@ export default function ModelTester() {
|
||||
};
|
||||
}, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, customRequestBody, customRequestMode, inputs, parameterEnabled]);
|
||||
|
||||
const sendWithPrompt = useCallback(async (prompt: string, baseMessages: ChatMessage[]) => {
|
||||
const userMessage = createMessage('user', prompt);
|
||||
const sendWithPrompt = useCallback(async (
|
||||
prompt: string,
|
||||
baseMessages: ChatMessage[],
|
||||
files: ConversationUploadedFile[] = [],
|
||||
) => {
|
||||
const userMessage = createConversationUserMessage(prompt, files);
|
||||
const loadingAssistant = createLoadingAssistantMessage();
|
||||
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
|
||||
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
|
||||
@@ -1646,7 +1761,7 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
|
||||
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]);
|
||||
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, inputs.protocol, pushDebug]);
|
||||
|
||||
const sendModeRequest = useCallback(async () => {
|
||||
const envelope = buildModeProxyEnvelope();
|
||||
@@ -1666,6 +1781,36 @@ export default function ModelTester() {
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (conversationFiles.length > 0 && customRequestMode) {
|
||||
const message = '自定义请求模式不会自动注入会话附件,请先关闭自定义请求模式或移除附件。';
|
||||
setError(message);
|
||||
pushDebug('warn', message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationFiles.length > 0 && !conversationFileSupported) {
|
||||
const message = '当前协议的会话附件仅支持 OpenAI / Responses;请切换协议或移除附件。';
|
||||
setError(message);
|
||||
pushDebug('warn', message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
|
||||
setSending(true);
|
||||
try {
|
||||
const uploadedFiles = await uploadConversationFiles();
|
||||
setInput('');
|
||||
setConversationFiles([]);
|
||||
await sendWithPrompt(trimmed, messages, uploadedFiles);
|
||||
} catch (uploadError: any) {
|
||||
const message = uploadError?.message || '附件上传失败';
|
||||
setError(message);
|
||||
pushDebug('error', message);
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
setInput('');
|
||||
await sendWithPrompt(trimmed, messages);
|
||||
@@ -1691,7 +1836,7 @@ export default function ModelTester() {
|
||||
{ stream: inputs.stream, jobMode: !inputs.stream },
|
||||
),
|
||||
);
|
||||
}, [canSend, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt]);
|
||||
}, [canSend, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
|
||||
|
||||
const retryPending = useCallback(async () => {
|
||||
if (sending || pendingJobId || !pendingPayload) return;
|
||||
@@ -1780,6 +1925,7 @@ export default function ModelTester() {
|
||||
setVideoInspectAction('GET');
|
||||
setImageSourceFile(null);
|
||||
setImageMaskFile(null);
|
||||
setConversationFiles([]);
|
||||
localStorage.removeItem(MODEL_TESTER_STORAGE_KEY);
|
||||
pushDebug('info', '对话已清除。');
|
||||
}, [pendingJobId, pushDebug]);
|
||||
@@ -1867,10 +2013,11 @@ export default function ModelTester() {
|
||||
|
||||
const base = messages.slice(0, userIndex);
|
||||
const prompt = messages[userIndex].content;
|
||||
const files = extractUploadedFilesFromMessage(messages[userIndex]);
|
||||
setEditingMessageId(null);
|
||||
setEditValue('');
|
||||
void sendWithPrompt(prompt, base);
|
||||
}, [messages, pendingJobId, sendWithPrompt, sending]);
|
||||
void sendWithPrompt(prompt, base, files);
|
||||
}, [extractUploadedFilesFromMessage, messages, pendingJobId, sendWithPrompt, sending]);
|
||||
|
||||
const startEditMessage = useCallback((target: ChatMessage) => {
|
||||
if (sending) return;
|
||||
@@ -1904,9 +2051,9 @@ export default function ModelTester() {
|
||||
|
||||
if (retry && target.role === 'user') {
|
||||
const base = updated.slice(0, targetIndex);
|
||||
void sendWithPrompt(nextContent, base);
|
||||
void sendWithPrompt(nextContent, base, extractUploadedFilesFromMessage(target));
|
||||
}
|
||||
}, [cancelEditMessage, editValue, editingMessageId, messages, sendWithPrompt]);
|
||||
}, [cancelEditMessage, editValue, editingMessageId, extractUploadedFilesFromMessage, messages, sendWithPrompt]);
|
||||
|
||||
const syncMessageToBody = useCallback(() => {
|
||||
const nextBody = syncMessagesToCustomRequestBody(customRequestBody, messages, inputs);
|
||||
@@ -2422,6 +2569,9 @@ export default function ModelTester() {
|
||||
const isError = message.status === MESSAGE_STATUS.ERROR;
|
||||
const showReasoning = Boolean(message.reasoningContent);
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const fileParts = Array.isArray(message.parts)
|
||||
? message.parts.filter((part): part is Extract<ConversationContentPart, { type: 'input_file' }> => part.type === 'input_file')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2538,6 +2688,34 @@ export default function ModelTester() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && fileParts.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{fileParts.map((part, index) => (
|
||||
<span
|
||||
key={`${message.id}-file-${part.fileId || part.filename || index}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 11,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
title={part.fileId || part.filename || '附件'}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{part.filename || part.fileId || '附件'}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{!isLoading && (
|
||||
@@ -2583,25 +2761,123 @@ export default function ModelTester() {
|
||||
|
||||
{inputs.mode === 'conversation' ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (sending) {
|
||||
void stopGenerating();
|
||||
return;
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-subtle)',
|
||||
}}>
|
||||
<input
|
||||
ref={conversationFileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={CONVERSATION_FILE_ACCEPT}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
void handleConversationFilesChange(event.target.files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 10px' }}
|
||||
disabled={sending || customRequestMode || !conversationFileSupported}
|
||||
onClick={() => conversationFileInputRef.current?.click()}
|
||||
>
|
||||
添加文件
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{customRequestMode
|
||||
? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。'
|
||||
: !conversationFileSupported
|
||||
? '当前协议暂不支持会话附件注入;请切换到 OpenAI 或 Responses。'
|
||||
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
|
||||
</span>
|
||||
</div>
|
||||
{conversationFiles.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
|
||||
{conversationFiles.map((file) => {
|
||||
const statusText = file.status === 'uploading'
|
||||
? '上传中'
|
||||
: file.status === 'uploaded'
|
||||
? '已上传'
|
||||
: file.status === 'error'
|
||||
? '失败'
|
||||
: '待上传';
|
||||
const statusColor = file.status === 'error'
|
||||
? 'var(--color-danger)'
|
||||
: file.status === 'uploaded'
|
||||
? 'var(--color-success)'
|
||||
: file.status === 'uploading'
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-text-muted)';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={file.localId}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
maxWidth: '100%',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-bg-card)',
|
||||
fontSize: 11,
|
||||
}}
|
||||
title={file.errorMessage || file.fileId || file.name}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span style={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span style={{ color: statusColor }}>· {statusText}</span>
|
||||
{!sending && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeConversationFile(file.localId)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (sending) {
|
||||
void stopGenerating();
|
||||
return;
|
||||
}
|
||||
void send();
|
||||
}
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder={customRequestMode
|
||||
? '自定义模式下输入可选。回车发送时将优先使用右侧自定义请求体。'
|
||||
: '输入提示词...(回车发送,Shift+回车换行)'}
|
||||
rows={3}
|
||||
style={{ ...inputBaseStyle, resize: 'none', flex: 1 }}
|
||||
/>
|
||||
}}
|
||||
placeholder={customRequestMode
|
||||
? '自定义模式下输入可选。回车发送时将优先使用右侧自定义请求体。'
|
||||
: '输入提示词,或只上传文件后直接发送…(回车发送,Shift+回车换行)'}
|
||||
rows={3}
|
||||
style={{ ...inputBaseStyle, resize: 'none', flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (sending) {
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
createConversationUserMessage,
|
||||
filterModelTesterModelNames,
|
||||
parseCustomRequestBody,
|
||||
parseModelTesterSession,
|
||||
serializeModelTesterSession,
|
||||
syncMessagesToCustomRequestBody,
|
||||
toApiMessages,
|
||||
type ChatMessage,
|
||||
type ModelTesterSessionState,
|
||||
} from './modelTesterSession.js';
|
||||
|
||||
@@ -171,6 +174,93 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds multipart upload envelopes for /v1/files', () => {
|
||||
expect(buildFileUploadRequestEnvelope({
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
})).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose: 'assistants',
|
||||
},
|
||||
multipartFiles: [
|
||||
{
|
||||
field: 'file',
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates user conversation messages that preserve uploaded file references', () => {
|
||||
const message = createConversationUserMessage('请总结附件', [
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.role).toBe('user');
|
||||
expect(message.content).toBe('请总结附件');
|
||||
expect(message.parts).toEqual([
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves file reference parts when building responses conversation payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: '请总结上传文件',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file_123',
|
||||
filename: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '请总结上传文件' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt', mime_type: 'text/plain' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds gemini conversation envelope with generationConfig', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
@@ -196,6 +286,87 @@ describe('modelTesterSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes conversation file parts into openai and responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
role: 'user' as const,
|
||||
content: 'summarize this',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file' as const,
|
||||
fileId: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openaiPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(openaiPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
|
||||
const responsesPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(responsesPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds embeddings and search envelopes', () => {
|
||||
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
|
||||
method: 'POST',
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ConversationContentPart =
|
||||
| { type: 'image_url'; url: string }
|
||||
| { type: 'image_inline'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'input_audio'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'input_file'; fileId?: string | null; filename?: string | null; mimeType?: string | null; data?: string | null }
|
||||
| { type: 'output_audio'; dataUrl: string; mimeType?: string | null }
|
||||
| { type: 'tool_call'; name?: string; argumentsText?: string }
|
||||
| { type: 'tool_result'; name?: string; outputText?: string }
|
||||
@@ -58,6 +59,7 @@ export type ChatMessage = {
|
||||
type ApiChatMessage = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
parts?: ConversationContentPart[] | null;
|
||||
};
|
||||
|
||||
export type PlaygroundMultipartFile = {
|
||||
@@ -67,6 +69,12 @@ export type PlaygroundMultipartFile = {
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export type ConversationUploadedFile = {
|
||||
fileId: string;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
export type TesterProxyEnvelope = {
|
||||
method: ProxyRequestMethod;
|
||||
path: string;
|
||||
@@ -249,21 +257,151 @@ const appendOptionalNumber = (target: Record<string, unknown>, key: string, enab
|
||||
}
|
||||
};
|
||||
|
||||
const parseDataUrl = (dataUrl: string): { mimeType: string; data: string } | null => {
|
||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec((dataUrl || '').trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1] || 'application/octet-stream',
|
||||
data: match[2],
|
||||
};
|
||||
};
|
||||
|
||||
const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
|
||||
if (part.type === 'image_url') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: part.url,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image_inline') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: part.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_audio') {
|
||||
const parsed = parseDataUrl(part.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: parsed.data,
|
||||
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_file') {
|
||||
const filePayload: Record<string, unknown> = {};
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(filePayload).length === 0) return null;
|
||||
return {
|
||||
type: 'file',
|
||||
file: filePayload,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const toResponsesContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
|
||||
if (part.type === 'image_url') {
|
||||
return {
|
||||
type: 'input_image',
|
||||
image_url: part.url,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image_inline') {
|
||||
return {
|
||||
type: 'input_image',
|
||||
image_url: part.dataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_audio') {
|
||||
const parsed = parseDataUrl(part.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: parsed.data,
|
||||
format: (part.mimeType || parsed.mimeType || 'audio/wav').split('/').pop() || 'wav',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'input_file') {
|
||||
const fileBlock: Record<string, unknown> = {
|
||||
type: 'input_file',
|
||||
};
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) fileBlock.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(fileBlock).length === 1) return null;
|
||||
return fileBlock;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const toGeminiContents = (messages: ApiChatMessage[]) =>
|
||||
messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: message.content }],
|
||||
}));
|
||||
|
||||
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => message.content;
|
||||
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
|
||||
const parts = Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toOpenAiContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [];
|
||||
|
||||
if (parts.length <= 0) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
return [
|
||||
{ type: 'text', text: message.content },
|
||||
...parts,
|
||||
];
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
if (messages.length === 1 && messages[0].role === 'user') {
|
||||
const hasStructuredParts = messages.some((message) => Array.isArray(message.parts) && message.parts.length > 0);
|
||||
if (!hasStructuredParts && messages.length === 1 && messages[0].role === 'user') {
|
||||
return messages[0].content;
|
||||
}
|
||||
return messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: message.content,
|
||||
content: [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [])),
|
||||
].length > 0
|
||||
? [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
.filter((item): item is Record<string, unknown> => !!item)
|
||||
: [])),
|
||||
]
|
||||
: message.content,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -636,6 +774,27 @@ export const createMessage = (role: ChatRole, content: string, extra: Partial<Ch
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const createConversationInputFilePart = (
|
||||
file: ConversationUploadedFile,
|
||||
): ConversationContentPart => ({
|
||||
type: 'input_file',
|
||||
fileId: file.fileId,
|
||||
filename: file.filename ?? null,
|
||||
mimeType: file.mimeType ?? null,
|
||||
});
|
||||
|
||||
export const createConversationUserMessage = (
|
||||
content: string,
|
||||
files: ConversationUploadedFile[] = [],
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage => {
|
||||
const parts = files.map((file) => createConversationInputFilePart(file));
|
||||
return createMessage('user', content, {
|
||||
...extra,
|
||||
...(parts.length > 0 ? { parts } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
export const createLoadingAssistantMessage = (): ChatMessage =>
|
||||
createMessage('assistant', '', {
|
||||
status: MESSAGE_STATUS.LOADING,
|
||||
@@ -772,6 +931,7 @@ export const toApiMessages = (messages: ChatMessage[]): ApiChatMessage[] =>
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
parts: message.parts,
|
||||
}));
|
||||
|
||||
export const buildConversationRequestEnvelope = (
|
||||
@@ -844,6 +1004,27 @@ export const buildImagesGenerationsRequestEnvelope = (
|
||||
},
|
||||
});
|
||||
|
||||
export const buildFileUploadRequestEnvelope = (
|
||||
file: Omit<PlaygroundMultipartFile, 'field'> & { field?: string },
|
||||
purpose = 'assistants',
|
||||
): TesterProxyEnvelope => ({
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose,
|
||||
},
|
||||
multipartFiles: [{
|
||||
field: file.field || 'file',
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
dataUrl: file.dataUrl,
|
||||
}],
|
||||
});
|
||||
|
||||
export const buildImagesEditRequestEnvelope = (
|
||||
inputs: ModelTesterInputs,
|
||||
modeState: ModelTesterModeState,
|
||||
|
||||
Reference in New Issue
Block a user