From 15d4b207cc5d00cf5a3c860a4d1dabcd4264961e Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 9 Mar 2026 18:25:09 +0800 Subject: [PATCH] feat: rewrite user journey documentation and enhance input file handling in responses --- docs/.vitepress/config.ts | 48 ++++++-- src/server/routes/proxy/chat.stream.test.ts | 105 ++++++++++++++++++ src/server/routes/proxy/inputFiles.test.ts | 2 +- src/server/routes/proxy/responses.ts | 39 ++++++- .../routes/proxy/upstreamEndpoint.test.ts | 48 +++++++- .../services/proxyInputFileResolver.test.ts | 3 +- src/server/services/proxyInputFileResolver.ts | 2 +- .../openai/responses/conversion.test.ts | 46 +++++++- src/server/transformers/shared/inputFile.ts | 15 ++- 9 files changed, 276 insertions(+), 32 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 502bb1d..6dd4895 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -5,7 +5,7 @@ export default withMermaid( defineConfig({ lang: 'zh-CN', title: 'Metapi 文档', - description: 'Metapi 使用文档、FAQ 与维护协作指南', + description: '面向普通用户的 Metapi 使用文档', cleanUrls: true, lastUpdated: true, ignoreDeadLinks: true, @@ -14,27 +14,51 @@ export default withMermaid( logo: '/logos/logo-icon-512.png', nav: [ { text: '首页', link: '/' }, - { text: '快速上手', link: '/getting-started' }, - { text: 'FAQ', link: '/faq' }, - { text: '文档维护', link: '/README' }, + { text: '快速开始', link: '/getting-started' }, + { text: '上游接入', link: '/upstream-integration' }, + { text: '下游接入', link: '/client-integration' }, + { text: 'FAQ / 排错', link: '/faq' }, { text: '项目主页', link: 'https://github.com/cita-777/metapi' }, ], sidebar: [ { - text: '开始', + text: '开始使用', items: [ { text: '文档首页', link: '/' }, - { text: '快速上手', link: '/getting-started' }, - { text: '部署指南', link: '/deployment' }, + { text: '快速开始', link: '/getting-started' }, ], }, { - text: '使用与运维', + text: '上游接入', items: [ - { text: '配置说明', link: '/configuration' }, - { text: '客户端接入', link: '/client-integration' }, - { text: '运维手册', link: '/operations' }, - { text: '常见问题 FAQ', link: '/faq' }, + { text: '上游接入总览', link: '/upstream-integration' }, + { text: 'New API', link: '/upstream/new-api' }, + { text: 'Sub2API', link: '/upstream/sub2api' }, + { 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' }, ], }, { diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 930cf7b..80b76f4 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -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 }, diff --git a/src/server/routes/proxy/inputFiles.test.ts b/src/server/routes/proxy/inputFiles.test.ts index c5c3309..843a420 100644 --- a/src/server/routes/proxy/inputFiles.test.ts +++ b/src/server/routes/proxy/inputFiles.test.ts @@ -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', diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 690ad69..a87193e 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -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; 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) => { diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 2cfcfe0..01eb370 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -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', diff --git a/src/server/services/proxyInputFileResolver.test.ts b/src/server/services/proxyInputFileResolver.test.ts index 5c1339b..a9028c7 100644 --- a/src/server/services/proxyInputFileResolver.test.ts +++ b/src/server/services/proxyInputFileResolver.test.ts @@ -37,7 +37,7 @@ describe('proxyInputFileResolver', () => { 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({ publicId: 'file-metapi-123', filename: 'brief.pdf', @@ -69,7 +69,6 @@ describe('proxyInputFileResolver', () => { content: [ { type: 'input_file', - file_id: 'file-metapi-123', filename: 'brief.pdf', file_data: `data:application/pdf;base64,${Buffer.from('%PDF-local').toString('base64')}`, }, diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index b610e8b..a3ed743 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -212,7 +212,7 @@ function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fil } return { type: 'input_file', - ...(file.fileId ? { file_id: file.fileId } : {}), + ...(!file.fileData && file.fileId ? { file_id: file.fileId } : {}), filename: file.filename, file_data: ensureBase64DataUrl(file.fileData, file.mimeType), }; diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index 55533c2..31c2956 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -393,7 +393,7 @@ describe('convertOpenAiBodyToResponsesBody', () => { 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( { model: 'gpt-5', @@ -425,7 +425,6 @@ describe('convertOpenAiBodyToResponsesBody', () => { { type: 'input_text', text: 'summarize this file' }, { type: 'input_file', - file_id: 'file_local_123', filename: 'report.pdf', 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( { model: 'gpt-5', @@ -799,7 +798,6 @@ describe('convertResponsesBodyToOpenAiBody', () => { { type: 'file', file: { - file_id: 'file_local_456', filename: 'notes.md', mime_type: 'text/markdown', 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', () => { const candidates = buildResponsesCompatibilityBodies({ model: 'gpt-5', diff --git a/src/server/transformers/shared/inputFile.ts b/src/server/transformers/shared/inputFile.ts index 9d3807e..43c2bdb 100644 --- a/src/server/transformers/shared/inputFile.ts +++ b/src/server/transformers/shared/inputFile.ts @@ -29,6 +29,7 @@ export type NormalizedInputFile = { sourceType?: 'file' | 'input_file'; fileId?: string; fileData?: string; + fileUrl?: string; filename?: string; mimeType?: string | null; hadDataUrl?: boolean; @@ -59,9 +60,10 @@ export function normalizeInputFileBlock(item: Record): Normaliz if (type === 'input_file') { const fileId = asTrimmedString(item.file_id); const fileData = asTrimmedString(item.file_data); + const fileUrl = asTrimmedString(item.file_url); const filename = asTrimmedString(item.filename); 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; if (parsedDataUrl) { mimeType = mimeType || parsedDataUrl.mimeType; @@ -70,6 +72,7 @@ export function normalizeInputFileBlock(item: Record): Normaliz sourceType: 'input_file', fileId: fileId || undefined, fileData: fileData || undefined, + fileUrl: fileUrl || undefined, filename: filename || undefined, mimeType, hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData), @@ -80,9 +83,10 @@ export function normalizeInputFileBlock(item: Record): Normaliz const file = isRecord(item.file) ? item.file : item; const fileId = asTrimmedString(file.file_id ?? item.file_id); 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); 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; if (parsedDataUrl) { mimeType = mimeType || parsedDataUrl.mimeType; @@ -91,6 +95,7 @@ export function normalizeInputFileBlock(item: Record): Normaliz sourceType: 'file', fileId: fileId || undefined, fileData: fileData || undefined, + fileUrl: fileUrl || undefined, filename: filename || undefined, mimeType, hadDataUrl: /^data:[^;,]+;base64,/i.test(fileData), @@ -103,13 +108,14 @@ export function normalizeInputFileBlock(item: Record): Normaliz export function toResponsesInputFileBlock(file: NormalizedInputFile): Record { const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null; const block: Record = { type: 'input_file' }; - if (file.fileId) block.file_id = file.fileId; if (file.fileData) { block.file_data = ensureBase64DataUrl( file.fileData, 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; return block; } @@ -117,8 +123,9 @@ export function toResponsesInputFileBlock(file: NormalizedInputFile): Record { const parsedDataUrl = file.fileData ? splitBase64DataUrl(file.fileData) : null; const payload: Record = {}; - if (file.fileId) payload.file_id = file.fileId; 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.mimeType) payload.mime_type = file.mimeType; else if (parsedDataUrl?.mimeType) payload.mime_type = parsedDataUrl.mimeType;