forked from boos/BoosAPI
feat: rewrite user journey documentation and enhance input file handling in responses
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user