forked from boos/BoosAPI
Merge pull request #43 from cita-777/codex/docs-user-journey-rewrite
feat: rewrite user journey documentation and enhance input file handl…
This commit is contained in:
+36
-12
@@ -5,7 +5,7 @@ export default withMermaid(
|
|||||||
defineConfig({
|
defineConfig({
|
||||||
lang: 'zh-CN',
|
lang: 'zh-CN',
|
||||||
title: 'Metapi 文档',
|
title: 'Metapi 文档',
|
||||||
description: 'Metapi 使用文档、FAQ 与维护协作指南',
|
description: '面向普通用户的 Metapi 使用文档',
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
ignoreDeadLinks: true,
|
ignoreDeadLinks: true,
|
||||||
@@ -14,27 +14,51 @@ export default withMermaid(
|
|||||||
logo: '/logos/logo-icon-512.png',
|
logo: '/logos/logo-icon-512.png',
|
||||||
nav: [
|
nav: [
|
||||||
{ text: '首页', link: '/' },
|
{ text: '首页', link: '/' },
|
||||||
{ text: '快速上手', link: '/getting-started' },
|
{ text: '快速开始', link: '/getting-started' },
|
||||||
{ text: 'FAQ', link: '/faq' },
|
{ text: '上游接入', link: '/upstream-integration' },
|
||||||
{ text: '文档维护', link: '/README' },
|
{ text: '下游接入', link: '/client-integration' },
|
||||||
|
{ text: 'FAQ / 排错', link: '/faq' },
|
||||||
{ text: '项目主页', link: 'https://github.com/cita-777/metapi' },
|
{ text: '项目主页', link: 'https://github.com/cita-777/metapi' },
|
||||||
],
|
],
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
text: '开始',
|
text: '开始使用',
|
||||||
items: [
|
items: [
|
||||||
{ text: '文档首页', link: '/' },
|
{ text: '文档首页', link: '/' },
|
||||||
{ text: '快速上手', link: '/getting-started' },
|
{ text: '快速开始', link: '/getting-started' },
|
||||||
{ text: '部署指南', link: '/deployment' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '使用与运维',
|
text: '上游接入',
|
||||||
items: [
|
items: [
|
||||||
{ text: '配置说明', link: '/configuration' },
|
{ text: '上游接入总览', link: '/upstream-integration' },
|
||||||
{ text: '客户端接入', link: '/client-integration' },
|
{ text: 'New API', link: '/upstream/new-api' },
|
||||||
{ text: '运维手册', link: '/operations' },
|
{ text: 'Sub2API', link: '/upstream/sub2api' },
|
||||||
{ text: '常见问题 FAQ', link: '/faq' },
|
{ text: 'AnyRouter', link: '/upstream/anyrouter' },
|
||||||
|
{ text: 'One API', link: '/upstream/one-api' },
|
||||||
|
{ text: 'OneHub', link: '/upstream/onehub' },
|
||||||
|
{ text: 'DoneHub', link: '/upstream/donehub' },
|
||||||
|
{ text: 'Veloera', link: '/upstream/veloera' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '下游接入',
|
||||||
|
items: [
|
||||||
|
{ text: '下游接入', link: '/client-integration' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'FAQ 与排错',
|
||||||
|
items: [
|
||||||
|
{ text: 'FAQ / 排错', link: '/faq' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '高级与自托管',
|
||||||
|
items: [
|
||||||
|
{ text: '自托管部署与反向代理', link: '/deployment' },
|
||||||
|
{ text: '运行配置与部署级配置', link: '/configuration' },
|
||||||
|
{ text: '运维与维护', link: '/operations' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1826,6 +1826,111 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).toContain('/v1/responses');
|
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 () => {
|
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('inlineLocalInputFileReferences', () => {
|
|||||||
getProxyFileByPublicIdForOwnerMock.mockReset();
|
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({
|
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
|
||||||
publicId: 'file-metapi-123',
|
publicId: 'file-metapi-123',
|
||||||
filename: 'report.pdf',
|
filename: 'report.pdf',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
|
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
import {
|
import {
|
||||||
ProxyInputFileResolutionError,
|
ProxyInputFileResolutionError,
|
||||||
hasNonImageFileInputInOpenAiBody,
|
hasNonImageFileInputInOpenAiBody,
|
||||||
@@ -135,6 +136,18 @@ function wantsNativeResponsesReasoning(body: unknown): boolean {
|
|||||||
return hasResponsesReasoningRequest(body.reasoning);
|
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>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
|
|
||||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||||
@@ -207,6 +220,15 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
||||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
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(
|
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
@@ -220,6 +242,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (requiresNativeResponsesFileUrl) {
|
||||||
|
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
|
||||||
|
}
|
||||||
if (endpointCandidates.length === 0) {
|
if (endpointCandidates.length === 0) {
|
||||||
endpointCandidates.push('responses', 'chat', 'messages');
|
endpointCandidates.push('responses', 'chat', 'messages');
|
||||||
}
|
}
|
||||||
@@ -329,6 +354,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
shouldDowngrade: (ctx) => (
|
shouldDowngrade: (ctx) => (
|
||||||
|
!requiresNativeResponsesFileUrl && (
|
||||||
ctx.response.status >= 500
|
ctx.response.status >= 500
|
||||||
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||||
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
|
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
|
||||||
@@ -336,6 +362,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
ctx.response.status,
|
ctx.response.status,
|
||||||
ctx.rawErrText,
|
ctx.rawErrText,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
logProxy(
|
||||||
|
|||||||
@@ -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({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
modelName: 'gpt-5.2',
|
modelName: 'gpt-5.2',
|
||||||
@@ -499,7 +499,6 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
{ type: 'input_text', text: 'read this' },
|
{ type: 'input_text', text: 'read this' },
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
file_id: 'file_local_123',
|
|
||||||
filename: 'paper.pdf',
|
filename: 'paper.pdf',
|
||||||
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
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', () => {
|
it('maps OpenAI file blocks to Anthropic document blocks', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'messages',
|
endpoint: 'messages',
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe('proxyInputFileResolver', () => {
|
|||||||
expect(getProxyFileByPublicIdForOwnerMock).not.toHaveBeenCalled();
|
expect(getProxyFileByPublicIdForOwnerMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves object-form responses input payloads with local file ids', async () => {
|
it('resolves object-form responses input payloads with local file ids into inline-only uploads', async () => {
|
||||||
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
|
getProxyFileByPublicIdForOwnerMock.mockResolvedValue({
|
||||||
publicId: 'file-metapi-123',
|
publicId: 'file-metapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
@@ -69,7 +69,6 @@ describe('proxyInputFileResolver', () => {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
file_id: 'file-metapi-123',
|
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-local').toString('base64')}`,
|
file_data: `data:application/pdf;base64,${Buffer.from('%PDF-local').toString('base64')}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fil
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
...(file.fileId ? { file_id: file.fileId } : {}),
|
...(!file.fileData && file.fileId ? { file_id: file.fileId } : {}),
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
file_data: ensureBase64DataUrl(file.fileData, file.mimeType),
|
file_data: ensureBase64DataUrl(file.fileData, file.mimeType),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
|||||||
expect(result.include).toBeUndefined();
|
expect(result.include).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
|
it('maps OpenAI file-style content blocks into inline-only Responses input_file blocks', () => {
|
||||||
const result = convertOpenAiBodyToResponsesBody(
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
{
|
{
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
@@ -425,7 +425,6 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
|||||||
{ type: 'input_text', text: 'summarize this file' },
|
{ type: 'input_text', text: 'summarize this file' },
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
file_id: 'file_local_123',
|
|
||||||
filename: 'report.pdf',
|
filename: 'report.pdf',
|
||||||
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
||||||
},
|
},
|
||||||
@@ -766,7 +765,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
|
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies without conflicting file ids', () => {
|
||||||
const result = convertResponsesBodyToOpenAiBody(
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
{
|
{
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
@@ -799,7 +798,6 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
{
|
{
|
||||||
type: 'file',
|
type: 'file',
|
||||||
file: {
|
file: {
|
||||||
file_id: 'file_local_456',
|
|
||||||
filename: 'notes.md',
|
filename: 'notes.md',
|
||||||
mime_type: 'text/markdown',
|
mime_type: 'text/markdown',
|
||||||
file_data: 'IyBoZWxsbwo=',
|
file_data: 'IyBoZWxsbwo=',
|
||||||
@@ -810,6 +808,46 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps Responses input_file file_url items when converting back to OpenAI-compatible bodies', () => {
|
||||||
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'read this remote file' },
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
file: {
|
||||||
|
filename: 'remote.pdf',
|
||||||
|
file_url: 'https://example.com/remote.pdf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps richer field parity on compatibility retry bodies when metadata is absent', () => {
|
it('keeps richer field parity on compatibility retry bodies when metadata is absent', () => {
|
||||||
const candidates = buildResponsesCompatibilityBodies({
|
const candidates = buildResponsesCompatibilityBodies({
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type NormalizedInputFile = {
|
|||||||
sourceType?: 'file' | 'input_file';
|
sourceType?: 'file' | 'input_file';
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
fileData?: string;
|
fileData?: string;
|
||||||
|
fileUrl?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
hadDataUrl?: boolean;
|
hadDataUrl?: boolean;
|
||||||
@@ -59,9 +60,10 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
|||||||
if (type === 'input_file') {
|
if (type === 'input_file') {
|
||||||
const fileId = asTrimmedString(item.file_id);
|
const fileId = asTrimmedString(item.file_id);
|
||||||
const fileData = asTrimmedString(item.file_data);
|
const fileData = asTrimmedString(item.file_data);
|
||||||
|
const fileUrl = asTrimmedString(item.file_url);
|
||||||
const filename = asTrimmedString(item.filename);
|
const filename = asTrimmedString(item.filename);
|
||||||
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
||||||
if (!fileId && !fileData) return null;
|
if (!fileId && !fileData && !fileUrl) return null;
|
||||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||||
if (parsedDataUrl) {
|
if (parsedDataUrl) {
|
||||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||||
@@ -70,6 +72,7 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
|||||||
sourceType: 'input_file',
|
sourceType: 'input_file',
|
||||||
fileId: fileId || undefined,
|
fileId: fileId || undefined,
|
||||||
fileData: fileData || undefined,
|
fileData: fileData || undefined,
|
||||||
|
fileUrl: fileUrl || undefined,
|
||||||
filename: filename || undefined,
|
filename: filename || undefined,
|
||||||
mimeType,
|
mimeType,
|
||||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||||
@@ -80,9 +83,10 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
|||||||
const file = isRecord(item.file) ? item.file : item;
|
const file = isRecord(item.file) ? item.file : item;
|
||||||
const fileId = asTrimmedString(file.file_id ?? item.file_id);
|
const fileId = asTrimmedString(file.file_id ?? item.file_id);
|
||||||
const fileData = asTrimmedString(file.file_data ?? item.file_data);
|
const fileData = asTrimmedString(file.file_data ?? item.file_data);
|
||||||
|
const fileUrl = asTrimmedString(file.file_url ?? item.file_url);
|
||||||
const filename = asTrimmedString(file.filename ?? item.filename);
|
const filename = asTrimmedString(file.filename ?? item.filename);
|
||||||
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
|
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
|
||||||
if (!fileId && !fileData) return null;
|
if (!fileId && !fileData && !fileUrl) return null;
|
||||||
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
const parsedDataUrl = fileData ? splitBase64DataUrl(fileData) : null;
|
||||||
if (parsedDataUrl) {
|
if (parsedDataUrl) {
|
||||||
mimeType = mimeType || parsedDataUrl.mimeType;
|
mimeType = mimeType || parsedDataUrl.mimeType;
|
||||||
@@ -91,6 +95,7 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
|||||||
sourceType: 'file',
|
sourceType: 'file',
|
||||||
fileId: fileId || undefined,
|
fileId: fileId || undefined,
|
||||||
fileData: fileData || undefined,
|
fileData: fileData || undefined,
|
||||||
|
fileUrl: fileUrl || undefined,
|
||||||
filename: filename || undefined,
|
filename: filename || undefined,
|
||||||
mimeType,
|
mimeType,
|
||||||
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData),
|
||||||
@@ -103,13 +108,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
|||||||
export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||||
const block: Record<string, unknown> = { type: 'input_file' };
|
const block: Record<string, unknown> = { type: 'input_file' };
|
||||||
if (file.fileId) block.file_id = file.fileId;
|
|
||||||
if (file.fileData) {
|
if (file.fileData) {
|
||||||
block.file_data = ensureBase64DataUrl(
|
block.file_data = ensureBase64DataUrl(
|
||||||
file.fileData,
|
file.fileData,
|
||||||
parsedDataUrl?.mimeType || inferInputFileMimeType(file),
|
parsedDataUrl?.mimeType || inferInputFileMimeType(file),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (file.fileUrl && !block.file_data) block.file_url = file.fileUrl;
|
||||||
|
if (file.fileId && !block.file_data && !block.file_url) block.file_id = file.fileId;
|
||||||
if (file.filename) block.filename = file.filename;
|
if (file.filename) block.filename = file.filename;
|
||||||
return block;
|
return block;
|
||||||
}
|
}
|
||||||
@@ -117,8 +123,9 @@ export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<str
|
|||||||
export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string, unknown> {
|
||||||
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null;
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (file.fileId) payload.file_id = file.fileId;
|
|
||||||
if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData;
|
if (file.fileData) payload.file_data = parsedDataUrl?.data || file.fileData;
|
||||||
|
else if (file.fileUrl) payload.file_url = file.fileUrl;
|
||||||
|
else if (file.fileId) payload.file_id = file.fileId;
|
||||||
if (file.filename) payload.filename = file.filename;
|
if (file.filename) payload.filename = file.filename;
|
||||||
if (file.mimeType) payload.mime_type = file.mimeType;
|
if (file.mimeType) payload.mime_type = file.mimeType;
|
||||||
else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;
|
else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;
|
||||||
|
|||||||
Reference in New Issue
Block a user