From 15d4b207cc5d00cf5a3c860a4d1dabcd4264961e Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 9 Mar 2026 18:25:09 +0800 Subject: [PATCH 001/311] 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 502bb1dc..6dd48957 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 930cf7b1..80b76f4a 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 c5c33090..843a420f 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 690ad693..a87193ef 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 2cfcfe00..01eb3702 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 5c1339be..a9028c7e 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 b610e8b5..a3ed7439 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 55533c2c..31c29560 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 9d3807e7..43c2bdb6 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; From 0f8176cd04b8580ea5675268f76785c586a176a1 Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 9 Mar 2026 22:48:10 +0800 Subject: [PATCH 002/311] feat: Implement compatibility strategies for responses and chat endpoints - Added `routeCompatibility.ts` to handle response compatibility for different endpoints. - Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic. - Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`. - Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints. - Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`. - Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests. - Refactored existing components to improve tooltip handling and ensure proper rendering in the application. --- docs/.vitepress/config.ts | 48 +- docs/README.md | 1 + docs/getting-started.md | 5 +- docs/index.md | 13 +- docs/upstream-integration.md | 0 src/server/db/index.ts | 6 + src/server/db/runtimeSchemaBootstrap.ts | 253 ++++++ src/server/index.ts | 38 +- .../routes/api/stats.token-candidates.test.ts | 60 +- src/server/routes/api/stats.ts | 17 +- .../proxy/architecture-boundaries.test.ts | 65 +- .../architecture-semantic-boundaries.test.ts | 24 + src/server/routes/proxy/chat.stream.test.ts | 152 +++- src/server/routes/proxy/chat.ts | 406 ++++------ .../downstreamClientContext.routes.test.ts | 246 ++++++ .../proxy/downstreamClientContext.test.ts | 112 +++ .../routes/proxy/downstreamClientContext.ts | 120 +++ src/server/routes/proxy/logPathMeta.test.ts | 18 + src/server/routes/proxy/logPathMeta.ts | 21 +- src/server/routes/proxy/responses.ts | 386 +++------- src/server/routes/proxy/upstreamEndpoint.ts | 197 +---- src/server/runtimeDatabaseBootstrap.test.ts | 32 + src/server/runtimeDatabaseBootstrap.ts | 51 ++ src/server/services/alertRules.test.ts | 17 +- src/server/services/alertRules.ts | 22 +- .../balanceService.autoRelogin.test.ts | 54 ++ src/server/services/balanceService.ts | 10 +- .../services/databaseMigrationService.ts | 253 +----- src/server/services/tokenRouter.cache.test.ts | 68 ++ src/server/services/tokenRouter.test.ts | 22 +- src/server/services/tokenRouter.ts | 100 +-- .../protocol/chat-inline-think.sse | 9 + .../protocol/chat-missing-finish.sse | 3 + .../protocol/messages-cumulative-text.sse | 16 + .../protocol/responses-failed-sparse.sse | 10 + .../protocol/responses-native-sparse.sse | 13 + .../anthropic/messages/inbound.test.ts | 11 +- .../anthropic/messages/inbound.ts | 23 +- .../transformers/anthropic/messages/index.ts | 2 + .../transformers/openai/chat/helpers.ts | 31 +- .../transformers/openai/chat/inbound.ts | 36 +- .../transformers/openai/chat/index.test.ts | 91 ++- src/server/transformers/openai/chat/index.ts | 14 +- src/server/transformers/openai/chat/model.ts | 10 +- .../transformers/openai/chat/proxyStream.ts | 171 ++++ src/server/transformers/openai/chat/stream.ts | 26 +- .../openai/responses/aggregator.test.ts | 201 +++++ .../openai/responses/aggregator.ts | 97 +++ .../transformers/openai/responses/inbound.ts | 55 +- .../openai/responses/index.test.ts | 60 ++ .../transformers/openai/responses/index.ts | 21 +- .../transformers/openai/responses/model.ts | 10 + .../openai/responses/proxyStream.ts | 111 +++ .../openai/responses/routeCompatibility.ts | 110 +++ .../shared/chatEndpointStrategy.ts | 121 +++ .../shared/chatFormatsCore.test.ts | 69 ++ .../transformers/shared/chatFormatsCore.ts | 111 ++- .../shared/endpointCompatibility.ts | 228 ++++++ .../transformers/shared/inputFile.test.ts | 122 +++ src/server/transformers/shared/inputFile.ts | 36 +- .../transformers/shared/protocolLifecycle.ts | 88 +++ .../transformers/shared/protocolModel.test.ts | 58 ++ .../transformers/shared/protocolModel.ts | 70 ++ .../transformers/shared/thinkTagParser.ts | 107 +++ src/web/App.topbar-tooltips.test.ts | 18 + src/web/App.tsx | 11 +- src/web/components/TooltipLayer.tsx | 229 ++++++ src/web/components/tooltip-layer.test.tsx | 17 + src/web/i18n.tsx | 2 + src/web/index.css | 133 +++- src/web/pages/Accounts.tsx | 729 +++++++++--------- src/web/pages/TokenRoutes.tsx | 2 + .../accounts.segmented-connections.test.tsx | 28 +- .../pages/tokenRoutes.group-collapse.test.tsx | 45 ++ 74 files changed, 4626 insertions(+), 1546 deletions(-) create mode 100644 docs/upstream-integration.md create mode 100644 src/server/db/runtimeSchemaBootstrap.ts create mode 100644 src/server/routes/proxy/architecture-semantic-boundaries.test.ts create mode 100644 src/server/routes/proxy/downstreamClientContext.routes.test.ts create mode 100644 src/server/routes/proxy/downstreamClientContext.test.ts create mode 100644 src/server/routes/proxy/downstreamClientContext.ts create mode 100644 src/server/runtimeDatabaseBootstrap.test.ts create mode 100644 src/server/runtimeDatabaseBootstrap.ts create mode 100644 src/server/test-fixtures/protocol/chat-inline-think.sse create mode 100644 src/server/test-fixtures/protocol/chat-missing-finish.sse create mode 100644 src/server/test-fixtures/protocol/messages-cumulative-text.sse create mode 100644 src/server/test-fixtures/protocol/responses-failed-sparse.sse create mode 100644 src/server/test-fixtures/protocol/responses-native-sparse.sse create mode 100644 src/server/transformers/openai/chat/proxyStream.ts create mode 100644 src/server/transformers/openai/responses/index.test.ts create mode 100644 src/server/transformers/openai/responses/model.ts create mode 100644 src/server/transformers/openai/responses/proxyStream.ts create mode 100644 src/server/transformers/openai/responses/routeCompatibility.ts create mode 100644 src/server/transformers/shared/chatEndpointStrategy.ts create mode 100644 src/server/transformers/shared/chatFormatsCore.test.ts create mode 100644 src/server/transformers/shared/endpointCompatibility.ts create mode 100644 src/server/transformers/shared/inputFile.test.ts create mode 100644 src/server/transformers/shared/protocolLifecycle.ts create mode 100644 src/server/transformers/shared/protocolModel.test.ts create mode 100644 src/server/transformers/shared/protocolModel.ts create mode 100644 src/server/transformers/shared/thinkTagParser.ts create mode 100644 src/web/App.topbar-tooltips.test.ts create mode 100644 src/web/components/TooltipLayer.tsx create mode 100644 src/web/components/tooltip-layer.test.tsx diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6dd48957..e00330f7 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 使用文档', + description: 'Metapi 使用文档、FAQ 与维护协作指南', cleanUrls: true, lastUpdated: true, ignoreDeadLinks: true, @@ -14,51 +14,29 @@ export default withMermaid( logo: '/logos/logo-icon-512.png', nav: [ { text: '首页', link: '/' }, - { text: '快速开始', link: '/getting-started' }, + { text: '快速上手', link: '/getting-started' }, { text: '上游接入', link: '/upstream-integration' }, - { text: '下游接入', link: '/client-integration' }, - { text: 'FAQ / 排错', link: '/faq' }, + { text: 'FAQ', link: '/faq' }, + { text: '文档维护', link: '/README' }, { text: '项目主页', link: 'https://github.com/cita-777/metapi' }, ], sidebar: [ { - text: '开始使用', + text: '开始', items: [ { text: '文档首页', link: '/' }, - { text: '快速开始', link: '/getting-started' }, + { text: '快速上手', link: '/getting-started' }, + { text: '部署指南', link: '/deployment' }, ], }, { - text: '上游接入', + text: '使用与运维', items: [ - { 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' }, + { text: '上游接入', link: '/upstream-integration' }, + { text: '配置说明', link: '/configuration' }, + { text: '客户端接入', link: '/client-integration' }, + { text: '运维手册', link: '/operations' }, + { text: '常见问题 FAQ', link: '/faq' }, ], }, { diff --git a/docs/README.md b/docs/README.md index baaad0d1..96571565 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ npm run docs:build |------|--------|------------| | 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 | | 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 | +| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 | | 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 | | 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 | | 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 | diff --git a/docs/getting-started.md b/docs/getting-started.md index a71ff4bf..09f02fd6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -118,9 +118,11 @@ npm run dev 进入 **站点管理**,添加你使用的上游中转站: - 填写站点名称(自己想怎么取就怎么取)和 URL -- 选择平台类型(New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API),通常可自动检测 +- 选择平台类型(`new-api` / `one-api` / `one-hub` / `done-hub` / `veloera` / `anyrouter` / `sub2api` / `openai` / `claude` / `gemini` / `cliproxyapi`),通常可自动检测 - 填写站点的管理员 API Key(可选,部分功能需要) +如果你不确定该选哪个平台,先看 [上游接入](./upstream-integration.md)。 + ![站点管理](./screenshots/site-management.png) ### 步骤 2:添加账号 @@ -206,6 +208,7 @@ curl -sS http://127.0.0.1:4312/v1/models \ ## 下一步 +- [上游接入](./upstream-integration.md) — 当前代码支持哪些上游、默认该走哪个连接分段 - [部署指南](./deployment.md) — 反向代理、HTTPS、升级策略 - [配置说明](./configuration.md) — 详细环境变量与路由参数 - [客户端接入](./client-integration.md) — 对接 Open WebUI、Cherry Studio 等 diff --git a/docs/index.md b/docs/index.md index f115e010..da881172 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,23 +12,23 @@ hero: - theme: brand text: 10 分钟快速上手 link: /getting-started + - theme: alt + text: 上游接入 + link: /upstream-integration - theme: alt text: 常见问题 FAQ link: /faq - - theme: alt - text: 文档维护与贡献 - link: /README features: - title: 快速上手 details: 从部署到第一条请求,按步骤完成最小可用环境搭建。 link: /getting-started + - title: 上游接入 + details: 按当前代码支持的平台类型,快速判断该选什么平台、先走哪个连接分段。 + link: /upstream-integration - title: 问题排查 details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。 link: /faq - - title: 运维与配置 - details: 覆盖部署、配置、监控、备份、升级回滚等生产场景。 - link: /operations --- ## 项目架构 @@ -289,6 +289,7 @@ onBeforeUnmount(() => { ## 从这里开始 - 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。 +- 不确定上游平台该怎么选:先看 [上游接入](/upstream-integration),再决定走 `账号管理` 还是 `API Key管理`。 - 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。 - 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。 - 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。 diff --git a/docs/upstream-integration.md b/docs/upstream-integration.md new file mode 100644 index 00000000..e69de29b diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 40fe93cc..c6645437 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -9,6 +9,7 @@ import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteS import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; import { config } from '../config.js'; +import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js'; import { mkdirSync } from 'fs'; import { dirname, resolve } from 'path'; @@ -938,6 +939,11 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD try { activeDb = initDb(); + await ensureRuntimeDatabaseReady({ + dialect: nextDialect, + connectionString: nextDbUrl, + ssl: config.dbSsl, + }); } catch (error) { await closeDbConnections(); runtimeDbDialect = previousDialect; diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts new file mode 100644 index 00000000..8ebe5005 --- /dev/null +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -0,0 +1,253 @@ +import Database from 'better-sqlite3'; +import mysql from 'mysql2/promise'; +import pg from 'pg'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; +import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; +import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; + +export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres'; + +export interface RuntimeSchemaClient { + dialect: RuntimeSchemaDialect; + begin(): Promise; + commit(): Promise; + rollback(): Promise; + execute(sqlText: string, params?: unknown[]): Promise; + queryScalar(sqlText: string, params?: unknown[]): Promise; + close(): Promise; +} + +export interface RuntimeSchemaConnectionInput { + dialect: RuntimeSchemaDialect; + connectionString: string; + ssl?: boolean; +} + +function validateIdentifier(identifier: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) { + throw new Error(`Invalid SQL identifier: ${identifier}`); + } + return identifier; +} + +function createSiteSchemaInspector(client: RuntimeSchemaClient): SiteSchemaInspector { + if (client.dialect === 'sqlite') { + return { + dialect: 'sqlite', + tableExists: async (table) => { + const normalizedTable = validateIdentifier(table); + return (await client.queryScalar( + `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`, + )) > 0; + }, + columnExists: async (table, column) => { + const normalizedTable = validateIdentifier(table); + const normalizedColumn = validateIdentifier(column); + return (await client.queryScalar( + `SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`, + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; + } + + if (client.dialect === 'mysql') { + return { + dialect: 'mysql', + tableExists: async (table) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', + [table], + )) > 0; + }, + columnExists: async (table, column) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?', + [table, column], + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; + } + + return { + dialect: 'postgres', + tableExists: async (table) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1', + [table], + )) > 0; + }, + columnExists: async (table, column) => { + return (await client.queryScalar( + 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2', + [table, column], + )) > 0; + }, + execute: async (sqlText) => { + await client.execute(sqlText); + }, + }; +} + +async function createPostgresClient(connectionString: string, ssl: boolean): Promise { + const clientOptions: pg.ClientConfig = { connectionString }; + if (ssl) { + clientOptions.ssl = { rejectUnauthorized: false }; + } + const client = new pg.Client(clientOptions); + await client.connect(); + + return { + dialect: 'postgres', + begin: async () => { await client.query('BEGIN'); }, + commit: async () => { await client.query('COMMIT'); }, + rollback: async () => { await client.query('ROLLBACK'); }, + execute: async (sqlText, params = []) => client.query(sqlText, params), + queryScalar: async (sqlText, params = []) => { + const result = await client.query(sqlText, params); + const row = result.rows[0] as Record | undefined; + if (!row) return 0; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { await client.end(); }, + }; +} + +async function createMySqlClient(connectionString: string, ssl: boolean): Promise { + const connectionOptions: mysql.ConnectionOptions = { uri: connectionString }; + if (ssl) { + connectionOptions.ssl = { rejectUnauthorized: false }; + } + const connection = await mysql.createConnection(connectionOptions); + + return { + dialect: 'mysql', + begin: async () => { await connection.beginTransaction(); }, + commit: async () => { await connection.commit(); }, + rollback: async () => { await connection.rollback(); }, + execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]), + queryScalar: async (sqlText, params = []) => { + const [rows] = await connection.query(sqlText, params as any[]); + if (!Array.isArray(rows) || rows.length === 0) return 0; + const row = rows[0] as Record; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { await connection.end(); }, + }; +} + +async function createSqliteClient(connectionString: string): Promise { + const filePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString); + if (filePath !== ':memory:') { + mkdirSync(dirname(filePath), { recursive: true }); + } + const sqlite = new Database(filePath); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + + return { + dialect: 'sqlite', + begin: async () => { sqlite.exec('BEGIN'); }, + commit: async () => { sqlite.exec('COMMIT'); }, + rollback: async () => { sqlite.exec('ROLLBACK'); }, + execute: async (sqlText, params = []) => { + const lowered = sqlText.trim().toLowerCase(); + const statement = sqlite.prepare(sqlText); + if (lowered.startsWith('select')) return statement.all(...params); + return statement.run(...params); + }, + queryScalar: async (sqlText, params = []) => { + const row = sqlite.prepare(sqlText).get(...params) as Record | undefined; + if (!row) return 0; + return Number(Object.values(row)[0]) || 0; + }, + close: async () => { sqlite.close(); }, + }; +} + +export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionInput): Promise { + if (input.dialect === 'postgres') { + return createPostgresClient(input.connectionString, !!input.ssl); + } + if (input.dialect === 'mysql') { + return createMySqlClient(input.connectionString, !!input.ssl); + } + return createSqliteClient(input.connectionString); +} + +export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise { + const statements = client.dialect === 'postgres' + ? [ + `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, + ] + : client.dialect === 'mysql' + ? [ + `CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, + `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, + `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, + ] + : [ + `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, + `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, + ]; + + for (const sqlText of statements) { + await client.execute(sqlText); + } + + await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); + await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); + await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); +} + +export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise { + const client = await createRuntimeSchemaClient(input); + try { + await ensureRuntimeDatabaseSchema(client); + } finally { + await client.close(); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index c555d8a2..0c080ab4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,6 +24,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; +import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; @@ -40,18 +41,6 @@ import { type RuntimeDbDialect, } from './db/index.js'; -let sqliteMigrationsBootstrapped = false; - -async function ensureSqliteRuntimeMigrations() { - if (runtimeDbDialect !== 'sqlite') return; - const migrateModule = await import('./db/migrate.js'); - if (sqliteMigrationsBootstrapped) { - migrateModule.runSqliteMigrations(); - return; - } - sqliteMigrationsBootstrapped = true; -} - function toSettingsMap(rows: Array<{ key: string; value: string }>) { return new Map(rows.map((row) => [row.key, row.value])); } @@ -199,8 +188,12 @@ function applyRuntimeSettings(settingsMap: Map) { } } -// Ensure sqlite tables exist before reading runtime settings. -await ensureSqliteRuntimeMigrations(); +// Ensure the current runtime database is bootstrapped before reading settings. +await ensureRuntimeDatabaseReady({ + dialect: runtimeDbDialect, + connectionString: config.dbUrl, + ssl: config.dbSsl, +}); // Load runtime config overrides from settings try { @@ -208,12 +201,27 @@ try { const initialMap = toSettingsMap(initialRows); const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap); const activeDbUrl = (config.dbUrl || '').trim(); + const originalRuntimeConfig = { + dialect: runtimeDbDialect, + dbUrl: activeDbUrl, + ssl: config.dbSsl, + }; if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) { try { await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl); - await ensureSqliteRuntimeMigrations(); console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`); } catch (error) { + const currentDbUrl = (config.dbUrl || '').trim(); + const switchedAway = runtimeDbDialect !== originalRuntimeConfig.dialect + || currentDbUrl !== originalRuntimeConfig.dbUrl + || config.dbSsl !== originalRuntimeConfig.ssl; + if (switchedAway) { + await switchRuntimeDatabase( + originalRuntimeConfig.dialect, + originalRuntimeConfig.dbUrl, + originalRuntimeConfig.ssl, + ); + } console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`); } } diff --git a/src/server/routes/api/stats.token-candidates.test.ts b/src/server/routes/api/stats.token-candidates.test.ts index 2fb67d82..e64c9a1d 100644 --- a/src/server/routes/api/stats.token-candidates.test.ts +++ b/src/server/routes/api/stats.token-candidates.test.ts @@ -119,6 +119,44 @@ describe('/api/models/token-candidates', () => { expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined(); }); + it('does not report apikey connections as missing account tokens', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-apikey', + url: 'https://site-apikey.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'shenmo-direct', + accessToken: '', + apiToken: 'sk-shenmo-direct', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + modelsWithoutToken: Record>; + modelsMissingTokenGroups: Record>; + }; + + expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined(); + expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); + }); + it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => { const site = await db.insert(schema.sites).values({ name: 'site-b', @@ -364,7 +402,7 @@ describe('/api/models/token-candidates', () => { ]); }); - it('does not pass deprecated site apiKey into pricing catalog lookup', async () => { + it('does not request token-group hints for apikey connections', async () => { const site = await db.insert(schema.sites).values({ name: 'site-e', url: 'https://site-e.example.com', @@ -425,19 +463,11 @@ describe('/api/models/token-candidates', () => { }); expect(response.statusCode).toBe(200); - expect(fetchModelPricingCatalogMock).toHaveBeenCalled(); - expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({ - site: { - id: site.id, - url: site.url, - platform: site.platform, - }, - account: { - id: account.id, - accessToken: '', - apiToken: null, - }, - }); - expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined(); + const body = response.json() as { + modelsMissingTokenGroups: Record>; + }; + + expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled(); + expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined(); }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 03a76fcc..696304fa 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -17,6 +17,7 @@ import { parseProxyLogBillingDetails, withProxyLogSelectFields, } from '../../services/proxyLogStore.js'; +import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; import { formatUtcSqlDateTime, getLocalDayRangeUtc, @@ -30,6 +31,12 @@ function parseBooleanFlag(raw?: string): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } +function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean { + const explicit = getCredentialModeFromExtraConfig(account.extraConfig); + if (explicit && explicit !== 'auto') return explicit === 'apikey'; + return !(account.accessToken || '').trim(); +} + const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000; const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000; @@ -711,6 +718,8 @@ export async function statsRoutes(app: FastifyInstance) { username: schema.accounts.username, siteId: schema.sites.id, siteName: schema.sites.name, + accessToken: schema.accounts.accessToken, + extraConfig: schema.accounts.extraConfig, }) .from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -788,6 +797,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { + if (isApiKeyConnection(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`; @@ -802,7 +812,11 @@ export async function statsRoutes(app: FastifyInstance) { }); } - const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId)); + const accountIdsForGroupHints = new Set( + availableModelRows + .filter((row) => !isApiKeyConnection(row)) + .map((row) => row.accountId), + ); const requiredGroupsByAccountModel = new Map>(); const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0; @@ -862,6 +876,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { + if (isApiKeyConnection(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`; diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index 903109c5..547b868c 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -25,9 +25,23 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain('const promoteResponsesCandidate ='); expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody('); expect(source).not.toContain('buildOpenAiSyntheticFinalStream('); - expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock('); - expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream('); - expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream('); + expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock('); + expect(source).not.toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream('); + expect(source).not.toContain('openAiChatTransformer.serializeUpstreamFinalAsStream('); + expect(source).toContain('openAiChatTransformer.proxyStream.createSession('); + expect(source).toContain('streamSession.consumeUpstreamFinalPayload('); + expect(source).toContain('streamSession.run('); + }); + + it('keeps chat endpoint retry and downgrade strategy out of the route', () => { + const source = readSource('./chat.ts'); + expect(source).toContain('downstreamTransformer.compatibility.createEndpointStrategy('); + expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody('); + expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility('); + expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError('); + expect(source).not.toContain('isEndpointDowngradeError('); + expect(source).not.toContain('isEndpointDispatchDeniedError('); + expect(source).not.toContain('isUnsupportedMediaTypeError('); }); it('keeps responses protocol assembly out of responses route', () => { @@ -44,16 +58,27 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses('); expect(source).not.toContain('function normalizeText('); expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); - expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies('); - expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders('); - expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry('); - expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages('); - expect(source).toContain('openAiResponsesTransformer.aggregator.createState('); - expect(source).toContain('openAiResponsesTransformer.aggregator.serialize('); - expect(source).toContain('openAiResponsesTransformer.aggregator.complete('); + expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy('); + expect(source).not.toContain('openAiResponsesTransformer.aggregator.createState('); + expect(source).not.toContain('openAiResponsesTransformer.aggregator.serialize('); + expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession('); + expect(source).toContain('streamSession.run('); expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal('); }); + it('keeps responses endpoint retry and downgrade strategy out of the route', () => { + const source = readSource('./responses.ts'); + expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldRetry('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryBodies('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders('); + expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages('); + expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility('); + expect(source).not.toContain('isEndpointDowngradeError('); + expect(source).not.toContain('isUnsupportedMediaTypeError('); + }); + it('removes normalizeContentText from upstream endpoint routing', () => { const source = readSource('./upstreamEndpoint.ts'); expect(source).not.toContain('function normalizeContentText('); @@ -71,5 +96,25 @@ describe('proxy route architecture boundaries', () => { expect(source).toContain('stream.consumeUpstreamSseBuffer('); expect(source).toContain('stream.serializeUpstreamJsonPayload('); }); + + it('keeps chat stream lifecycle behind transformer-owned facade', () => { + const source = readSource('./chat.ts'); + expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'"); + expect(source).not.toContain('createProxyStreamLifecycle'); + expect(source).not.toContain('let shouldTerminateEarly = false;'); + expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {'); + expect(source).not.toContain('writeDone();'); + expect(source).toContain('openAiChatTransformer.proxyStream.createSession('); + }); + + it('keeps responses stream lifecycle behind transformer-owned facade', () => { + const source = readSource('./responses.ts'); + expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'"); + expect(source).not.toContain('createProxyStreamLifecycle'); + expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {'); + expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(source).not.toContain('reply.raw.end();'); + expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession('); + }); }); diff --git a/src/server/routes/proxy/architecture-semantic-boundaries.test.ts b/src/server/routes/proxy/architecture-semantic-boundaries.test.ts new file mode 100644 index 00000000..01ee46fc --- /dev/null +++ b/src/server/routes/proxy/architecture-semantic-boundaries.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('proxy route semantic ownership boundaries', () => { + it('keeps chat stream closeout semantics out of the route', () => { + const source = readSource('./chat.ts'); + + expect(source).not.toContain('const finalizeChatStream ='); + expect(source).not.toContain('openAiChatTransformer.buildSyntheticChunks('); + expect(source).not.toContain('openAiChatTransformer.aggregator.finalize('); + }); + + it('keeps responses stream closeout semantics out of the route', () => { + const source = readSource('./responses.ts'); + + expect(source).not.toContain('const finalizeResponsesSse ='); + expect(source).not.toContain("reply.raw.write('data: [DONE]"); + expect(source).not.toContain('successfulUpstreamPath === \'/v1/responses\''); + }); +}); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 80b76f4a..f566a57e 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -201,6 +201,117 @@ describe('chat proxy stream behavior', () => { expect(response.body).toContain('data: [DONE]'); }); + it('normalizes inline think tags into reasoning_content for /v1/chat/completions streams', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"plan quietly"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'show your work and answer' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"reasoning_content":"plan quietly"'); + expect(response.body).toContain('"content":"visible answer"'); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain(''); + expect(response.body).toContain('data: [DONE]'); + }); + + it('tracks split inline think tags across SSE chunks for /v1/chat/completions streams', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"plan "},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"quietlyvisible "},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'show your work and answer' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('"reasoning_content":"plan "'); + expect(response.body).toContain('"reasoning_content":"quietly"'); + expect(response.body).toContain('"content":"visible "'); + expect(response.body).toContain('"content":"answer"'); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain(''); + expect(response.body).not.toContain('visible'); + expect(response.body).toContain('data: [DONE]'); + }); + + it('synthesizes a terminal finish chunk when /v1/chat/completions upstream EOFs after visible content', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n')); + controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]}\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + stream: true, + messages: [{ role: 'user', content: 'finish cleanly' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('tail before eof'); + expect(response.body).toContain('"finish_reason":"stop"'); + expect(response.body).toContain('data: [DONE]'); + }); + it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ @@ -486,6 +597,40 @@ describe('chat proxy stream behavior', () => { expect(response.body).not.toContain('event: ping'); }); + it('does not synthesize message_stop when anthropic upstream EOFs before terminal event on /v1/messages', async () => { + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_eof_early","model":"claude-opus-4-6"}}\n\n')); + controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n')); + controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'claude-opus-4-6', + stream: true, + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: message_start'); + expect(response.body).toContain('event: content_block_delta'); + expect(response.body).not.toContain('event: message_stop'); + expect(response.body).not.toContain('"stop_reason":"end_turn"'); + }); + it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'msg_headers_adaptive', @@ -839,7 +984,7 @@ describe('chat proxy stream behavior', () => { expect(body.output_text).toContain('ok from messages fallback'); }); - it('passes through /v1/responses SSE payloads', async () => { + it('canonicalizes native /v1/responses SSE payloads instead of passing them through raw', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { @@ -867,6 +1012,9 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(response.headers['content-type']).toContain('text/event-stream'); expect(response.body).toContain('response.output_text.delta'); + expect(response.body).toContain('response.output_text.done'); + expect(response.body).toContain('response.output_item.done'); + expect(response.body).toContain('response.completed'); expect(response.body).toContain('[DONE]'); }); @@ -1524,7 +1672,7 @@ describe('chat proxy stream behavior', () => { expect(deltaMatches.length).toBe(1); const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || []; expect(textMatches.length).toBeGreaterThan(0); - expect(textMatches.length).toBeLessThanOrEqual(4); + expect(textMatches.length).toBeLessThanOrEqual(6); }); it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => { diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 5b2ccbbb..556e44d3 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -11,12 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { - buildMinimalJsonHeadersForCompatibility, buildUpstreamEndpointRequest, - isEndpointDispatchDeniedError, - isEndpointDowngradeError, - isUnsupportedMediaTypeError, - promoteResponsesCandidateAfterLegacyChatError, resolveUpstreamEndpointCandidates, } from './upstreamEndpoint.js'; import { @@ -25,11 +20,12 @@ import { recordDownstreamCostUsage, } from './downstreamPolicy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; +import { executeEndpointFlow } from './endpointFlow.js'; 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 { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; import { getProxyResourceOwner } from '../../middleware/auth.js'; import { ProxyInputFileResolutionError, @@ -39,10 +35,6 @@ import { const MAX_RETRIES = 2; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => handleChatProxyRequest(request, reply, 'openai')); @@ -61,13 +53,24 @@ async function handleChatProxyRequest( const downstreamTransformer = downstreamFormat === 'claude' ? anthropicMessagesTransformer : openAiChatTransformer; - const parsedRequest = downstreamTransformer.transformRequest(request.body); - if (parsedRequest.error) { - return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload); + const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body: request.body, + }); + const parsedRequestEnvelope = downstreamTransformer.transformRequest(request.body); + if (parsedRequestEnvelope.error) { + return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload); } - const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!; - const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions'; + const requestEnvelope = parsedRequestEnvelope.value!; + const { + requestedModel, + isStream, + upstreamBody, + claudeOriginalBody, + } = requestEnvelope.parsed; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); const owner = getProxyResourceOwner(request); @@ -110,8 +113,8 @@ async function handleChatProxyRequest( excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; - const endpointCandidates = [ - ...await resolveUpstreamEndpointCandidates( + const endpointCandidates = [ + ...await resolveUpstreamEndpointCandidates( { site: selected.site, account: selected.account, @@ -122,8 +125,52 @@ async function handleChatProxyRequest( { hasNonImageFileInput, }, - ), - ]; + ), + ]; + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + options: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: isStream, + tokenValue: selected.tokenValue, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: resolvedOpenAiBody, + downstreamFormat, + claudeOriginalBody, + forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record, + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + }; + }; + const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ + downstreamFormat, + endpointCandidates, + modelName, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest: (compatibilityRequest, targetUrl) => fetch( + targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`, + withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: compatibilityRequest.headers, + body: JSON.stringify(compatibilityRequest.body), + }), + ), + }); let startTime = Date.now(); try { @@ -131,126 +178,9 @@ async function handleChatProxyRequest( siteUrl: selected.site.url, proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, - buildRequest: (endpoint) => { - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, - downstreamFormat, - claudeOriginalBody, - downstreamHeaders: request.headers as Record, - }); - return { - endpoint, - path: endpointRequest.path, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - }; - }, - tryRecover: async (ctx) => { - if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({ - downstreamFormat, - endpointPath: ctx.request.path, - status: ctx.response.status, - upstreamErrorText: ctx.rawErrText, - })) { - const normalizedClaudeRequest = buildUpstreamEndpointRequest({ - endpoint: ctx.request.endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, - downstreamFormat, - claudeOriginalBody, - forceNormalizeClaudeBody: true, - downstreamHeaders: request.headers as Record, - }); - const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`; - const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: normalizedClaudeRequest.headers, - body: JSON.stringify(normalizedClaudeRequest.body), - })); - - if (normalizedResponse.ok) { - return { - upstream: normalizedResponse, - upstreamPath: normalizedClaudeRequest.path, - }; - } - - ctx.request = { - ...ctx.request, - path: normalizedClaudeRequest.path, - headers: normalizedClaudeRequest.headers, - body: normalizedClaudeRequest.body as Record, - }; - ctx.response = normalizedResponse; - ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error'); - } - - if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { - return null; - } - - const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ - headers: ctx.request.headers, - endpoint: ctx.request.endpoint, - stream: isStream, - }); - const normalizedCurrentHeaders = Object.fromEntries( - Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]), - ); - if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) { - return null; - } - - const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: minimalHeaders, - body: JSON.stringify(ctx.request.body), - })); - - if (minimalResponse.ok) { - return { - upstream: minimalResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: minimalHeaders, - }; - ctx.response = minimalResponse; - ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); - return null; - }, - shouldDowngrade: (ctx) => ( - (() => { - promoteResponsesCandidateAfterLegacyChatError(endpointCandidates, { - status: ctx.response.status, - upstreamErrorText: ctx.rawErrText, - downstreamFormat, - sitePlatform: selected.site.platform, - modelName, - requestedModelHint: requestedModel, - currentEndpoint: ctx.request.endpoint, - }); - return ( - ctx.response.status >= 500 - || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) - || anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText) - || isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText) - ); - })() - ), + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + tryRecover: endpointStrategy.tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { logProxy( selected, @@ -261,6 +191,13 @@ async function handleChatProxyRequest( ctx.errText, retryCount, downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, ); }, }); @@ -269,7 +206,23 @@ async function handleChatProxyRequest( const status = endpointResult.status || 502; const errText = endpointResult.errText || 'unknown error'; tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); + logProxy( + selected, + requestedModel, + 'failed', + status, + Date.now() - startTime, + errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + ); if (isTokenExpiredError({ status, message: errText })) { await reportTokenExpired({ @@ -306,8 +259,6 @@ async function handleChatProxyRequest( reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); - const streamContext = downstreamTransformer.createStreamContext(modelName); - const claudeContext = anthropicMessagesTransformer.createDownstreamContext(); let parsedUsage: ReturnType = { promptTokens: 0, completionTokens: 0, @@ -322,10 +273,19 @@ async function handleChatProxyRequest( reply.raw.write(line); } }; - - const writeDone = () => { - writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); - }; + const streamSession = openAiChatTransformer.proxyStream.createSession({ + downstreamFormat, + modelName, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); if (!upstreamContentType.includes('text/event-stream')) { @@ -336,29 +296,7 @@ async function handleChatProxyRequest( } catch { fallbackData = fallbackText; } - - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData)); - if (downstreamFormat === 'openai') { - const syntheticLines = openAiChatTransformer.serializeUpstreamFinalAsStream( - fallbackData, - modelName, - fallbackText, - streamContext, - ); - writeLines(syntheticLines); - } else { - writeLines( - anthropicMessagesTransformer.serializeUpstreamFinalAsStream( - fallbackData, - modelName, - fallbackText, - streamContext, - claudeContext, - ), - ); - } - writeDone(); - reply.raw.end(); + streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); const latency = Date.now() - startTime; const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ @@ -407,111 +345,7 @@ async function handleChatProxyRequest( } const reader = upstream.body?.getReader(); - if (!reader) { - writeDone(); - reply.raw.end(); - return; - } - - const decoder = new TextDecoder(); - let sseBuffer = ''; - let shouldTerminateEarly = false; - - const consumeSseBuffer = (incoming: string): string => { - const pulled = downstreamTransformer.pullSseEvents(incoming); - for (const eventBlock of pulled.events) { - if (eventBlock.data === '[DONE]') { - writeDone(); - shouldTerminateEarly = true; - continue; - } - - let parsedPayload: unknown = null; - if (downstreamFormat === 'claude') { - const consumed = anthropicMessagesTransformer.consumeSseEventBlock( - eventBlock, - streamContext, - claudeContext, - modelName, - ); - parsedPayload = consumed.parsedPayload; - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - if (consumed.handled) { - writeLines(consumed.lines); - if (consumed.done) { - shouldTerminateEarly = true; - break; - } - continue; - } - } else { - try { - parsedPayload = JSON.parse(eventBlock.data); - } catch { - parsedPayload = null; - } - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - } - - if (parsedPayload && typeof parsedPayload === 'object') { - const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); - writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext)); - if (downstreamFormat === 'claude' && claudeContext.doneSent) { - shouldTerminateEarly = true; - break; - } - if (streamContext.doneSent) { - shouldTerminateEarly = true; - break; - } - continue; - } - - if (downstreamFormat === 'openai') { - reply.raw.write(`data: ${eventBlock.data}\n\n`); - } else { - writeLines(anthropicMessagesTransformer.serializeStreamEvent({ - contentDelta: eventBlock.data, - }, streamContext, claudeContext)); - if (claudeContext.doneSent) { - shouldTerminateEarly = true; - break; - } - } - } - - return pulled.rest; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - sseBuffer += decoder.decode(value, { stream: true }); - sseBuffer = consumeSseBuffer(sseBuffer); - if (shouldTerminateEarly) { - await reader.cancel().catch(() => {}); - break; - } - } - - if (!shouldTerminateEarly) { - sseBuffer += decoder.decode(); - } - if (!shouldTerminateEarly && sseBuffer.trim().length > 0) { - sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`); - } - } finally { - reader.releaseLock(); - writeDone(); - reply.raw.end(); - } + await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ @@ -618,7 +452,23 @@ async function handleChatProxyRequest( return reply.send(downstreamResponse); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err?.message || 'network error', + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; @@ -655,10 +505,16 @@ async function logProxy( estimatedCost = 0, billingDetails: unknown = null, upstreamPath: string | null = null, + clientContext: DownstreamClientContext | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, downstreamPath, upstreamPath, errorMessage, diff --git a/src/server/routes/proxy/downstreamClientContext.routes.test.ts b/src/server/routes/proxy/downstreamClientContext.routes.test.ts new file mode 100644 index 00000000..366e22a9 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.routes.test.ts @@ -0,0 +1,246 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0); +const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null); +const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise => null); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbValuesMock = vi.fn((_arg?: any) => ({ + run: () => undefined, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: (arg: any) => dbValuesMock(arg), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: (arg: any) => estimateProxyCostMock(arg), + buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg), + fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + }, +})); + +describe('downstream client context route logging', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { claudeMessagesProxyRoute } = await import('./chat.js'); + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(claudeMessagesProxyRoute); + await app.register(responsesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + estimateProxyCostMock.mockClear(); + buildProxyBillingDetailsMock.mockClear(); + fetchModelPricingCatalogMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + dbValuesMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + selectNextChannelMock.mockReturnValue(null); + fetchModelPricingCatalogMock.mockResolvedValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('includes Codex client and session metadata in /v1/responses failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'bad request', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:codex]'); + expect(insertedLog.errorMessage).toContain('[session:codex-session-123]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses]'); + }); + + it('reuses the same Codex detection on /v1/responses/compact failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'compact failed', + type: 'upstream_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + headers: { + 'x-stainless-lang': 'typescript', + Session_id: 'codex-session-compact', + }, + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:codex]'); + expect(insertedLog.errorMessage).toContain('[session:codex-session-compact]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses/compact]'); + }); + + it('includes Claude Code client and session metadata in /v1/messages failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'messages failed', + type: 'upstream_error', + }, + }), { + status: 400, + 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: 'hello' }], + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[client:claude_code]'); + expect(insertedLog.errorMessage).toContain('[session:f25958b8-e75c-455d-8b40-f006d87cc2a4]'); + expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); + }); + + it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + message: 'messages failed', + type: 'upstream_error', + }, + }), { + status: 400, + 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: 'hello' }], + metadata: { + user_id: 'user_123', + }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(dbValuesMock).toHaveBeenCalled(); + const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0]; + expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]'); + expect(insertedLog.errorMessage).not.toContain('[client:'); + expect(insertedLog.errorMessage).not.toContain('[session:'); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts new file mode 100644 index 00000000..f2389bad --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + detectDownstreamClientContext, + extractClaudeCodeSessionId, + isCodexResponsesSurface, +} from './downstreamClientContext.js'; + +describe('extractClaudeCodeSessionId', () => { + it('extracts session uuid from axonhub-compatible Claude Code user ids', () => { + expect(extractClaudeCodeSessionId( + 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + )).toBe('f25958b8-e75c-455d-8b40-f006d87cc2a4'); + }); + + it('returns null for non-Claude-Code user ids', () => { + expect(extractClaudeCodeSessionId('user_123')).toBe(null); + expect(extractClaudeCodeSessionId('session_f25958b8-e75c-455d-8b40-f006d87cc2a4')).toBe(null); + }); +}); + +describe('isCodexResponsesSurface', () => { + it('detects Codex responses surface from originator and stainless headers', () => { + expect(isCodexResponsesSurface({ + originator: 'codex_cli_rs', + })).toBe(true); + + expect(isCodexResponsesSurface({ + 'x-stainless-lang': 'typescript', + })).toBe(true); + }); + + it('returns false for generic responses clients', () => { + expect(isCodexResponsesSurface({ + 'content-type': 'application/json', + })).toBe(false); + }); +}); + +describe('detectDownstreamClientContext', () => { + it('recognizes Codex requests and attaches Session_id as session and trace hint', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + })).toEqual({ + clientKind: 'codex', + sessionId: 'codex-session-123', + traceHint: 'codex-session-123', + }); + }); + + it('keeps Codex requests without Session_id as client-only context', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/responses/compact', + headers: { + 'x-stainless-lang': 'typescript', + }, + })).toEqual({ + clientKind: 'codex', + }); + }); + + it('recognizes Claude Code requests from metadata.user_id without mutating the body', () => { + const body = { + model: 'claude-opus-4-6', + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }; + + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body, + })).toEqual({ + clientKind: 'claude_code', + sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + }); + expect(body).toEqual({ + model: 'claude-opus-4-6', + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }); + }); + + it('falls back to generic when Claude metadata.user_id is missing or invalid', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + metadata: { + user_id: 'user_123', + }, + }, + })).toEqual({ + clientKind: 'generic', + }); + + expect(detectDownstreamClientContext({ + downstreamPath: '/v1/messages', + body: { + metadata: { + session_id: 'abc123', + }, + }, + })).toEqual({ + clientKind: 'generic', + }); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts new file mode 100644 index 00000000..3d461104 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -0,0 +1,120 @@ +export type DownstreamClientKind = 'generic' | 'codex' | 'claude_code'; + +export type DownstreamClientContext = { + clientKind: DownstreamClientKind; + sessionId?: string; + traceHint?: string; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function headerValueToString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + + return null; +} + +function getHeaderValue(headers: Record | undefined, targetKey: string): string | null { + if (!headers) return null; + const normalizedTarget = targetKey.trim().toLowerCase(); + + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawKey.trim().toLowerCase() !== normalizedTarget) continue; + return headerValueToString(rawValue); + } + + return null; +} + +export function isCodexResponsesSurface(headers?: Record): boolean { + if (!headers) return false; + + let sawOpenAiBeta = false; + let sawStainless = false; + + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.trim().toLowerCase(); + const value = headerValueToString(rawValue); + if (!key || !value) continue; + + if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') { + return true; + } + if (key === 'openai-beta') { + sawOpenAiBeta = true; + } + if (key.startsWith('x-stainless-')) { + sawStainless = true; + } + } + + return sawOpenAiBeta || sawStainless; +} + +const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function extractClaudeCodeSessionId(userId: string): string | null { + const trimmed = userId.trim(); + if (!claudeCodeUserIdPattern.test(trimmed)) return null; + + const sessionPrefix = '__session_'; + const sessionIndex = trimmed.lastIndexOf(sessionPrefix); + if (sessionIndex === -1) return null; + + const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim(); + return sessionId || null; +} + +export function detectDownstreamClientContext(input: { + downstreamPath: string; + headers?: Record; + body?: unknown; +}): DownstreamClientContext { + const normalizedPath = input.downstreamPath.trim().toLowerCase(); + + if (normalizedPath === '/v1/messages' || normalizedPath === '/anthropic/v1/messages') { + if (isRecord(input.body) && isRecord(input.body.metadata)) { + const userId = typeof input.body.metadata.user_id === 'string' + ? input.body.metadata.user_id.trim() + : ''; + const sessionId = userId ? extractClaudeCodeSessionId(userId) : null; + if (sessionId) { + return { + clientKind: 'claude_code', + sessionId, + traceHint: sessionId, + }; + } + } + + return { clientKind: 'generic' }; + } + + if (normalizedPath.startsWith('/v1/responses') && isCodexResponsesSurface(input.headers)) { + const sessionId = getHeaderValue(input.headers, 'session_id') || getHeaderValue(input.headers, 'session-id'); + if (sessionId) { + return { + clientKind: 'codex', + sessionId, + traceHint: sessionId, + }; + } + + return { clientKind: 'codex' }; + } + + return { clientKind: 'generic' }; +} diff --git a/src/server/routes/proxy/logPathMeta.test.ts b/src/server/routes/proxy/logPathMeta.test.ts index a0e5e77a..68b39eca 100644 --- a/src/server/routes/proxy/logPathMeta.test.ts +++ b/src/server/routes/proxy/logPathMeta.test.ts @@ -31,4 +31,22 @@ describe('composeProxyLogMessage', () => { errorMessage: '', })).toBe(null); }); + + it('adds client and session metadata ahead of path metadata', () => { + expect(composeProxyLogMessage({ + clientKind: 'codex', + sessionId: 'codex-session-123', + downstreamPath: '/v1/responses', + errorMessage: 'upstream failed', + })).toBe('[client:codex] [session:codex-session-123] [downstream:/v1/responses] upstream failed'); + }); + + it('reuses existing client and session metadata without duplication', () => { + expect(composeProxyLogMessage({ + clientKind: 'claude_code', + sessionId: 'session-123', + downstreamPath: '/v1/messages', + errorMessage: '[client:claude_code] [session:session-123] [upstream:/v1/messages] bad request', + })).toBe('[client:claude_code] [session:session-123] [downstream:/v1/messages] [upstream:/v1/messages] bad request'); + }); }); diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index 59fbb65e..6d7a7ac9 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,20 +1,32 @@ type ComposeProxyLogMessageArgs = { + clientKind?: string | null; + sessionId?: string | null; + traceHint?: string | null; downstreamPath?: string | null; upstreamPath?: string | null; errorMessage?: string | null; }; type ParsedPathMeta = { + clientKind: string | null; + sessionId: string | null; downstreamPath: string | null; upstreamPath: string | null; messageText: string; }; function parseExistingPathMeta(rawMessage: string): ParsedPathMeta { + const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); + const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); - const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim(); + const messageText = rawMessage.replace( + /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, + '', + ).trim(); return { + clientKind: clientMatch?.[1]?.trim() || null, + sessionId: sessionMatch?.[1]?.trim() || null, downstreamPath: downstreamMatch?.[1]?.trim() || null, upstreamPath: upstreamMatch?.[1]?.trim() || null, messageText, @@ -22,17 +34,24 @@ function parseExistingPathMeta(rawMessage: string): ParsedPathMeta { } export function composeProxyLogMessage({ + clientKind, + sessionId, + traceHint, downstreamPath, upstreamPath, errorMessage, }: ComposeProxyLogMessageArgs): string | null { const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : ''; const parsed = parseExistingPathMeta(rawMessage); + const finalClientKind = (clientKind || parsed.clientKind || '').trim(); + const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim(); const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim(); const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim(); const finalMessageText = parsed.messageText.trim(); const prefixParts: string[] = []; + if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`); + if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`); if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`); if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index a87193ef..1b6a0673 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -11,10 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { - buildMinimalJsonHeadersForCompatibility, buildUpstreamEndpointRequest, - isEndpointDowngradeError, - isUnsupportedMediaTypeError, resolveUpstreamEndpointCandidates, } from './upstreamEndpoint.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; @@ -23,6 +20,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 { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { ProxyInputFileResolutionError, @@ -85,42 +83,6 @@ function carriesResponsesReasoningContinuity(value: unknown): boolean { || carriesResponsesReasoningContinuity(value.content); } -function isCodexResponsesSurface(headers?: Record): boolean { - if (!headers) return false; - - const normalizeHeaderValue = (value: unknown): string => { - if (typeof value === 'string') return value.trim(); - if (Array.isArray(value)) { - return value - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .find((item) => item.length > 0) || ''; - } - return ''; - }; - - let sawOpenAiBeta = false; - let sawStainless = false; - - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.trim().toLowerCase(); - const value = normalizeHeaderValue(rawValue); - if (!key || !value) continue; - - if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') { - return true; - } - if (key === 'openai-beta') { - sawOpenAiBeta = true; - } - if (key.startsWith('x-stainless-')) { - sawStainless = true; - } - } - - return sawOpenAiBeta || sawStainless; -} - function wantsNativeResponsesReasoning(body: unknown): boolean { if (!isRecord(body)) return false; const include = normalizeIncludeList(body.include); @@ -156,16 +118,27 @@ export async function responsesProxyRoute(app: FastifyInstance) { reply: FastifyReply, downstreamPath: string, ) => { - const body = request.body as any; - const requestedModel = typeof body?.model === 'string' ? body.model.trim() : ''; - if (!requestedModel) { - return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); + const body = request.body as Record; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); + const defaultEncryptedReasoningInclude = isCodexResponsesSurface( + request.headers as Record, + ); + const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, { + defaultEncryptedReasoningInclude, + }); + if (parsedRequestEnvelope.error) { + return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload); } + const requestEnvelope = parsedRequestEnvelope.value!; + const requestedModel = requestEnvelope.model; + const isStream = requestEnvelope.stream; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); const isCompactRequest = downstreamPath === '/v1/responses/compact'; - - const isStream = body.stream === true; const excludeChannelIds: number[] = []; let retryCount = 0; @@ -193,15 +166,11 @@ export async function responsesProxyRoute(app: FastifyInstance) { const modelName = selected.actualModel || requestedModel; const owner = getProxyResourceOwner(request); - const defaultEncryptedReasoningInclude = isCodexResponsesSurface( - request.headers as Record, - ); - let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody( - body, - modelName, - isStream, - { defaultEncryptedReasoningInclude }, - ); + let normalizedResponsesBody: Record = { + ...requestEnvelope.parsed.normalizedBody, + model: modelName, + stream: isStream, + }; if (owner) { try { normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); @@ -248,6 +217,43 @@ export async function responsesProxyRoute(app: FastifyInstance) { if (endpointCandidates.length === 0) { endpointCandidates.push('responses', 'chat', 'messages'); } + const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: isStream, + tokenValue: selected.tokenValue, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'responses', + responsesOriginalBody: normalizedResponsesBody, + downstreamHeaders: request.headers as Record, + }); + const upstreamPath = ( + isCompactRequest && endpoint === 'responses' + ? `${endpointRequest.path}/compact` + : endpointRequest.path + ); + return { + endpoint, + path: upstreamPath, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + }; + }; + const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ + isStream, + requiresNativeResponsesFileUrl, + dispatchRequest: (compatibilityRequest, targetUrl) => fetch( + targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`, + withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: compatibilityRequest.headers, + body: JSON.stringify(compatibilityRequest.body), + }), + ), + }); const startTime = Date.now(); @@ -256,114 +262,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { siteUrl: selected.site.url, proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, - buildRequest: (endpoint) => { - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: isStream, - tokenValue: selected.tokenValue, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: openAiBody, - downstreamFormat: 'responses', - responsesOriginalBody: normalizedResponsesBody, - downstreamHeaders: request.headers as Record, - }); - const upstreamPath = ( - isCompactRequest && endpoint === 'responses' - ? `${endpointRequest.path}/compact` - : endpointRequest.path - ); - return { - endpoint, - path: upstreamPath, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - }; - }, - tryRecover: async (ctx) => { - if (openAiResponsesTransformer.compatibility.shouldRetry({ - endpoint: ctx.request.endpoint, - status: ctx.response.status, - rawErrText: ctx.rawErrText, - })) { - const compatibilityBodies = openAiResponsesTransformer.compatibility.buildRetryBodies(ctx.request.body); - const compatibilityHeaders = openAiResponsesTransformer.compatibility.buildRetryHeaders( - ctx.request.headers, - isStream, - ); - - for (const compatibilityHeadersCandidate of compatibilityHeaders) { - for (const compatibilityBody of compatibilityBodies) { - const compatibilityResponse = await fetch( - ctx.targetUrl, - withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: compatibilityHeadersCandidate, - body: JSON.stringify(compatibilityBody), - }), - ); - if (compatibilityResponse.ok) { - return { - upstream: compatibilityResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: compatibilityHeadersCandidate, - body: compatibilityBody, - }; - ctx.response = compatibilityResponse; - ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error'); - } - } - } - - if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { - return null; - } - - const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ - headers: ctx.request.headers, - endpoint: ctx.request.endpoint, - stream: isStream, - }); - const minimalResponse = await fetch( - ctx.targetUrl, - withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: minimalHeaders, - body: JSON.stringify(ctx.request.body), - }), - ); - if (minimalResponse.ok) { - return { - upstream: minimalResponse, - upstreamPath: ctx.request.path, - }; - } - - ctx.request = { - ...ctx.request, - headers: minimalHeaders, - }; - ctx.response = minimalResponse; - ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); - return null; - }, - shouldDowngrade: (ctx) => ( - !requiresNativeResponsesFileUrl && ( - ctx.response.status >= 500 - || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) - || openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages( - ctx.request.path, - ctx.response.status, - ctx.rawErrText, - ) - ) - ), + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + tryRecover: endpointStrategy.tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { logProxy( selected, @@ -374,6 +275,13 @@ export async function responsesProxyRoute(app: FastifyInstance) { ctx.errText, retryCount, downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, ); }, }); @@ -382,7 +290,23 @@ export async function responsesProxyRoute(app: FastifyInstance) { const status = endpointResult.status || 502; const errText = endpointResult.errText || 'unknown error'; tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); + logProxy( + selected, + requestedModel, + 'failed', + status, + Date.now() - startTime, + errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + ); if (isTokenExpiredError({ status, message: errText })) { await reportTokenExpired({ @@ -416,12 +340,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { reply.raw.setHeader('X-Accel-Buffering', 'no'); const reader = upstream.body?.getReader(); - if (!reader) { - reply.raw.end(); - return; - } - - const decoder = new TextDecoder(); let parsedUsage: UsageSummary = { promptTokens: 0, completionTokens: 0, @@ -430,102 +348,24 @@ export async function responsesProxyRoute(app: FastifyInstance) { cacheCreationTokens: 0, promptTokensIncludeCache: null, }; - let sseBuffer = ''; - - const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses'; - const streamContext = openAiResponsesTransformer.createStreamContext(modelName); - const responsesState = openAiResponsesTransformer.aggregator.createState(modelName); - const writeLines = (lines: string[]) => { for (const line of lines) reply.raw.write(line); }; - - const consumeSseBuffer = (incoming: string): string => { - const pulled = openAiResponsesTransformer.pullSseEvents(incoming); - for (const eventBlock of pulled.events) { - if (eventBlock.data === '[DONE]') { - if (passthroughResponsesStream) { - reply.raw.write('data: [DONE]\n\n'); - } else if (!responsesState.completed) { - writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage)); - } - continue; - } - - let parsedPayload: unknown = null; - try { - parsedPayload = JSON.parse(eventBlock.data); - } catch { - parsedPayload = null; - } - - if (parsedPayload && typeof parsedPayload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload)); - } - - if (passthroughResponsesStream) { - const eventName = eventBlock.event ? `event: ${eventBlock.event}\n` : ''; - reply.raw.write(`${eventName}data: ${eventBlock.data}\n\n`); - continue; - } - - const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') - ? parsedPayload.type - : ''; - const isFailureEvent = ( - eventBlock.event === 'error' - || eventBlock.event === 'response.failed' - || payloadType === 'error' - || payloadType === 'response.failed' - ); - if (isFailureEvent) { - writeLines(openAiResponsesTransformer.aggregator.fail(responsesState, streamContext, parsedUsage, parsedPayload)); - continue; - } - - if (parsedPayload && typeof parsedPayload === 'object') { - const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName); - writeLines(openAiResponsesTransformer.aggregator.serialize({ - state: responsesState, - streamContext, - event: normalizedEvent, - usage: parsedUsage, - })); - continue; + const streamSession = openAiResponsesTransformer.proxyStream.createSession({ + modelName, + successfulUpstreamPath, + getUsage: () => parsedUsage, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); } - - writeLines(openAiResponsesTransformer.aggregator.serialize({ - state: responsesState, - streamContext, - event: { contentDelta: eventBlock.data }, - usage: parsedUsage, - })); - } - - return pulled.rest; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - sseBuffer += decoder.decode(value, { stream: true }); - sseBuffer = consumeSseBuffer(sseBuffer); - } - - sseBuffer += decoder.decode(); - if (sseBuffer.trim().length > 0) { - sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`); - } - } finally { - reader.releaseLock(); - if (!passthroughResponsesStream && !responsesState.completed) { - writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage)); - } - reply.raw.end(); - } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); + await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ @@ -612,7 +452,23 @@ export async function responsesProxyRoute(app: FastifyInstance) { return reply.send(downstreamData); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; @@ -649,10 +505,16 @@ async function logProxy( estimatedCost = 0, billingDetails: unknown = null, upstreamPath: string | null = null, + clientContext: DownstreamClientContext | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' + ? clientContext.clientKind + : null, + sessionId: clientContext?.sessionId || null, + traceHint: clientContext?.traceHint || null, downstreamPath, upstreamPath, errorMessage, diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 71160c4b..5698bd98 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -8,6 +8,14 @@ import { convertOpenAiBodyToAnthropicMessagesBody, sanitizeAnthropicMessagesBody, } from '../../transformers/anthropic/messages/conversion.js'; +export { + buildMinimalJsonHeadersForCompatibility, + isEndpointDispatchDeniedError, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + promoteResponsesCandidateAfterLegacyChatError, + shouldPreferResponsesAfterLegacyChatError, +} from '../../transformers/shared/endpointCompatibility.js'; export type UpstreamEndpoint = 'chat' | 'messages' | 'responses'; export type EndpointPreference = DownstreamFormat | 'responses'; @@ -562,193 +570,4 @@ export function buildUpstreamEndpointRequest(input: { }; } -function normalizeHeaderMap(headers: Record): Record { - const normalized: Record = {}; - for (const [rawKey, rawValue] of Object.entries(headers)) { - const key = rawKey.trim().toLowerCase(); - if (!key) continue; - const value = headerValueToString(rawValue); - if (!value) continue; - normalized[key] = value; - } - return normalized; -} - -export function buildMinimalJsonHeadersForCompatibility(input: { - headers: Record; - endpoint: UpstreamEndpoint; - stream: boolean; -}): Record { - const source = normalizeHeaderMap(input.headers); - const minimal: Record = {}; - - if (source.authorization) minimal.authorization = source.authorization; - if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key']; - - if (input.endpoint === 'messages') { - for (const [key, value] of Object.entries(source)) { - if (!key.startsWith('anthropic-')) continue; - minimal[key] = value; - } - if (!minimal['anthropic-version']) { - minimal['anthropic-version'] = '2023-06-01'; - } - } - - minimal['content-type'] = 'application/json'; - minimal.accept = input.stream ? 'text/event-stream' : 'application/json'; - return minimal; -} - -export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean { - if (status < 400) return false; - if (status !== 400 && status !== 415) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (!text) return status === 415; - - return ( - text.includes('unsupported media type') - || text.includes("only 'application/json' is allowed") - || text.includes('only "application/json" is allowed') - || text.includes('application/json') - || text.includes('content-type') - ); -} - -export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean { - if (status !== 403) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (!text) return false; - - return ( - /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '') - || text.includes('dispatch denied') - ); -} - -export function shouldPreferResponsesAfterLegacyChatError(input: { - status: number; - upstreamErrorText?: string | null; - downstreamFormat: EndpointPreference; - sitePlatform?: string | null; - modelName?: string | null; - requestedModelHint?: string | null; - currentEndpoint?: UpstreamEndpoint | null; -}): boolean { - if (input.status < 400) return false; - if (input.downstreamFormat !== 'openai') return false; - if (input.currentEndpoint !== 'chat') return false; - - const sitePlatform = normalizePlatformName(input.sitePlatform); - if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') { - return false; - } - - const modelName = asTrimmedString(input.modelName); - const requestedModelHint = asTrimmedString(input.requestedModelHint); - if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) { - return false; - } - - const text = (input.upstreamErrorText || '').toLowerCase(); - return ( - text.includes('unsupported legacy protocol') - && text.includes('/v1/chat/completions') - && text.includes('/v1/responses') - ); -} - -export function promoteResponsesCandidateAfterLegacyChatError( - endpointCandidates: UpstreamEndpoint[], - input: Parameters[0], -): void { - if (!shouldPreferResponsesAfterLegacyChatError(input)) return; - - const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint); - const responsesIndex = endpointCandidates.indexOf('responses'); - if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return; - - endpointCandidates.splice(responsesIndex, 1); - endpointCandidates.splice(currentIndex + 1, 0, 'responses'); -} - -export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { - if (status < 400) return false; - const text = (upstreamErrorText || '').toLowerCase(); - if (status === 404 || status === 405 || status === 415 || status === 501) return true; - if (!text) return false; - - let parsedCode = ''; - let parsedType = ''; - let parsedMessage = ''; - try { - const parsed = JSON.parse(upstreamErrorText || '{}') as Record; - const error = (parsed.error && typeof parsed.error === 'object') - ? parsed.error as Record - : parsed; - parsedCode = asTrimmedString(error.code).toLowerCase(); - parsedType = asTrimmedString(error.type).toLowerCase(); - parsedMessage = asTrimmedString(error.message).toLowerCase(); - } catch { - parsedCode = ''; - parsedType = ''; - parsedMessage = ''; - } - - return ( - isEndpointDispatchDeniedError(status, upstreamErrorText) - || - text.includes('convert_request_failed') - || text.includes('not found') - || text.includes('unknown endpoint') - || text.includes('unsupported endpoint') - || text.includes('unsupported path') - || text.includes('unrecognized request url') - || text.includes('no route matched') - || text.includes('does not exist') - || text.includes('openai_error') - || text.includes('upstream_error') - || text.includes('bad_response_status_code') - || text.includes('unsupported media type') - || text.includes("only 'application/json' is allowed") - || text.includes('only "application/json" is allowed') - || (status === 400 && text.includes('unsupported')) - || text.includes('not implemented') - || text.includes('api not implemented') - || text.includes('unsupported legacy protocol') - || parsedCode === 'convert_request_failed' - || parsedCode === 'not_found' - || parsedCode === 'endpoint_not_found' - || parsedCode === 'unknown_endpoint' - || parsedCode === 'unsupported_endpoint' - || parsedCode === 'bad_response_status_code' - || parsedCode === 'openai_error' - || parsedCode === 'upstream_error' - || parsedType === 'not_found_error' - || parsedType === 'invalid_request_error' - || parsedType === 'unsupported_endpoint' - || parsedType === 'unsupported_path' - || parsedType === 'bad_response_status_code' - || parsedType === 'openai_error' - || parsedType === 'upstream_error' - || parsedMessage.includes('unknown endpoint') - || parsedMessage.includes('unsupported endpoint') - || parsedMessage.includes('unsupported path') - || parsedMessage.includes('unrecognized request url') - || parsedMessage.includes('no route matched') - || parsedMessage.includes('does not exist') - || parsedMessage.includes('bad_response_status_code') - || parsedMessage === 'openai_error' - || parsedMessage === 'upstream_error' - || parsedMessage.includes('unsupported media type') - || parsedMessage.includes("only 'application/json' is allowed") - || parsedMessage.includes('only "application/json" is allowed') - || ( - status === 400 - && parsedCode === 'invalid_request' - && parsedType === 'new_api_error' - && (parsedMessage.includes('claude code cli') || text.includes('claude code cli')) - ) - ); -} diff --git a/src/server/runtimeDatabaseBootstrap.test.ts b/src/server/runtimeDatabaseBootstrap.test.ts new file mode 100644 index 00000000..66ba073a --- /dev/null +++ b/src/server/runtimeDatabaseBootstrap.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; + +describe('runtimeDatabaseBootstrap', () => { + it('runs sqlite runtime migrations when dialect is sqlite', async () => { + const runSqliteRuntimeMigrations = vi.fn(async () => {}); + const ensureExternalRuntimeSchema = vi.fn(async () => {}); + + await ensureRuntimeDatabaseReady({ + dialect: 'sqlite', + runSqliteRuntimeMigrations, + ensureExternalRuntimeSchema, + }); + + expect(runSqliteRuntimeMigrations).toHaveBeenCalledTimes(1); + expect(ensureExternalRuntimeSchema).not.toHaveBeenCalled(); + }); + + it.each(['postgres', 'mysql'] as const)('bootstraps external schema when dialect is %s', async (dialect) => { + const runSqliteRuntimeMigrations = vi.fn(async () => {}); + const ensureExternalRuntimeSchema = vi.fn(async () => {}); + + await ensureRuntimeDatabaseReady({ + dialect, + runSqliteRuntimeMigrations, + ensureExternalRuntimeSchema, + }); + + expect(ensureExternalRuntimeSchema).toHaveBeenCalledTimes(1); + expect(runSqliteRuntimeMigrations).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/runtimeDatabaseBootstrap.ts b/src/server/runtimeDatabaseBootstrap.ts new file mode 100644 index 00000000..e326a282 --- /dev/null +++ b/src/server/runtimeDatabaseBootstrap.ts @@ -0,0 +1,51 @@ +import { + bootstrapRuntimeDatabaseSchema, + type RuntimeSchemaDialect, +} from './db/runtimeSchemaBootstrap.js'; + +let sqliteMigrationsBootstrapped = false; + +export async function runSqliteRuntimeMigrations(): Promise { + const migrateModule = await import('./db/migrate.js'); + if (sqliteMigrationsBootstrapped) { + migrateModule.runSqliteMigrations(); + return; + } + sqliteMigrationsBootstrapped = true; +} + +type EnsureRuntimeDatabaseReadyInput = { + dialect: RuntimeSchemaDialect; + connectionString?: string; + ssl?: boolean; + runSqliteRuntimeMigrations?: () => Promise; + ensureExternalRuntimeSchema?: () => Promise; +}; + +export async function ensureRuntimeDatabaseReady(input: EnsureRuntimeDatabaseReadyInput): Promise { + if (input.dialect === 'sqlite') { + const runSqlite = input.runSqliteRuntimeMigrations || runSqliteRuntimeMigrations; + await runSqlite(); + return; + } + + const ensureExternal = input.ensureExternalRuntimeSchema || (async () => { + const connectionString = (input.connectionString || '').trim(); + if (!connectionString) { + throw new Error(`DB_URL is required when DB_TYPE=${input.dialect}`); + } + await bootstrapRuntimeDatabaseSchema({ + dialect: input.dialect, + connectionString, + ssl: !!input.ssl, + }); + }); + + await ensureExternal(); +} + +export const __runtimeDatabaseBootstrapTestUtils = { + resetSqliteMigrationsBootstrapped() { + sqliteMigrationsBootstrapped = false; + }, +}; diff --git a/src/server/services/alertRules.test.ts b/src/server/services/alertRules.test.ts index dc77ab11..218b5bc0 100644 --- a/src/server/services/alertRules.test.ts +++ b/src/server/services/alertRules.test.ts @@ -10,7 +10,8 @@ describe('alertRules', () => { it('detects token expiration by status or message', () => { expect(isTokenExpiredError({ status: 401, message: 'Unauthorized' })).toBe(true); - expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(true); + expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(false); + expect(isTokenExpiredError({ message: 'HTTP 401: access token required' })).toBe(true); expect(isTokenExpiredError({ message: 'jwt expired' })).toBe(true); expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true); expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true); @@ -19,6 +20,20 @@ describe('alertRules', () => { expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false); }); + it('does not treat endpoint dispatch denial as token expiration', () => { + expect(isTokenExpiredError({ + status: 403, + message: 'This group does not allow /v1/messages dispatch', + })).toBe(false); + expect(isTokenExpiredError({ + status: 403, + message: 'dispatch denied for /v1/responses', + })).toBe(false); + expect(isTokenExpiredError({ + message: 'unauthorized', + })).toBe(false); + }); + it('appends rebind hint for invalid access token messages', () => { expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效')) .toContain('请在中转站重新生成系统访问令牌后重新绑定账号'); diff --git a/src/server/services/alertRules.ts b/src/server/services/alertRules.ts index 297c0bfd..d18a5c99 100644 --- a/src/server/services/alertRules.ts +++ b/src/server/services/alertRules.ts @@ -6,9 +6,25 @@ export function isCloudflareChallenge(message?: string | null): boolean { const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号'; +function isEndpointDispatchDeniedMessage(message?: string | null): boolean { + if (!message) return false; + const text = message.toLowerCase(); + return ( + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(message) + || text.includes('dispatch denied') + ); +} + +function containsHttpStatus(message: string | null | undefined, status: number): boolean { + if (!message) return false; + return new RegExp(`(?:^|\\b)(?:http\\s*)?${status}(?:\\b|:)`, 'i').test(message); +} + export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean { - if (input.status === 401 || input.status === 403) return true; + const rawMessage = input.message || ''; const text = (input.message || '').toLowerCase(); + if (isEndpointDispatchDeniedMessage(rawMessage)) return false; + if (input.status === 401 || containsHttpStatus(rawMessage, 401)) return true; if (!text) return false; // NewAPI-like sites may return this when session context is missing for an action, @@ -24,9 +40,7 @@ export function isTokenExpiredError(input: { status?: number; message?: string | text.includes('token expired') || (tokenPhrase && (hasInvalid || hasExpired)) || /invalid\s+access\s+token/.test(text) || - /access\s+token\s+is\s+invalid/.test(text) || - text.includes('unauthorized') || - text.includes('forbidden') + /access\s+token\s+is\s+invalid/.test(text) ); } diff --git a/src/server/services/balanceService.autoRelogin.test.ts b/src/server/services/balanceService.autoRelogin.test.ts index 60f34839..6c6ae553 100644 --- a/src/server/services/balanceService.autoRelogin.test.ts +++ b/src/server/services/balanceService.autoRelogin.test.ts @@ -172,6 +172,60 @@ describe('balanceService auto relogin', () => { expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1); }); + it('does not report token expired for generic forbidden balance errors', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 12, + username: 'linuxdo_forbidden', + accessToken: 'stale-token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 12, + name: 'kfc', + url: 'https://kfc-api.sxxe.net', + platform: 'new-api', + }, + }, + ]); + + adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 403: forbidden')); + + const { refreshBalance } = await import('./balanceService.js'); + await expect(refreshBalance(12)).rejects.toThrow('forbidden'); + + expect(reportTokenExpiredMock).not.toHaveBeenCalled(); + }); + + it('does not report token expired for missing new-api-user errors', async () => { + selectAllMock.mockReturnValue([ + { + accounts: { + id: 13, + username: 'linuxdo_missing_user', + accessToken: 'stale-token', + status: 'active', + extraConfig: null, + }, + sites: { + id: 13, + name: 'kfc', + url: 'https://kfc-api.sxxe.net', + platform: 'new-api', + }, + }, + ]); + + adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 400: new-api-user required')); + + const { refreshBalance } = await import('./balanceService.js'); + await expect(refreshBalance(13)).rejects.toThrow('new-api-user'); + + expect(reportTokenExpiredMock).not.toHaveBeenCalled(); + }); + it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => { selectAllMock.mockReturnValue([ { diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index 62bcb609..70459f75 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -43,15 +43,7 @@ function shouldAttemptAutoRelogin(message?: string | null): boolean { function shouldReportExpired(message?: string | null): boolean { if (!message) return false; - if (isTokenExpiredError({ message })) return true; - - const text = message.toLowerCase(); - return ( - text.includes('access token') || - text.includes('new-api-user') || - text.includes('unauthorized') || - text.includes('forbidden') - ); + return isTokenExpiredError({ message }); } function isUnsupportedCheckinRuntimeHealth(health: ReturnType): boolean { diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 45035da4..b1323d19 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -1,14 +1,13 @@ import Database from 'better-sqlite3'; -import mysql from 'mysql2/promise'; -import pg from 'pg'; -import { mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; import { db, schema } from '../db/index.js'; -import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js'; -import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js'; -import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js'; +import { + createRuntimeSchemaClient, + ensureRuntimeDatabaseSchema, + type RuntimeSchemaClient, + type RuntimeSchemaDialect, +} from '../db/runtimeSchemaBootstrap.js'; -export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres'; +export type MigrationDialect = RuntimeSchemaDialect; export interface DatabaseMigrationInput { dialect?: unknown; @@ -67,15 +66,7 @@ export interface DatabaseMigrationSummary { }; } -interface SqlClient { - dialect: MigrationDialect; - begin(): Promise; - commit(): Promise; - rollback(): Promise; - execute(sqlText: string, params?: unknown[]): Promise; - queryScalar(sqlText: string, params?: unknown[]): Promise; - close(): Promise; -} +type SqlClient = RuntimeSchemaClient; interface InsertStatement { table: string; @@ -230,216 +221,8 @@ async function toBackupSnapshot(): Promise { }; } -async function createPostgresClient(connectionString: string, ssl: boolean): Promise { - const clientOptions: pg.ClientConfig = { connectionString }; - if (ssl) { - clientOptions.ssl = { rejectUnauthorized: false }; - } - const client = new pg.Client(clientOptions); - await client.connect(); - - return { - dialect: 'postgres', - begin: async () => { await client.query('BEGIN'); }, - commit: async () => { await client.query('COMMIT'); }, - rollback: async () => { await client.query('ROLLBACK'); }, - execute: async (sqlText, params = []) => client.query(sqlText, params), - queryScalar: async (sqlText, params = []) => { - const result = await client.query(sqlText, params); - const row = result.rows[0] as Record | undefined; - if (!row) return 0; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { await client.end(); }, - }; -} - -async function createMySqlClient(connectionString: string, ssl: boolean): Promise { - const connectionOptions: mysql.ConnectionOptions = { uri: connectionString }; - if (ssl) { - connectionOptions.ssl = { rejectUnauthorized: false }; - } - const connection = await mysql.createConnection(connectionOptions); - - return { - dialect: 'mysql', - begin: async () => { await connection.beginTransaction(); }, - commit: async () => { await connection.commit(); }, - rollback: async () => { await connection.rollback(); }, - execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]), - queryScalar: async (sqlText, params = []) => { - const [rows] = await connection.query(sqlText, params as any[]); - if (!Array.isArray(rows) || rows.length === 0) return 0; - const row = rows[0] as Record; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { await connection.end(); }, - }; -} - -async function createSqliteClient(connectionString: string): Promise { - const filePath = resolve(connectionString); - mkdirSync(dirname(filePath), { recursive: true }); - const sqlite = new Database(filePath); - sqlite.pragma('journal_mode = WAL'); - sqlite.pragma('foreign_keys = ON'); - - return { - dialect: 'sqlite', - begin: async () => { sqlite.exec('BEGIN'); }, - commit: async () => { sqlite.exec('COMMIT'); }, - rollback: async () => { sqlite.exec('ROLLBACK'); }, - execute: async (sqlText, params = []) => { - const lowered = sqlText.trim().toLowerCase(); - const stmt = sqlite.prepare(sqlText); - if (lowered.startsWith('select')) return await stmt.all(...params); - return await stmt.run(...params); - }, - queryScalar: async (sqlText, params = []) => { - const row = await sqlite.prepare(sqlText).get(...params) as Record | undefined; - if (!row) return 0; - return Number(Object.values(row)[0]) || 0; - }, - close: async () => { sqlite.close(); }, - }; -} - async function createClient(input: NormalizedDatabaseMigrationInput): Promise { - if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl); - if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl); - return createSqliteClient(input.connectionString); -} - -function validateIdentifier(identifier: string): string { - if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) { - throw new Error(`Invalid SQL identifier: ${identifier}`); - } - return identifier; -} - -function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector { - if (client.dialect === 'sqlite') { - return { - dialect: 'sqlite', - tableExists: async (table) => { - const normalizedTable = validateIdentifier(table); - return (await client.queryScalar( - `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`, - )) > 0; - }, - columnExists: async (table, column) => { - const normalizedTable = validateIdentifier(table); - const normalizedColumn = validateIdentifier(column); - return (await client.queryScalar( - `SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`, - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; - } - - if (client.dialect === 'mysql') { - return { - dialect: 'mysql', - tableExists: async (table) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?', - [table], - )) > 0; - }, - columnExists: async (table, column) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?', - [table, column], - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; - } - - return { - dialect: 'postgres', - tableExists: async (table) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1', - [table], - )) > 0; - }, - columnExists: async (table, column) => { - return (await client.queryScalar( - 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2', - [table, column], - )) > 0; - }, - execute: async (sqlText) => { - await client.execute(sqlText); - }, - }; -} - -async function ensureSchema(client: SqlClient): Promise { - const statements = client.dialect === 'postgres' - ? [ - `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, - ] - : client.dialect === 'mysql' - ? [ - `CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`, - ] - : [ - `CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`, - ]; - - for (const sqlText of statements) { - await client.execute(sqlText); - } - - await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client)); - await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client)); - await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client)); + return createRuntimeSchemaClient(input); } async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise { @@ -755,13 +538,27 @@ async function syncPostgresSequences(client: SqlClient): Promise { } } +export async function bootstrapRuntimeDatabaseSchema(input: Pick): Promise { + const client = await createClient({ + dialect: input.dialect, + connectionString: input.connectionString, + overwrite: true, + ssl: input.ssl, + }); + try { + await ensureRuntimeDatabaseSchema(client); + } finally { + await client.close(); + } +} + export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise { const normalized = normalizeMigrationInput(input); const snapshot = await toBackupSnapshot(); const client = await createClient(normalized); try { - await ensureSchema(client); + await ensureRuntimeDatabaseSchema(client); await ensureTargetState(client, normalized.overwrite); await client.begin(); @@ -819,6 +616,6 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro } export const __databaseMigrationServiceTestUtils = { - ensureSchema, + ensureSchema: ensureRuntimeDatabaseSchema, buildStatements, }; diff --git a/src/server/services/tokenRouter.cache.test.ts b/src/server/services/tokenRouter.cache.test.ts index 738d1561..793e66f4 100644 --- a/src/server/services/tokenRouter.cache.test.ts +++ b/src/server/services/tokenRouter.cache.test.ts @@ -100,4 +100,72 @@ describe('TokenRouter runtime cache', () => { const refreshedSelection = await router.selectChannel('gpt-4o-mini'); expect(refreshedSelection).toBeNull(); }); + + it('uses fibonacci-style cooldown across repeated failures', async () => { + const site = await db.insert(schema.sites).values({ + name: 'cooldown-site', + url: 'https://cooldown-site.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'cooldown-user', + accessToken: 'cooldown-access-token', + apiToken: 'cooldown-api-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'cooldown-token', + token: 'sk-cooldown-token', + enabled: true, + isDefault: true, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const channel = await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: token.id, + priority: 0, + weight: 10, + enabled: true, + }).returning().get(); + + const router = new TokenRouter(); + + const firstStartedAt = Date.now(); + await router.recordFailure(channel.id); + const firstRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const firstCooldownMs = Date.parse(String(firstRecord?.cooldownUntil || '')) - firstStartedAt; + expect(firstCooldownMs).toBeGreaterThanOrEqual(10_000); + expect(firstCooldownMs).toBeLessThanOrEqual(20_000); + + const secondStartedAt = Date.now(); + await router.recordFailure(channel.id); + const secondRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const secondCooldownMs = Date.parse(String(secondRecord?.cooldownUntil || '')) - secondStartedAt; + expect(secondCooldownMs).toBeGreaterThanOrEqual(10_000); + expect(secondCooldownMs).toBeLessThanOrEqual(20_000); + + const thirdStartedAt = Date.now(); + await router.recordFailure(channel.id); + const thirdRecord = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.id, channel.id)) + .get(); + const thirdCooldownMs = Date.parse(String(thirdRecord?.cooldownUntil || '')) - thirdStartedAt; + expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000); + expect(thirdCooldownMs).toBeLessThanOrEqual(35_000); + }); }); diff --git a/src/server/services/tokenRouter.test.ts b/src/server/services/tokenRouter.test.ts index 0493f813..e33ecbd9 100644 --- a/src/server/services/tokenRouter.test.ts +++ b/src/server/services/tokenRouter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { filterRecentlyFailedCandidates } from './tokenRouter.js'; +import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js'; type Candidate = { channel: { @@ -10,6 +10,26 @@ type Candidate = { }; describe('filterRecentlyFailedCandidates', () => { + it('uses a short default recent-failure window', () => { + const nowMs = Date.now(); + expect(isChannelRecentlyFailed({ + failCount: 1, + lastFailAt: new Date(nowMs - 20 * 1000).toISOString(), + }, nowMs)).toBe(false); + }); + + it('expands the avoidance window with fibonacci-style backoff', () => { + const nowMs = Date.now(); + expect(isChannelRecentlyFailed({ + failCount: 4, + lastFailAt: new Date(nowMs - 40 * 1000).toISOString(), + }, nowMs)).toBe(true); + expect(isChannelRecentlyFailed({ + failCount: 4, + lastFailAt: new Date(nowMs - 50 * 1000).toISOString(), + }, nowMs)).toBe(false); + }); + it('prefers healthy channels when at least one healthy channel exists', () => { const nowMs = Date.now(); const candidates: Candidate[] = [ diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index efb8ad89..358a42d7 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -27,13 +27,30 @@ interface SelectedChannel { actualModel: string; } -type FailureAwareChannel = { - failCount?: number | null; - lastFailAt?: string | null; -}; - -const RECENT_FAILURE_AVOID_SEC = 10 * 60; -const MIN_EFFECTIVE_UNIT_COST = 1e-6; +type FailureAwareChannel = { + failCount?: number | null; + lastFailAt?: string | null; +}; + +const FAILURE_BACKOFF_BASE_SEC = 15; +const MIN_EFFECTIVE_UNIT_COST = 1e-6; + +function fibonacciNumber(index: number): number { + if (index <= 2) return 1; + let prev = 1; + let current = 1; + for (let i = 3; i <= index; i += 1) { + const next = prev + current; + prev = current; + current = next; + } + return current; +} + +function resolveFailureBackoffSec(failCount?: number | null): number { + const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0)); + return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount); +} type RouteRow = typeof schema.tokenRoutes.$inferSelect; type ChannelRow = typeof schema.routeChannels.$inferSelect; @@ -130,11 +147,11 @@ function isSiteDisabled(status?: string | null): boolean { return (status || 'active') === 'disabled'; } -export function isChannelRecentlyFailed( - channel: FailureAwareChannel, - nowMs = Date.now(), - avoidSec = RECENT_FAILURE_AVOID_SEC, -): boolean { +export function isChannelRecentlyFailed( + channel: FailureAwareChannel, + nowMs = Date.now(), + avoidSec = resolveFailureBackoffSec(channel.failCount), +): boolean { if (avoidSec <= 0) return false; if ((channel.failCount ?? 0) <= 0) return false; if (!channel.lastFailAt) return false; @@ -145,15 +162,15 @@ export function isChannelRecentlyFailed( return nowMs - failTs < avoidSec * 1000; } -export function filterRecentlyFailedCandidates( - candidates: T[], - nowMs = Date.now(), - avoidSec = RECENT_FAILURE_AVOID_SEC, -): T[] { +export function filterRecentlyFailedCandidates( + candidates: T[], + nowMs = Date.now(), + avoidSec?: number, +): T[] { if (candidates.length <= 1) return candidates; if (avoidSec <= 0) return candidates; - const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec)); + const healthy = candidates.filter((candidate) => !isChannelRecentlyFailed(candidate.channel, nowMs, avoidSec)); // If all channels failed recently, keep them all and let weight/random decide. return healthy.length > 0 ? healthy : candidates; } @@ -421,11 +438,10 @@ export class TokenRouter { // Try each priority layer for (const priority of sortedPriorities) { - const candidates = filterRecentlyFailedCandidates( - layers.get(priority)!, - nowMs, - RECENT_FAILURE_AVOID_SEC, - ); + const candidates = filterRecentlyFailedCandidates( + layers.get(priority)!, + nowMs, + ); const selected = this.weightedRandomSelect( candidates, requestedByDisplayName @@ -494,11 +510,10 @@ export class TokenRouter { const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); for (const priority of sortedPriorities) { - const candidates = filterRecentlyFailedCandidates( - layers.get(priority)!, - nowMs, - RECENT_FAILURE_AVOID_SEC, - ); + const candidates = filterRecentlyFailedCandidates( + layers.get(priority)!, + nowMs, + ); const selected = this.weightedRandomSelect( candidates, requestedByDisplayName @@ -631,7 +646,7 @@ export class TokenRouter { nowIso, }); - const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC); + const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs); const eligible = reasonParts.length === 0; const candidate: RouteDecisionCandidate = { channelId: row.channel.id, @@ -678,16 +693,16 @@ export class TokenRouter { const rawLayer = availableByPriority.get(priority) ?? []; if (rawLayer.length === 0) continue; - const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs, RECENT_FAILURE_AVOID_SEC); + const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs); const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); if (avoided.length > 0) { for (const row of avoided) { - const target = candidateMap.get(row.channel.id); - if (!target) continue; - target.avoidedByRecentFailure = true; - target.reason = `最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)`; - } - } + const target = candidateMap.get(row.channel.id); + if (!target) continue; + target.avoidedByRecentFailure = true; + target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`; + } + } const weighted = this.calculateWeightedSelection( filteredLayer, @@ -829,13 +844,12 @@ export class TokenRouter { /** * Record failure and set cooldown. */ - async recordFailure(channelId: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; - const failCount = (ch.failCount ?? 0) + 1; - // Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min - const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300); - const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString(); + async recordFailure(channelId: number) { + const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + if (!ch) return; + const failCount = (ch.failCount ?? 0) + 1; + const cooldownSec = resolveFailureBackoffSec(failCount); + const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString(); const nowIso = new Date().toISOString(); await db.update(schema.routeChannels).set({ failCount, diff --git a/src/server/test-fixtures/protocol/chat-inline-think.sse b/src/server/test-fixtures/protocol/chat-inline-think.sse new file mode 100644 index 00000000..24a537d6 --- /dev/null +++ b/src/server/test-fixtures/protocol/chat-inline-think.sse @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"plan quietly"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]} + +data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/chat-missing-finish.sse b/src/server/test-fixtures/protocol/chat-missing-finish.sse new file mode 100644 index 00000000..e8d55f50 --- /dev/null +++ b/src/server/test-fixtures/protocol/chat-missing-finish.sse @@ -0,0 +1,3 @@ +data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]} diff --git a/src/server/test-fixtures/protocol/messages-cumulative-text.sse b/src/server/test-fixtures/protocol/messages-cumulative-text.sse new file mode 100644 index 00000000..c3815765 --- /dev/null +++ b/src/server/test-fixtures/protocol/messages-cumulative-text.sse @@ -0,0 +1,16 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_dup_1","model":"upstream-gpt"}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}} + +event: message_stop +data: {"type":"message_stop"} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/responses-failed-sparse.sse b/src/server/test-fixtures/protocol/responses-failed-sparse.sse new file mode 100644 index 00000000..abe7a3b7 --- /dev/null +++ b/src/server/test-fixtures/protocol/responses-failed-sparse.sse @@ -0,0 +1,10 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_fail_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}} + +event: response.failed +data: {"type":"response.failed","response":{"id":"resp_fail_1","model":"upstream-gpt","status":"failed","error":{"message":"upstream stream failed"}}} + +data: [DONE] diff --git a/src/server/test-fixtures/protocol/responses-native-sparse.sse b/src/server/test-fixtures/protocol/responses-native-sparse.sse new file mode 100644 index 00000000..5cb5ebd5 --- /dev/null +++ b/src/server/test-fixtures/protocol/responses-native-sparse.sse @@ -0,0 +1,13 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_sparse_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_0","delta":"hello world"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_sparse_1","model":"upstream-gpt","status":"completed","usage":{"input_tokens":2,"output_tokens":4,"total_tokens":6}}} + +data: [DONE] diff --git a/src/server/transformers/anthropic/messages/inbound.test.ts b/src/server/transformers/anthropic/messages/inbound.test.ts index 1c70f6d4..fa2ba0a8 100644 --- a/src/server/transformers/anthropic/messages/inbound.test.ts +++ b/src/server/transformers/anthropic/messages/inbound.test.ts @@ -54,7 +54,12 @@ describe('anthropicMessagesInbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'anthropic/messages', + model: 'claude-opus-4-6', + stream: false, + }); + expect(result.value?.parsed.claudeOriginalBody).toMatchObject({ thinking: { type: 'adaptive' }, output_config: { effort: 'high', preserve: true }, tool_choice: { type: 'tool', name: 'lookup' }, @@ -89,7 +94,7 @@ describe('anthropicMessagesInbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toEqual({ + expect(result.value?.parsed.claudeOriginalBody).toEqual({ model: 'claude-opus-4-6', max_tokens: 512, system: [ @@ -151,6 +156,6 @@ describe('anthropicMessagesInbound', () => { const result = anthropicMessagesInbound.parse(nativeBody); expect(result.error).toBeUndefined(); - expect(result.value?.claudeOriginalBody).toEqual(nativeBody); + expect(result.value?.parsed.claudeOriginalBody).toEqual(nativeBody); }); }); diff --git a/src/server/transformers/anthropic/messages/inbound.ts b/src/server/transformers/anthropic/messages/inbound.ts index 29b53d77..ec071bce 100644 --- a/src/server/transformers/anthropic/messages/inbound.ts +++ b/src/server/transformers/anthropic/messages/inbound.ts @@ -1,4 +1,5 @@ import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js'; +import { createProtocolRequestEnvelope, type ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; import { validateAnthropicMessagesBody } from './conversion.js'; function isRecord(value: unknown): value is Record { @@ -136,7 +137,10 @@ function sanitizeAnthropicInboundBody( } export const anthropicMessagesInbound = { - parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { + parse(body: unknown): { + value?: ProtocolRequestEnvelope<'anthropic/messages', ParsedDownstreamChatRequest>; + error?: { statusCode: number; payload: unknown }; + } { const rawBody = isRecord(body) ? body : null; const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null; if (inboundValidation?.error) { @@ -145,12 +149,25 @@ export const anthropicMessagesInbound = { const effectiveBody = inboundValidation?.sanitizedBody ?? body; const parsed = parseDownstreamChatRequest(effectiveBody, 'claude'); - if (parsed.error || !parsed.value) return parsed; + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { error: invalidRequest('invalid messages request') }; + } if (inboundValidation?.sanitizedBody) { parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody; } - return parsed; + return { + value: createProtocolRequestEnvelope({ + protocol: 'anthropic/messages', + model: parsed.value.requestedModel, + stream: parsed.value.isStream, + rawBody: body, + parsed: parsed.value, + }), + }; }, }; diff --git a/src/server/transformers/anthropic/messages/index.ts b/src/server/transformers/anthropic/messages/index.ts index 01eafaca..78f9ae3c 100644 --- a/src/server/transformers/anthropic/messages/index.ts +++ b/src/server/transformers/anthropic/messages/index.ts @@ -1,4 +1,5 @@ import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js'; +import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { anthropicMessagesInbound } from './inbound.js'; import { anthropicMessagesOutbound } from './outbound.js'; import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; @@ -22,6 +23,7 @@ export const anthropicMessagesTransformer = { stream: anthropicMessagesStream, usage: anthropicMessagesUsage, compatibility: { + createEndpointStrategy: createChatEndpointStrategy, shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody, isMessagesRequiredError, }, diff --git a/src/server/transformers/openai/chat/helpers.ts b/src/server/transformers/openai/chat/helpers.ts index cf70d277..a7c98e28 100644 --- a/src/server/transformers/openai/chat/helpers.ts +++ b/src/server/transformers/openai/chat/helpers.ts @@ -8,6 +8,7 @@ import type { OpenAiChatUsageDetails, } from './model.js'; import { fromTransformerMetadataRecord } from '../../shared/normalized.js'; +import { extractInlineThinkTags } from '../../shared/thinkTagParser.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string { return ''; } +function joinNonEmpty(parts: string[]): string { + return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n'); +} + function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { - if (typeof value === 'string') return { content: value, reasoning: '' }; + if (typeof value === 'string') return extractInlineThinkTags(value); if (Array.isArray(value)) { const contentParts: string[] = []; const reasoningParts: string[] = []; for (const item of value) { if (!isRecord(item)) { - if (typeof item === 'string') contentParts.push(item); + if (typeof item === 'string') { + const parsed = extractInlineThinkTags(item); + if (parsed.content) contentParts.push(parsed.content); + if (parsed.reasoning) reasoningParts.push(parsed.reasoning); + } continue; } @@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: reasoningParts.push(item.reasoning); continue; } - const text = extractTextPart(item); - if (text) contentParts.push(text); + const parsed = extractInlineThinkTags(extractTextPart(item)); + if (parsed.content) contentParts.push(parsed.content); + if (parsed.reasoning) reasoningParts.push(parsed.reasoning); } return { @@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: } if (!isRecord(value)) return { content: '', reasoning: '' }; + const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value)); return { - content: extractTextPart(value.content ?? value), - reasoning: typeof value.reasoning_content === 'string' - ? value.reasoning_content - : typeof value.reasoning === 'string' - ? value.reasoning - : '', + content: parsed.content, + reasoning: joinNonEmpty([ + typeof value.reasoning_content === 'string' ? value.reasoning_content : '', + typeof value.reasoning === 'string' ? value.reasoning : '', + parsed.reasoning, + ]), }; } diff --git a/src/server/transformers/openai/chat/inbound.ts b/src/server/transformers/openai/chat/inbound.ts index 3d1091e3..0e7f5b95 100644 --- a/src/server/transformers/openai/chat/inbound.ts +++ b/src/server/transformers/openai/chat/inbound.ts @@ -1,19 +1,41 @@ import { parseDownstreamChatRequest } from '../../shared/normalized.js'; +import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js'; import { extractChatRequestMetadata } from './helpers.js'; -import type { OpenAiChatParsedRequest } from './model.js'; +import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js'; export const openAiChatInbound = { - parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } { + parse(body: unknown): { value?: OpenAiChatRequestEnvelope; error?: { statusCode: number; payload: unknown } } { const parsed = parseDownstreamChatRequest(body, 'openai') as { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown }; }; - if (!parsed.value) return parsed; + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid chat request', + type: 'invalid_request_error', + }, + }, + }, + }; + } - parsed.value = { - ...parsed.value, - requestMetadata: extractChatRequestMetadata(body), + const metadata = extractChatRequestMetadata(body); + return { + value: createProtocolRequestEnvelope({ + protocol: 'openai/chat', + model: parsed.value.requestedModel, + stream: parsed.value.isStream, + rawBody: body, + parsed: parsed.value, + ...(metadata ? { metadata } : {}), + }), }; - return parsed; }, }; diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts index 23497017..5c0198c2 100644 --- a/src/server/transformers/openai/chat/index.test.ts +++ b/src/server/transformers/openai/chat/index.test.ts @@ -31,7 +31,16 @@ describe('openAiChatTransformer.inbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.upstreamBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: false, + rawBody: { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + }, + }); + expect(result.value?.parsed.upstreamBody).toMatchObject({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'mp3' }, reasoning_effort: 'high', @@ -46,7 +55,7 @@ describe('openAiChatTransformer.inbound', () => { response_format: { type: 'json_object' }, stream_options: { include_usage: true }, }); - expect((result.value as any)?.requestMetadata).toEqual({ + expect((result.value as any)?.metadata).toEqual({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'mp3' }, reasoningEffort: 'high', @@ -82,14 +91,19 @@ describe('openAiChatTransformer.inbound', () => { }); expect(result.error).toBeUndefined(); - expect(result.value?.upstreamBody).toMatchObject({ + expect(result.value).toMatchObject({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: false, + }); + expect(result.value?.parsed.upstreamBody).toMatchObject({ modalities: ['text', 42, 'audio', '', null], reasoning_budget: '2048', top_logprobs: '5', logit_bias: { '42': '7', invalid: 'oops' }, stream_options: { include_usage: 1 }, }); - expect((result.value as any)?.requestMetadata).toEqual({ + expect((result.value as any)?.metadata).toEqual({ modalities: ['text', 'audio'], audio: { voice: 'alloy', format: 'wav' }, reasoningEffort: 'medium', @@ -107,6 +121,28 @@ describe('openAiChatTransformer.inbound', () => { }); describe('openAiChatTransformer.outbound', () => { + it('normalizes inline think tags in final chat responses', () => { + const normalized = openAiChatTransformer.transformFinalResponse({ + id: 'chatcmpl-inline-think', + model: 'gpt-5', + created: 123, + choices: [{ + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'plan quietlyvisible answer', + }, + }], + }, 'gpt-5'); + + expect(normalized).toMatchObject({ + content: 'visible answer', + reasoningContent: 'plan quietly', + finishReason: 'stop', + }); + }); + it('carries annotations, citations, and detailed usage through final serialization', () => { const normalized = openAiChatTransformer.transformFinalResponse({ id: 'chatcmpl-1', @@ -402,6 +438,53 @@ describe('openAiChatTransformer.stream', () => { 'https://b.example', ]); }); + + it('normalizes inline think tags inside multi-choice stream chunks', () => { + const context = openAiChatTransformer.createStreamContext('gpt-5'); + const event = openAiChatTransformer.transformStreamEvent({ + id: 'chatcmpl-stream-multi-think', + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: null, + delta: { + role: 'assistant', + content: 'plan-0choice-0', + }, + }, + { + index: 1, + finish_reason: null, + delta: { + role: 'assistant', + content: 'plan-1choice-1', + }, + }, + ], + }, context, 'gpt-5'); + + const payloads = parseSsePayloads( + openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()), + ); + + expect((payloads[0] as any).choices[0]).toMatchObject({ + index: 0, + delta: { + role: 'assistant', + content: 'choice-0', + reasoning_content: 'plan-0', + }, + }); + expect((payloads[0] as any).choices[1]).toMatchObject({ + index: 1, + delta: { + role: 'assistant', + content: 'choice-1', + reasoning_content: 'plan-1', + }, + }); + }); }); describe('openAiChatTransformer.aggregator', () => { diff --git a/src/server/transformers/openai/chat/index.ts b/src/server/transformers/openai/chat/index.ts index ee086869..4bff17f8 100644 --- a/src/server/transformers/openai/chat/index.ts +++ b/src/server/transformers/openai/chat/index.ts @@ -1,10 +1,15 @@ import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js'; +import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { openAiChatInbound } from './inbound.js'; import { openAiChatOutbound } from './outbound.js'; +import { createChatProxyStreamSession } from './proxyStream.js'; import { openAiChatStream } from './stream.js'; import { openAiChatUsage } from './usage.js'; import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; -import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js'; +import type { + OpenAiChatParsedRequest as OpenAiChatParsedRequestModel, + OpenAiChatRequestEnvelope as OpenAiChatRequestEnvelopeModel, +} from './model.js'; export const openAiChatTransformer = { protocol: 'openai/chat' as const, @@ -12,11 +17,17 @@ export const openAiChatTransformer = { outbound: openAiChatOutbound, stream: openAiChatStream, usage: openAiChatUsage, + compatibility: { + createEndpointStrategy: createChatEndpointStrategy, + }, aggregator: { createState: createOpenAiChatAggregateState, applyEvent: applyOpenAiChatStreamEvent, finalize: finalizeOpenAiChatAggregate, }, + proxyStream: { + createSession: createChatProxyStreamSession, + }, transformRequest(body: unknown): ReturnType { return openAiChatInbound.parse(body); }, @@ -72,3 +83,4 @@ export const openAiChatTransformer = { export type OpenAiChatTransformer = typeof openAiChatTransformer; export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel; +export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel; diff --git a/src/server/transformers/openai/chat/model.ts b/src/server/transformers/openai/chat/model.ts index 074295dd..7b324eca 100644 --- a/src/server/transformers/openai/chat/model.ts +++ b/src/server/transformers/openai/chat/model.ts @@ -3,6 +3,7 @@ import type { NormalizedStreamEvent, ParsedDownstreamChatRequest, } from '../../shared/normalized.js'; +import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; export type OpenAiChatAudioRequest = { format?: string; @@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = { completion_tokens_details?: Record; }; -export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & { - requestMetadata?: OpenAiChatRequestMetadata; -}; +export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest; +export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope< + 'openai/chat', + OpenAiChatParsedRequest, + OpenAiChatRequestMetadata +>; export type OpenAiChatToolCall = { id: string; diff --git a/src/server/transformers/openai/chat/proxyStream.ts b/src/server/transformers/openai/chat/proxyStream.ts new file mode 100644 index 00000000..15af7ad2 --- /dev/null +++ b/src/server/transformers/openai/chat/proxyStream.ts @@ -0,0 +1,171 @@ +import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js'; +import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; +import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js'; +import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; +import { openAiChatOutbound } from './outbound.js'; +import { openAiChatStream } from './stream.js'; + +type StreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ChatProxyStreamSessionInput = { + downstreamFormat: DownstreamFormat; + modelName: string; + onParsedPayload?: (payload: unknown) => void; + writeLines: (lines: string[]) => void; + writeRaw: (chunk: string) => void; +}; + +type ResponseSink = { + end(): void; +}; + +export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) { + const downstreamTransformer = input.downstreamFormat === 'claude' + ? anthropicMessagesTransformer + : { + createStreamContext: openAiChatStream.createContext, + transformStreamEvent: openAiChatStream.normalizeEvent, + serializeStreamEvent: openAiChatStream.serializeEvent, + serializeDone: openAiChatStream.serializeDone, + pullSseEvents: openAiChatStream.pullSseEvents, + }; + const streamContext = downstreamTransformer.createStreamContext(input.modelName); + const claudeContext = anthropicMessagesTransformer.createDownstreamContext(); + const chatAggregateState = input.downstreamFormat === 'openai' + ? createOpenAiChatAggregateState() + : null; + let finalized = false; + + const finalize = () => { + if (finalized) return; + finalized = true; + + // For native Anthropic streams, EOF without message_stop is not a clean + // completion. Forward the partial stream as-is instead of fabricating an + // end_turn/message_stop pair that makes clients think the run finished. + if (input.downstreamFormat === 'claude' && !claudeContext.doneSent) { + return; + } + + if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) { + const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values()) + .some((choice) => !choice.finishReason); + if (needsTerminalFinishChunk) { + const terminalChunk = openAiChatOutbound.buildSyntheticChunks( + finalizeOpenAiChatAggregate(chatAggregateState, { + id: streamContext.id, + model: streamContext.model, + created: streamContext.created, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }), + ).slice(-1)[0]; + if (terminalChunk) { + input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]); + } + } + } + + input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); + }; + + const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise => { + if (eventBlock.data === '[DONE]') { + finalize(); + return true; + } + + let parsedPayload: unknown = null; + if (input.downstreamFormat === 'claude') { + const consumed = anthropicMessagesTransformer.consumeSseEventBlock( + eventBlock, + streamContext, + claudeContext, + input.modelName, + ); + parsedPayload = consumed.parsedPayload; + if (parsedPayload && typeof parsedPayload === 'object') { + input.onParsedPayload?.(parsedPayload); + } + if (consumed.handled) { + input.writeLines(consumed.lines); + return consumed.done; + } + } else { + try { + parsedPayload = JSON.parse(eventBlock.data); + } catch { + parsedPayload = null; + } + if (parsedPayload && typeof parsedPayload === 'object') { + input.onParsedPayload?.(parsedPayload); + } + } + + if (parsedPayload && typeof parsedPayload === 'object') { + const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName); + if (input.downstreamFormat === 'openai' && chatAggregateState) { + applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent); + } + input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext)); + return input.downstreamFormat === 'claude' && claudeContext.doneSent; + } + + if (input.downstreamFormat === 'openai') { + input.writeRaw(`data: ${eventBlock.data}\n\n`); + return false; + } + + input.writeLines(anthropicMessagesTransformer.serializeStreamEvent({ + contentDelta: eventBlock.data, + }, streamContext, claudeContext)); + return claudeContext.doneSent; + }; + + return { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) { + if (payload && typeof payload === 'object') { + input.onParsedPayload?.(payload); + } + if (input.downstreamFormat === 'openai') { + const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText); + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + input.writeLines( + openAiChatOutbound + .buildSyntheticChunks(normalizedFinal) + .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`), + ); + } else { + input.writeLines( + anthropicMessagesTransformer.serializeUpstreamFinalAsStream( + payload, + input.modelName, + fallbackText, + streamContext, + claudeContext, + ), + ); + } + finalize(); + response?.end(); + }, + async run(reader: StreamReader | null | undefined, response: ResponseSink) { + const lifecycle = createProxyStreamLifecycle({ + reader, + response, + pullEvents: (buffer) => downstreamTransformer.pullSseEvents(buffer), + handleEvent: handleEventBlock, + onEof: finalize, + }); + await lifecycle.run(); + }, + }; +} diff --git a/src/server/transformers/openai/chat/stream.ts b/src/server/transformers/openai/chat/stream.ts index 3d34bb95..58744f6a 100644 --- a/src/server/transformers/openai/chat/stream.ts +++ b/src/server/transformers/openai/chat/stream.ts @@ -36,23 +36,35 @@ export const openAiChatStream = { return createStreamTransformContext(modelName); }, normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent { + const normalized = normalizeUpstreamStreamEvent(payload, context, modelName); const choiceEvents = extractChatChoiceEvents(payload); const primaryChoice = choiceEvents[0]; + const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => ( + index === 0 + ? { + ...choiceEvent, + role: normalized.role !== undefined ? normalized.role : choiceEvent.role, + contentDelta: normalized.contentDelta, + reasoningDelta: normalized.reasoningDelta, + toolCallDeltas: normalized.toolCallDeltas !== undefined + ? normalized.toolCallDeltas + : choiceEvent.toolCallDeltas, + finishReason: normalized.finishReason !== undefined + ? normalized.finishReason + : choiceEvent.finishReason, + } + : choiceEvent + )); return { - ...normalizeUpstreamStreamEvent(payload, context, modelName), + ...normalized, ...(primaryChoice ? { choiceIndex: primaryChoice.index, - role: primaryChoice.role, - contentDelta: primaryChoice.contentDelta, - reasoningDelta: primaryChoice.reasoningDelta, - toolCallDeltas: primaryChoice.toolCallDeltas, - finishReason: primaryChoice.finishReason, annotations: primaryChoice.annotations, citations: primaryChoice.citations, } : {}), - ...(choiceEvents.length > 0 ? { choiceEvents } : {}), + ...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}), ...extractChatResponseExtras(payload), }; }, diff --git a/src/server/transformers/openai/responses/aggregator.test.ts b/src/server/transformers/openai/responses/aggregator.test.ts index 9a0d4d61..b8ba0b93 100644 --- a/src/server/transformers/openai/responses/aggregator.test.ts +++ b/src/server/transformers/openai/responses/aggregator.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { createStreamTransformContext } from '../../shared/normalized.js'; import { + completeResponsesStream, createOpenAiResponsesAggregateState, + failResponsesStream, serializeConvertedResponsesEvents, } from './aggregator.js'; @@ -23,6 +25,35 @@ function parseSsePayloads(lines: string[]): Array> { .filter((item): item is Record => !!item); } +function parseSseEvents(lines: string[]): Array<{ event: string | null; payload: Record | '[DONE]' }> { + return lines + .flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0)) + .map((block) => { + const eventLine = block + .split('\n') + .find((line) => line.startsWith('event: ')); + const dataLine = block + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!dataLine) return null; + if (dataLine === 'data: [DONE]') { + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: '[DONE]' as const, + }; + } + try { + return { + event: eventLine ? eventLine.slice('event: '.length) : null, + payload: JSON.parse(dataLine.slice('data: '.length)) as Record, + }; + } catch { + return null; + } + }) + .filter((item): item is { event: string | null; payload: Record | '[DONE]' } => !!item); +} + describe('serializeConvertedResponsesEvents', () => { it('aggregates reasoning summary events into the completed response payload', () => { const state = createOpenAiResponsesAggregateState('gpt-5'); @@ -301,4 +332,174 @@ describe('serializeConvertedResponsesEvents', () => { ], }); }); + + it('emits canonical message done events before response.completed when recovering a sparse text stream', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.output_text.done', + output_index: 0, + item_id: 'msg_0', + text: 'hello world', + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.content_part.done', + output_index: 0, + item_id: 'msg_0', + content_index: 0, + part: { + type: 'output_text', + text: 'hello world', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_0', + type: 'message', + status: 'completed', + }, + }); + }); + + it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningSignature: 'enc-signature', + reasoningDelta: 'plan first', + } as any, + }); + + const completionLines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(completionLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + + expect(events[0]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + text: 'plan first', + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.reasoning_summary_part.done', + item_id: 'rs_0', + output_index: 0, + summary_index: 0, + part: { + type: 'summary_text', + text: 'plan first', + }, + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'rs_0', + type: 'reasoning', + status: 'completed', + encrypted_content: 'enc-signature', + }, + }); + }); + + it('emits canonical message done events before response.failed when failing a sparse text stream', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'partial answer', + } as any, + }); + + const failedLines = failResponsesStream(state, streamContext, usage, { + error: { + message: 'upstream stream failed', + }, + }); + const events = parseSseEvents(failedLines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.failed', + '[DONE]', + ]); + + expect(events[2]?.payload).toMatchObject({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_0', + type: 'message', + status: 'failed', + }, + }); + expect(events[3]?.payload).toMatchObject({ + type: 'response.failed', + response: { + status: 'failed', + output_text: 'partial answer', + }, + error: { + message: 'upstream stream failed', + type: 'upstream_error', + }, + }); + }); }); diff --git a/src/server/transformers/openai/responses/aggregator.ts b/src/server/transformers/openai/responses/aggregator.ts index c6e820b6..0ef3e876 100644 --- a/src/server/transformers/openai/responses/aggregator.ts +++ b/src/server/transformers/openai/responses/aggregator.ts @@ -768,6 +768,99 @@ function buildSyntheticToolEvents( return lines; } +function buildSyntheticTerminalItemDoneEvents( + state: OpenAiResponsesAggregateState, + status: 'completed' | 'failed', +): string[] { + const lines: string[] = []; + + for (let index = 0; index < state.outputItems.length; index += 1) { + const item = state.outputItems[index]; + if (!isRecord(item)) continue; + + const currentStatus = asTrimmedString(item.status).toLowerCase(); + if (currentStatus === 'completed' || currentStatus === 'failed') continue; + + const itemType = asTrimmedString(item.type).toLowerCase(); + const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index); + item.id = itemId; + + if (itemType === 'message') { + const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : []; + const part = content[0]; + if (isRecord(part)) { + const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); + lines.push(serializeSse('response.output_text.done', { + type: 'response.output_text.done', + output_index: index, + item_id: itemId, + text, + })); + lines.push(serializeSse('response.content_part.done', { + type: 'response.content_part.done', + output_index: index, + item_id: itemId, + content_index: 0, + part: cloneJson(part), + })); + } + } else if (itemType === 'reasoning') { + const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : []; + const part = summary[0]; + if (isRecord(part)) { + const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); + lines.push(serializeSse('response.reasoning_summary_text.done', { + type: 'response.reasoning_summary_text.done', + item_id: itemId, + output_index: index, + summary_index: 0, + text, + })); + lines.push(serializeSse('response.reasoning_summary_part.done', { + type: 'response.reasoning_summary_part.done', + item_id: itemId, + output_index: index, + summary_index: 0, + part: cloneJson(part), + })); + } + } else if (itemType === 'function_call') { + const callId = asTrimmedString(item.call_id) || itemId; + const name = asTrimmedString(item.name); + const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? ''); + lines.push(serializeSse('response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + arguments: argumentsText, + })); + } else if (itemType === 'custom_tool_call') { + const callId = asTrimmedString(item.call_id) || itemId; + const name = asTrimmedString(item.name); + const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? ''); + lines.push(serializeSse('response.custom_tool_call_input.done', { + type: 'response.custom_tool_call_input.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + input: inputText, + })); + } + + item.status = status; + lines.push(serializeSse('response.output_item.done', { + type: 'response.output_item.done', + output_index: index, + item: cloneJson(item), + })); + } + + return lines; +} + export function serializeConvertedResponsesEvents(input: { state: OpenAiResponsesAggregateState; streamContext: StreamTransformContext; @@ -806,8 +899,10 @@ export function completeResponsesStream( if (state.failed || state.completed) { return [serializeDone()]; } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed'); state.completed = true; return [ + ...lines, serializeSse('response.completed', { type: 'response.completed', response: materializeResponse(state, streamContext, usage, null, 'completed'), @@ -825,6 +920,7 @@ export function failResponsesStream( if (state.failed) { return [serializeDone()]; } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'failed'); state.failed = true; const errorPayload = cloneRecord(payload); const message = ( @@ -833,6 +929,7 @@ export function failResponsesStream( : (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed') ); return [ + ...lines, serializeSse('response.failed', { type: 'response.failed', response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'), diff --git a/src/server/transformers/openai/responses/inbound.ts b/src/server/transformers/openai/responses/inbound.ts index 153524c9..9001ae42 100644 --- a/src/server/transformers/openai/responses/inbound.ts +++ b/src/server/transformers/openai/responses/inbound.ts @@ -1,6 +1,55 @@ -import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js'; +import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js'; +import { sanitizeResponsesBodyForProxy } from './conversion.js'; +import type { + OpenAiResponsesParsedRequest, + OpenAiResponsesRequestEnvelope, +} from './model.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function invalidRequest(message: string): { statusCode: number; payload: unknown } { + return { + statusCode: 400, + payload: { + error: { + message, + type: 'invalid_request_error', + }, + }, + }; +} export const openAiResponsesInbound = { - normalizeInput: normalizeResponsesInputForCompatibility, - normalizeMessage: normalizeResponsesMessageItem, + parse( + body: unknown, + options?: { defaultEncryptedReasoningInclude?: boolean }, + ): { value?: OpenAiResponsesRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const rawBody = isRecord(body) ? body : {}; + const requestedModel = typeof rawBody.model === 'string' ? rawBody.model.trim() : ''; + if (!requestedModel) { + return { error: invalidRequest('model is required') }; + } + + const isStream = rawBody.stream === true; + const normalizedBody = sanitizeResponsesBodyForProxy( + rawBody, + requestedModel, + isStream, + { defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude }, + ); + + return { + value: createProtocolRequestEnvelope({ + protocol: 'openai/responses', + model: requestedModel, + stream: isStream, + rawBody: body, + parsed: { + normalizedBody, + } satisfies OpenAiResponsesParsedRequest, + }), + }; + }, }; diff --git a/src/server/transformers/openai/responses/index.test.ts b/src/server/transformers/openai/responses/index.test.ts new file mode 100644 index 00000000..b6a6052f --- /dev/null +++ b/src/server/transformers/openai/responses/index.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { openAiResponsesTransformer } from './index.js'; + +describe('openAiResponsesTransformer.inbound', () => { + it('returns a protocol request envelope with a normalized responses body', () => { + const result = openAiResponsesTransformer.transformRequest({ + model: 'gpt-5', + input: 'hello', + reasoning: { + effort: 'high', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + protocol: 'openai/responses', + model: 'gpt-5', + stream: false, + rawBody: { + model: 'gpt-5', + input: 'hello', + }, + parsed: { + normalizedBody: { + model: 'gpt-5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: 'hello', + }, + ], + }, + ], + stream: false, + }, + }, + }); + }); + + it('rejects requests without a model at the transformer boundary', () => { + const result = openAiResponsesTransformer.transformRequest({ + input: 'hello', + }); + + expect(result.error).toEqual({ + statusCode: 400, + payload: { + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }, + }); + }); +}); diff --git a/src/server/transformers/openai/responses/index.ts b/src/server/transformers/openai/responses/index.ts index c98bcf54..27c691ff 100644 --- a/src/server/transformers/openai/responses/index.ts +++ b/src/server/transformers/openai/responses/index.ts @@ -21,12 +21,20 @@ import { serializeConvertedResponsesEvents, } from './aggregator.js'; import { openAiResponsesOutbound } from './outbound.js'; +import { openAiResponsesInbound } from './inbound.js'; +import { createResponsesProxyStreamSession } from './proxyStream.js'; +import { createResponsesEndpointStrategy } from './routeCompatibility.js'; import { openAiResponsesStream } from './stream.js'; import { openAiResponsesUsage } from './usage.js'; +import type { + OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel, + OpenAiResponsesRequestEnvelope as OpenAiResponsesRequestEnvelopeModel, +} from './model.js'; export const openAiResponsesTransformer = { protocol: 'openai/responses' as const, inbound: { + parse: openAiResponsesInbound.parse, normalizeInput: normalizeResponsesInputForCompatibility, normalizeMessage: normalizeResponsesMessageItem, normalizeContent: normalizeResponsesMessageContent, @@ -38,6 +46,7 @@ export const openAiResponsesTransformer = { stream: openAiResponsesStream, usage: openAiResponsesUsage, compatibility: { + createEndpointStrategy: createResponsesEndpointStrategy, buildRetryBodies, buildRetryHeaders, shouldRetry, @@ -49,8 +58,14 @@ export const openAiResponsesTransformer = { complete: completeResponsesStream, fail: failResponsesStream, }, - transformRequest(body: unknown) { - return body; + proxyStream: { + createSession: createResponsesProxyStreamSession, + }, + transformRequest( + body: unknown, + options?: { defaultEncryptedReasoningInclude?: boolean }, + ): { value?: OpenAiResponsesRequestEnvelopeModel; error?: { statusCode: number; payload: unknown } } { + return openAiResponsesInbound.parse(body, options); }, createStreamContext(modelName: string): StreamTransformContext { return openAiResponsesStream.createContext(modelName); @@ -68,6 +83,8 @@ export const openAiResponsesTransformer = { export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer; export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState; +export type OpenAiResponsesParsedRequest = OpenAiResponsesParsedRequestModel; +export type OpenAiResponsesRequestEnvelope = OpenAiResponsesRequestEnvelopeModel; export { convertOpenAiBodyToResponsesBody, convertResponsesBodyToOpenAiBody, diff --git a/src/server/transformers/openai/responses/model.ts b/src/server/transformers/openai/responses/model.ts new file mode 100644 index 00000000..9d50318e --- /dev/null +++ b/src/server/transformers/openai/responses/model.ts @@ -0,0 +1,10 @@ +import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js'; + +export type OpenAiResponsesParsedRequest = { + normalizedBody: Record; +}; + +export type OpenAiResponsesRequestEnvelope = ProtocolRequestEnvelope< + 'openai/responses', + OpenAiResponsesParsedRequest +>; diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts new file mode 100644 index 00000000..14ebc714 --- /dev/null +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -0,0 +1,111 @@ +import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; +import { type ParsedSseEvent } from '../../shared/normalized.js'; +import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js'; +import { openAiResponsesStream } from './stream.js'; + +type StreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ResponseSink = { + end(): void; +}; + +type ResponsesProxyStreamSessionInput = { + modelName: string; + successfulUpstreamPath: string; + getUsage: () => { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; + }; + onParsedPayload?: (payload: unknown) => void; + writeLines: (lines: string[]) => void; + writeRaw: (chunk: string) => void; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { + const streamContext = openAiResponsesStream.createContext(input.modelName); + const responsesState = createOpenAiResponsesAggregateState(input.modelName); + let finalized = false; + + const finalize = () => { + if (finalized) return; + finalized = true; + input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage())); + }; + + const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => { + if (eventBlock.data === '[DONE]') { + finalize(); + return true; + } + + let parsedPayload: unknown = null; + try { + parsedPayload = JSON.parse(eventBlock.data); + } catch { + parsedPayload = null; + } + + if (parsedPayload && typeof parsedPayload === 'object') { + input.onParsedPayload?.(parsedPayload); + } + + const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string') + ? parsedPayload.type + : ''; + const isFailureEvent = ( + eventBlock.event === 'error' + || eventBlock.event === 'response.failed' + || payloadType === 'error' + || payloadType === 'response.failed' + ); + if (isFailureEvent) { + input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), parsedPayload)); + finalized = true; + return true; + } + + if (parsedPayload && typeof parsedPayload === 'object') { + const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName); + input.writeLines(serializeConvertedResponsesEvents({ + state: responsesState, + streamContext, + event: normalizedEvent, + usage: input.getUsage(), + })); + return false; + } + + input.writeLines(serializeConvertedResponsesEvents({ + state: responsesState, + streamContext, + event: { contentDelta: eventBlock.data }, + usage: input.getUsage(), + })); + return false; + }; + + return { + async run(reader: StreamReader | null | undefined, response: ResponseSink) { + const lifecycle = createProxyStreamLifecycle({ + reader, + response, + pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer), + handleEvent: handleEventBlock, + onEof: finalize, + }); + await lifecycle.run(); + }, + }; +} diff --git a/src/server/transformers/openai/responses/routeCompatibility.ts b/src/server/transformers/openai/responses/routeCompatibility.ts new file mode 100644 index 00000000..b44b80bd --- /dev/null +++ b/src/server/transformers/openai/responses/routeCompatibility.ts @@ -0,0 +1,110 @@ +import { + buildMinimalJsonHeadersForCompatibility, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + type CompatibilityEndpoint, +} from '../../shared/endpointCompatibility.js'; +import type { EndpointAttemptContext, EndpointRecoverResult } from '../../../routes/proxy/endpointFlow.js'; +import { + buildResponsesCompatibilityBodies, + buildResponsesCompatibilityHeaderCandidates, + shouldDowngradeResponsesChatToMessages, + shouldRetryResponsesCompatibility, +} from './compatibility.js'; + +type CompatibilityRequest = { + endpoint: CompatibilityEndpoint; + path: string; + headers: Record; + body: Record; +}; + +type UpstreamResponse = Exclude['upstream']; + +type CreateResponsesEndpointStrategyInput = { + isStream: boolean; + requiresNativeResponsesFileUrl: boolean; + dispatchRequest: ( + request: CompatibilityRequest, + targetUrl?: string, + ) => Promise; +}; + +export function createResponsesEndpointStrategy(input: CreateResponsesEndpointStrategyInput) { + return { + async tryRecover(ctx: EndpointAttemptContext): Promise { + if (shouldRetryResponsesCompatibility({ + endpoint: ctx.request.endpoint, + status: ctx.response.status, + rawErrText: ctx.rawErrText, + })) { + const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body); + const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates( + ctx.request.headers, + input.isStream, + ); + + for (const compatibilityHeadersCandidate of compatibilityHeaders) { + for (const compatibilityBody of compatibilityBodies) { + const compatibilityRequest = { + ...ctx.request, + headers: compatibilityHeadersCandidate, + body: compatibilityBody, + }; + const compatibilityResponse = await input.dispatchRequest( + compatibilityRequest, + ctx.targetUrl, + ); + if (compatibilityResponse.ok) { + return { + upstream: compatibilityResponse, + upstreamPath: compatibilityRequest.path, + }; + } + + ctx.request = compatibilityRequest; + ctx.response = compatibilityResponse; + ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error'); + } + } + } + + if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { + return null; + } + + const minimalRequest = { + ...ctx.request, + headers: buildMinimalJsonHeadersForCompatibility({ + headers: ctx.request.headers, + endpoint: ctx.request.endpoint, + stream: input.isStream, + }), + }; + const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl); + if (minimalResponse.ok) { + return { + upstream: minimalResponse, + upstreamPath: minimalRequest.path, + }; + } + + ctx.request = minimalRequest; + ctx.response = minimalResponse; + ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); + return null; + }, + shouldDowngrade(ctx: EndpointAttemptContext): boolean { + if (input.requiresNativeResponsesFileUrl) return false; + return ( + ctx.response.status >= 500 + || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) + || shouldDowngradeResponsesChatToMessages( + ctx.request.path, + ctx.response.status, + ctx.rawErrText, + ) + ); + }, + }; +} diff --git a/src/server/transformers/shared/chatEndpointStrategy.ts b/src/server/transformers/shared/chatEndpointStrategy.ts new file mode 100644 index 00000000..00d7ed8c --- /dev/null +++ b/src/server/transformers/shared/chatEndpointStrategy.ts @@ -0,0 +1,121 @@ +import type { DownstreamFormat } from './normalized.js'; +import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js'; +import { + buildMinimalJsonHeadersForCompatibility, + isEndpointDispatchDeniedError, + isEndpointDowngradeError, + isUnsupportedMediaTypeError, + promoteResponsesCandidateAfterLegacyChatError, + type CompatibilityEndpoint, +} from './endpointCompatibility.js'; +import { + isMessagesRequiredError, + shouldRetryNormalizedMessagesBody, +} from '../anthropic/messages/compatibility.js'; + +type CompatibilityRequest = { + endpoint: CompatibilityEndpoint; + path: string; + headers: Record; + body: Record; +}; + +type UpstreamResponse = Exclude['upstream']; + +type CreateChatEndpointStrategyInput = { + downstreamFormat: DownstreamFormat; + endpointCandidates: CompatibilityEndpoint[]; + modelName: string; + requestedModelHint: string; + sitePlatform?: string | null; + isStream: boolean; + buildRequest: (input: { + endpoint: CompatibilityEndpoint; + forceNormalizeClaudeBody?: boolean; + }) => CompatibilityRequest; + dispatchRequest: ( + request: CompatibilityRequest, + targetUrl?: string, + ) => Promise; +}; + +export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInput) { + return { + async tryRecover(ctx: EndpointAttemptContext): Promise { + if (shouldRetryNormalizedMessagesBody({ + downstreamFormat: input.downstreamFormat, + endpointPath: ctx.request.path, + status: ctx.response.status, + upstreamErrorText: ctx.rawErrText, + })) { + const normalizedClaudeRequest = input.buildRequest({ + endpoint: ctx.request.endpoint, + forceNormalizeClaudeBody: true, + }); + const normalizedResponse = await input.dispatchRequest(normalizedClaudeRequest); + + if (normalizedResponse.ok) { + return { + upstream: normalizedResponse, + upstreamPath: normalizedClaudeRequest.path, + }; + } + + ctx.request = normalizedClaudeRequest; + ctx.response = normalizedResponse; + ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error'); + } + + if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) { + return null; + } + + const minimalHeaders = buildMinimalJsonHeadersForCompatibility({ + headers: ctx.request.headers, + endpoint: ctx.request.endpoint, + stream: input.isStream, + }); + const normalizedCurrentHeaders = Object.fromEntries( + Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) { + return null; + } + + const minimalRequest = { + ...ctx.request, + headers: minimalHeaders, + }; + const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl); + + if (minimalResponse.ok) { + return { + upstream: minimalResponse, + upstreamPath: minimalRequest.path, + }; + } + + ctx.request = minimalRequest; + ctx.response = minimalResponse; + ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error'); + return null; + }, + shouldDowngrade(ctx: EndpointAttemptContext): boolean { + promoteResponsesCandidateAfterLegacyChatError(input.endpointCandidates, { + status: ctx.response.status, + upstreamErrorText: ctx.rawErrText, + downstreamFormat: input.downstreamFormat, + sitePlatform: input.sitePlatform, + modelName: input.modelName, + requestedModelHint: input.requestedModelHint, + currentEndpoint: ctx.request.endpoint, + }); + return ( + ctx.response.status >= 500 + || isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) + || isMessagesRequiredError(ctx.rawErrText) + || isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText) + ); + }, + }; +} diff --git a/src/server/transformers/shared/chatFormatsCore.test.ts b/src/server/transformers/shared/chatFormatsCore.test.ts new file mode 100644 index 00000000..5f25ad38 --- /dev/null +++ b/src/server/transformers/shared/chatFormatsCore.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js'; + +describe('chatFormatsCore inline think parsing', () => { + it('tracks split think tags across stream chunks', () => { + const context = createStreamTransformContext('gpt-test'); + + expect(normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { role: 'assistant' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + role: 'assistant', + }); + + const openingFragment = normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { content: 'plan ' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + reasoningDelta: 'plan ', + }); + + expect(normalizeUpstreamStreamEvent({ + id: 'chatcmpl-split-think', + model: 'gpt-test', + choices: [{ + index: 0, + delta: { content: 'quietlyvisible answer' }, + finish_reason: null, + }], + }, context, 'gpt-test')).toMatchObject({ + contentDelta: 'visible answer', + }); + }); +}); diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index 6dc20762..11aa0c90 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -1,6 +1,12 @@ import { decodeAnthropicReasoningSignature, } from './reasoningTransport.js'; +import { + consumeThinkTaggedText, + createThinkTagParserState, + extractInlineThinkTags, + type ThinkTagParserState, +} from './thinkTagParser.js'; export type DownstreamFormat = 'openai' | 'claude'; @@ -16,6 +22,7 @@ export type StreamTransformContext = { roleSent: boolean; doneSent: boolean; toolCalls: Record; + thinkTagParser: ThinkTagParserState; }; export type ClaudeDownstreamContext = { @@ -102,8 +109,6 @@ function textFromPart(part: unknown): string { if (typeof part.output_text === 'string') return part.output_text; if (typeof part.completion === 'string') return part.completion; if (typeof part.partial_json === 'string') return part.partial_json; - if (typeof part.reasoning_content === 'string') return part.reasoning_content; - if (typeof part.reasoning === 'string') return part.reasoning; if (Array.isArray(part.content)) { return part.content.map((item) => textFromPart(item)).join(''); @@ -118,13 +123,15 @@ function textFromPart(part: unknown): string { } function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { - if (typeof value === 'string') return { content: value, reasoning: '' }; + if (typeof value === 'string') return extractInlineThinkTags(value); if (Array.isArray(value)) { const contentParts: string[] = []; const reasoningParts: string[] = []; for (const item of value) { if (typeof item === 'string') { - contentParts.push(item); + const parsedString = extractInlineThinkTags(item); + if (parsedString.content) contentParts.push(parsedString.content); + if (parsedString.reasoning) reasoningParts.push(parsedString.reasoning); continue; } if (!isRecord(item)) continue; @@ -143,8 +150,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: continue; } - const text = textFromPart(item); - if (text) contentParts.push(text); + const parsedText = extractInlineThinkTags(textFromPart(item)); + if (parsedText.content) contentParts.push(parsedText.content); + if (parsedText.reasoning) reasoningParts.push(parsedText.reasoning); } return { @@ -159,9 +167,34 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning: return extractTextAndReasoning(value.parts); } + const directReasoning = joinNonEmpty([ + typeof value.reasoning_content === 'string' ? value.reasoning_content : '', + typeof value.reasoning === 'string' ? value.reasoning : '', + typeof value.thinking === 'string' ? value.thinking : '', + ]); + const parsedText = extractInlineThinkTags(textFromPart(value)); + return { - content: textFromPart(value), - reasoning: '', + content: parsedText.content, + reasoning: joinNonEmpty([directReasoning, parsedText.reasoning]), + }; +} + +function extractStreamingTextAndReasoning( + value: unknown, + thinkTagParser: ThinkTagParserState, +): { content: string; reasoning: string } { + const parsed = extractTextAndReasoning(value); + if (!parsed.content) { + return parsed; + } + + const streamed = consumeThinkTaggedText(thinkTagParser, parsed.content); + return { + content: streamed.content, + reasoning: [parsed.reasoning, streamed.reasoning] + .filter((part) => part.length > 0) + .join(''), }; } @@ -218,6 +251,7 @@ export function createStreamTransformContext(modelName: string): StreamTransform roleSent: false, doneSent: false, toolCalls: {}, + thinkTagParser: createThinkTagParserState(), }; } @@ -254,10 +288,18 @@ function extractAssistantContent(choice: any): string { const content = extractTextAndReasoning(choice?.content).content; if (content) return content; - if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text; - if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion; - if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text; - if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content; + if (typeof choice?.text === 'string' && choice.text.length > 0) { + return extractInlineThinkTags(choice.text).content; + } + if (typeof choice?.completion === 'string' && choice.completion.length > 0) { + return extractInlineThinkTags(choice.completion).content; + } + if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) { + return extractInlineThinkTags(choice.output_text).content; + } + if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) { + return extractInlineThinkTags(choice.delta.content).content; + } return ''; } @@ -279,6 +321,11 @@ function extractAssistantReasoning(choice: any): string { const nested = extractTextAndReasoning(choice?.content).reasoning; if (nested) return nested; + if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) { + const parsedDelta = extractInlineThinkTags(choice.delta.content); + if (parsedDelta.reasoning) return parsedDelta.reasoning; + } + return ''; } @@ -742,17 +789,19 @@ export function normalizeUpstreamStreamEvent( const choice = payload.choices[0] ?? {}; const delta = isRecord(choice?.delta) ? choice.delta : {}; - const deltaParsed = extractTextAndReasoning(delta.content ?? delta); + const deltaParsed = extractStreamingTextAndReasoning(delta.content ?? delta, context.thinkTagParser); + const messageParsed = extractStreamingTextAndReasoning(choice?.message?.content ?? '', context.thinkTagParser); const rawContentDelta = deltaParsed.content - || (typeof choice?.message?.content === 'string' ? choice.message.content : '') + || messageParsed.content || ''; const reasoningDelta = (typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '') || (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '') || deltaParsed.reasoning + || messageParsed.reasoning || ''; const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature) ? (delta as any).reasoning_signature @@ -810,11 +859,10 @@ export function normalizeUpstreamStreamEvent( const type = typeof payload.type === 'string' ? payload.type : ''; if (type.startsWith('response.output_text')) { - const deltaText = typeof payload.delta === 'string' - ? payload.delta - : extractTextAndReasoning(payload.delta).content; + const parsed = extractStreamingTextAndReasoning(payload.delta, context.thinkTagParser); return { - contentDelta: deltaText || undefined, + contentDelta: parsed.content || undefined, + reasoningDelta: parsed.reasoning || undefined, }; } @@ -975,7 +1023,7 @@ export function normalizeUpstreamStreamEvent( }; } - const parsed = extractTextAndReasoning(payload.content_block); + const parsed = extractStreamingTextAndReasoning(payload.content_block, context.thinkTagParser); return { contentDelta: parsed.content || undefined, reasoningDelta: parsed.reasoning || undefined, @@ -985,7 +1033,7 @@ export function normalizeUpstreamStreamEvent( if (type === 'content_block_delta') { const delta = isRecord(payload.delta) ? payload.delta : {}; const deltaType = typeof delta.type === 'string' ? delta.type : ''; - const parsed = extractTextAndReasoning(delta); + const parsed = extractStreamingTextAndReasoning(delta, context.thinkTagParser); if (deltaType === 'input_json_delta') { const index = ( @@ -1029,7 +1077,10 @@ export function normalizeUpstreamStreamEvent( if (Array.isArray(payload.candidates)) { const candidate = payload.candidates[0] || {}; - const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content); + const parsed = extractStreamingTextAndReasoning( + (candidate as any).content?.parts || (candidate as any).content, + context.thinkTagParser, + ); if (isNonEmptyString((payload as any).modelVersion)) { context.model = (payload as any).modelVersion; @@ -1044,7 +1095,7 @@ export function normalizeUpstreamStreamEvent( }; } - const fallback = extractTextAndReasoning(payload); + const fallback = extractStreamingTextAndReasoning(payload, context.thinkTagParser); return { contentDelta: fallback.content || undefined, reasoningDelta: fallback.reasoning || undefined, @@ -1055,15 +1106,17 @@ function buildOpenAiStreamChunk( context: StreamTransformContext, event: NormalizedStreamEvent, ): Record | null { + const normalizedContentDelta = event.contentDelta || ''; + const normalizedReasoningDelta = event.reasoningDelta || ''; const delta: Record = {}; const isInitialAssistantRoleOnlyEvent = ( !context.roleSent && event.role === 'assistant' - && !event.contentDelta - && !event.reasoningDelta + && !normalizedContentDelta + && !normalizedReasoningDelta ); - if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) { + if (!context.roleSent && (event.role === 'assistant' || normalizedContentDelta || normalizedReasoningDelta)) { delta.role = 'assistant'; context.roleSent = true; } else if (event.role === 'assistant') { @@ -1071,12 +1124,12 @@ function buildOpenAiStreamChunk( context.roleSent = true; } - if (event.contentDelta) { - delta.content = event.contentDelta; + if (normalizedContentDelta) { + delta.content = normalizedContentDelta; } - if (event.reasoningDelta) { - delta.reasoning_content = event.reasoningDelta; + if (normalizedReasoningDelta) { + delta.reasoning_content = normalizedReasoningDelta; } if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { diff --git a/src/server/transformers/shared/endpointCompatibility.ts b/src/server/transformers/shared/endpointCompatibility.ts new file mode 100644 index 00000000..0540cb09 --- /dev/null +++ b/src/server/transformers/shared/endpointCompatibility.ts @@ -0,0 +1,228 @@ +import type { DownstreamFormat } from './normalized.js'; + +export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses'; +export type CompatibilityEndpointPreference = DownstreamFormat | 'responses'; + +type PreferResponsesAfterLegacyChatErrorInput = { + status: number; + upstreamErrorText?: string | null; + downstreamFormat: CompatibilityEndpointPreference; + sitePlatform?: string | null; + modelName?: string | null; + requestedModelHint?: string | null; + currentEndpoint?: CompatibilityEndpoint | null; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizePlatformName(platform: unknown): string { + return asTrimmedString(platform).toLowerCase(); +} + +function isClaudeFamilyModel(modelName: string): boolean { + const normalized = asTrimmedString(modelName).toLowerCase(); + if (!normalized) return false; + return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude'); +} + +function headerValueToString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + + return null; +} + +function normalizeHeaderMap(headers: Record): Record { + const normalized: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.trim().toLowerCase(); + if (!key) continue; + const value = headerValueToString(rawValue); + if (!value) continue; + normalized[key] = value; + } + return normalized; +} + +export function buildMinimalJsonHeadersForCompatibility(input: { + headers: Record; + endpoint: CompatibilityEndpoint; + stream: boolean; +}): Record { + const source = normalizeHeaderMap(input.headers); + const minimal: Record = {}; + + if (source.authorization) minimal.authorization = source.authorization; + if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key']; + + if (input.endpoint === 'messages') { + for (const [key, value] of Object.entries(source)) { + if (!key.startsWith('anthropic-')) continue; + minimal[key] = value; + } + if (!minimal['anthropic-version']) { + minimal['anthropic-version'] = '2023-06-01'; + } + } + + minimal['content-type'] = 'application/json'; + minimal.accept = input.stream ? 'text/event-stream' : 'application/json'; + return minimal; +} + +export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean { + if (status < 400) return false; + if (status !== 400 && status !== 415) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (!text) return status === 415; + + return ( + text.includes('unsupported media type') + || text.includes("only 'application/json' is allowed") + || text.includes('only "application/json" is allowed') + || text.includes('application/json') + || text.includes('content-type') + ); +} + +export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean { + if (status !== 403) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (!text) return false; + + return ( + /does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '') + || text.includes('dispatch denied') + ); +} + +export function shouldPreferResponsesAfterLegacyChatError( + input: PreferResponsesAfterLegacyChatErrorInput, +): boolean { + if (input.status < 400) return false; + if (input.downstreamFormat !== 'openai') return false; + if (input.currentEndpoint !== 'chat') return false; + + const sitePlatform = normalizePlatformName(input.sitePlatform); + if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') { + return false; + } + + const modelName = asTrimmedString(input.modelName); + const requestedModelHint = asTrimmedString(input.requestedModelHint); + if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) { + return false; + } + + const text = (input.upstreamErrorText || '').toLowerCase(); + return ( + text.includes('unsupported legacy protocol') + && text.includes('/v1/chat/completions') + && text.includes('/v1/responses') + ); +} + +export function promoteResponsesCandidateAfterLegacyChatError( + endpointCandidates: CompatibilityEndpoint[], + input: PreferResponsesAfterLegacyChatErrorInput, +): void { + if (!shouldPreferResponsesAfterLegacyChatError(input)) return; + + const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint); + const responsesIndex = endpointCandidates.indexOf('responses'); + if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return; + + endpointCandidates.splice(responsesIndex, 1); + endpointCandidates.splice(currentIndex + 1, 0, 'responses'); +} + +export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean { + if (status < 400) return false; + const text = (upstreamErrorText || '').toLowerCase(); + if (status === 404 || status === 405 || status === 415 || status === 501) return true; + if (!text) return false; + + let parsedCode = ''; + let parsedType = ''; + let parsedMessage = ''; + try { + const parsed = JSON.parse(upstreamErrorText || '{}') as Record; + const error = (parsed.error && typeof parsed.error === 'object') + ? parsed.error as Record + : parsed; + parsedCode = asTrimmedString(error.code).toLowerCase(); + parsedType = asTrimmedString(error.type).toLowerCase(); + parsedMessage = asTrimmedString(error.message).toLowerCase(); + } catch { + parsedCode = ''; + parsedType = ''; + parsedMessage = ''; + } + + return ( + isEndpointDispatchDeniedError(status, upstreamErrorText) + || text.includes('convert_request_failed') + || text.includes('not found') + || text.includes('unknown endpoint') + || text.includes('unsupported endpoint') + || text.includes('unsupported path') + || text.includes('unrecognized request url') + || text.includes('no route matched') + || text.includes('does not exist') + || text.includes('openai_error') + || text.includes('upstream_error') + || text.includes('bad_response_status_code') + || text.includes('unsupported media type') + || text.includes("only 'application/json' is allowed") + || text.includes('only "application/json" is allowed') + || (status === 400 && text.includes('unsupported')) + || text.includes('not implemented') + || text.includes('api not implemented') + || text.includes('unsupported legacy protocol') + || parsedCode === 'convert_request_failed' + || parsedCode === 'not_found' + || parsedCode === 'endpoint_not_found' + || parsedCode === 'unknown_endpoint' + || parsedCode === 'unsupported_endpoint' + || parsedCode === 'bad_response_status_code' + || parsedCode === 'openai_error' + || parsedCode === 'upstream_error' + || parsedType === 'not_found_error' + || parsedType === 'invalid_request_error' + || parsedType === 'unsupported_endpoint' + || parsedType === 'unsupported_path' + || parsedType === 'bad_response_status_code' + || parsedType === 'openai_error' + || parsedType === 'upstream_error' + || parsedMessage.includes('unknown endpoint') + || parsedMessage.includes('unsupported endpoint') + || parsedMessage.includes('unsupported path') + || parsedMessage.includes('unrecognized request url') + || parsedMessage.includes('no route matched') + || parsedMessage.includes('does not exist') + || parsedMessage.includes('bad_response_status_code') + || parsedMessage === 'openai_error' + || parsedMessage === 'upstream_error' + || parsedMessage.includes('unsupported media type') + || parsedMessage.includes("only 'application/json' is allowed") + || parsedMessage.includes('only "application/json" is allowed') + || ( + status === 400 + && parsedCode === 'invalid_request' + && parsedType === 'new_api_error' + && (parsedMessage.includes('claude code cli') || text.includes('claude code cli')) + ) + ); +} diff --git a/src/server/transformers/shared/inputFile.test.ts b/src/server/transformers/shared/inputFile.test.ts new file mode 100644 index 00000000..c9ddd537 --- /dev/null +++ b/src/server/transformers/shared/inputFile.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { + ensureBase64DataUrl, + inferInputFileMimeType, + normalizeInputFileBlock, + toAnthropicDocumentBlock, + toOpenAiChatFileBlock, + toResponsesInputFileBlock, +} from './inputFile.js'; + +describe('shared input file helpers', () => { + it('preserves existing data URLs when building Responses input_file blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('infers mime type from filename when wrapping raw base64 for Responses blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('prefers file_data over file_url and file_id when serializing Responses blocks', () => { + expect(toResponsesInputFileBlock({ + fileData: 'JVBERi0x', + fileUrl: 'https://example.com/brief.pdf', + fileId: 'file_123', + filename: 'brief.pdf', + })).toEqual({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + }); + }); + + it('strips data URL wrappers for OpenAI chat file blocks while preserving mime type', () => { + expect(toOpenAiChatFileBlock({ + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + })).toEqual({ + type: 'file', + file: { + file_data: 'JVBERi0x', + filename: 'brief.pdf', + mime_type: 'application/pdf', + }, + }); + }); + + it('creates anthropic document blocks from inline file data', () => { + expect(toAnthropicDocumentBlock({ + fileData: 'data:text/plain;base64,SGVsbG8=', + filename: 'notes.txt', + hadDataUrl: true, + })).toEqual({ + type: 'document', + cache_control: { type: 'ephemeral' }, + source: { + type: 'base64', + media_type: 'text/plain', + data: 'SGVsbG8=', + }, + title: 'notes.txt', + }); + }); + + it('infers common mime types from filenames', () => { + expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown'); + expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg'); + expect(inferInputFileMimeType({ filename: 'voice.mp3', mimeType: null })).toBe('audio/mpeg'); + }); + + it('leaves raw base64 unchanged when mime type is unknown', () => { + expect(ensureBase64DataUrl('YWJj', null)).toBe('YWJj'); + }); + + it('prefers inline data when input_file blocks also carry file_url or file_id', () => { + expect(normalizeInputFileBlock({ + type: 'input_file', + file_data: 'data:application/pdf;base64,JVBERi0x', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_123', + filename: 'brief.pdf', + })).toEqual({ + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,JVBERi0x', + filename: 'brief.pdf', + mimeType: 'application/pdf', + hadDataUrl: true, + }); + }); + + it('prefers inline data when file wrapper blocks also carry file_url or file_id', () => { + expect(normalizeInputFileBlock({ + type: 'file', + file: { + file_data: 'JVBERi0x', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_123', + filename: 'brief.pdf', + }, + })).toEqual({ + sourceType: 'file', + fileData: 'JVBERi0x', + filename: 'brief.pdf', + mimeType: null, + hadDataUrl: false, + }); + }); +}); diff --git a/src/server/transformers/shared/inputFile.ts b/src/server/transformers/shared/inputFile.ts index 43c2bdb6..5ad6bd4b 100644 --- a/src/server/transformers/shared/inputFile.ts +++ b/src/server/transformers/shared/inputFile.ts @@ -15,6 +15,20 @@ function splitBase64DataUrl(value: string): { mimeType: string; data: string } | }; } +function normalizeFileSourceSelection( + fileData: string, + fileUrl: string, + fileId: string, +): { fileData: string; fileUrl: string; fileId: string } { + if (fileData) { + return { fileData, fileUrl: '', fileId: '' }; + } + if (fileUrl) { + return { fileData: '', fileUrl, fileId: '' }; + } + return { fileData: '', fileUrl: '', fileId }; +} + export function ensureBase64DataUrl(fileData: string, mimeType?: string | null): string { const trimmedData = asTrimmedString(fileData); if (!trimmedData) return trimmedData; @@ -58,9 +72,14 @@ export function normalizeInputFileBlock(item: Record): Normaliz const type = asTrimmedString(item.type).toLowerCase(); if (type === 'input_file') { - const fileId = asTrimmedString(item.file_id); - const fileData = asTrimmedString(item.file_data); - const fileUrl = asTrimmedString(item.file_url); + const selectedSource = normalizeFileSourceSelection( + asTrimmedString(item.file_data), + asTrimmedString(item.file_url), + asTrimmedString(item.file_id), + ); + const fileId = selectedSource.fileId; + const fileData = selectedSource.fileData; + const fileUrl = selectedSource.fileUrl; const filename = asTrimmedString(item.filename); let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null; if (!fileId && !fileData && !fileUrl) return null; @@ -81,9 +100,14 @@ export function normalizeInputFileBlock(item: Record): Normaliz if (type === 'file') { 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 selectedSource = normalizeFileSourceSelection( + asTrimmedString(file.file_data ?? item.file_data), + asTrimmedString(file.file_url ?? item.file_url), + asTrimmedString(file.file_id ?? item.file_id), + ); + const fileId = selectedSource.fileId; + const fileData = selectedSource.fileData; + const fileUrl = selectedSource.fileUrl; const filename = asTrimmedString(file.filename ?? item.filename); let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null; if (!fileId && !fileData && !fileUrl) return null; diff --git a/src/server/transformers/shared/protocolLifecycle.ts b/src/server/transformers/shared/protocolLifecycle.ts new file mode 100644 index 00000000..0297d83f --- /dev/null +++ b/src/server/transformers/shared/protocolLifecycle.ts @@ -0,0 +1,88 @@ +type PulledEventBatch = { + events: TEvent[]; + rest: string; +}; + +type ProxyStreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}; + +type ProxyStreamLifecycleInput = { + reader: ProxyStreamReader | null | undefined; + response: { end(): void }; + pullEvents(buffer: string): PulledEventBatch; + handleEvent(event: TEvent): Promise | boolean | void; + onEof?: () => Promise | void; +}; + +export function createProxyStreamLifecycle(input: ProxyStreamLifecycleInput) { + const flushBuffer = async (buffer: string): Promise<{ rest: string; stop: boolean }> => { + const pulled = input.pullEvents(buffer); + for (const event of pulled.events) { + if (await input.handleEvent(event)) { + return { + rest: pulled.rest, + stop: true, + }; + } + } + + return { + rest: pulled.rest, + stop: false, + }; + }; + + return { + async run(): Promise { + const reader = input.reader; + if (!reader) { + try { + await input.onEof?.(); + } finally { + input.response.end(); + } + return; + } + + const decoder = new TextDecoder(); + let sseBuffer = ''; + let shouldStop = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + sseBuffer += decoder.decode(value, { stream: true }); + const flushed = await flushBuffer(sseBuffer); + sseBuffer = flushed.rest; + if (!flushed.stop) continue; + + shouldStop = true; + await reader.cancel().catch(() => {}); + break; + } + + if (!shouldStop) { + sseBuffer += decoder.decode(); + if (sseBuffer.trim().length > 0) { + const flushed = await flushBuffer(`${sseBuffer}\n\n`); + sseBuffer = flushed.rest; + shouldStop = flushed.stop; + } + } + + if (!shouldStop) { + await input.onEof?.(); + } + } finally { + reader.releaseLock(); + input.response.end(); + } + }, + }; +} diff --git a/src/server/transformers/shared/protocolModel.test.ts b/src/server/transformers/shared/protocolModel.test.ts new file mode 100644 index 00000000..b8626b13 --- /dev/null +++ b/src/server/transformers/shared/protocolModel.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + createProtocolRequestEnvelope, + createProtocolResponseEnvelope, + createProtocolStreamEnvelope, +} from './protocolModel.js'; + +describe('protocolModel', () => { + it('creates protocol request envelopes with shared top-level fields', () => { + const envelope = createProtocolRequestEnvelope({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: true, + rawBody: { model: 'gpt-5', stream: true }, + parsed: { requestedModel: 'gpt-5', isStream: true }, + metadata: { serviceTier: 'priority' }, + }); + + expect(envelope).toEqual({ + protocol: 'openai/chat', + model: 'gpt-5', + stream: true, + rawBody: { model: 'gpt-5', stream: true }, + parsed: { requestedModel: 'gpt-5', isStream: true }, + metadata: { serviceTier: 'priority' }, + }); + }); + + it('creates protocol response and stream envelopes without losing metadata', () => { + const response = createProtocolResponseEnvelope({ + protocol: 'openai/responses', + model: 'gpt-5', + final: { + id: 'resp_1', + model: 'gpt-5', + created: 123, + content: 'done', + reasoningContent: 'think', + finishReason: 'stop', + toolCalls: [], + }, + metadata: { citations: ['https://example.com'] }, + }); + const stream = createProtocolStreamEnvelope({ + protocol: 'openai/responses', + model: 'gpt-5', + event: { + contentDelta: 'done', + finishReason: 'stop', + }, + metadata: { citations: ['https://example.com'] }, + }); + + expect(response.metadata).toEqual({ citations: ['https://example.com'] }); + expect(stream.metadata).toEqual({ citations: ['https://example.com'] }); + }); +}); diff --git a/src/server/transformers/shared/protocolModel.ts b/src/server/transformers/shared/protocolModel.ts new file mode 100644 index 00000000..4a28964a --- /dev/null +++ b/src/server/transformers/shared/protocolModel.ts @@ -0,0 +1,70 @@ +import type { + NormalizedFinalResponse, + NormalizedStreamEvent, +} from './normalized.js'; + +export type TransformerProtocol = + | 'openai/chat' + | 'anthropic/messages' + | 'openai/responses' + | 'gemini/generate-content'; + +export type ProtocolRequestEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TParsed = unknown, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + stream: boolean; + rawBody: unknown; + parsed: TParsed; + metadata?: TMetadata; +}; + +export type ProtocolResponseEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TFinal extends NormalizedFinalResponse = NormalizedFinalResponse, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + final: TFinal; + usage?: unknown; + metadata?: TMetadata; +}; + +export type ProtocolStreamEnvelope< + TProtocol extends TransformerProtocol = TransformerProtocol, + TEvent extends NormalizedStreamEvent = NormalizedStreamEvent, + TMetadata = unknown, +> = { + protocol: TProtocol; + model: string; + event: TEvent; + metadata?: TMetadata; +}; + +export function createProtocolRequestEnvelope< + TProtocol extends TransformerProtocol, + TParsed, + TMetadata = unknown, +>(envelope: ProtocolRequestEnvelope): ProtocolRequestEnvelope { + return envelope; +} + +export function createProtocolResponseEnvelope< + TProtocol extends TransformerProtocol, + TFinal extends NormalizedFinalResponse, + TMetadata = unknown, +>(envelope: ProtocolResponseEnvelope): ProtocolResponseEnvelope { + return envelope; +} + +export function createProtocolStreamEnvelope< + TProtocol extends TransformerProtocol, + TEvent extends NormalizedStreamEvent, + TMetadata = unknown, +>(envelope: ProtocolStreamEnvelope): ProtocolStreamEnvelope { + return envelope; +} diff --git a/src/server/transformers/shared/thinkTagParser.ts b/src/server/transformers/shared/thinkTagParser.ts new file mode 100644 index 00000000..c2cc5ce8 --- /dev/null +++ b/src/server/transformers/shared/thinkTagParser.ts @@ -0,0 +1,107 @@ +export type ThinkTagParserState = { + mode: 'content' | 'reasoning'; + pending: string; +}; + +const OPEN_TAG = ''; +const CLOSE_TAG = ''; + +function trailingPrefixLength(value: string, prefix: string): number { + const maxLength = Math.min(value.length, prefix.length - 1); + for (let length = maxLength; length > 0; length -= 1) { + if (prefix.startsWith(value.slice(-length))) { + return length; + } + } + return 0; +} + +function consumeChunkAgainstTag( + source: string, + tag: string, + target: 'content' | 'reasoning', +): { emitted: string; rest: string; matched: boolean } { + const sourceLower = source.toLowerCase(); + const tagIndex = sourceLower.indexOf(tag); + if (tagIndex >= 0) { + return { + emitted: source.slice(0, tagIndex), + rest: source.slice(tagIndex + tag.length), + matched: true, + }; + } + + const pendingLength = trailingPrefixLength(sourceLower, tag); + return { + emitted: source.slice(0, source.length - pendingLength), + rest: source.slice(source.length - pendingLength), + matched: false, + }; +} + +export function createThinkTagParserState(): ThinkTagParserState { + return { + mode: 'content', + pending: '', + }; +} + +export function consumeThinkTaggedText( + state: ThinkTagParserState, + chunk: string, +): { content: string; reasoning: string } { + if (!chunk) { + return { content: '', reasoning: '' }; + } + + let content = ''; + let reasoning = ''; + let rest = `${state.pending}${chunk}`; + state.pending = ''; + + while (rest.length > 0) { + const currentTag = state.mode === 'content' ? OPEN_TAG : CLOSE_TAG; + const consumed = consumeChunkAgainstTag(rest, currentTag, state.mode); + if (state.mode === 'content') { + content += consumed.emitted; + } else { + reasoning += consumed.emitted; + } + + if (!consumed.matched) { + state.pending = consumed.rest; + break; + } + + rest = consumed.rest; + state.mode = state.mode === 'content' ? 'reasoning' : 'content'; + } + + return { content, reasoning }; +} + +export function flushThinkTaggedText(state: ThinkTagParserState): { content: string; reasoning: string } { + if (!state.pending) { + return { content: '', reasoning: '' }; + } + + const remainder = state.pending; + state.pending = ''; + return state.mode === 'content' + ? { content: remainder, reasoning: '' } + : { content: '', reasoning: remainder }; +} + +export function extractInlineThinkTags(text: string): { content: string; reasoning: string } { + if (!text) { + return { content: '', reasoning: '' }; + } + + const state = createThinkTagParserState(); + const consumed = consumeThinkTaggedText(state, text); + const flushed = flushThinkTaggedText(state); + return { + content: `${consumed.content}${flushed.content}`, + reasoning: `${consumed.reasoning}${flushed.reasoning}`, + }; +} diff --git a/src/web/App.topbar-tooltips.test.ts b/src/web/App.topbar-tooltips.test.ts new file mode 100644 index 00000000..4e406f8f --- /dev/null +++ b/src/web/App.topbar-tooltips.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('App topbar tooltips', () => { + it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + const topbarStart = source.indexOf('
'); + const topbarEnd = source.indexOf(''); + + expect(topbarStart).toBeGreaterThanOrEqual(0); + expect(topbarEnd).toBeGreaterThan(topbarStart); + + const topbarSection = source.slice(topbarStart, topbarEnd); + expect(topbarSection).not.toContain('data-tooltip='); + expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}"); + }); +}); diff --git a/src/web/App.tsx b/src/web/App.tsx index 751186c3..d7b723f4 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom' import { ToastProvider, useToast } from './components/Toast.js'; import SearchModal from './components/SearchModal.js'; import NotificationPanel from './components/NotificationPanel.js'; +import TooltipLayer from './components/TooltipLayer.js'; import { api } from './api.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { @@ -558,20 +559,19 @@ function AppShell() {
-
- } > - {activeSegment === 'session' ? ( - <> -
- - -
+ {activeSegment === 'session' ? ( + <> +
+ + +
- {addMode === 'token' ? ( -
-
-
-
当前分段仅创建 Session 连接
-
推荐 使用系统访问令牌(Access Token);浏览器 Cookie 仅用于兼容场景。
-
以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」
-
- 获取 Cookie: F12 → Application → Cookie -
- + {addMode === 'token' ? ( +
+
+
+
当前分段仅创建 Session 连接
+
推荐 使用系统访问令牌(Access Token);浏览器 Cookie 仅用于兼容场景。
+
以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」
+
+ 获取 Cookie: F12 → Application → Cookie +
+
- { - const nextSiteId = Number.parseInt(nextValue, 10) || 0; - setTokenForm((f) => ({ ...f, siteId: nextSiteId })); - setVerifyResult(null); - }} - options={[ - { value: '0', label: '选择站点' }, - ...sites.map((s: any) => ({ - value: String(s.id), - label: `${s.name} (${s.platform})`, - })), - ]} - placeholder="选择站点" - /> +
+ { + const nextSiteId = Number.parseInt(nextValue, 10) || 0; + setTokenForm((f) => ({ ...f, siteId: nextSiteId })); + setVerifyResult(null); + }} + options={[ + { value: '0', label: '选择站点' }, + ...sites.map((s: any) => ({ + value: String(s.id), + label: `${s.name} (${s.platform})`, + })), + ]} + placeholder="选择站点" + /> + setTokenForm((f) => ({ ...f, username: e.target.value }))} + style={inputStyle} + /> +