feat: rewrite user journey documentation and enhance input file handling in responses

This commit is contained in:
cita
2026-03-09 18:25:09 +08:00
parent 3da1b93c39
commit 15d4b207cc
9 changed files with 276 additions and 32 deletions
+105
View File
@@ -1826,6 +1826,111 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/responses');
});
it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', 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_file_url_1',
object: 'response',
model: 'upstream-gpt',
output_text: 'hello from responses upstream',
output: [
{
id: 'msg_file_url_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: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
},
});
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.input[0].content[1]).toEqual({
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
});
});
it('returns clear 400 when input_file file_url is sent to a claude-only upstream without native responses support', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-claude',
});
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'claude-haiku-4-5-20251001',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
},
});
expect(response.statusCode).toBe(400);
const body = response.json();
expect(body?.error?.type).toBe('invalid_request_error');
expect(body?.error?.message).toContain('input_file.file_url');
expect(body?.error?.message).toContain('/v1/responses');
expect(fetchMock).not.toHaveBeenCalled();
});
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
+1 -1
View File
@@ -11,7 +11,7 @@ describe('inlineLocalInputFileReferences', () => {
getProxyFileByPublicIdForOwnerMock.mockReset();
});
it('replaces local responses input_file ids with inline file_data payloads', async () => {
it('replaces local responses input_file ids with inline-only file_data payloads', async () => {
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
publicId: 'file-metapi-123',
filename: 'report.pdf',
+33 -6
View File
@@ -23,6 +23,7 @@ import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
@@ -135,6 +136,18 @@ function wantsNativeResponsesReasoning(body: unknown): boolean {
return hasResponsesReasoningRequest(body.reasoning);
}
function carriesResponsesFileUrlInput(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some((item) => carriesResponsesFileUrlInput(item));
}
if (!isRecord(value)) return false;
const normalizedFile = normalizeInputFileBlock(value);
if (normalizedFile?.fileUrl) return true;
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
}
type UsageSummary = ReturnType<typeof parseProxyUsage>;
export async function responsesProxyRoute(app: FastifyInstance) {
@@ -207,6 +220,15 @@ export async function responsesProxyRoute(app: FastifyInstance) {
);
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
return reply.code(400).send({
error: {
message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.',
type: 'invalid_request_error',
},
});
}
const endpointCandidates = await resolveUpstreamEndpointCandidates(
{
site: selected.site,
@@ -220,6 +242,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
);
if (requiresNativeResponsesFileUrl) {
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
}
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
@@ -329,12 +354,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return null;
},
shouldDowngrade: (ctx) => (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
!requiresNativeResponsesFileUrl && (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
)
)
),
onDowngrade: (ctx) => {
@@ -461,7 +461,7 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('serializes file uploads into Responses input_file blocks for upstream responses endpoints', () => {
it('serializes file uploads into Responses input_file blocks without conflicting file ids', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.2',
@@ -499,7 +499,6 @@ describe('buildUpstreamEndpointRequest', () => {
{ type: 'input_text', text: 'read this' },
{
type: 'input_file',
file_id: 'file_local_123',
filename: 'paper.pdf',
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
@@ -712,6 +711,51 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('preserves structured input_file file_url 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: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
},
});
expect(request.body.input).toEqual([
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
]);
});
it('maps OpenAI file blocks to Anthropic document blocks', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',