feat: implement proxy input file resolution and storage

- Added `inputFiles.ts` for handling input file resolution, including validation and normalization of file types.
- Introduced `proxyFileStore.ts` for managing file storage, retrieval, and deletion with owner-based isolation.
- Created tests for proxy file storage functionality in `proxyFileStore.test.ts`.
- Developed `proxyInputFileResolver.ts` to resolve input files in OpenAI and response formats.
- Added utility functions for handling reasoning signatures in `reasoningTransport.ts` and corresponding tests.
- Enhanced shared input file transformers in `inputFile.ts` for better file handling and type inference.
This commit is contained in:
cita
2026-03-08 22:10:31 +08:00
parent ede8998244
commit 7bd3114ea0
54 changed files with 4504 additions and 164 deletions
+172
View File
@@ -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 },
+37 -14
View File
@@ -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}`;
+177
View File
@@ -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,
});
});
});
+159
View File
@@ -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,
});
});
}
+74
View File
@@ -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: [
+8 -29
View File
@@ -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' },
+143
View File
@@ -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);
});
});
+114
View File
@@ -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);
}
+52 -2
View File
@@ -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 = (
+2
View File
@@ -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',
+66 -19
View File
@@ -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,