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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user