From c0c993f448149dace28248a99b99476f5f5614aa Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 00:47:22 +0800 Subject: [PATCH 1/8] fix: align explicit group route picker and details (#158) * fix: align explicit group route picker and details * fix: move responses stream closeout into transformer facade --- .../api/tokens.route-update-rebuild.test.ts | 69 +++ src/server/routes/api/tokens.ts | 25 +- src/server/routes/proxy/responses.ts | 120 +++-- .../openai/responses/proxyStream.test.ts | 72 +++ .../openai/responses/proxyStream.ts | 46 ++ src/web/pages/TokenRoutes.tsx | 11 + .../pages/token-routes/ManualRoutePanel.tsx | 456 ++++++++++++++++-- src/web/pages/token-routes/RouteCard.tsx | 87 ++-- src/web/pages/token-routes/RouteFilterBar.tsx | 2 +- .../pages/token-routes/SortableChannelRow.tsx | 92 ++-- src/web/pages/token-routes/types.ts | 2 + .../pages/tokenRoutes.group-collapse.test.tsx | 282 +++++++++++ 12 files changed, 1065 insertions(+), 199 deletions(-) create mode 100644 src/server/transformers/openai/responses/proxyStream.test.ts diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index d3479be..b13db3b 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -244,6 +244,75 @@ describe('PUT /api/routes/:id route rebuild', () => { ])); }); + it('fills missing sourceModel from source exact routes when loading explicit-group channels', async () => { + const sourceA = await seedAccountWithToken('deepseek-chat'); + const sourceB = await seedAccountWithToken('deepseek-reasoner'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-chat', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-reasoner', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: null, + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'deepseekv1', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }, + }); + + expect(createResponse.statusCode).toBe(200); + const createdRouteId = (createResponse.json() as { id: number }).id; + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'deepseek-chat', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'deepseek-reasoner', + }), + ])); + }); + it('rejects invalid explicit-group payloads', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 298f531..6a5ff5f 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -534,7 +534,7 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise isExplicitGroupRoute(route)) .flatMap((route) => route.sourceRouteIds))); - const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0 + const explicitSourceRoutes = explicitSourceRouteIds.length > 0 ? (await db.select({ id: schema.tokenRoutes.id, modelPattern: schema.tokenRoutes.modelPattern, @@ -543,9 +543,10 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) - .map((route) => route.id) : []; + const enabledExplicitSourceRouteIds = explicitSourceRoutes + .filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) + .map((route) => route.id); const actualRouteIds = Array.from(new Set([ ...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id), ...enabledExplicitSourceRouteIds, @@ -554,6 +555,14 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise [route.id, []])); } + const actualRouteById = new Map(); + for (const route of routes.filter((item) => !isExplicitGroupRoute(item))) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + for (const route of explicitSourceRoutes) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + const channelRows = await db.select().from(schema.routeChannels) .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -565,13 +574,19 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise { + for (const line of lines) reply.raw.write(line); + }; + const streamSession = openAiResponsesTransformer.proxyStream.createSession({ + modelName, + successfulUpstreamPath, + strictTerminalEvents: Object.entries(request.headers as Record) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'), + getUsage: () => parsedUsage, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); if (!upstreamContentType.includes('text/event-stream')) { const rawText = await upstream.text(); @@ -447,7 +475,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { upstreamData = unwrapGeminiCliPayload(upstreamData); } - const parsedUsage = parseProxyUsage(upstreamData); + parsedUsage = parseProxyUsage(upstreamData); const latency = Date.now() - startTime; const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { @@ -483,17 +511,31 @@ export async function responsesProxyRoute(app: FastifyInstance) { return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); } - const normalized = openAiResponsesTransformer.transformFinalResponse( - upstreamData, - modelName, - rawText, - ); - const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ - upstreamPayload: upstreamData, - normalized, - usage: parsedUsage, - serializationMode: 'response', - }); + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -526,32 +568,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { clientContext, logDownstreamApiKeyId ? downstreamApiKeyId : null, ); - - const streamPayload = isRecord(downstreamData) ? downstreamData : { - id: `resp_${Date.now()}`, - object: 'response', - status: 'completed', - model: modelName, - output: [], - output_text: '', - usage: { - input_tokens: parsedUsage.promptTokens, - output_tokens: parsedUsage.completionTokens, - total_tokens: parsedUsage.totalTokens, - }, - }; - const createdPayload = { - ...streamPayload, - status: 'in_progress', - output: [], - output_text: '', - }; - - startSseResponse(); - reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`); - reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`); - reply.raw.write('data: [DONE]\n\n'); - reply.raw.end(); return; } @@ -561,14 +577,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader ? createGeminiCliStreamReader(upstreamReader) : upstreamReader; - let parsedUsage: UsageSummary = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; let rawText = ''; const decoder = new TextDecoder(); const reader = baseReader @@ -588,26 +596,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { }, } : baseReader; - const writeLines = (lines: string[]) => { - for (const line of lines) reply.raw.write(line); - }; - const streamSession = openAiResponsesTransformer.proxyStream.createSession({ - modelName, - successfulUpstreamPath, - strictTerminalEvents: Object.entries(request.headers as Record) - .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' - && String(rawValue).trim() === '1'), - getUsage: () => parsedUsage, - onParsedPayload: (payload) => { - if (payload && typeof payload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); - } - }, - writeLines, - writeRaw: (chunk) => { - reply.raw.write(chunk); - }, - }); const streamResult = await streamSession.run(reader, reply.raw); rawText += decoder.decode(); diff --git a/src/server/transformers/openai/responses/proxyStream.test.ts b/src/server/transformers/openai/responses/proxyStream.test.ts new file mode 100644 index 0000000..fa59cf2 --- /dev/null +++ b/src/server/transformers/openai/responses/proxyStream.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { createResponsesProxyStreamSession } from './proxyStream.js'; + +describe('createResponsesProxyStreamSession', () => { + it('serializes non-SSE fallback payloads into canonical responses SSE closeout events', () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const payload = { + id: 'resp_fallback_1', + object: 'response', + status: 'completed', + model: 'gpt-5.2', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_fallback_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5.2', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = session.consumeUpstreamFinalPayload( + payload, + JSON.stringify(payload), + { + end() { + ended = true; + }, + }, + ); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + + const output = lines.join(''); + expect(output).toContain('event: response.created'); + expect(output).toContain('event: response.completed'); + expect(output).toContain('"type":"response.completed"'); + expect(output).toContain('"output_text":"hello from responses upstream"'); + expect(output).toContain('data: [DONE]'); + }); +}); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index c7e73a3..d27191d 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -1,6 +1,7 @@ import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; import { type ParsedSseEvent } from '../../shared/normalized.js'; import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js'; +import { openAiResponsesOutbound } from './outbound.js'; import { openAiResponsesStream } from './stream.js'; type StreamReader = { @@ -148,6 +149,51 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes }; return { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ResponsesProxyStreamResult { + if (payload && typeof payload === 'object') { + input.onParsedPayload?.(payload); + } + + const payloadType = (isRecord(payload) && typeof payload.type === 'string') + ? payload.type + : ''; + if (payloadType === 'error' || payloadType === 'response.failed') { + fail(payload); + response?.end(); + return terminalResult; + } + + const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText); + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + + const streamPayload = openAiResponsesOutbound.serializeFinal({ + upstreamPayload: payload, + normalized: normalizedFinal, + usage: input.getUsage(), + serializationMode: 'response', + }); + const createdPayload = { + ...streamPayload, + status: 'in_progress', + output: [], + output_text: '', + }; + + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + response?.end(); + return terminalResult; + }, async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 7e6df4f..c009335 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -563,6 +563,15 @@ export default function TokenRoutes() { }) as [string, number][]; }, [listVisibleRoutes, routeEndpointTypesByRouteId]); + const sourceEndpointTypesByRouteId = useMemo(() => { + const next: Record = {}; + for (const route of exactSourceRouteOptions) { + next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set()) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } + return next; + }, [exactSourceRouteOptions, routeEndpointTypesByRouteId]); + const routeBrandIconCandidates = useMemo(() => { const byIcon = new Map(); @@ -600,6 +609,7 @@ export default function TokenRoutes() { brand: routeBrandById.get(route.id) || null, modelPattern: route.modelPattern, channelCount: route.channelCount, + sourceRouteCount: Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0, })) .sort((a, b) => { if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); @@ -1152,6 +1162,7 @@ export default function TokenRoutes() { routeIconSelectOptions={routeIconSelectOptions} previewModelSamples={previewModelSamples} exactSourceRouteOptions={exactSourceRouteOptions} + sourceEndpointTypesByRouteId={sourceEndpointTypesByRouteId} onSave={handleAddRoute} onCancel={handleCancelEditRoute} /> diff --git a/src/web/pages/token-routes/ManualRoutePanel.tsx b/src/web/pages/token-routes/ManualRoutePanel.tsx index 2a11f92..1feec1a 100644 --- a/src/web/pages/token-routes/ManualRoutePanel.tsx +++ b/src/web/pages/token-routes/ManualRoutePanel.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'; +import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js'; import CenteredModal from '../../components/CenteredModal.js'; import ModernSelect from '../../components/ModernSelect.js'; import { tr } from '../../i18n.js'; @@ -11,6 +12,9 @@ import { matchesModelPattern, normalizeRouteDisplayIconValue, normalizeRouteMode, + resolveEndpointTypeIconModel, + resolveRouteBrand, + siteAvatarLetters, } from './utils.js'; type RouteEditorForm = { @@ -32,6 +36,7 @@ type ManualRoutePanelProps = { routeIconSelectOptions: RouteIconOption[]; previewModelSamples: string[]; exactSourceRouteOptions: RouteSummaryRow[]; + sourceEndpointTypesByRouteId: Record; onSave: () => void; onCancel: () => void; }; @@ -71,6 +76,41 @@ function SearchField({ ); } +function FilterChip({ + active, + label, + count, + icon, + onClick, +}: { + active: boolean; + label: string; + count?: number; + icon?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + export default function ManualRoutePanel({ show, editingRouteId, @@ -81,18 +121,25 @@ export default function ManualRoutePanel({ routeIconSelectOptions, previewModelSamples, exactSourceRouteOptions, + sourceEndpointTypesByRouteId, onSave, onCancel, }: ManualRoutePanelProps) { const [showSourcePicker, setShowSourcePicker] = useState(false); const [sourceSearch, setSourceSearch] = useState(''); const [sourcePickerSelection, setSourcePickerSelection] = useState([]); + const [activeSourceBrand, setActiveSourceBrand] = useState(null); + const [activeSourceSite, setActiveSourceSite] = useState(null); + const [activeSourceEndpointType, setActiveSourceEndpointType] = useState(null); useEffect(() => { if (!show) { setShowSourcePicker(false); setSourceSearch(''); setSourcePickerSelection([]); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); } }, [show]); @@ -119,14 +166,136 @@ export default function ManualRoutePanel({ return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern)); }, [form.modelPattern, modelPatternError, previewModelSamples]); + const sourceRouteBrandById = useMemo(() => { + const next = new Map(); + for (const route of exactSourceRouteOptions) { + next.set(route.id, resolveRouteBrand(route)); + } + return next; + }, [exactSourceRouteOptions]); + + const sourceBrandList = useMemo(() => { + const grouped = new Map(); + let otherCount = 0; + + for (const route of exactSourceRouteOptions) { + const brand = sourceRouteBrandById.get(route.id) || null; + if (!brand) { + otherCount += 1; + continue; + } + const existing = grouped.get(brand.name); + if (existing) { + existing.count += 1; + } else { + grouped.set(brand.name, { count: 1, brand }); + } + } + + return { + list: [...grouped.entries()].sort((a, b) => { + if (a[1].count === b[1].count) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1].count - a[1].count; + }) as [string, { count: number; brand: BrandInfo }][], + otherCount, + }; + }, [exactSourceRouteOptions, sourceRouteBrandById]); + + const sourceSiteList = useMemo(() => { + const grouped = new Map(); + + for (const route of exactSourceRouteOptions) { + const seenSites = new Set(); + for (const siteName of route.siteNames || []) { + const normalizedSite = String(siteName || '').trim(); + if (!normalizedSite || seenSites.has(normalizedSite)) continue; + seenSites.add(normalizedSite); + grouped.set(normalizedSite, (grouped.get(normalizedSite) || 0) + 1); + } + } + + return [...grouped.entries()].sort((a, b) => { + if (a[1] === b[1]) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1] - a[1]; + }) as [string, number][]; + }, [exactSourceRouteOptions]); + + const sourceEndpointTypeList = useMemo(() => { + const grouped = new Map(); + + for (const route of exactSourceRouteOptions) { + const endpointTypes = sourceEndpointTypesByRouteId[route.id] || []; + for (const endpointType of endpointTypes) { + const normalizedType = String(endpointType || '').trim(); + if (!normalizedType) continue; + grouped.set(normalizedType, (grouped.get(normalizedType) || 0) + 1); + } + } + + return [...grouped.entries()].sort((a, b) => { + if (a[1] === b[1]) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1] - a[1]; + }) as [string, number][]; + }, [exactSourceRouteOptions, sourceEndpointTypesByRouteId]); + const filteredSourceRoutes = useMemo(() => { + let list = [...exactSourceRouteOptions]; + + if (activeSourceBrand) { + if (activeSourceBrand === '__other__') { + list = list.filter((route) => !(sourceRouteBrandById.get(route.id) || null)); + } else { + list = list.filter((route) => (sourceRouteBrandById.get(route.id)?.name || '') === activeSourceBrand); + } + } + + if (activeSourceSite) { + list = list.filter((route) => (route.siteNames || []).includes(activeSourceSite)); + } + + if (activeSourceEndpointType) { + list = list.filter((route) => (sourceEndpointTypesByRouteId[route.id] || []).includes(activeSourceEndpointType)); + } + const normalizedSearch = sourceSearch.trim().toLowerCase(); - if (!normalizedSearch) return exactSourceRouteOptions; - return exactSourceRouteOptions.filter((route) => { - const label = renderRouteOptionLabel(route).toLowerCase(); - return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch); + if (normalizedSearch) { + list = list.filter((route) => { + const label = renderRouteOptionLabel(route).toLowerCase(); + const modelPattern = route.modelPattern.toLowerCase(); + const brandName = (sourceRouteBrandById.get(route.id)?.name || '').toLowerCase(); + const siteText = (route.siteNames || []).join(' ').toLowerCase(); + const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).join(' ').toLowerCase(); + return ( + label.includes(normalizedSearch) + || modelPattern.includes(normalizedSearch) + || brandName.includes(normalizedSearch) + || siteText.includes(normalizedSearch) + || endpointTypes.includes(normalizedSearch) + ); + }); + } + + return list.sort((a, b) => { + if (a.channelCount === b.channelCount) { + return renderRouteOptionLabel(a).localeCompare(renderRouteOptionLabel(b), undefined, { sensitivity: 'base' }); + } + return b.channelCount - a.channelCount; }); - }, [exactSourceRouteOptions, sourceSearch]); + }, [ + activeSourceBrand, + activeSourceEndpointType, + activeSourceSite, + exactSourceRouteOptions, + sourceEndpointTypesByRouteId, + sourceRouteBrandById, + sourceSearch, + ]); const selectedSourceRoutes = useMemo(() => { const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route])); @@ -135,18 +304,29 @@ export default function ManualRoutePanel({ .filter((route): route is RouteSummaryRow => !!route); }, [exactSourceRouteOptions, form.sourceRouteIds]); + const sourcePickerSelectionSet = useMemo( + () => new Set(sourcePickerSelection), + [sourcePickerSelection], + ); + const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon); const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon); const openSourcePicker = () => { setSourcePickerSelection([...form.sourceRouteIds]); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); setShowSourcePicker(true); }; const closeSourcePicker = () => { setShowSourcePicker(false); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); }; const confirmSourcePicker = () => { @@ -156,6 +336,9 @@ export default function ManualRoutePanel({ })); setShowSourcePicker(false); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); }; const footer = ( @@ -584,7 +767,101 @@ export default function ManualRoutePanel({ placeholder={tr('搜索来源模型')} /> -
+
+
+ + ✦} + onClick={() => setActiveSourceBrand(null)} + /> + {sourceBrandList.list.map(([brandName, { count, brand }]) => ( + } + onClick={() => setActiveSourceBrand(activeSourceBrand === brandName ? null : brandName)} + /> + ))} + {sourceBrandList.otherCount > 0 ? ( + ?} + onClick={() => setActiveSourceBrand(activeSourceBrand === '__other__' ? null : '__other__')} + /> + ) : null} + + + {sourceSiteList.length > 0 ? ( + + ⚡} + onClick={() => setActiveSourceSite(null)} + /> + {sourceSiteList.map(([siteName, count]) => ( + + {siteAvatarLetters(siteName)} + + )} + onClick={() => setActiveSourceSite(activeSourceSite === siteName ? null : siteName)} + /> + ))} + + ) : null} + + + ⚙} + onClick={() => setActiveSourceEndpointType(null)} + /> + {sourceEndpointTypeList.map(([endpointType, count]) => { + const iconModel = resolveEndpointTypeIconModel(endpointType); + return ( + : ⚙} + onClick={() => setActiveSourceEndpointType(activeSourceEndpointType === endpointType ? null : endpointType)} + /> + ); + })} + {sourceEndpointTypeList.length === 0 ? ( + {tr('暂无接口能力数据')} + ) : null} + +
+
+ +
{filteredSourceRoutes.length === 0 ? (
{exactSourceRouteOptions.length === 0 @@ -592,50 +869,131 @@ export default function ManualRoutePanel({ : tr('没有匹配的来源模型。')}
) : ( - filteredSourceRoutes.map((route) => { - const selected = sourcePickerSelection.includes(route.id); - const label = renderRouteOptionLabel(route); - return ( -
- - {selected ? tr('已选中') : tr('可选择')} - - - ); - }) + + ); + })} +
)} diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index e75646d..8804b2e 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -116,6 +116,7 @@ function RouteCardInner({ const routeIcon = resolveRouteIcon(route); const exactRoute = isRouteExactModel(route); const explicitGroupRoute = isExplicitGroupRoute(route); + const explicitGroupSourceCount = Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0; const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; const channelManagementDisabled = explicitGroupRoute; const title = resolveRouteTitle(route); @@ -206,9 +207,20 @@ function RouteCardInner({ )} - - {route.channelCount} {tr('通道')} - + {explicitGroupRoute && explicitGroupSourceCount > 0 ? ( + <> + + {explicitGroupSourceCount} {tr('来源模型')} + + + {route.channelCount} {tr('通道')} + + + ) : ( + + {route.channelCount} {tr('通道')} + + )} {readOnlyRoute ? ( @@ -269,9 +281,20 @@ function RouteCardInner({ {route.enabled ? tr('启用') : tr('禁用')} )} - - {route.channelCount} {tr('通道')} - + {explicitGroupRoute && explicitGroupSourceCount > 0 ? ( + <> + + {explicitGroupSourceCount} {tr('来源模型')} + + + {route.channelCount} {tr('通道')} + + + ) : ( + + {route.channelCount} {tr('通道')} + + )} {readOnlyRoute && ( {tr('0 通道')} @@ -445,35 +468,29 @@ function RouteCardInner({ {explicitGroupRoute ? ( -
- {group.channels.map((channel) => ( -
- - {channel.account?.username || `account-${channel.accountId}`} - - - {channel.site?.name || 'unknown'} - - {channel.sourceModel ? ( - - {channel.sourceModel} - - ) : null} -
- ))} -
+ c.id)} strategy={verticalListSortingStrategy}> + {group.channels.map((channel) => { + const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; + const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; + return ( + onSaveToken(route.id, channel.id, channel.accountId)} + onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + /> + ); + })} + ) : ( c.id)} strategy={verticalListSortingStrategy}> {group.channels.map((channel) => { diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx index bb86f6d..edc3824 100644 --- a/src/web/pages/token-routes/RouteFilterBar.tsx +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -203,7 +203,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { key={groupRoute.id} active={activeGroupFilter === groupRoute.id} label={groupRoute.title} - count={groupRoute.channelCount} + count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount} icon={ groupRoute.icon.kind === 'brand' ? ( diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx index 1b21069..6d4b54b 100644 --- a/src/web/pages/token-routes/SortableChannelRow.tsx +++ b/src/web/pages/token-routes/SortableChannelRow.tsx @@ -16,6 +16,7 @@ export function SortableChannelRow({ isExactRoute, loadingDecision, isSavingPriority, + readOnly = false, tokenOptions, activeTokenId, isUpdatingToken, @@ -33,7 +34,7 @@ export function SortableChannelRow({ isDragging, } = useSortable({ id: channel.id, - disabled: isSavingPriority, + disabled: isSavingPriority || readOnly, }); const rowStyle: CSSProperties = { @@ -42,7 +43,7 @@ export function SortableChannelRow({ opacity: isDragging ? 0.72 : 1, zIndex: isDragging ? 10 : 1, display: 'grid', - gridTemplateColumns: 'minmax(0, 1fr) auto auto', + gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto', alignItems: 'center', gap: 8, padding: '8px 12px', @@ -63,7 +64,7 @@ export function SortableChannelRow({ ref={setActivatorNodeRef} {...attributes} {...listeners} - disabled={isSavingPriority} + disabled={isSavingPriority || readOnly} className="btn btn-ghost" style={{ width: 22, @@ -72,9 +73,10 @@ export function SortableChannelRow({ padding: 0, border: '1px solid var(--color-border-light)', color: 'var(--color-text-muted)', - cursor: isSavingPriority ? 'not-allowed' : 'grab', + cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab', + opacity: readOnly ? 0.65 : 1, }} - data-tooltip="拖拽调整优先级" + data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'} aria-label="拖拽调整优先级" > @@ -197,46 +199,50 @@ export function SortableChannelRow({ -
-
- onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)} - disabled={isUpdatingToken} - options={[ - { - value: '0', - label: '跟随账号默认', - description: tokenBinding.followOptionDescription, - }, - ...tokenOptions.map((token) => ({ - value: String(token.id), - label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }), - description: buildFixedTokenOptionDescription(token), - })), - ]} - placeholder="选择令牌绑定方式" - /> -
- {tokenBinding.helperText} + {!readOnly ? ( + <> +
+
+ onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)} + disabled={isUpdatingToken} + options={[ + { + value: '0', + label: '跟随账号默认', + description: tokenBinding.followOptionDescription, + }, + ...tokenOptions.map((token) => ({ + value: String(token.id), + label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }), + description: buildFixedTokenOptionDescription(token), + })), + ]} + placeholder="选择令牌绑定方式" + /> +
+ {tokenBinding.helperText} +
+
+
-
- -
- + + + ) : null}
); } diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 2745e84..4a79410 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -150,6 +150,7 @@ export type SortableChannelRowProps = { isExactRoute: boolean; loadingDecision: boolean; isSavingPriority: boolean; + readOnly?: boolean; tokenOptions: RouteTokenOption[]; activeTokenId: number; isUpdatingToken: boolean; @@ -165,4 +166,5 @@ export type GroupRouteItem = { brand: BrandInfo | null; modelPattern: string; channelCount: number; + sourceRouteCount: number; }; diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 3c99b35..04df77e 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -48,6 +48,23 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta )); } +function findButtonByClassAndText(root: ReactTestInstance, className: string, text: string): ReactTestInstance { + return root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && String(node.props.className || '').includes(className) + && collectText(node).includes(text) + )); +} + +function findButtonByAriaLabel(root: ReactTestInstance, label: string): ReactTestInstance { + return root.find((node) => ( + node.type === 'button' + && typeof node.props['aria-label'] === 'string' + && node.props['aria-label'] === label + )); +} + function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance { return root.find((node) => ( node.type === 'input' @@ -865,6 +882,195 @@ describe('TokenRoutes grouped source models', () => { } }); + it('renders the source picker like the route page with brand, site, ability filters and a card grid', async () => { + getBrandMock.mockImplementation((modelName: string) => { + const model = String(modelName); + if (model.includes('gpt')) { + return { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg,#111,#555)' }; + } + if (model.includes('claude')) { + return { name: 'Anthropic', icon: 'anthropic', color: 'linear-gradient(135deg,#d97706,#f59e0b)' }; + } + if (model.includes('gemini')) { + return { name: 'Gemini', icon: 'gemini', color: 'linear-gradient(135deg,#2563eb,#7c3aed)' }; + } + return null; + }); + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'gpt-5.4', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 3, enabledChannelCount: 3, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 2, enabledChannelCount: 2, siteNames: ['Alpha'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 13, modelPattern: 'gemini-2.5-pro', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getModelTokenCandidates.mockResolvedValue({ + models: {}, + endpointTypesByModel: { + 'gpt-5.4': ['openai'], + 'claude-sonnet-4-5': ['anthropic'], + 'gemini-2.5-pro': ['gemini'], + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, '新建群组').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, '选择来源模型').props.onClick(); + }); + await flushMicrotasks(); + + const pickerGrid = root.root.find((node) => ( + node.type === 'div' + && String(node.props.className || '').includes('source-route-picker-grid') + )); + expect(String(pickerGrid.props.style?.display || '')).toBe('grid'); + expect(String(pickerGrid.props.style?.gridTemplateColumns || '')).toContain('repeat('); + + expect(findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI')).toBeTruthy(); + expect(findButtonByClassAndText(root.root, 'filter-chip', 'Wong')).toBeTruthy(); + expect(findButtonByClassAndText(root.root, 'filter-chip', 'gemini')).toBeTruthy(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('gpt-5.4'); + expect(collectText(pickerGrid)).toContain('gemini-2.5-pro'); + expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5'); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('OpenAI'); + expect(collectText(pickerGrid)).toContain('gpt-5.4'); + expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5'); + expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro'); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'anthropic').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('Anthropic'); + expect(collectText(pickerGrid)).toContain('claude-sonnet-4-5'); + expect(collectText(pickerGrid)).not.toContain('gpt-5.4'); + expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro'); + } finally { + root?.unmount(); + } + }); + + it('shows explicit-group source counts instead of aggregated channel counts in the route list and filter chips', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'deepseek-chat', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 40, enabledChannelCount: 40, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'deepseek-reasoner', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 55, enabledChannelCount: 55, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'deepseekv1', displayName: 'deepseekv1', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11, 12], + channelCount: 95, enabledChannelCount: 95, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([ + { + id: 101, accountId: 1, tokenId: 1, sourceModel: 'deepseek-chat', + priority: 0, weight: 1, enabled: true, manualOverride: false, + successCount: 0, failCount: 0, + account: { username: 'user-a' }, site: { name: 'Wong' }, token: null, + }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const filterSummary = root.root.find((node) => + node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'), + ); + await act(async () => { + filterSummary.props.onClick(); + }); + await flushMicrotasks(); + + const groupChip = findButtonByClassAndText(root.root, 'filter-chip', 'deepseekv1'); + expect(collectText(groupChip)).toContain('2'); + expect(collectText(groupChip)).not.toContain('95'); + + const routeCard = root.root.find((node) => + node.type === 'div' + && String(node.props.className || '').includes('route-card-collapsed') + && collectText(node).includes('deepseekv1'), + ); + expect(collectText(routeCard).replace(/\s+/g, '')).toContain('2来源模型'); + } finally { + root?.unmount(); + } + }); + it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => { apiMock.getRoutesSummary.mockResolvedValue([ { @@ -1169,4 +1375,80 @@ describe('TokenRoutes grouped source models', () => { root?.unmount(); } }); + + it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'claude-haiku-proxy', displayName: 'claude-haiku-proxy', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11], + channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([ + { + id: 101, + routeId: 11, + accountId: 301, + tokenId: 401, + sourceModel: 'claude-haiku-4-5-20251001', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 6, + failCount: 1, + account: { username: 'linuxdo_131936' }, + site: { name: 'Wong' }, + token: { id: 401, name: 'token-a', accountId: 301, enabled: true, isDefault: true }, + }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const expandBtn = root.root.find((node) => + node.type === 'div' + && String(node.props.className || '').includes('route-card-collapsed') + && collectText(node).includes('claude-haiku-proxy'), + ); + await act(async () => { + expandBtn.props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick(); + }); + await flushMicrotasks(); + + const expandedText = collectText(root.root); + expect(expandedText).toContain('P0'); + expect(expandedText).toContain('当前生效:token-a'); + expect(expandedText).toContain('选中概率'); + expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true); + expect(expandedText).not.toContain('保存'); + expect(expandedText).not.toContain('移除'); + } finally { + root?.unmount(); + } + }); }); From 24483c28ebe93623f4b106a5f8985aff3eba07f7 Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 16:07:56 +0800 Subject: [PATCH 2/8] Refactor proxy-core surfaces and transformer boundaries (#163) * refactor: add canonical request contracts * test: harden transformer boundary contracts * refactor: adapt protocol transformers to canonical contract * refactor: add cli profile registry * refactor: extract provider profiles * refactor: add conductor and provider executors * refactor: add models surface * refactor: migrate responses surface * refactor: migrate chat surface * refactor: migrate gemini surface * refactor: migrate file surfaces * test: align runtime executor undici mock * fix: preserve compact SSE closeout payloads * feat: support all-api-hub v2 backup imports * fix: aggregate codex oauth SSE final payloads * fix: harden responses SSE failure handling --- .../cliProfiles/claudeCodeProfile.ts | 53 + .../proxy-core/cliProfiles/codexProfile.ts | 91 ++ .../cliProfiles/geminiCliProfile.ts | 37 + .../proxy-core/cliProfiles/genericProfile.ts | 17 + .../proxy-core/cliProfiles/registry.test.ts | 100 ++ src/server/proxy-core/cliProfiles/registry.ts | 38 + src/server/proxy-core/cliProfiles/types.ts | 32 + .../conductor/DefaultProxyConductor.test.ts | 241 ++++ .../conductor/DefaultProxyConductor.ts | 127 ++ .../proxy-core/conductor/retryPolicy.ts | 21 + .../proxy-core/conductor/streamTermination.ts | 10 + src/server/proxy-core/conductor/types.ts | 80 ++ src/server/proxy-core/conductor/usageHooks.ts | 20 + .../executors/antigravityExecutor.ts | 212 ++++ .../proxy-core/executors/claudeExecutor.ts | 8 + .../proxy-core/executors/codexExecutor.ts | 8 + .../proxy-core/executors/geminiCliExecutor.ts | 60 + src/server/proxy-core/executors/types.ts | 75 ++ .../providers/antigravityProviderProfile.ts | 47 + .../providers/claudeProviderProfile.ts | 128 +++ .../providers/codexProviderProfile.ts | 131 +++ .../providers/geminiCliProviderProfile.ts | 113 ++ .../proxy-core/providers/registry.test.ts | 181 +++ src/server/proxy-core/providers/registry.ts | 17 + src/server/proxy-core/providers/types.ts | 52 + src/server/proxy-core/surfaces/chatSurface.ts | 1019 +++++++++++++++++ .../proxy-core/surfaces/filesSurface.ts | 159 +++ .../proxy-core/surfaces/geminiSurface.ts | 843 ++++++++++++++ .../proxy-core/surfaces/inputFilesSurface.ts | 114 ++ .../proxy-core/surfaces/modelsSurface.test.ts | 97 ++ .../proxy-core/surfaces/modelsSurface.ts | 70 ++ .../surfaces/openAiResponsesSurface.ts | 870 ++++++++++++++ src/server/routes/api/stats.ts | 3 +- .../proxy/architecture-boundaries.test.ts | 79 +- src/server/routes/proxy/chat.ts | 1009 +--------------- .../proxy/downstreamClientContext.test.ts | 23 +- .../routes/proxy/downstreamClientContext.ts | 117 +- src/server/routes/proxy/files.ts | 160 +-- src/server/routes/proxy/gemini.ts | 844 +------------- src/server/routes/proxy/inputFiles.ts | 118 +- src/server/routes/proxy/models.ts | 62 +- .../proxy/responses.codex-oauth.test.ts | 78 ++ .../proxy/responses.compact-upstream.test.ts | 109 ++ src/server/routes/proxy/responses.ts | 851 +------------- .../routes/proxy/responsesSseFinal.test.ts | 23 + src/server/routes/proxy/responsesSseFinal.ts | 52 +- .../routes/proxy/runtimeExecutor.test.ts | 10 +- src/server/routes/proxy/runtimeExecutor.ts | 344 +----- src/server/routes/proxy/upstreamEndpoint.ts | 111 +- src/server/services/backupService.test.ts | 286 ++++- src/server/services/backupService.ts | 421 ++++++- src/server/services/localTimeService.ts | 35 +- .../services/modelAnalysisService.test.ts | 31 + src/server/services/modelAnalysisService.ts | 4 +- .../anthropic/messages/index.test.ts | 70 ++ .../transformers/anthropic/messages/index.ts | 50 +- .../canonical/attachments.test.ts | 53 + .../transformers/canonical/attachments.ts | 57 + .../transformers/canonical/reasoning.test.ts | 30 + .../transformers/canonical/reasoning.ts | 98 ++ .../transformers/canonical/request.test.ts | 48 + src/server/transformers/canonical/request.ts | 509 ++++++++ src/server/transformers/canonical/tools.ts | 14 + src/server/transformers/canonical/types.ts | 108 ++ src/server/transformers/contracts.ts | 78 ++ .../final-hard-cut.architecture.test.ts | 37 +- .../gemini/generate-content/index.test.ts | 80 ++ .../gemini/generate-content/index.ts | 236 ++++ .../transformers/openai/chat/index.test.ts | 60 + src/server/transformers/openai/chat/index.ts | 43 + .../openai/responses/index.test.ts | 69 ++ .../transformers/openai/responses/index.ts | 61 + .../shared/chatEndpointStrategy.ts | 14 +- src/web/pages/ImportExport.test.tsx | 225 ++++ src/web/pages/ImportExport.tsx | 126 +- 75 files changed, 8333 insertions(+), 3574 deletions(-) create mode 100644 src/server/proxy-core/cliProfiles/claudeCodeProfile.ts create mode 100644 src/server/proxy-core/cliProfiles/codexProfile.ts create mode 100644 src/server/proxy-core/cliProfiles/geminiCliProfile.ts create mode 100644 src/server/proxy-core/cliProfiles/genericProfile.ts create mode 100644 src/server/proxy-core/cliProfiles/registry.test.ts create mode 100644 src/server/proxy-core/cliProfiles/registry.ts create mode 100644 src/server/proxy-core/cliProfiles/types.ts create mode 100644 src/server/proxy-core/conductor/DefaultProxyConductor.test.ts create mode 100644 src/server/proxy-core/conductor/DefaultProxyConductor.ts create mode 100644 src/server/proxy-core/conductor/retryPolicy.ts create mode 100644 src/server/proxy-core/conductor/streamTermination.ts create mode 100644 src/server/proxy-core/conductor/types.ts create mode 100644 src/server/proxy-core/conductor/usageHooks.ts create mode 100644 src/server/proxy-core/executors/antigravityExecutor.ts create mode 100644 src/server/proxy-core/executors/claudeExecutor.ts create mode 100644 src/server/proxy-core/executors/codexExecutor.ts create mode 100644 src/server/proxy-core/executors/geminiCliExecutor.ts create mode 100644 src/server/proxy-core/executors/types.ts create mode 100644 src/server/proxy-core/providers/antigravityProviderProfile.ts create mode 100644 src/server/proxy-core/providers/claudeProviderProfile.ts create mode 100644 src/server/proxy-core/providers/codexProviderProfile.ts create mode 100644 src/server/proxy-core/providers/geminiCliProviderProfile.ts create mode 100644 src/server/proxy-core/providers/registry.test.ts create mode 100644 src/server/proxy-core/providers/registry.ts create mode 100644 src/server/proxy-core/providers/types.ts create mode 100644 src/server/proxy-core/surfaces/chatSurface.ts create mode 100644 src/server/proxy-core/surfaces/filesSurface.ts create mode 100644 src/server/proxy-core/surfaces/geminiSurface.ts create mode 100644 src/server/proxy-core/surfaces/inputFilesSurface.ts create mode 100644 src/server/proxy-core/surfaces/modelsSurface.test.ts create mode 100644 src/server/proxy-core/surfaces/modelsSurface.ts create mode 100644 src/server/proxy-core/surfaces/openAiResponsesSurface.ts create mode 100644 src/server/routes/proxy/responsesSseFinal.test.ts create mode 100644 src/server/transformers/anthropic/messages/index.test.ts create mode 100644 src/server/transformers/canonical/attachments.test.ts create mode 100644 src/server/transformers/canonical/attachments.ts create mode 100644 src/server/transformers/canonical/reasoning.test.ts create mode 100644 src/server/transformers/canonical/reasoning.ts create mode 100644 src/server/transformers/canonical/request.test.ts create mode 100644 src/server/transformers/canonical/request.ts create mode 100644 src/server/transformers/canonical/tools.ts create mode 100644 src/server/transformers/canonical/types.ts create mode 100644 src/server/transformers/contracts.ts create mode 100644 src/web/pages/ImportExport.test.tsx diff --git a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts new file mode 100644 index 0000000..87b8c8a --- /dev/null +++ b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts @@ -0,0 +1,53 @@ +import type { CliProfileDefinition } from './types.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +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; + +function isClaudeSurface(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath === '/v1/messages' + || normalizedPath === '/anthropic/v1/messages' + || normalizedPath === '/v1/messages/count_tokens'; +} + +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 const claudeCodeCliProfile: CliProfileDefinition = { + id: 'claude_code', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: true, + supportsCountTokens: true, + echoesTurnState: false, + }, + detect(input) { + if (!isClaudeSurface(input.downstreamPath)) return null; + if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null; + + const userId = typeof input.body.metadata.user_id === 'string' + ? input.body.metadata.user_id.trim() + : ''; + const sessionId = userId ? extractClaudeCodeSessionId(userId) : null; + if (!sessionId) return null; + + return { + id: 'claude_code', + sessionId, + traceHint: sessionId, + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts new file mode 100644 index 0000000..36a8780 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -0,0 +1,91 @@ +import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; + +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; +} + +function hasHeaderPrefix(headers: Record | undefined, prefix: string): boolean { + if (!headers) return false; + const normalizedPrefix = prefix.trim().toLowerCase(); + return Object.entries(headers).some(([rawKey, rawValue]) => { + const key = rawKey.trim().toLowerCase(); + return key.startsWith(normalizedPrefix) && !!headerValueToString(rawValue); + }); +} + +function isCodexPath(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath.startsWith('/v1/responses') + || normalizedPath === '/v1/chat/completions' + || normalizedPath.startsWith('/v1/messages'); +} + +export function isCodexResponsesSurface(headers?: Record): boolean { + return isCodexRequest({ + downstreamPath: '/v1/responses', + headers, + }); +} + +export function getCodexSessionId(headers?: Record): string | null { + return getHeaderValue(headers, 'session_id') || getHeaderValue(headers, 'session-id'); +} + +export function isCodexRequest(input: DetectCliProfileInput): boolean { + if (!isCodexPath(input.downstreamPath)) return false; + const headers = input.headers; + if (!headers) return false; + + const originator = getHeaderValue(headers, 'originator'); + if (originator?.toLowerCase() === 'codex_cli_rs') return true; + if (getHeaderValue(headers, 'openai-beta')) return true; + if (hasHeaderPrefix(headers, 'x-stainless-')) return true; + if (getCodexSessionId(headers)) return true; + if (getHeaderValue(headers, 'x-codex-turn-state')) return true; + return false; +} + +export const codexCliProfile: CliProfileDefinition = { + id: 'codex', + capabilities: { + supportsResponsesCompact: true, + supportsResponsesWebsocketIncremental: true, + preservesContinuation: true, + supportsCountTokens: false, + echoesTurnState: true, + }, + detect(input) { + if (!isCodexRequest(input)) return null; + + const sessionId = getCodexSessionId(input.headers) || undefined; + return { + id: 'codex', + ...(sessionId ? { sessionId, traceHint: sessionId } : {}), + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/geminiCliProfile.ts b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts new file mode 100644 index 0000000..cf27c7c --- /dev/null +++ b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts @@ -0,0 +1,37 @@ +import type { CliProfileDefinition } from './types.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isGeminiCliPath(path: string): boolean { + const normalizedPath = path.trim().toLowerCase(); + return normalizedPath === '/v1internal:generatecontent' + || normalizedPath === '/v1internal:streamgeneratecontent' + || normalizedPath === '/v1internal:counttokens'; +} + +function hasGeminiCliBodyShape(body: unknown): boolean { + if (!isRecord(body)) return false; + return typeof body.model === 'string' + || Array.isArray(body.contents) + || (isRecord(body.request) && (Array.isArray(body.request.contents) || typeof body.request.model === 'string')); +} + +export const geminiCliProfile: CliProfileDefinition = { + id: 'gemini_cli', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: true, + echoesTurnState: false, + }, + detect(input) { + if (!isGeminiCliPath(input.downstreamPath)) return null; + if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null; + return { + id: 'gemini_cli', + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/genericProfile.ts b/src/server/proxy-core/cliProfiles/genericProfile.ts new file mode 100644 index 0000000..8bda115 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/genericProfile.ts @@ -0,0 +1,17 @@ +import type { CliProfileDefinition } from './types.js'; + +export const genericCliProfile: CliProfileDefinition = { + id: 'generic', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: false, + echoesTurnState: false, + }, + detect() { + return { + id: 'generic', + }; + }, +}; diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts new file mode 100644 index 0000000..df9eb4b --- /dev/null +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { detectCliProfile } from './registry.js'; + +describe('detectCliProfile', () => { + it('detects Codex responses requests and exposes Codex capability flags', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + originator: 'codex_cli_rs', + Session_id: 'codex-session-123', + }, + })).toEqual({ + id: 'codex', + sessionId: 'codex-session-123', + traceHint: 'codex-session-123', + capabilities: { + supportsResponsesCompact: true, + supportsResponsesWebsocketIncremental: true, + preservesContinuation: true, + supportsCountTokens: false, + echoesTurnState: true, + }, + }); + }); + + it('treats x-codex-turn-state as a Codex marker even when session_id is absent', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/responses', + headers: { + 'x-codex-turn-state': 'turn-state-123', + }, + })).toMatchObject({ + id: 'codex', + capabilities: { + supportsResponsesWebsocketIncremental: true, + echoesTurnState: true, + }, + }); + }); + + it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => { + expect(detectCliProfile({ + downstreamPath: '/v1/messages/count_tokens', + body: { + metadata: { + user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4', + }, + }, + })).toEqual({ + id: 'claude_code', + sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: true, + supportsCountTokens: true, + echoesTurnState: false, + }, + }); + }); + + it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => { + expect(detectCliProfile({ + downstreamPath: '/v1internal:countTokens', + body: { + model: 'gpt-4.1', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + })).toEqual({ + id: 'gemini_cli', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: true, + echoesTurnState: false, + }, + }); + }); + + it('falls back to generic for native Gemini routes', () => { + expect(detectCliProfile({ + downstreamPath: '/v1beta/models/gemini-2.5-flash:generateContent', + body: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + })).toEqual({ + id: 'generic', + capabilities: { + supportsResponsesCompact: false, + supportsResponsesWebsocketIncremental: false, + preservesContinuation: false, + supportsCountTokens: false, + echoesTurnState: false, + }, + }); + }); +}); diff --git a/src/server/proxy-core/cliProfiles/registry.ts b/src/server/proxy-core/cliProfiles/registry.ts new file mode 100644 index 0000000..4f69536 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/registry.ts @@ -0,0 +1,38 @@ +import { claudeCodeCliProfile } from './claudeCodeProfile.js'; +import { codexCliProfile } from './codexProfile.js'; +import { geminiCliProfile } from './geminiCliProfile.js'; +import { genericCliProfile } from './genericProfile.js'; +import type { CliProfileDefinition, CliProfileId, DetectCliProfileInput, DetectedCliProfile } from './types.js'; + +const orderedProfiles: CliProfileDefinition[] = [ + claudeCodeCliProfile, + codexCliProfile, + geminiCliProfile, +]; + +export const cliProfileRegistry: Record = { + generic: genericCliProfile, + codex: codexCliProfile, + claude_code: claudeCodeCliProfile, + gemini_cli: geminiCliProfile, +}; + +export function getCliProfileDefinition(id: CliProfileId): CliProfileDefinition { + return cliProfileRegistry[id]; +} + +export function detectCliProfile(input: DetectCliProfileInput): DetectedCliProfile { + for (const profile of orderedProfiles) { + const detected = profile.detect(input); + if (!detected) continue; + return { + ...detected, + capabilities: profile.capabilities, + }; + } + + return { + id: genericCliProfile.id, + capabilities: genericCliProfile.capabilities, + }; +} diff --git a/src/server/proxy-core/cliProfiles/types.ts b/src/server/proxy-core/cliProfiles/types.ts new file mode 100644 index 0000000..6e43479 --- /dev/null +++ b/src/server/proxy-core/cliProfiles/types.ts @@ -0,0 +1,32 @@ +export type CliProfileId = + | 'generic' + | 'codex' + | 'claude_code' + | 'gemini_cli'; + +export type CliProfileCapabilities = { + supportsResponsesCompact: boolean; + supportsResponsesWebsocketIncremental: boolean; + preservesContinuation: boolean; + supportsCountTokens: boolean; + echoesTurnState: boolean; +}; + +export type DetectCliProfileInput = { + downstreamPath: string; + headers?: Record; + body?: unknown; +}; + +export type DetectedCliProfile = { + id: CliProfileId; + sessionId?: string; + traceHint?: string; + capabilities: CliProfileCapabilities; +}; + +export type CliProfileDefinition = { + id: CliProfileId; + capabilities: CliProfileCapabilities; + detect(input: DetectCliProfileInput): Omit | null; +}; diff --git a/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts b/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts new file mode 100644 index 0000000..932d6c5 --- /dev/null +++ b/src/server/proxy-core/conductor/DefaultProxyConductor.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DefaultProxyConductor } from './DefaultProxyConductor.js'; +import { terminalStreamFailure } from './streamTermination.js'; + +const baseSelectedChannel = { + 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', +}; + +describe('DefaultProxyConductor', () => { + it('returns the first selected channel when the first attempt succeeds', async () => { + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn(); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn().mockResolvedValue({ + ok: true, + response: new Response('ok', { status: 200 }), + latencyMs: 12, + cost: 0.25, + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: baseSelectedChannel, + attempts: 1, + }); + expect(selectChannel).toHaveBeenCalledWith('gpt-5.4', undefined); + expect(selectNextChannel).not.toHaveBeenCalled(); + expect(recordFailure).not.toHaveBeenCalled(); + expect(recordSuccess).toHaveBeenCalledWith(11, { + latencyMs: 12, + cost: 0.25, + }); + }); + + it('retries on the same channel when the attempt asks for a same-channel retry', async () => { + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn(); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'retry_same_channel', + status: 429, + rawErrorText: 'rate limited', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + attempts: 2, + }); + expect(selectNextChannel).not.toHaveBeenCalled(); + expect(attempt).toHaveBeenCalledTimes(2); + expect(recordFailure).toHaveBeenCalledWith(11, { + status: 429, + rawErrorText: 'rate limited', + }); + }); + + it('fails over to the next channel when the attempt asks for failover', async () => { + const nextSelectedChannel = { + ...baseSelectedChannel, + channel: { id: 12, routeId: 22 }, + tokenValue: 'sk-next', + }; + const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel); + const selectNextChannel = vi.fn().mockResolvedValue(nextSelectedChannel); + const recordSuccess = vi.fn().mockResolvedValue(undefined); + const recordFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel, + selectNextChannel, + recordSuccess, + recordFailure, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'failover', + status: 503, + rawErrorText: 'upstream unavailable', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: nextSelectedChannel, + attempts: 2, + }); + expect(selectNextChannel).toHaveBeenCalledWith('gpt-5.4', [11], undefined); + expect(recordFailure).toHaveBeenCalledWith(11, { + status: 503, + rawErrorText: 'upstream unavailable', + }); + expect(recordSuccess).toHaveBeenCalledWith(12, { + latencyMs: null, + cost: null, + }); + }); + + it('refreshes auth on 401 and retries the same channel with the refreshed selection', async () => { + const refreshedChannel = { + ...baseSelectedChannel, + tokenValue: 'sk-refreshed', + }; + const refreshAuth = vi.fn().mockResolvedValue(refreshedChannel); + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn().mockResolvedValue(undefined), + recordFailure: vi.fn().mockResolvedValue(undefined), + refreshAuth, + }); + const attempt = vi.fn() + .mockResolvedValueOnce({ + ok: false, + action: 'refresh_auth', + status: 401, + rawErrorText: 'expired token', + }) + .mockResolvedValueOnce({ + ok: true, + response: new Response('ok', { status: 200 }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + }); + + expect(result).toMatchObject({ + ok: true, + selected: refreshedChannel, + attempts: 2, + }); + expect(refreshAuth).toHaveBeenCalledWith(baseSelectedChannel, { + status: 401, + rawErrorText: 'expired token', + }); + }); + + it('returns a no_channel result when no channel is available', async () => { + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(null), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(), + previewSelectedChannel: vi.fn().mockResolvedValue(null), + }); + + expect(await conductor.previewSelectedChannel('gpt-5.4')).toBe(null); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt: vi.fn(), + }); + + expect(result).toEqual({ + ok: false, + reason: 'no_channel', + attempts: 0, + }); + }); + + it('propagates terminal stream failures and calls the terminal failure hook', async () => { + const onTerminalFailure = vi.fn().mockResolvedValue(undefined); + const conductor = new DefaultProxyConductor({ + selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel), + selectNextChannel: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn().mockResolvedValue(undefined), + }); + const attempt = vi.fn().mockResolvedValue({ + ok: false, + ...terminalStreamFailure({ + status: 502, + rawErrorText: 'stream disconnected before completion', + }), + }); + + const result = await conductor.execute({ + requestedModel: 'gpt-5.4', + attempt, + onTerminalFailure, + }); + + expect(result).toEqual({ + ok: false, + reason: 'terminal', + selected: baseSelectedChannel, + status: 502, + rawErrorText: 'stream disconnected before completion', + attempts: 1, + }); + expect(onTerminalFailure).toHaveBeenCalledWith(baseSelectedChannel, { + status: 502, + rawErrorText: 'stream disconnected before completion', + }); + }); +}); diff --git a/src/server/proxy-core/conductor/DefaultProxyConductor.ts b/src/server/proxy-core/conductor/DefaultProxyConductor.ts new file mode 100644 index 0000000..d95ac80 --- /dev/null +++ b/src/server/proxy-core/conductor/DefaultProxyConductor.ts @@ -0,0 +1,127 @@ +import { + failureActionOf, + isTerminalFailure, + shouldFailover, + shouldRefreshAuth, + shouldRetrySameChannel, +} from './retryPolicy.js'; +import type { ExecuteInput, ExecuteResult, ProxyConductorDependencies, SelectedChannelLike } from './types.js'; +import { recordFailedAttempt, recordSuccessfulAttempt } from './usageHooks.js'; + +export class DefaultProxyConductor { + constructor(private readonly deps: ProxyConductorDependencies) {} + + async previewSelectedChannel(requestedModel: string, downstreamPolicy?: unknown): Promise { + if (this.deps.previewSelectedChannel) { + return this.deps.previewSelectedChannel(requestedModel, downstreamPolicy); + } + return this.deps.selectChannel(requestedModel, downstreamPolicy); + } + + async execute(input: ExecuteInput): Promise { + const excludeChannelIds: number[] = []; + let attempts = 0; + let selected = await this.deps.selectChannel(input.requestedModel, input.downstreamPolicy); + if (!selected) { + return { + ok: false, + reason: 'no_channel', + attempts: 0, + }; + } + + while (selected) { + const result = await input.attempt({ + selected, + attemptIndex: attempts, + excludeChannelIds: [...excludeChannelIds], + }); + attempts += 1; + + if (result.ok) { + await recordSuccessfulAttempt(this.deps, selected.channel.id, { + latencyMs: result.latencyMs ?? null, + cost: result.cost ?? null, + }); + return { + ok: true, + selected, + response: result.response, + attempts, + }; + } + + const action = failureActionOf(result); + await recordFailedAttempt(this.deps, selected.channel.id, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + + if (isTerminalFailure(action)) { + await input.onTerminalFailure?.(selected, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + return { + ok: false, + reason: 'terminal', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + + if (shouldRetrySameChannel(action)) { + continue; + } + + if (shouldRefreshAuth(action) && this.deps.refreshAuth) { + const refreshed = await this.deps.refreshAuth(selected, { + status: result.status, + rawErrorText: result.rawErrorText, + }); + if (refreshed) { + selected = refreshed; + continue; + } + } + + if (shouldFailover(action)) { + excludeChannelIds.push(selected.channel.id); + const next = await this.deps.selectNextChannel( + input.requestedModel, + excludeChannelIds, + input.downstreamPolicy, + ); + if (!next) { + return { + ok: false, + reason: 'failed', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + selected = next; + continue; + } + + return { + ok: false, + reason: 'failed', + selected, + status: result.status, + rawErrorText: result.rawErrorText, + attempts, + }; + } + + return { + ok: false, + reason: 'failed', + attempts, + }; + } +} diff --git a/src/server/proxy-core/conductor/retryPolicy.ts b/src/server/proxy-core/conductor/retryPolicy.ts new file mode 100644 index 0000000..9658d8c --- /dev/null +++ b/src/server/proxy-core/conductor/retryPolicy.ts @@ -0,0 +1,21 @@ +import type { AttemptFailure, AttemptFailureAction } from './types.js'; + +export function failureActionOf(result: AttemptFailure): AttemptFailureAction { + return result.action; +} + +export function shouldRetrySameChannel(action: AttemptFailureAction): boolean { + return action === 'retry_same_channel'; +} + +export function shouldRefreshAuth(action: AttemptFailureAction): boolean { + return action === 'refresh_auth'; +} + +export function shouldFailover(action: AttemptFailureAction): boolean { + return action === 'failover' || action === 'refresh_auth'; +} + +export function isTerminalFailure(action: AttemptFailureAction): boolean { + return action === 'terminal'; +} diff --git a/src/server/proxy-core/conductor/streamTermination.ts b/src/server/proxy-core/conductor/streamTermination.ts new file mode 100644 index 0000000..8bbdc2e --- /dev/null +++ b/src/server/proxy-core/conductor/streamTermination.ts @@ -0,0 +1,10 @@ +export function terminalStreamFailure(input: { + status?: number; + rawErrorText?: string; +}) { + return { + action: 'terminal' as const, + ...(input.status !== undefined ? { status: input.status } : {}), + ...(input.rawErrorText ? { rawErrorText: input.rawErrorText } : {}), + }; +} diff --git a/src/server/proxy-core/conductor/types.ts b/src/server/proxy-core/conductor/types.ts new file mode 100644 index 0000000..fef18c8 --- /dev/null +++ b/src/server/proxy-core/conductor/types.ts @@ -0,0 +1,80 @@ +export type SelectedChannelLike = { + channel: { id: number; routeId?: number }; + site: Record; + account: Record; + tokenName?: string; + tokenValue?: string; + actualModel?: string; +}; + +export type AttemptSuccess = { + ok: true; + response: Response; + latencyMs?: number | null; + cost?: number | null; +}; + +export type AttemptFailureAction = + | 'retry_same_channel' + | 'refresh_auth' + | 'failover' + | 'terminal' + | 'stop'; + +export type AttemptFailure = { + ok: false; + action: AttemptFailureAction; + status?: number; + rawErrorText?: string; + error?: unknown; +}; + +export type AttemptResult = AttemptSuccess | AttemptFailure; + +export type ExecuteAttemptContext = { + selected: SelectedChannelLike; + attemptIndex: number; + excludeChannelIds: number[]; +}; + +export type ProxyConductorDependencies = { + selectChannel: (requestedModel: string, downstreamPolicy?: unknown) => Promise; + previewSelectedChannel?: (requestedModel: string, downstreamPolicy?: unknown) => Promise; + selectNextChannel: ( + requestedModel: string, + excludeChannelIds: number[], + downstreamPolicy?: unknown, + ) => Promise; + recordSuccess?: (channelId: number, metrics: { latencyMs: number | null; cost: number | null }) => Promise | void; + recordFailure?: (channelId: number, failure: { status?: number; rawErrorText?: string }) => Promise | void; + refreshAuth?: ( + selected: SelectedChannelLike, + failure: { status?: number; rawErrorText?: string }, + ) => Promise; +}; + +export type ExecuteInput = { + requestedModel: string; + downstreamPolicy?: unknown; + attempt: (context: ExecuteAttemptContext) => Promise; + onTerminalFailure?: ( + selected: SelectedChannelLike, + failure: { status?: number; rawErrorText?: string }, + ) => Promise | void; +}; + +export type ExecuteResult = + | { + ok: true; + selected: SelectedChannelLike; + response: Response; + attempts: number; + } + | { + ok: false; + reason: 'no_channel' | 'failed' | 'terminal'; + selected?: SelectedChannelLike; + status?: number; + rawErrorText?: string; + attempts: number; + }; diff --git a/src/server/proxy-core/conductor/usageHooks.ts b/src/server/proxy-core/conductor/usageHooks.ts new file mode 100644 index 0000000..2b8455d --- /dev/null +++ b/src/server/proxy-core/conductor/usageHooks.ts @@ -0,0 +1,20 @@ +import type { ProxyConductorDependencies } from './types.js'; + +export async function recordSuccessfulAttempt( + deps: ProxyConductorDependencies, + channelId: number, + metrics: { latencyMs?: number | null; cost?: number | null }, +): Promise { + await deps.recordSuccess?.(channelId, { + latencyMs: metrics.latencyMs ?? null, + cost: metrics.cost ?? null, + }); +} + +export async function recordFailedAttempt( + deps: ProxyConductorDependencies, + channelId: number, + failure: { status?: number; rawErrorText?: string }, +): Promise { + await deps.recordFailure?.(channelId, failure); +} diff --git a/src/server/proxy-core/executors/antigravityExecutor.ts b/src/server/proxy-core/executors/antigravityExecutor.ts new file mode 100644 index 0000000..96ac1b1 --- /dev/null +++ b/src/server/proxy-core/executors/antigravityExecutor.ts @@ -0,0 +1,212 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Response } from 'undici'; +import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js'; +import { + asTrimmedString, + materializeErrorResponse, + performFetch, + withRequestBody, +} from './types.js'; + +const ANTIGRAVITY_RUNTIME_BASE_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com', + 'https://daily-cloudcode-pa.sandbox.googleapis.com', +] as const; + +function antigravityRequestType(modelName: string): 'image_gen' | 'agent' { + return modelName.includes('image') ? 'image_gen' : 'agent'; +} + +function generateAntigravityProjectId(): string { + const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; + const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge']; + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful'; + const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal'; + const suffix = Math.floor(100000 + Math.random() * 900000); + return `${adjective}-${noun}-${suffix}`; +} + +function extractFirstUserText(value: unknown): string { + if (!Array.isArray(value)) return ''; + for (const content of value) { + if (!content || typeof content !== 'object' || Array.isArray(content)) continue; + const record = content as Record; + if (asTrimmedString(record.role) !== 'user') continue; + const parts = Array.isArray(record.parts) ? record.parts : []; + for (const part of parts) { + if (!part || typeof part !== 'object' || Array.isArray(part)) continue; + const text = asTrimmedString((part as Record).text); + if (text) return text; + } + } + return ''; +} + +function generateStableAntigravitySessionId(payload: Record): string { + const firstUserText = extractFirstUserText( + payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request) + ? (payload.request as Record).contents + : undefined, + ); + if (!firstUserText) { + return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`; + } + const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16); + const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff'); + return `-${bigint.toString()}`; +} + +function renameParametersJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => renameParametersJsonSchema(item)); + } + if (!value || typeof value !== 'object') return value; + + const input = value as Record; + const output: Record = {}; + for (const [key, entry] of Object.entries(input)) { + const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key; + output[nextKey] = renameParametersJsonSchema(entry); + } + return output; +} + +function deleteNestedMaxOutputTokens(payload: Record): void { + const request = payload.request; + if (!request || typeof request !== 'object' || Array.isArray(request)) return; + const generationConfig = (request as Record).generationConfig; + if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return; + delete (generationConfig as Record).maxOutputTokens; +} + +function buildAntigravityRuntimeBody( + originalBody: Record, + modelName: string, + action?: NonNullable['action'], +): Record { + const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record; + if (action === 'countTokens') { + return payload; + } + const requestType = antigravityRequestType(modelName); + const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId(); + + payload.model = modelName; + payload.project = projectId; + payload.userAgent = 'antigravity'; + payload.requestType = requestType; + payload.requestId = requestType === 'image_gen' + ? `image_gen/${Date.now()}/${randomUUID()}/12` + : `agent-${randomUUID()}`; + + const request = payload.request; + if (request && typeof request === 'object' && !Array.isArray(request)) { + delete (request as Record).safetySettings; + if (requestType !== 'image_gen') { + (request as Record).sessionId = generateStableAntigravitySessionId(payload); + } + if (modelName.includes('claude')) { + const toolConfig = ( + (request as Record).toolConfig + && typeof (request as Record).toolConfig === 'object' + && !Array.isArray((request as Record).toolConfig) + ) + ? (request as Record).toolConfig as Record + : (((request as Record).toolConfig = {}) as Record); + toolConfig.functionCallingConfig = { mode: 'VALIDATED' }; + } else { + deleteNestedMaxOutputTokens(payload); + } + } + + return payload; +} + +function antigravityShouldRetryNoCapacity( + status: number, + responseText: string, +): boolean { + return status === 503 && responseText.toLowerCase().includes('no capacity available'); +} + +function antigravityNoCapacityRetryDelay(attempt: number): number { + return Math.min((attempt + 1) * 250, 2000); +} + +async function waitMs(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const antigravityExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const runtimeBody = buildAntigravityRuntimeBody( + input.request.body, + modelName, + input.request.runtime?.action, + ); + const baseAttempts = 3; + let lastResponse: RuntimeResponse | null = null; + + attemptLoop: + for (let attempt = 0; attempt < baseAttempts; attempt += 1) { + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + const requestUrl = `${baseUrl}${input.request.path}`; + const minimalHeaders: Record = { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }; + let response: RuntimeResponse; + try { + response = await performFetch( + input, + withRequestBody(input.request, runtimeBody, minimalHeaders), + requestUrl, + ); + } catch (error) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + continue attemptLoop; + } + throw error; + } + if (response.ok) return response; + + const errorResponse = await materializeErrorResponse(response); + const errorText = await errorResponse.text().catch(() => ''); + lastResponse = new Response(errorText, { + status: errorResponse.status, + headers: errorResponse.headers, + }); + + if (errorResponse.status === 429) { + continue; + } + + if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + await waitMs(antigravityNoCapacityRetryDelay(attempt)); + continue attemptLoop; + } + } + + return lastResponse; + } + } + + return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + })); + }, +}; diff --git a/src/server/proxy-core/executors/claudeExecutor.ts b/src/server/proxy-core/executors/claudeExecutor.ts new file mode 100644 index 0000000..621a01b --- /dev/null +++ b/src/server/proxy-core/executors/claudeExecutor.ts @@ -0,0 +1,8 @@ +import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js'; +import { performFetch } from './types.js'; + +export const claudeExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + return performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/codexExecutor.ts b/src/server/proxy-core/executors/codexExecutor.ts new file mode 100644 index 0000000..eefefc0 --- /dev/null +++ b/src/server/proxy-core/executors/codexExecutor.ts @@ -0,0 +1,8 @@ +import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js'; +import { performFetch } from './types.js'; + +export const codexExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + return performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/geminiCliExecutor.ts b/src/server/proxy-core/executors/geminiCliExecutor.ts new file mode 100644 index 0000000..af2a18e --- /dev/null +++ b/src/server/proxy-core/executors/geminiCliExecutor.ts @@ -0,0 +1,60 @@ +import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js'; +import { + asTrimmedString, + materializeErrorResponse, + performFetch, + withRequestBody, +} from './types.js'; + +function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined { + const raw = asTrimmedString(userAgent); + if (!raw) return undefined; + return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `); +} + +function buildGeminiCliAttemptRequest( + request: RuntimeDispatchInput['request'], + modelName: string, +) { + const body = structuredClone(request.body); + const action = request.runtime?.action; + if (action === 'countTokens') { + delete body.model; + delete body.project; + } else { + body.model = modelName; + } + const headers = { ...request.headers }; + const nextUserAgent = replaceGeminiCliModelInUserAgent( + headers['User-Agent'] || headers['user-agent'], + modelName, + ); + if (nextUserAgent) { + headers['User-Agent'] = nextUserAgent; + delete headers['user-agent']; + } + return withRequestBody(request, body, headers); +} + +export const geminiCliExecutor: RuntimeExecutor = { + async dispatch(input: RuntimeDispatchInput) { + const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const models = [baseModel].filter(Boolean); + let lastResponse: RuntimeResponse | null = null; + + for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) { + const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName); + const response = await performFetch(input, attemptRequest); + if (response.ok) return response; + + if (response.status === 429) { + lastResponse = await materializeErrorResponse(response); + continue; + } + + return materializeErrorResponse(response); + } + + return lastResponse || performFetch(input, input.request); + }, +}; diff --git a/src/server/proxy-core/executors/types.ts b/src/server/proxy-core/executors/types.ts new file mode 100644 index 0000000..b609cfa --- /dev/null +++ b/src/server/proxy-core/executors/types.ts @@ -0,0 +1,75 @@ +import { + Response, + fetch, + type RequestInit as UndiciRequestInit, + type Response as UndiciResponse, +} from 'undici'; + +export type ProxyRuntimeRequest = { + endpoint: 'chat' | 'messages' | 'responses'; + path: string; + headers: Record; + body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; +}; + +export type RuntimeDispatchInput = { + siteUrl: string; + request: ProxyRuntimeRequest; + targetUrl?: string; + buildInit: (requestUrl: string, request: ProxyRuntimeRequest) => Promise | UndiciRequestInit; +}; + +export type RuntimeResponse = UndiciResponse; + +export type RuntimeExecutor = { + dispatch(input: RuntimeDispatchInput): Promise; +}; + +export function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function withRequestBody( + request: ProxyRuntimeRequest, + body: Record, + headers?: Record, +): ProxyRuntimeRequest { + return { + ...request, + headers: headers ? { ...headers } : { ...request.headers }, + body, + }; +} + +function buildUpstreamUrl(siteUrl: string, path: string): string { + const normalizedBase = siteUrl.replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; +} + +export async function performFetch( + input: RuntimeDispatchInput, + request: ProxyRuntimeRequest, + requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path), +): Promise { + const init = await input.buildInit(requestUrl, request); + return fetch(requestUrl, init); +} + +export async function materializeErrorResponse( + response: RuntimeResponse, +): Promise { + if (response.ok) return response; + const text = await response.text().catch(() => ''); + return new Response(text, { + status: response.status, + headers: response.headers, + }); +} diff --git a/src/server/proxy-core/providers/antigravityProviderProfile.ts b/src/server/proxy-core/providers/antigravityProviderProfile.ts new file mode 100644 index 0000000..40dd31b --- /dev/null +++ b/src/server/proxy-core/providers/antigravityProviderProfile.ts @@ -0,0 +1,47 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js'; + +const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction { + if (action) return action; + return stream ? 'streamGenerateContent' : 'generateContent'; +} + +function resolvePath(action: ProviderAction): string { + if (action === 'countTokens') return '/v1internal:countTokens'; + if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +export const antigravityProviderProfile: ProviderProfile = { + id: 'antigravity', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const action = resolveAction(input.action, input.stream); + const projectId = asTrimmedString(input.oauthProjectId); + return { + path: resolvePath(action), + headers: { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + Accept: action === 'streamGenerateContent' ? 'text/event-stream' : 'application/json', + 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, + }, + body: { + project: projectId, + model: input.modelName, + request: input.body, + }, + runtime: { + executor: 'antigravity', + modelName: input.modelName, + stream: action === 'streamGenerateContent', + oauthProjectId: projectId, + action, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/claudeProviderProfile.ts b/src/server/proxy-core/providers/claudeProviderProfile.ts new file mode 100644 index 0000000..e8301da --- /dev/null +++ b/src/server/proxy-core/providers/claudeProviderProfile.ts @@ -0,0 +1,128 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; + +const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; +const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; + +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 getInputHeader( + headers: Record | Record | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +function mergeClaudeBetaHeader( + explicitValue: string | null, + extraBetas: string[] = [], +): string { + const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER; + const seen = new Set(); + const merged: string[] = []; + for (const entry of source.split(',')) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + if (!explicitValue) { + for (const entry of extraBetas) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + } + return merged.join(','); +} + +function buildClaudeRuntimeHeaders(input: { + baseHeaders: Record; + claudeHeaders: Record; + anthropicVersion: string; + stream: boolean; + isClaudeOauthUpstream: boolean; + tokenValue: string; +}): Record { + const anthropicBeta = mergeClaudeBetaHeader( + getInputHeader(input.claudeHeaders, 'anthropic-beta'), + ); + const headers: Record = { + ...input.baseHeaders, + ...input.claudeHeaders, + 'anthropic-version': input.anthropicVersion, + ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), + 'Anthropic-Dangerous-Direct-Browser-Access': 'true', + 'X-App': 'cli', + 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', + 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', + 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', + 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', + 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', + 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', + 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', + 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', + 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, + Connection: 'keep-alive', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'Accept-Encoding': input.stream ? 'identity' : 'gzip, deflate, br, zstd', + }; + if (input.isClaudeOauthUpstream) { + headers.Authorization = `Bearer ${input.tokenValue}`; + } else { + headers['x-api-key'] = input.tokenValue; + } + return headers; +} + +export const claudeProviderProfile: ProviderProfile = { + id: 'claude', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const anthropicVersion = ( + input.claudeHeaders?.['anthropic-version'] + || input.baseHeaders['anthropic-version'] + || '2023-06-01' + ); + const isClaudeOauthUpstream = input.sitePlatform?.trim().toLowerCase() === 'claude' + && input.oauthProvider === 'claude'; + const isCountTokens = input.action === 'countTokens'; + + return { + path: isCountTokens ? '/v1/messages/count_tokens?beta=true' : '/v1/messages', + headers: buildClaudeRuntimeHeaders({ + baseHeaders: input.baseHeaders, + claudeHeaders: input.claudeHeaders ?? {}, + anthropicVersion, + stream: isCountTokens ? false : input.stream, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }), + body: input.body, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: isCountTokens ? false : input.stream, + oauthProjectId: null, + ...(isCountTokens ? { action: 'countTokens' } : {}), + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/codexProviderProfile.ts b/src/server/proxy-core/providers/codexProviderProfile.ts new file mode 100644 index 0000000..e859aa6 --- /dev/null +++ b/src/server/proxy-core/providers/codexProviderProfile.ts @@ -0,0 +1,131 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; + +const CODEX_CLIENT_VERSION = '0.101.0'; +const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +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 getInputHeader( + headers: Record | Record | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +function uuidFromSeed(seed: string): string { + const hash = createHash('sha1').update(seed).digest(); + const bytes = new Uint8Array(hash.subarray(0, 16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +function buildCodexRuntimeHeaders(input: { + baseHeaders: Record; + providerHeaders?: Record; + explicitSessionId?: string | null; + continuityKey?: string | null; +}): Record { + const authorization = ( + getInputHeader(input.baseHeaders, 'authorization') + || getInputHeader(input.baseHeaders, 'Authorization') + || '' + ); + const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs'; + const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); + const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION; + const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT; + const explicitSessionId = asTrimmedString(input.explicitSessionId); + const continuityKey = asTrimmedString(input.continuityKey); + const sessionId = ( + getInputHeader(input.baseHeaders, 'session_id') + || getInputHeader(input.baseHeaders, 'session-id') + || explicitSessionId + || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) + || randomUUID() + ); + const conversationId = ( + getInputHeader(input.baseHeaders, 'conversation_id') + || getInputHeader(input.baseHeaders, 'conversation-id') + || explicitSessionId + || (continuityKey ? sessionId : null) + ); + + return { + Authorization: authorization, + 'Content-Type': 'application/json', + ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), + Originator: originator, + Version: version, + Session_id: sessionId, + ...(conversationId ? { Conversation_id: conversationId } : {}), + 'User-Agent': userAgent, + Accept: 'text/event-stream', + Connection: 'Keep-Alive', + }; +} + +export const codexProviderProfile: ProviderProfile = { + id: 'codex', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const headers = buildCodexRuntimeHeaders({ + baseHeaders: input.baseHeaders, + providerHeaders: input.providerHeaders, + explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null, + continuityKey: asTrimmedString(input.codexSessionCacheKey) || null, + }); + const codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'); + const shouldInjectDerivedPromptCacheKey = !!codexSessionId + && !asTrimmedString(input.body.prompt_cache_key) + && !asTrimmedString(input.codexExplicitSessionId) + && !!asTrimmedString(input.codexSessionCacheKey); + const body = shouldInjectDerivedPromptCacheKey + ? { + ...input.body, + prompt_cache_key: codexSessionId, + } + : input.body; + + return { + path: '/responses', + headers, + body, + runtime: { + executor: 'codex', + modelName: input.modelName, + stream: input.stream, + oauthProjectId: asTrimmedString(input.oauthProjectId) || null, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/geminiCliProviderProfile.ts b/src/server/proxy-core/providers/geminiCliProviderProfile.ts new file mode 100644 index 0000000..6b9182f --- /dev/null +++ b/src/server/proxy-core/providers/geminiCliProviderProfile.ts @@ -0,0 +1,113 @@ +import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js'; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +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 getInputHeader( + headers: Record | Record | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +function parseGeminiCliUserAgentRuntime(userAgent: string | null): { + version: string; + platform: string; + arch: string; +} | null { + if (!userAgent) return null; + const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); + if (!match) return null; + return { + version: match[1] || '0.31.0', + platform: match[2] || 'win32', + arch: match[3] || 'x64', + }; +} + +function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string { + const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); + const version = parsed?.version || '0.31.0'; + const platform = parsed?.platform || 'win32'; + const arch = parsed?.arch || 'x64'; + const effectiveModel = asTrimmedString(modelName) || 'unknown'; + return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`; +} + +function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction { + if (action) return action; + return stream ? 'streamGenerateContent' : 'generateContent'; +} + +function resolvePath(action: ProviderAction): string { + if (action === 'countTokens') return '/v1internal:countTokens'; + if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +export const geminiCliProviderProfile: ProviderProfile = { + id: 'gemini-cli', + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const projectId = asTrimmedString(input.oauthProjectId); + if (!projectId) { + throw new Error('gemini-cli oauth project id missing'); + } + const action = resolveAction(input.action, input.stream); + const apiClient = ( + getInputHeader(input.providerHeaders, 'x-goog-api-client') + || getInputHeader(input.baseHeaders, 'x-goog-api-client') + ); + const userAgent = buildGeminiCLIUserAgent( + input.modelName, + getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), + ); + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + if (apiClient) { + headers['X-Goog-Api-Client'] = apiClient; + } + if (action === 'streamGenerateContent') { + headers.Accept = 'text/event-stream'; + } + return { + path: resolvePath(action), + headers, + body: { + project: projectId, + model: input.modelName, + request: input.body, + }, + runtime: { + executor: 'gemini-cli', + modelName: input.modelName, + stream: action === 'streamGenerateContent', + oauthProjectId: projectId, + action, + }, + }; + }, +}; diff --git a/src/server/proxy-core/providers/registry.test.ts b/src/server/proxy-core/providers/registry.test.ts new file mode 100644 index 0000000..8b7c746 --- /dev/null +++ b/src/server/proxy-core/providers/registry.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProviderProfile } from './registry.js'; + +describe('resolveProviderProfile', () => { + it('builds codex provider requests with codex-specific path, headers, and runtime metadata', () => { + const profile = resolveProviderProfile('codex'); + expect(profile?.id).toBe('codex'); + + const result = profile!.prepareRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2-codex', + stream: true, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }, + codexSessionCacheKey: 'gpt-5.2-codex:user-123', + body: { + model: 'gpt-5.2-codex', + stream: true, + store: false, + }, + }); + + expect(result.path).toBe('/responses'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers.Originator).toBe('codex_cli_rs'); + expect(result.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); + expect(result.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(result.headers.Conversation_id).toBe(result.headers.Session_id); + expect(result.runtime).toMatchObject({ + executor: 'codex', + modelName: 'gpt-5.2-codex', + stream: true, + }); + expect(result.body.prompt_cache_key).toBe(result.headers.Session_id); + }); + + it('builds claude provider requests without rebuilding protocol bodies', () => { + const profile = resolveProviderProfile('claude'); + expect(profile?.id).toBe('claude'); + + const protocolBody = { + model: 'claude-opus-4-6', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: {}, + body: protocolBody, + }); + + expect(result.path).toBe('/v1/messages'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['x-api-key']).toBeUndefined(); + expect(result.headers['anthropic-version']).toBe('2023-06-01'); + expect(result.runtime).toMatchObject({ + executor: 'claude', + modelName: 'claude-opus-4-6', + stream: false, + }); + expect(result.body).toBe(protocolBody); + }); + + it('builds gemini-cli provider requests with wrapped runtime envelope and project validation', () => { + const profile = resolveProviderProfile('gemini-cli'); + expect(profile?.id).toBe('gemini-cli'); + + const protocolBody = { + contents: [{ role: 'user', parts: [{ text: 'hello gemini cli' }] }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-2.5-pro', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + oauthProjectId: 'project-demo', + sitePlatform: 'gemini-cli', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: protocolBody, + }); + + expect(result.path).toBe('/v1internal:generateContent'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)'); + expect(result.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(result.runtime).toMatchObject({ + executor: 'gemini-cli', + modelName: 'gemini-2.5-pro', + oauthProjectId: 'project-demo', + action: 'generateContent', + }); + expect(result.body).toEqual({ + project: 'project-demo', + model: 'gemini-2.5-pro', + request: protocolBody, + }); + expect((result.body as Record).request).toBe(protocolBody); + + expect(() => profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-2.5-pro', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + sitePlatform: 'gemini-cli', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + body: protocolBody, + })).toThrow(/project id missing/i); + }); + + it('builds antigravity provider requests without leaking gemini-cli client headers', () => { + const profile = resolveProviderProfile('antigravity'); + expect(profile?.id).toBe('antigravity'); + + const protocolBody = { + contents: [{ role: 'user', parts: [{ text: 'hello antigravity' }] }], + }; + + const result = profile!.prepareRequest({ + endpoint: 'chat', + modelName: 'gemini-3-pro-preview', + stream: true, + tokenValue: 'oauth-access-token', + oauthProjectId: 'project-demo', + sitePlatform: 'antigravity', + baseHeaders: { + Authorization: 'Bearer oauth-access-token', + }, + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: protocolBody, + }); + + expect(result.path).toBe('/v1internal:streamGenerateContent?alt=sse'); + expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(result.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64'); + expect(result.headers['X-Goog-Api-Client']).toBeUndefined(); + expect(result.headers.Accept).toBe('text/event-stream'); + expect(result.runtime).toMatchObject({ + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + oauthProjectId: 'project-demo', + action: 'streamGenerateContent', + }); + expect(result.body).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: protocolBody, + }); + expect((result.body as Record).request).toBe(protocolBody); + }); +}); diff --git a/src/server/proxy-core/providers/registry.ts b/src/server/proxy-core/providers/registry.ts new file mode 100644 index 0000000..052bc78 --- /dev/null +++ b/src/server/proxy-core/providers/registry.ts @@ -0,0 +1,17 @@ +import { antigravityProviderProfile } from './antigravityProviderProfile.js'; +import { claudeProviderProfile } from './claudeProviderProfile.js'; +import { codexProviderProfile } from './codexProviderProfile.js'; +import { geminiCliProviderProfile } from './geminiCliProviderProfile.js'; +import type { ProviderProfile } from './types.js'; + +const providerProfilesByPlatform: Record = { + codex: codexProviderProfile, + claude: claudeProviderProfile, + 'gemini-cli': geminiCliProviderProfile, + antigravity: antigravityProviderProfile, +}; + +export function resolveProviderProfile(sitePlatform?: string | null): ProviderProfile | null { + const normalized = typeof sitePlatform === 'string' ? sitePlatform.trim().toLowerCase() : ''; + return providerProfilesByPlatform[normalized] ?? null; +} diff --git a/src/server/proxy-core/providers/types.ts b/src/server/proxy-core/providers/types.ts new file mode 100644 index 0000000..080b497 --- /dev/null +++ b/src/server/proxy-core/providers/types.ts @@ -0,0 +1,52 @@ +export type ProviderProfileId = + | 'codex' + | 'claude' + | 'gemini-cli' + | 'antigravity'; + +export type ProviderEndpoint = + | 'chat' + | 'messages' + | 'responses'; + +export type ProviderAction = + | 'generateContent' + | 'streamGenerateContent' + | 'countTokens'; + +export type ProviderRuntimeDescriptor = { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: ProviderAction; +}; + +export type PreparedProviderRequest = { + path: string; + headers: Record; + body: Record; + runtime: ProviderRuntimeDescriptor; +}; + +export type PrepareProviderRequestInput = { + endpoint: ProviderEndpoint; + modelName: string; + stream: boolean; + tokenValue: string; + oauthProvider?: string; + oauthProjectId?: string; + sitePlatform?: string; + baseHeaders: Record; + providerHeaders?: Record; + claudeHeaders?: Record; + codexSessionCacheKey?: string | null; + codexExplicitSessionId?: string | null; + body: Record; + action?: ProviderAction; +}; + +export type ProviderProfile = { + id: ProviderProfileId; + prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest; +}; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts new file mode 100644 index 0000000..9828151 --- /dev/null +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -0,0 +1,1019 @@ +import { TextDecoder } from 'node:util'; +import type { FastifyRequest, FastifyReply } from 'fastify'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; +import { + buildClaudeCountTokensUpstreamRequest, + buildUpstreamEndpointRequest, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { + ensureModelAllowedForDownstreamKey, + getDownstreamRoutingPolicy, + recordDownstreamCostUsage, +} from '../../routes/proxy/downstreamPolicy.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; +import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; +import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; +import { + ProxyInputFileResolutionError, + hasNonImageFileInputInOpenAiBody, + resolveOpenAiBodyInputFiles, +} from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { collectResponsesFinalPayloadFromSse } from '../../routes/proxy/responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from '../../routes/proxy/geminiCliCompat.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; +import { detectCliProfile } from '../cliProfiles/registry.js'; +import type { CliProfileId } from '../cliProfiles/types.js'; + +const MAX_RETRIES = 2; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +type DownstreamClientContext = { + clientKind: CliProfileId; + sessionId?: string; + traceHint?: string; +}; + +function detectDownstreamClientContext(input: { + downstreamPath: string; + headers?: Record; + body?: unknown; +}): DownstreamClientContext { + const detected = detectCliProfile(input); + return { + clientKind: detected.id, + ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), + ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), + }; +} + +export async function handleChatSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, + downstreamFormat: DownstreamFormat, +) { + const downstreamTransformer = downstreamFormat === 'claude' + ? anthropicMessagesTransformer + : openAiChatTransformer; + 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 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); + let resolvedOpenAiBody = upstreamBody; + if (owner) { + try { + resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(upstreamBody, owner); + } catch (error) { + if (error instanceof ProxyInputFileResolutionError) { + return reply.code(error.statusCode).send(error.payload); + } + throw error; + } + } + const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); + const codexSessionCacheKey = deriveCodexSessionCacheKey({ + downstreamFormat, + body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body, + requestedModel, + proxyToken: getProxyAuthContext(request)?.token || null, + }); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); + + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; + const endpointCandidates = [ + ...await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + modelName, + downstreamFormat, + requestedModel, + { + hasNonImageFileInput, + }, + ), + ]; + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }) + ); + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + options: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: upstreamStream, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: resolvedOpenAiBody, + downstreamFormat, + claudeOriginalBody, + forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), + codexSessionCacheKey, + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, + }; + }; + const dispatchRequest = ( + compatibilityRequest: BuiltEndpointRequest, + targetUrl?: string, + ) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); + const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ + downstreamFormat, + endpointCandidates, + modelName, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStream || isCodexSite, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest, + }); + const tryRecover = async (ctx: Parameters>[0]) => { + if (ctx.response.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); + const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } catch { + return endpointStrategy.tryRecover(ctx); + } + } + return endpointStrategy.tryRecover(ctx); + }; + let startTime = Date.now(); + + try { + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + proxyUrl: resolveProxyUrlForSite(selected.site), + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + onDowngrade: (ctx) => { + logProxy( + selected, + requestedModel, + 'failed', + ctx.response.status, + Date.now() - startTime, + ctx.errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + }, + }); + + if (!endpointResult.ok) { + const status = endpointResult.status || 502; + const errText = endpointResult.errText || 'unknown error'; + const rawErrText = endpointResult.rawErrText || errText; + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + status, + Date.now() - startTime, + errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + await recordOauthQuotaResetHint({ + accountId: selected.account.id, + statusCode: status, + errorText: rawErrText, + }); + + if (isTokenExpiredError({ status, message: errText })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${status}`, + }); + } + + if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${status}`, + }); + + return reply.code(status).send({ + error: { message: errText, type: 'upstream_error' }, + }); + } + + const upstream = endpointResult.upstream; + const successfulUpstreamPath = endpointResult.upstreamPath; + + if (isStream) { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + const startSseResponse = () => { + reply.hijack(); + reply.raw.statusCode = 200; + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + }; + + let parsedUsage: ReturnType = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + + const writeLines = (lines: string[]) => { + for (const line of lines) { + reply.raw.write(line); + } + }; + const streamSession = openAiChatTransformer.proxyStream.createSession({ + downstreamFormat, + modelName, + successfulUpstreamPath, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); + let rawText = ''; + if (!upstreamContentType.includes('text/event-stream')) { + const fallbackText = await upstream.text(); + rawText = fallbackText; + let fallbackData: unknown = null; + try { + fallbackData = JSON.parse(fallbackText); + } catch { + fallbackData = fallbackText; + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + fallbackData = unwrapGeminiCliPayload(fallbackData); + } + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData)); + const latency = Date.now() - startTime; + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + failure.status, + latency, + failure.reason, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + + if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: failure.reason, + }); + + return reply.code(failure.status).send({ + error: { message: failure.reason, type: 'upstream_error' }, + }); + } + + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + } else { + startSseResponse(); + const upstreamReader = upstream.body?.getReader(); + const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + const decoder = new TextDecoder(); + const reader = baseReader + ? { + async read() { + const result = await baseReader.read(); + if (result.value) { + rawText += decoder.decode(result.value, { stream: true }); + } + return result; + }, + async cancel(reason?: unknown) { + return baseReader.cancel(reason); + }, + releaseLock() { + return baseReader.releaseLock(); + }, + } + : baseReader; + const streamResult = await streamSession.run(reader, reply.raw); + rawText += decoder.decode(); + + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + + // Once SSE has been hijacked and streamed downstream, we can no longer + // safely fall back to an HTTP error response or retry by switching the + // channel mid-flight. Stream-level failures must be handled in-band by + // the proxy stream session itself. + } + + const latency = Date.now() - startTime; + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName, + parsedUsage, + resolvedUsage, + }); + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, + requestedModel, + 'success', + 200, + latency, + null, + retryCount, + downstreamPath, + resolvedUsage.promptTokens, + resolvedUsage.completionTokens, + resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await upstream.text(); + upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(upstreamData); + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + failure.status, + latency, + failure.reason, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + + if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: failure.reason, + }); + + return reply.code(failure.status).send({ + error: { message: failure.reason, type: 'upstream_error' }, + }); + } + const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); + const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); + + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName, + parsedUsage, + resolvedUsage, + }); + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, + requestedModel, + 'success', + 200, + latency, + null, + retryCount, + downstreamPath, + resolvedUsage.promptTokens, + resolvedUsage.completionTokens, + resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + + 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, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: err?.message || 'network failure', + }); + + return reply.code(502).send({ + error: { + message: `Upstream error: ${err?.message || 'network failure'}`, + type: 'upstream_error', + }, + }); + } + } +} + +function deriveCodexSessionCacheKey(input: { + downstreamFormat: DownstreamFormat | 'responses'; + body: unknown; + requestedModel: string; + proxyToken: string | null; +}): string | null { + if (isRecord(input.body)) { + if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) { + const userId = asTrimmedString(input.body.metadata.user_id); + if (userId) return `${input.requestedModel}:claude:${userId}`; + } + const promptCacheKey = asTrimmedString(input.body.prompt_cache_key); + if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`; + } + + const proxyToken = asTrimmedString(input.proxyToken); + if (proxyToken) { + return `${input.requestedModel}:proxy:${proxyToken}`; + } + + return null; +} + +export async function handleClaudeCountTokensSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, +) { + const rawBody = isRecord(request.body) ? { ...request.body } : null; + if (!rawBody) { + return reply.code(400).send({ + error: { + message: 'Request body must be a JSON object', + type: 'invalid_request_error', + }, + }); + } + + const requestedModel = asTrimmedString(rawBody.model); + if (!requestedModel) { + return reply.code(400).send({ + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }); + } + + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPath = '/v1/messages/count_tokens'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body: rawBody, + }); + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + if (String(selected.site.platform || '').trim().toLowerCase() !== 'claude') { + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(501).send({ + error: { + message: 'Claude count_tokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + } + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const startTime = Date.now(); + + const buildRequest = () => { + const upstreamRequest = buildClaudeCountTokensUpstreamRequest({ + modelName, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + sitePlatform: selected.site.platform, + claudeBody: rawBody, + downstreamHeaders: request.headers as Record, + }); + return { + endpoint: 'messages' as const, + path: upstreamRequest.path, + headers: upstreamRequest.headers, + body: upstreamRequest.body, + runtime: upstreamRequest.runtime, + }; + }; + + try { + let upstreamRequest = buildRequest(); + let upstream = await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + request: upstreamRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }); + + if (upstream.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + upstreamRequest = buildRequest(); + upstream = await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + request: upstreamRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }); + } catch { + // Fall through to the regular upstream error handling below. + } + } + + const latency = Date.now() - startTime; + const contentType = upstream.headers.get('content-type') || 'application/json'; + const text = await upstream.text(); + let payload: unknown = text; + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + latency, + typeof payload === 'string' ? payload : JSON.stringify(payload), + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + upstreamRequest.path, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(upstream.status).type(contentType).send(payload); + } + + tokenRouter.recordSuccess(selected.channel.id, latency, 0); + recordDownstreamCostUsage(request, 0); + logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + upstreamRequest.path, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return reply.code(upstream.status).type(contentType).send(payload); + } catch (error: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error?.message || 'network error', + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(502).send({ + error: { + message: `Upstream error: ${error?.message || 'network failure'}`, + type: 'upstream_error', + }, + }); + } + } +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + billingDetails: unknown = null, + upstreamPath: string | null = null, + clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | 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, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/chat] failed to write proxy log', error); + } +} diff --git a/src/server/proxy-core/surfaces/filesSurface.ts b/src/server/proxy-core/surfaces/filesSurface.ts new file mode 100644 index 0000000..fdb3b87 --- /dev/null +++ b/src/server/proxy-core/surfaces/filesSurface.ts @@ -0,0 +1,159 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { + getProxyFileByPublicIdForOwner, + getProxyFileContentByPublicIdForOwner, + listProxyFilesByOwner, + saveProxyFile, + softDeleteProxyFileByPublicIdForOwner, +} from '../../services/proxyFileStore.js'; +import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js'; + +function invalidRequest(reply: FastifyReply, message: string) { + return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); +} + +function notFound(reply: FastifyReply, message = 'file not found') { + return reply.code(404).send({ error: { message, type: 'not_found_error' } }); +} + +function toUnixSeconds(sqlDateTime: string | null | undefined): number { + if (!sqlDateTime) return Math.floor(Date.now() / 1000); + const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z'); + if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000); + return Math.floor(parsed / 1000); +} + +function toFileObject(record: { + publicId: string; + filename: string; + mimeType: string; + purpose: string | null; + byteSize: number; + createdAt: string | null; +}) { + return { + id: record.publicId, + object: 'file', + bytes: record.byteSize, + created_at: toUnixSeconds(record.createdAt), + filename: record.filename, + purpose: record.purpose || 'assistants', + mime_type: record.mimeType, + }; +} + +function inferMimeTypeFromFilename(filename: string): string { + const normalized = filename.trim().toLowerCase(); + if (normalized.endsWith('.pdf')) return 'application/pdf'; + if (normalized.endsWith('.txt')) return 'text/plain'; + if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; + if (normalized.endsWith('.json')) return 'application/json'; + if (normalized.endsWith('.png')) return 'image/png'; + if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; + if (normalized.endsWith('.gif')) return 'image/gif'; + if (normalized.endsWith('.webp')) return 'image/webp'; + if (normalized.endsWith('.wav')) return 'audio/wav'; + if (normalized.endsWith('.mp3')) return 'audio/mpeg'; + return 'application/octet-stream'; +} + +function isSupportedProxyFileMimeType(mimeType: string): boolean { + return mimeType === 'application/pdf' + || mimeType === 'text/plain' + || mimeType === 'text/markdown' + || mimeType === 'application/json' + || mimeType.startsWith('image/') + || mimeType.startsWith('audio/'); +} + +export async function filesProxyRoute(app: FastifyInstance) { + ensureMultipartBufferParser(app); + + app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + + const formData = await parseMultipartFormData(request); + if (!formData) { + return invalidRequest(reply, 'multipart/form-data with a file field is required'); + } + + const fileEntry = formData.get('file'); + if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') { + return invalidRequest(reply, 'file field is required'); + } + + const filename = fileEntry.name || 'upload.bin'; + const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); + if (!isSupportedProxyFileMimeType(mimeType)) { + return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); + } + + const purposeValue = formData.get('purpose'); + const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0 + ? purposeValue.trim() + : 'assistants'; + const buffer = Buffer.from(await fileEntry.arrayBuffer()); + + const saved = await saveProxyFile({ + ...owner, + filename, + mimeType, + purpose, + contentBase64: buffer.toString('base64'), + }); + return reply.send(toFileObject(saved)); + }); + + app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const files = await listProxyFilesByOwner(owner); + return reply.send({ + object: 'list', + data: files.map((item) => toFileObject(item)), + has_more: false, + }); + }); + + app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner); + if (!file) return notFound(reply); + return reply.send(toFileObject(file)); + }); + + app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner); + if (!file) return notFound(reply); + reply.type(file.mimeType); + reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`); + return reply.send(file.buffer); + }); + + app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { + const owner = getProxyResourceOwner(request); + if (!owner) { + return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); + } + const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner); + if (!deleted) return notFound(reply); + return reply.send({ + id: request.params.fileId, + object: 'file', + deleted: true, + }); + }); +} diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts new file mode 100644 index 0000000..fdac3d3 --- /dev/null +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -0,0 +1,843 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { TextDecoder } from 'node:util'; +import { fetch } from 'undici'; +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { + buildUpstreamEndpointRequest, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { + geminiGenerateContentTransformer, +} from '../../transformers/gemini/generate-content/index.js'; +import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js'; +import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, + wrapGeminiCliRequest, +} from '../../routes/proxy/geminiCliCompat.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; + +const MAX_RETRIES = 2; +const GEMINI_MODEL_PROBES = [ + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gemini-pro', +]; +const GEMINI_CLI_STATIC_MODELS = [ + { name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' }, + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' }, + { name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' }, + { name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' }, + { name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' }, + { name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' }, +]; +const EMPTY_PROXY_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +}; + +function isGeminiCliPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'gemini-cli'; +} + +function isAntigravityPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'antigravity'; +} + +function isInternalGeminiPlatform(platform: unknown): boolean { + return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform); +} + +function buildGeminiCliActionPath(input: { + isStreamAction: boolean; + isCountTokensAction: boolean; +}) { + if (input.isCountTokensAction) return '/v1internal:countTokens'; + if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +function isDirectGeminiFamilyPlatform(platform: unknown): boolean { + const normalized = String(platform || '').trim().toLowerCase(); + return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity'; +} + +function omitGeminiCliModelField(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; + const { model: _model, ...rest } = body as Record; + return rest; +} + +async function selectGeminiChannel(request: FastifyRequest) { + const policy = getDownstreamRoutingPolicy(request); + for (const candidate of GEMINI_MODEL_PROBES) { + const selected = await tokenRouter.selectChannel(candidate, policy); + if (selected) return selected; + } + return null; +} + +async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) { + const policy = getDownstreamRoutingPolicy(request); + for (const candidate of GEMINI_MODEL_PROBES) { + const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy); + if (selected) return selected; + } + return null; +} + +function resolveDownstreamPath(request: FastifyRequest): string { + const rawUrl = request.raw.url || request.url || ''; + const withoutQuery = rawUrl.split('?')[0] || rawUrl; + return withoutQuery || '/v1beta/models'; +} + +function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string { + const normalizedVersion = apiVersion.replace(/^\/+/, ''); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + return `/${normalizedVersion}/${normalizedAction}`; +} + +function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean { + const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; + const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; + return supportedModels.length > 0 || allowedRouteIds.length > 0; +} + +function extractGeminiListedModelName(item: unknown): string { + if (!item || typeof item !== 'object') return ''; + const rawName = typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''; + if (!rawName) return ''; + return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName; +} + +async function filterGeminiListedModelsForPolicy( + payload: unknown, + request: FastifyRequest, +): Promise { + if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) { + return payload; + } + + const policy = getDownstreamRoutingPolicy(request); + if (!hasDownstreamModelRestrictions(policy)) { + return payload; + } + + const filteredModels: unknown[] = []; + for (const item of (payload as { models: unknown[] }).models) { + const modelName = extractGeminiListedModelName(item); + if (!modelName) continue; + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + filteredModels.push(item); + } + + return { + ...(payload as Record), + models: filteredModels, + }; +} + +async function readRouteAwareGeminiModels(request: FastifyRequest): Promise> { + const policy = getDownstreamRoutingPolicy(request); + const rows = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and( + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + )) + .all(); + const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName }) + .from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all(); + const deduped = Array.from(new Set([ + ...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean), + ...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean), + ])).sort(); + + const allowed: Array<{ name: string; displayName: string }> = []; + for (const modelName of deduped) { + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + allowed.push({ + name: `models/${modelName}`, + displayName: modelName, + }); + } + + return allowed; +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + upstreamPath: string | null, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath, + upstreamPath, + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel || modelRequested, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost: 0, + billingDetails: null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/gemini] failed to write proxy log', error); + } +} + +export async function geminiProxyRoute(app: FastifyInstance) { + const listModels = async (request: FastifyRequest, reply: FastifyReply) => { + const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( + request.params as { geminiApiVersion?: string } | undefined, + ); + const excludeChannelIds: number[] = []; + let retryCount = 0; + let lastStatus = 503; + let lastText = 'No available channels for Gemini models'; + let lastContentType = 'application/json'; + + while (retryCount <= MAX_RETRIES) { + const selected = retryCount === 0 + ? await selectGeminiChannel(request) + : await selectNextGeminiProbeChannel(request, excludeChannelIds); + if (!selected) { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + excludeChannelIds.push(selected.channel.id); + + try { + if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { + let models = await readRouteAwareGeminiModels(request); + if (models.length <= 0) { + await refreshModelsAndRebuildRoutes(); + models = await readRouteAwareGeminiModels(request); + } + return reply.code(200).send({ models }); + } + + if (isGeminiCliPlatform(selected.site.platform)) { + const filtered = await filterGeminiListedModelsForPolicy( + { models: GEMINI_CLI_STATIC_MODELS }, + request, + ); + return reply.code(200).send(filtered); + } + + const upstream = await fetch( + geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), + { method: 'GET' }, + ); + const text = await upstream.text(); + if (!upstream.ok) { + lastStatus = upstream.status; + lastText = text; + lastContentType = upstream.headers.get('content-type') || 'application/json'; + await tokenRouter.recordFailure?.(selected.channel.id); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + } + + try { + const parsed = JSON.parse(text); + const filtered = await filterGeminiListedModelsForPolicy(parsed, request); + return reply.code(upstream.status).send(filtered); + } catch { + return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); + } + } catch (error) { + await tokenRouter.recordFailure?.(selected.channel.id); + lastStatus = 502; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: error instanceof Error ? error.message : 'Gemini upstream request failed', + type: 'upstream_error', + }, + }); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + } + } + }; + + const handleGenerateContent = async ( + request: FastifyRequest, + reply: FastifyReply, + options?: { + downstreamProtocol?: 'gemini' | 'gemini-cli'; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }, + ) => { + const downstreamProtocol = options?.downstreamProtocol || 'gemini'; + const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli'; + const cliRequestedModel = isGeminiCliDownstream + ? (typeof (request.body as Record | null | undefined)?.model === 'string' + ? String((request.body as Record).model).trim() + : '') + : ''; + const parsedPath = isGeminiCliDownstream + ? { + apiVersion: 'v1beta', + modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`, + isStreamAction: options?.action === 'streamGenerateContent', + requestedModel: cliRequestedModel, + } + : geminiGenerateContentTransformer.parseProxyRequestPath({ + rawUrl: request.raw.url || request.url || '', + params: request.params as { geminiApiVersion?: string } | undefined, + }); + const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; + const isCountTokensAction = isGeminiCliDownstream + ? options?.action === 'countTokens' + : modelActionPath.endsWith(':countTokens'); + if (!requestedModel) { + return reply.code(400).send({ + error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, + }); + } + + const policy = getDownstreamRoutingPolicy(request); + const downstreamPath = resolveDownstreamPath(request); + const excludeChannelIds: number[] = []; + let retryCount = 0; + let lastStatus = 503; + let lastText = 'No available channels for this model'; + let lastContentType = 'application/json'; + + while (retryCount <= MAX_RETRIES) { + const selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, policy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy); + if (!selected) { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + excludeChannelIds.push(selected.channel.id); + + const actualModel = selected.actualModel || requestedModel; + const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest( + isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), + actualModel, + ); + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isGeminiCli = isGeminiCliPlatform(selected.site.platform); + const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); + const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); + const startTime = Date.now(); + let upstreamPath = ''; + + try { + if (isDirectGeminiFamily) { + if (isGeminiCli && !oauth?.projectId) { + lastStatus = 500; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini CLI OAuth project is missing', + type: 'server_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const actualModelAction = modelActionPath.replace( + /^models\/[^:]+/, + `models/${actualModel}`, + ); + upstreamPath = isInternalGemini + ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) + : resolveUpstreamPath(apiVersion, actualModelAction); + const query = new URLSearchParams(request.query as Record).toString(); + const requestBody = isInternalGemini + ? ( + isCountTokensAction + ? { request: normalizedBody } + : wrapGeminiCliRequest({ + modelName: actualModel, + projectId: oauth?.projectId || '', + request: normalizedBody as Record, + }) + ) + : normalizedBody; + const requestHeaders = isInternalGemini + ? { + 'Content-Type': 'application/json', + ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), + Authorization: `Bearer ${selected.tokenValue}`, + ...buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + } + : { + 'Content-Type': 'application/json', + }; + const targetUrl = isInternalGemini + ? `${selected.site.url}${upstreamPath}` + : geminiGenerateContentTransformer.resolveActionUrl( + selected.site.url, + apiVersion, + actualModelAction, + selected.tokenValue, + query, + ); + const upstream = isInternalGemini + ? await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: { + endpoint: 'chat', + path: upstreamPath, + headers: requestHeaders, + body: requestBody as Record, + runtime: { + executor: isGeminiCli ? 'gemini-cli' : 'antigravity', + modelName: actualModel, + stream: isStreamAction, + oauthProjectId: oauth?.projectId || null, + action: isCountTokensAction + ? 'countTokens' + : (isStreamAction ? 'streamGenerateContent' : 'generateContent'), + }, + }, + buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + : await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + }); + const contentType = upstream.headers.get('content-type') || 'application/json'; + if (!upstream.ok) { + lastStatus = upstream.status; + lastContentType = contentType; + lastText = await upstream.text(); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + lastText, + retryCount, + downstreamPath, + upstreamPath, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + try { + return reply.code(lastStatus).send(JSON.parse(lastText)); + } catch { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + + if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { + reply.hijack(); + reply.raw.statusCode = upstream.status; + reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); + const upstreamReader = upstream.body?.getReader(); + const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + if (!reader) { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); + reply.raw.end(); + return; + } + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const decoder = new TextDecoder(); + let rest = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunkText = decoder.decode(value, { stream: true }); + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + chunkText, + ); + rest = consumed.rest; + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + const tail = decoder.decode(); + if (tail) { + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + tail, + ); + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + const parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return; + } + + const text = await upstream.text(); + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + let parsedUsage = EMPTY_PROXY_USAGE; + try { + const parsed = JSON.parse(text); + const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream + ? unwrapGeminiCliPayload(parsed) + : parsed; + const responsePayload = isCountTokensAction + ? unwrappedPayload + : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( + aggregateState, + unwrappedPayload, + isStreamAction, + ); + parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream && !isCountTokensAction + ? { response: responsePayload } + : responsePayload, + ); + } catch { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); + return reply.code(upstream.status).type(contentType || 'application/json').send(text); + } + } + + if (isCountTokensAction) { + lastStatus = 501; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini countTokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody as Record, + modelName: actualModel, + stream: isStreamAction, + }); + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + actualModel, + 'openai', + requestedModel, + ); + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + requestOptions: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName: actualModel, + stream: isStreamAction, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'openai', + forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record, + providerHeaders: buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, + }; + }; + const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); + const endpointStrategy = createChatEndpointStrategy({ + downstreamFormat: 'openai', + endpointCandidates, + modelName: actualModel, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStreamAction, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest, + }); + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover: endpointStrategy.tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + }); + if (!endpointResult.ok) { + lastStatus = endpointResult.status; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: endpointResult.errText, + type: 'upstream_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + endpointResult.errText, + retryCount, + downstreamPath, + null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + upstreamPath = endpointResult.upstreamPath; + const upstream = endpointResult.upstream; + const rawText = await upstream.text(); + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch {} + const parsedUsage = parseProxyUsage(upstreamData); + const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText); + const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({ + normalized: normalizedFinal, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream + ? { response: geminiResponse } + : geminiResponse, + ); + } catch (error) { + lastStatus = 502; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: error instanceof Error ? error.message : 'Gemini upstream request failed', + type: 'upstream_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error instanceof Error ? error.message : 'Gemini upstream request failed', + retryCount, + downstreamPath, + upstreamPath || null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + }; + + const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply); + const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'generateContent', + }); + const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'streamGenerateContent', + }); + const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'countTokens', + }); + + app.get('/v1beta/models', listModels); + app.get('/gemini/:geminiApiVersion/models', listModels); + app.post('/v1beta/models/*', generateContent); + app.post('/gemini/:geminiApiVersion/models/*', generateContent); + app.post('/v1internal::generateContent', geminiCliGenerateContent); + app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent); + app.post('/v1internal::countTokens', geminiCliCountTokens); +} diff --git a/src/server/proxy-core/surfaces/inputFilesSurface.ts b/src/server/proxy-core/surfaces/inputFilesSurface.ts new file mode 100644 index 0000000..46f3d9c --- /dev/null +++ b/src/server/proxy-core/surfaces/inputFilesSurface.ts @@ -0,0 +1,114 @@ +import type { ProxyResourceOwner } from '../../middleware/auth.js'; +import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js'; +import { + inferInputFileMimeType, + normalizeInputFileBlock, + toOpenAiChatFileBlock, + toResponsesInputFileBlock, + type NormalizedInputFile, +} from '../../transformers/shared/inputFile.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function isSupportedFileMimeType(mimeType: string): boolean { + return mimeType === 'application/pdf' + || mimeType === 'text/plain' + || mimeType === 'text/markdown' + || mimeType === 'application/json' + || mimeType.startsWith('image/') + || mimeType.startsWith('audio/'); +} + +export class ProxyInputFileResolutionError extends Error { + statusCode: number; + payload: unknown; + + constructor(statusCode: number, payload: unknown) { + super(typeof payload === 'object' && payload && 'error' in (payload as Record) + ? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed') + : 'input file resolution failed'); + this.name = 'ProxyInputFileResolutionError'; + this.statusCode = statusCode; + this.payload = payload; + } +} + +function buildInvalidRequest(message: string): ProxyInputFileResolutionError { + return new ProxyInputFileResolutionError(400, { + error: { + message, + type: 'invalid_request_error', + }, + }); +} + +function buildNotFound(message: string): ProxyInputFileResolutionError { + return new ProxyInputFileResolutionError(404, { + error: { + message, + type: 'not_found_error', + }, + }); +} + +async function resolveInputFileBlock( + item: Record, + owner: ProxyResourceOwner, +): Promise> { + const normalized = normalizeInputFileBlock(item); + if (!normalized) return item; + + let resolved: NormalizedInputFile = { ...normalized }; + + if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) { + const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner); + if (!stored) { + throw buildNotFound(`file not found: ${resolved.fileId}`); + } + resolved = { + filename: stored.filename, + mimeType: stored.mimeType, + fileData: stored.contentBase64, + }; + } + + const inferredMimeType = inferInputFileMimeType(resolved); + if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) { + throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`); + } + if (inferredMimeType) { + resolved.mimeType = inferredMimeType; + } + + const type = typeof item.type === 'string' ? item.type : ''; + if (type === 'input_file') { + return toResponsesInputFileBlock(resolved); + } + if (type === 'file') { + return toOpenAiChatFileBlock(resolved); + } + return item; +} + +export async function inlineLocalInputFileReferences( + value: unknown, + owner: ProxyResourceOwner, +): Promise { + if (Array.isArray(value)) { + return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); + } + + if (!isRecord(value)) return value; + + const type = typeof value.type === 'string' ? value.type : ''; + if (type === 'input_file' || type === 'file') { + return resolveInputFileBlock(value, owner); + } + + const entries = await Promise.all( + Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const), + ); + return Object.fromEntries(entries); +} diff --git a/src/server/proxy-core/surfaces/modelsSurface.test.ts b/src/server/proxy-core/surfaces/modelsSurface.test.ts new file mode 100644 index 0000000..2fe8427 --- /dev/null +++ b/src/server/proxy-core/surfaces/modelsSurface.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { listModelsSurface } from './modelsSurface.js'; + +describe('listModelsSurface', () => { + it('returns OpenAI list shape and hides models without a resolvable channel', async () => { + const result = await listModelsSurface({ + downstreamPolicy: { type: 'all' }, + responseFormat: 'openai', + tokenRouter: { + getAvailableModels: vi.fn().mockResolvedValue(['routable-model', 'orphan-model']), + explainSelection: vi.fn() + .mockResolvedValueOnce({ selectedChannelId: null }) + .mockResolvedValueOnce({ selectedChannelId: 11 }), + }, + refreshModelsAndRebuildRoutes: vi.fn(), + isModelAllowed: vi.fn().mockResolvedValue(true), + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(result).toEqual({ + object: 'list', + data: [ + { + id: 'routable-model', + object: 'model', + created: 1773878400, + owned_by: 'metapi', + }, + ], + }); + }); + + it('returns Claude list shape when requested', async () => { + const result = await listModelsSurface({ + downstreamPolicy: { type: 'all' }, + responseFormat: 'claude', + tokenRouter: { + getAvailableModels: vi.fn().mockResolvedValue(['claude-opus-4-6']), + explainSelection: vi.fn().mockResolvedValue({ selectedChannelId: 22 }), + }, + refreshModelsAndRebuildRoutes: vi.fn(), + isModelAllowed: vi.fn().mockResolvedValue(true), + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(result).toEqual({ + data: [ + { + id: 'claude-opus-4-6', + type: 'model', + display_name: 'claude-opus-4-6', + created_at: '2026-03-19T00:00:00.000Z', + }, + ], + first_id: 'claude-opus-4-6', + last_id: 'claude-opus-4-6', + has_more: false, + }); + }); + + it('applies downstream policy filtering before selection checks and refreshes once when the first read is empty', async () => { + const getAvailableModels = vi.fn() + .mockResolvedValueOnce(['blocked-model']) + .mockResolvedValueOnce(['allowed-model']); + const refreshModelsAndRebuildRoutes = vi.fn().mockResolvedValue(undefined); + const isModelAllowed = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const explainSelection = vi.fn().mockResolvedValue({ selectedChannelId: 33 }); + + const result = await listModelsSurface({ + downstreamPolicy: { type: 'whitelist' }, + responseFormat: 'openai', + tokenRouter: { + getAvailableModels, + explainSelection, + }, + refreshModelsAndRebuildRoutes, + isModelAllowed, + now: () => new Date('2026-03-19T00:00:00.000Z'), + }); + + expect(refreshModelsAndRebuildRoutes).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + object: 'list', + data: [ + { + id: 'allowed-model', + object: 'model', + created: 1773878400, + owned_by: 'metapi', + }, + ], + }); + }); +}); diff --git a/src/server/proxy-core/surfaces/modelsSurface.ts b/src/server/proxy-core/surfaces/modelsSurface.ts new file mode 100644 index 0000000..c560da2 --- /dev/null +++ b/src/server/proxy-core/surfaces/modelsSurface.ts @@ -0,0 +1,70 @@ +function isSearchPseudoModel(modelName: string): boolean { + const normalized = (modelName || '').trim().toLowerCase(); + if (!normalized) return false; + return normalized === '__search' || /^__.+_search$/.test(normalized); +} + +type ModelsSurfaceInput = { + downstreamPolicy: unknown; + responseFormat: 'openai' | 'claude'; + tokenRouter: { + getAvailableModels(): Promise; + explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{ + selectedChannelId?: number | null; + }>; + }; + refreshModelsAndRebuildRoutes(): Promise; + isModelAllowed(modelName: string, downstreamPolicy: unknown): Promise; + now?: () => Date; +}; + +async function readVisibleModels(input: ModelsSurfaceInput): Promise { + const deduped = Array.from(new Set(await input.tokenRouter.getAvailableModels())) + .filter((modelName) => !isSearchPseudoModel(modelName)) + .sort(); + const allowed: string[] = []; + for (const modelName of deduped) { + if (!await input.isModelAllowed(modelName, input.downstreamPolicy)) { + continue; + } + const decision = await input.tokenRouter.explainSelection(modelName, [], input.downstreamPolicy); + if (typeof decision.selectedChannelId === 'number') { + allowed.push(modelName); + } + } + return allowed; +} + +export async function listModelsSurface(input: ModelsSurfaceInput) { + let models = await readVisibleModels(input); + if (models.length === 0) { + await input.refreshModelsAndRebuildRoutes(); + models = await readVisibleModels(input); + } + + const now = input.now?.() ?? new Date(); + if (input.responseFormat === 'claude') { + const data = models.map((id) => ({ + id, + type: 'model' as const, + display_name: id, + created_at: now.toISOString(), + })); + return { + data, + first_id: data[0]?.id || null, + last_id: data[data.length - 1]?.id || null, + has_more: false, + }; + } + + return { + object: 'list' as const, + data: models.map((id) => ({ + id, + object: 'model' as const, + created: Math.floor(now.getTime() / 1000), + owned_by: 'metapi', + })), + }; +} diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts new file mode 100644 index 0000000..d35aec1 --- /dev/null +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -0,0 +1,870 @@ +import { TextDecoder } from 'node:util'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { + buildUpstreamEndpointRequest, + resolveUpstreamEndpointCandidates, +} from '../../routes/proxy/upstreamEndpoint.js'; +import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; +import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { + ProxyInputFileResolutionError, + hasNonImageFileInputInOpenAiBody, + resolveResponsesBodyInputFiles, +} from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { collectResponsesFinalPayloadFromSse } from '../../routes/proxy/responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from '../../routes/proxy/geminiCliCompat.js'; +import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js'; +import { detectCliProfile } from '../cliProfiles/registry.js'; +import type { CliProfileId } from '../cliProfiles/types.js'; + +const MAX_RETRIES = 2; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function normalizeIncludeList(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +function hasExplicitInclude(body: Record): boolean { + return Object.prototype.hasOwnProperty.call(body, 'include'); +} + +function hasResponsesReasoningRequest(value: unknown): boolean { + if (!isRecord(value)) return false; + const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary']; + return relevantKeys.some((key) => { + const entry = value[key]; + if (typeof entry === 'string') return entry.trim().length > 0; + return entry !== undefined && entry !== null; + }); +} + +function carriesResponsesReasoningContinuity(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => carriesResponsesReasoningContinuity(item)); + } + if (!isRecord(value)) return false; + + const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : ''; + if (type === 'reasoning') { + if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) { + return true; + } + if (Array.isArray(value.summary) && value.summary.length > 0) { + return true; + } + } + + if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) { + return true; + } + + return carriesResponsesReasoningContinuity(value.input) + || carriesResponsesReasoningContinuity(value.content); +} + +function wantsNativeResponsesReasoning(body: unknown): boolean { + if (!isRecord(body)) return false; + const include = normalizeIncludeList(body.include); + if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) { + return true; + } + if (carriesResponsesReasoningContinuity(body.input)) { + return true; + } + if (hasExplicitInclude(body)) { + return false; + } + 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; + +type DownstreamClientContext = { + clientKind: CliProfileId; + sessionId?: string; + traceHint?: string; +}; + +function detectDownstreamClientContext(input: { + downstreamPath: string; + headers?: Record; + body?: unknown; +}): DownstreamClientContext { + const detected = detectCliProfile(input); + return { + clientKind: detected.id, + ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), + ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), + }; +} + +function deriveCodexExplicitSessionId(body: Record): string | null { + const promptCacheKey = typeof body.prompt_cache_key === 'string' + ? body.prompt_cache_key.trim() + : ''; + return promptCacheKey || null; +} + +export async function handleOpenAiResponsesSurfaceRequest( + request: FastifyRequest, + reply: FastifyReply, + downstreamPath: '/v1/responses' | '/v1/responses/compact', +) { + 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; + const isCompactRequest = downstreamPath === '/v1/responses/compact'; + if (isCompactRequest && isStream) { + return reply.code(400).send({ + error: { + message: 'stream is not supported on /v1/responses/compact', + type: 'invalid_request_error', + }, + }); + } + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; + const owner = getProxyResourceOwner(request); + let normalizedResponsesBody: Record = { + ...requestEnvelope.parsed.normalizedBody, + model: modelName, + stream: isStream, + }; + if (body.generate === false) { + normalizedResponsesBody.generate = false; + } + if (owner) { + try { + normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); + } catch (error) { + if (error instanceof ProxyInputFileResolutionError) { + return reply.code(error.statusCode).send(error.payload); + } + throw error; + } + } + const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody( + normalizedResponsesBody, + modelName, + isStream, + { defaultEncryptedReasoningInclude }, + ); + 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, + account: selected.account, + }, + modelName, + 'responses', + requestedModel, + { + hasNonImageFileInput, + wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, + }, + ); + if (requiresNativeResponsesFileUrl) { + endpointCandidates.splice(0, endpointCandidates.length, 'responses'); + } + if (endpointCandidates.length === 0) { + endpointCandidates.push('responses', 'chat', 'messages'); + } + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }) + ); + const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody); + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName, + stream: upstreamStream, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'responses', + responsesOriginalBody: normalizedResponsesBody, + downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), + codexExplicitSessionId, + }); + const upstreamPath = ( + isCompactRequest && endpoint === 'responses' + ? `${endpointRequest.path}/compact` + : endpointRequest.path + ); + return { + endpoint, + path: upstreamPath, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, + }; + }; + const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); + const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ + isStream: isStream || isCodexSite, + requiresNativeResponsesFileUrl, + dispatchRequest, + }); + const tryRecover = async (ctx: Parameters>[0]) => { + if (ctx.response.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); + const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } catch { + return endpointStrategy.tryRecover(ctx); + } + } + return endpointStrategy.tryRecover(ctx); + }; + + const startTime = Date.now(); + + try { + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + proxyUrl: resolveProxyUrlForSite(selected.site), + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + onDowngrade: (ctx) => { + logProxy( + selected, + requestedModel, + 'failed', + ctx.response.status, + Date.now() - startTime, + ctx.errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + }, + }); + + if (!endpointResult.ok) { + const status = endpointResult.status || 502; + const errText = endpointResult.errText || 'unknown error'; + const rawErrText = endpointResult.rawErrText || errText; + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + status, + Date.now() - startTime, + errText, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + await recordOauthQuotaResetHint({ + accountId: selected.account.id, + statusCode: status, + errorText: rawErrText, + }); + + if (isTokenExpiredError({ status, message: errText })) { + await reportTokenExpired({ + accountId: selected.account.id, + username: selected.account.username, + siteName: selected.site.name, + detail: `HTTP ${status}`, + }); + } + + if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: `upstream returned HTTP ${status}`, + }); + return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); + } + + const upstream = endpointResult.upstream; + const successfulUpstreamPath = endpointResult.upstreamPath; + const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => { + let usageForLog = { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }; + let estimatedCost = 0; + let billingDetails: unknown = null; + + try { + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + usageForLog = { + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + }; + const billing = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + estimatedCost = billing.estimatedCost; + billingDetails = billing.billingDetails; + } catch (error) { + console.error('[responses] post-stream bookkeeping failed:', error); + } + + try { + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, + usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + } catch (error) { + console.error('[responses] post-stream success logging failed:', error); + } + }; + + if (isStream) { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + const startSseResponse = () => { + reply.hijack(); + reply.raw.statusCode = 200; + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + }; + + let parsedUsage: UsageSummary = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const writeLines = (lines: string[]) => { + for (const line of lines) reply.raw.write(line); + }; + const streamSession = openAiResponsesTransformer.proxyStream.createSession({ + modelName, + successfulUpstreamPath, + strictTerminalEvents: Object.entries(request.headers as Record) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'), + getUsage: () => parsedUsage, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); + if (!upstreamContentType.includes('text/event-stream')) { + const rawText = await upstream.text(); + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + + parsedUsage = parseProxyUsage(upstreamData); + const latency = Date.now() - startTime; + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + failure.status, + latency, + failure.reason, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + + if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: failure.reason, + }); + return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); + } + + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + + await finalizeStreamSuccess(parsedUsage, latency); + return; + } + + startSseResponse(); + + const upstreamReader = upstream.body?.getReader(); + const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + let rawText = ''; + const decoder = new TextDecoder(); + const reader = baseReader + ? { + async read() { + const result = await baseReader.read(); + if (result.value) { + rawText += decoder.decode(result.value, { stream: true }); + } + return result; + }, + async cancel(reason?: unknown) { + return baseReader.cancel(reason); + }, + releaseLock() { + return baseReader.releaseLock(); + }, + } + : baseReader; + const streamResult = await streamSession.run(reader, reply.raw); + rawText += decoder.decode(); + + const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + + // Once SSE has been hijacked and bytes may already be on the wire, we + // must not attempt to convert stream failures into a fresh HTTP error + // response or retry on another channel. Responses stream failures are + // handled in-band by the proxy stream session. + + await finalizeStreamSuccess(parsedUsage, latency); + return; + } + + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if ( + upstreamContentType.includes('text/event-stream') + && ( + successfulUpstreamPath.endsWith('/responses') + || successfulUpstreamPath.endsWith('/responses/compact') + ) + ) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await upstream.text(); + upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); + } + const latency = Date.now() - startTime; + const parsedUsage = parseProxyUsage(upstreamData); + const failure = detectProxyFailure({ rawText, usage: parsedUsage }); + if (failure) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + failure.status, + latency, + failure.reason, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + await reportProxyAllFailed({ + model: requestedModel, + reason: failure.reason, + }); + return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); + } + const normalized = openAiResponsesTransformer.transformFinalResponse( + upstreamData, + modelName, + rawText, + ); + const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ + upstreamPayload: upstreamData, + normalized, + usage: parsedUsage, + serializationMode: isCompactRequest ? 'compact' : 'response', + }); + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); + + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); + recordDownstreamCostUsage(request, estimatedCost); + logProxy( + selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, + resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return reply.send(downstreamData); + } catch (err: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + err.message, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + await reportProxyAllFailed({ + model: requestedModel, + reason: err.message || 'network failure', + }); + return reply.code(502).send({ + error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, + }); + } + } +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, + estimatedCost = 0, + billingDetails: unknown = null, + upstreamPath: string | null = null, + clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | 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, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, + billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/responses] failed to write proxy log', error); + } +} diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index d37c8ed..43107bf 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -25,6 +25,7 @@ import { getLocalDayRangeUtc, getLocalRangeStartUtc, parseStoredUtcDateTime, + type StoredUtcDateTimeInput, toLocalDayKeyFromStoredUtc, } from '../../services/localTimeService.js'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; @@ -202,7 +203,7 @@ type SiteAvailabilitySiteRow = { type SiteAvailabilityLogRow = { siteId: number | null; - createdAt: string | null; + createdAt: StoredUtcDateTimeInput; status: string | null; latencyMs: number | null; }; diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index fe51847..c60c140 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -8,12 +8,15 @@ function readSource(relativePath: string): string { describe('proxy route architecture boundaries', () => { it('keeps shared protocol helpers out of chat route', () => { const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/chatSurface.js'"); expect(source).not.toContain("from './chatFormats.js'"); - expect(source).toContain("from '../../transformers/openai/chat/index.js'"); - expect(source).toContain("from '../../transformers/anthropic/messages/index.js'"); + expect(surfaceSource).toContain("from '../../transformers/openai/chat/index.js'"); + expect(surfaceSource).toContain("from '../../transformers/anthropic/messages/index.js'"); }); it('keeps anthropic-specific stream orchestration out of chat route', () => { const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); expect(source).not.toContain('serializeAnthropicRawSseEvent'); expect(source).not.toContain('syncAnthropicRawStreamStateFromEvent'); expect(source).not.toContain('isAnthropicRawSseEventName'); @@ -28,14 +31,15 @@ describe('proxy route architecture boundaries', () => { 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('); + expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession('); + expect(surfaceSource).toContain('streamSession.consumeUpstreamFinalPayload('); + expect(surfaceSource).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('); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + expect(surfaceSource).toContain('downstreamTransformer.compatibility.createEndpointStrategy('); expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody('); expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility('); expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError('); @@ -46,6 +50,8 @@ describe('proxy route architecture boundaries', () => { it('keeps responses protocol assembly out of responses route', () => { const source = readSource('./responses.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/openAiResponsesSurface.js'"); expect(source).not.toContain('function toResponsesPayload('); expect(source).not.toContain('function createResponsesStreamState('); expect(source).not.toContain("from '../../transformers/openai/responses/conversion.js'"); @@ -57,19 +63,20 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain("from './protocolCompat.js'"); expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses('); expect(source).not.toContain('function normalizeText('); - expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); - 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('); + expect(surfaceSource).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); + expect(surfaceSource).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.createState('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.serialize('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession('); + expect(surfaceSource).toContain('streamSession.run('); + expect(surfaceSource).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('); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + expect(surfaceSource).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('); @@ -87,35 +94,65 @@ describe('proxy route architecture boundaries', () => { it('keeps gemini runtime closure in transformer-owned helpers', () => { const source = readSource('./gemini.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/geminiSurface.js'"); expect(source).not.toContain('outbound.serializeAggregateResponse('); expect(source).not.toContain('aggregator.apply('); expect(source).not.toContain('stream.serializeAggregateSsePayload('); expect(source).not.toContain('stream.serializeAggregateJsonPayload('); expect(source).not.toContain('stream.applyJsonPayloadToAggregate('); expect(source).not.toContain('stream.parseSsePayloads('); - expect(source).toContain('stream.consumeUpstreamSseBuffer('); - expect(source).toContain('stream.serializeUpstreamJsonPayload('); + expect(surfaceSource).toContain('stream.consumeUpstreamSseBuffer('); + expect(surfaceSource).toContain('stream.serializeUpstreamJsonPayload('); + }); + + it('keeps proxy file persistence out of files route', () => { + const source = readSource('./files.ts'); + expect(source).toContain("from '../../proxy-core/surfaces/filesSurface.js'"); + expect(source).not.toContain('saveProxyFile('); + expect(source).not.toContain('listProxyFilesByOwner('); + expect(source).not.toContain('getProxyFileByPublicIdForOwner('); + expect(source).not.toContain('getProxyFileContentByPublicIdForOwner('); + expect(source).not.toContain('softDeleteProxyFileByPublicIdForOwner('); }); it('keeps chat stream lifecycle behind transformer-owned facade', () => { const source = readSource('./chat.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.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('); + expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession('); }); it('keeps responses stream lifecycle behind transformer-owned facade', () => { const source = readSource('./responses.ts'); + const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.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('reply.hijack();'); - expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession('); + expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete('); + expect(surfaceSource).toContain('reply.hijack();'); + expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession('); + }); + + it('keeps canonical transformer contracts imported from the transformer boundary only', () => { + const chatSource = readSource('./chat.ts'); + const responsesSource = readSource('./responses.ts'); + const geminiSource = readSource('./gemini.ts'); + + expect(chatSource).not.toContain("from '../proxy-core/"); + expect(responsesSource).not.toContain("from '../proxy-core/"); + expect(geminiSource).not.toContain("from '../proxy-core/"); + expect(chatSource).not.toContain("from '../../transformers/contracts.js'"); + expect(responsesSource).not.toContain("from '../../transformers/contracts.js'"); + expect(geminiSource).not.toContain("from '../../transformers/contracts.js'"); + expect(chatSource).not.toContain("from '../../transformers/canonical/"); + expect(responsesSource).not.toContain("from '../../transformers/canonical/"); + expect(geminiSource).not.toContain("from '../../transformers/canonical/"); }); }); diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index cc8301f..2e0da68 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -1,1012 +1,17 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { - buildClaudeCountTokensUpstreamRequest, - buildUpstreamEndpointRequest, - resolveUpstreamEndpointCandidates, -} from './upstreamEndpoint.js'; -import { - ensureModelAllowedForDownstreamKey, - getDownstreamRoutingPolicy, - recordDownstreamCostUsage, -} from './downstreamPolicy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js'; -import { detectProxyFailure } from './proxyFailureJudge.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 { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; -import { - ProxyInputFileResolutionError, - hasNonImageFileInputInOpenAiBody, - resolveOpenAiBodyInputFiles, -} from '../../services/proxyInputFileResolver.js'; -import { - buildOauthProviderHeaders, -} from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; -import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; -import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; -import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; -import { - createGeminiCliStreamReader, - unwrapGeminiCliPayload, -} from './geminiCliCompat.js'; -import { dispatchRuntimeRequest } from './runtimeExecutor.js'; - -const MAX_RETRIES = 2; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - -function asTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} + handleChatSurfaceRequest, + handleClaudeCountTokensSurfaceRequest, +} from '../../proxy-core/surfaces/chatSurface.js'; export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => - handleChatProxyRequest(request, reply, 'openai')); + handleChatSurfaceRequest(request, reply, 'openai')); } export async function claudeMessagesProxyRoute(app: FastifyInstance) { app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) => - handleChatProxyRequest(request, reply, 'claude')); + handleChatSurfaceRequest(request, reply, 'claude')); app.post('/v1/messages/count_tokens', async (request: FastifyRequest, reply: FastifyReply) => - handleClaudeCountTokensRequest(request, reply)); -} - -async function handleChatProxyRequest( - request: FastifyRequest, - reply: FastifyReply, - downstreamFormat: DownstreamFormat, -) { - const downstreamTransformer = downstreamFormat === 'claude' - ? anthropicMessagesTransformer - : openAiChatTransformer; - 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 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); - let resolvedOpenAiBody = upstreamBody; - if (owner) { - try { - resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(upstreamBody, owner); - } catch (error) { - if (error instanceof ProxyInputFileResolutionError) { - return reply.code(error.statusCode).send(error.payload); - } - throw error; - } - } - const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); - const codexSessionCacheKey = deriveCodexSessionCacheKey({ - downstreamFormat, - body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body, - requestedModel, - proxyToken: getProxyAuthContext(request)?.token || null, - }); - const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; - const logDownstreamApiKeyId = downstreamApiKeyId !== null - && await hasProxyLogDownstreamApiKeyIdColumn(); - - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - - const modelName = selected.actualModel || requestedModel; - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); - const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; - const endpointCandidates = [ - ...await resolveUpstreamEndpointCandidates( - { - site: selected.site, - account: selected.account, - }, - modelName, - downstreamFormat, - requestedModel, - { - hasNonImageFileInput, - }, - ), - ]; - const buildProviderHeaders = () => ( - buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, - downstreamHeaders: request.headers as Record, - }) - ); - const buildEndpointRequest = ( - endpoint: 'chat' | 'messages' | 'responses', - options: { forceNormalizeClaudeBody?: boolean } = {}, - ) => { - const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: upstreamStream, - tokenValue: selected.tokenValue, - oauthProvider: oauth?.provider, - oauthProjectId: oauth?.projectId, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, - downstreamFormat, - claudeOriginalBody, - forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, - downstreamHeaders: request.headers as Record, - providerHeaders: buildProviderHeaders(), - codexSessionCacheKey, - }); - return { - endpoint, - path: endpointRequest.path, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - runtime: endpointRequest.runtime, - }; - }; - const dispatchRequest = ( - compatibilityRequest: BuiltEndpointRequest, - targetUrl?: string, - ) => ( - dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: compatibilityRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }) - ); - const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ - downstreamFormat, - endpointCandidates, - modelName, - requestedModelHint: requestedModel, - sitePlatform: selected.site.platform, - isStream: isStream || isCodexSite, - buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( - endpoint, - { forceNormalizeClaudeBody }, - ), - dispatchRequest, - }); - const tryRecover = async (ctx: Parameters>[0]) => { - if (ctx.response.status === 401 && oauth) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; - const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; - const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); - if (refreshedResponse.ok) { - return { - upstream: refreshedResponse, - upstreamPath: refreshedRequest.path, - }; - } - ctx.request = refreshedRequest; - ctx.response = refreshedResponse; - ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); - } catch { - return endpointStrategy.tryRecover(ctx); - } - } - return endpointStrategy.tryRecover(ctx); - }; - let startTime = Date.now(); - - try { - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), - endpointCandidates, - buildRequest: (endpoint) => buildEndpointRequest(endpoint), - dispatchRequest, - tryRecover, - shouldDowngrade: endpointStrategy.shouldDowngrade, - onDowngrade: (ctx) => { - logProxy( - selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - }, - }); - - if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - const rawErrText = endpointResult.rawErrText || errText; - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - status, - Date.now() - startTime, - errText, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - await recordOauthQuotaResetHint({ - accountId: selected.account.id, - statusCode: status, - errorText: rawErrText, - }); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - - return reply.code(status).send({ - error: { message: errText, type: 'upstream_error' }, - }); - } - - const upstream = endpointResult.upstream; - const successfulUpstreamPath = endpointResult.upstreamPath; - - if (isStream) { - const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); - const startSseResponse = () => { - reply.hijack(); - reply.raw.statusCode = 200; - reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); - reply.raw.setHeader('Connection', 'keep-alive'); - reply.raw.setHeader('X-Accel-Buffering', 'no'); - }; - - let parsedUsage: ReturnType = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - - const writeLines = (lines: string[]) => { - for (const line of lines) { - reply.raw.write(line); - } - }; - const streamSession = openAiChatTransformer.proxyStream.createSession({ - downstreamFormat, - modelName, - successfulUpstreamPath, - onParsedPayload: (payload) => { - if (payload && typeof payload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); - } - }, - writeLines, - writeRaw: (chunk) => { - reply.raw.write(chunk); - }, - }); - let rawText = ''; - - if (!upstreamContentType.includes('text/event-stream')) { - const fallbackText = await upstream.text(); - rawText = fallbackText; - let fallbackData: unknown = null; - try { - fallbackData = JSON.parse(fallbackText); - } catch { - fallbackData = fallbackText; - } - if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { - fallbackData = unwrapGeminiCliPayload(fallbackData); - } - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData)); - const latency = Date.now() - startTime; - const failure = detectProxyFailure({ rawText, usage: parsedUsage }); - if (failure) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - failure.status, - latency, - failure.reason, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - - return reply.code(failure.status).send({ - error: { message: failure.reason, type: 'upstream_error' }, - }); - } - - startSseResponse(); - const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); - if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - } else { - startSseResponse(); - // Preserve real SSE streaming instead of buffering the whole upstream body. - const upstreamReader = upstream.body?.getReader(); - const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader - ? createGeminiCliStreamReader(upstreamReader) - : upstreamReader; - const decoder = new TextDecoder(); - const reader = baseReader - ? { - async read() { - const result = await baseReader.read(); - if (result.value) { - rawText += decoder.decode(result.value, { stream: true }); - } - return result; - }, - async cancel(reason?: unknown) { - return baseReader.cancel(reason); - }, - releaseLock() { - return baseReader.releaseLock(); - }, - } - : baseReader; - const streamResult = await streamSession.run(reader, reply.raw); - rawText += decoder.decode(); - - const latency = Date.now() - startTime; - if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - // Once SSE has been hijacked and streamed downstream, we can no longer - // safely fall back to an HTTP error response or retry by switching the - // channel mid-flight. Stream-level failures must be handled in-band by - // the proxy stream session itself. - } - - const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, - requestedModel, - 'success', - 200, - latency, - null, - retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); - let rawText = ''; - let upstreamData: unknown; - if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { - const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); - rawText = collected.rawText; - upstreamData = collected.payload; - } else { - rawText = await upstream.text(); - upstreamData = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { - upstreamData = rawText; - } - } - if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { - upstreamData = unwrapGeminiCliPayload(upstreamData); - } - - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(upstreamData); - const failure = detectProxyFailure({ rawText, usage: parsedUsage }); - if (failure) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - failure.status, - latency, - failure.reason, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - - return reply.code(failure.status).send({ - error: { message: failure.reason, type: 'upstream_error' }, - }); - } - const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); - const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); - - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, - requestedModel, - 'success', - 200, - latency, - null, - retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - - 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, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: err?.message || 'network failure', - }); - - return reply.code(502).send({ - error: { - message: `Upstream error: ${err?.message || 'network failure'}`, - type: 'upstream_error', - }, - }); - } - } -} - -function deriveCodexSessionCacheKey(input: { - downstreamFormat: DownstreamFormat | 'responses'; - body: unknown; - requestedModel: string; - proxyToken: string | null; -}): string | null { - if (isRecord(input.body)) { - if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) { - const userId = asTrimmedString(input.body.metadata.user_id); - if (userId) return `${input.requestedModel}:claude:${userId}`; - } - const promptCacheKey = asTrimmedString(input.body.prompt_cache_key); - if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`; - } - - const proxyToken = asTrimmedString(input.proxyToken); - if (proxyToken) { - return `${input.requestedModel}:proxy:${proxyToken}`; - } - - return null; -} - -async function handleClaudeCountTokensRequest( - request: FastifyRequest, - reply: FastifyReply, -) { - const rawBody = isRecord(request.body) ? { ...request.body } : null; - if (!rawBody) { - return reply.code(400).send({ - error: { - message: 'Request body must be a JSON object', - type: 'invalid_request_error', - }, - }); - } - - const requestedModel = asTrimmedString(rawBody.model); - if (!requestedModel) { - return reply.code(400).send({ - error: { - message: 'model is required', - type: 'invalid_request_error', - }, - }); - } - - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPath = '/v1/messages/count_tokens'; - const clientContext = detectDownstreamClientContext({ - downstreamPath, - headers: request.headers as Record, - body: rawBody, - }); - const downstreamPolicy = getDownstreamRoutingPolicy(request); - const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; - const logDownstreamApiKeyId = downstreamApiKeyId !== null - && await hasProxyLogDownstreamApiKeyIdColumn(); - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - if (String(selected.site.platform || '').trim().toLowerCase() !== 'claude') { - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(501).send({ - error: { - message: 'Claude count_tokens compatibility is not implemented for this upstream', - type: 'invalid_request_error', - }, - }); - } - - const modelName = selected.actualModel || requestedModel; - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); - const startTime = Date.now(); - - const buildRequest = () => { - const upstreamRequest = buildClaudeCountTokensUpstreamRequest({ - modelName, - tokenValue: selected.tokenValue, - oauthProvider: oauth?.provider, - sitePlatform: selected.site.platform, - claudeBody: rawBody, - downstreamHeaders: request.headers as Record, - }); - return { - endpoint: 'messages' as const, - path: upstreamRequest.path, - headers: upstreamRequest.headers, - body: upstreamRequest.body, - runtime: upstreamRequest.runtime, - }; - }; - - try { - let upstreamRequest = buildRequest(); - let upstream = await dispatchRuntimeRequest({ - siteUrl: selected.site.url, - request: upstreamRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }); - - if (upstream.status === 401 && oauth) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; - upstreamRequest = buildRequest(); - upstream = await dispatchRuntimeRequest({ - siteUrl: selected.site.url, - request: upstreamRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }); - } catch { - // Fall through to the regular upstream error handling below. - } - } - - const latency = Date.now() - startTime; - const contentType = upstream.headers.get('content-type') || 'application/json'; - const text = await upstream.text(); - let payload: unknown = text; - try { - payload = JSON.parse(text); - } catch { - payload = text; - } - - if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - upstream.status, - latency, - typeof payload === 'string' ? payload : JSON.stringify(payload), - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - upstreamRequest.path, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(upstream.status).type(contentType).send(payload); - } - - tokenRouter.recordSuccess(selected.channel.id, latency, 0); - recordDownstreamCostUsage(request, 0); - logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - upstreamRequest.path, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return reply.code(upstream.status).type(contentType).send(payload); - } catch (error: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 0, - Date.now() - startTime, - error?.message || 'network error', - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(502).send({ - error: { - message: `Upstream error: ${error?.message || 'network failure'}`, - type: 'upstream_error', - }, - }); - } - } -} - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, - clientContext: DownstreamClientContext | null = null, - downstreamApiKeyId: number | 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, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/chat] failed to write proxy log', error); - } + handleClaudeCountTokensSurfaceRequest(request, reply)); } diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts index f2389ba..5212dec 100644 --- a/src/server/routes/proxy/downstreamClientContext.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -19,7 +19,7 @@ describe('extractClaudeCodeSessionId', () => { }); describe('isCodexResponsesSurface', () => { - it('detects Codex responses surface from originator and stainless headers', () => { + it('detects Codex responses surface from originator, stainless, and turn-state headers', () => { expect(isCodexResponsesSurface({ originator: 'codex_cli_rs', })).toBe(true); @@ -27,6 +27,10 @@ describe('isCodexResponsesSurface', () => { expect(isCodexResponsesSurface({ 'x-stainless-lang': 'typescript', })).toBe(true); + + expect(isCodexResponsesSurface({ + 'x-codex-turn-state': 'turn-state-123', + })).toBe(true); }); it('returns false for generic responses clients', () => { @@ -109,4 +113,21 @@ describe('detectDownstreamClientContext', () => { clientKind: 'generic', }); }); + + it('recognizes Gemini CLI internal routes as a first-class downstream client kind', () => { + expect(detectDownstreamClientContext({ + downstreamPath: '/v1internal:generateContent', + body: { + model: 'gpt-4.1', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + })).toEqual({ + clientKind: 'gemini_cli', + }); + }); }); diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts index 3d46110..0b8eea8 100644 --- a/src/server/routes/proxy/downstreamClientContext.ts +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -1,4 +1,11 @@ -export type DownstreamClientKind = 'generic' | 'codex' | 'claude_code'; +import { extractClaudeCodeSessionId as extractClaudeCodeSessionIdViaProfile } from '../../proxy-core/cliProfiles/claudeCodeProfile.js'; +import { + detectCliProfile, +} from '../../proxy-core/cliProfiles/registry.js'; +import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '../../proxy-core/cliProfiles/codexProfile.js'; +import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js'; + +export type DownstreamClientKind = CliProfileId; export type DownstreamClientContext = { clientKind: DownstreamClientKind; @@ -6,76 +13,12 @@ export type DownstreamClientContext = { 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; + return isCodexResponsesSurfaceViaProfile(headers); } -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; + return extractClaudeCodeSessionIdViaProfile(userId); } export function detectDownstreamClientContext(input: { @@ -83,38 +26,10 @@ export function detectDownstreamClientContext(input: { 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' }; + const detected = detectCliProfile(input); + return { + clientKind: detected.id, + ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), + ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), + }; } diff --git a/src/server/routes/proxy/files.ts b/src/server/routes/proxy/files.ts index e4f56dc..2299e05 100644 --- a/src/server/routes/proxy/files.ts +++ b/src/server/routes/proxy/files.ts @@ -1,159 +1 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; -import { - getProxyFileByPublicIdForOwner, - getProxyFileContentByPublicIdForOwner, - listProxyFilesByOwner, - saveProxyFile, - softDeleteProxyFileByPublicIdForOwner, -} from '../../services/proxyFileStore.js'; -import { ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; - -function invalidRequest(reply: FastifyReply, message: string) { - return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); -} - -function notFound(reply: FastifyReply, message = 'file not found') { - return reply.code(404).send({ error: { message, type: 'not_found_error' } }); -} - -function toUnixSeconds(sqlDateTime: string | null | undefined): number { - if (!sqlDateTime) return Math.floor(Date.now() / 1000); - const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z'); - if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000); - return Math.floor(parsed / 1000); -} - -function toFileObject(record: { - publicId: string; - filename: string; - mimeType: string; - purpose: string | null; - byteSize: number; - createdAt: string | null; -}) { - return { - id: record.publicId, - object: 'file', - bytes: record.byteSize, - created_at: toUnixSeconds(record.createdAt), - filename: record.filename, - purpose: record.purpose || 'assistants', - mime_type: record.mimeType, - }; -} - -function inferMimeTypeFromFilename(filename: string): string { - const normalized = filename.trim().toLowerCase(); - if (normalized.endsWith('.pdf')) return 'application/pdf'; - if (normalized.endsWith('.txt')) return 'text/plain'; - if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; - if (normalized.endsWith('.json')) return 'application/json'; - if (normalized.endsWith('.png')) return 'image/png'; - if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; - if (normalized.endsWith('.gif')) return 'image/gif'; - if (normalized.endsWith('.webp')) return 'image/webp'; - if (normalized.endsWith('.wav')) return 'audio/wav'; - if (normalized.endsWith('.mp3')) return 'audio/mpeg'; - return 'application/octet-stream'; -} - -function isSupportedProxyFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - -export async function filesProxyRoute(app: FastifyInstance) { - ensureMultipartBufferParser(app); - - app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - - const formData = await parseMultipartFormData(request); - if (!formData) { - return invalidRequest(reply, 'multipart/form-data with a file field is required'); - } - - const fileEntry = formData.get('file'); - if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') { - return invalidRequest(reply, 'file field is required'); - } - - const filename = fileEntry.name || 'upload.bin'; - const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); - if (!isSupportedProxyFileMimeType(mimeType)) { - return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); - } - - const purposeValue = formData.get('purpose'); - const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0 - ? purposeValue.trim() - : 'assistants'; - const buffer = Buffer.from(await fileEntry.arrayBuffer()); - - const saved = await saveProxyFile({ - ...owner, - filename, - mimeType, - purpose, - contentBase64: buffer.toString('base64'), - }); - return reply.send(toFileObject(saved)); - }); - - app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const files = await listProxyFilesByOwner(owner); - return reply.send({ - object: 'list', - data: files.map((item) => toFileObject(item)), - has_more: false, - }); - }); - - app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner); - if (!file) return notFound(reply); - return reply.send(toFileObject(file)); - }); - - app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner); - if (!file) return notFound(reply); - reply.type(file.mimeType); - reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`); - return reply.send(file.buffer); - }); - - app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => { - const owner = getProxyResourceOwner(request); - if (!owner) { - return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } }); - } - const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner); - if (!deleted) return notFound(reply); - return reply.send({ - id: request.params.fileId, - object: 'file', - deleted: true, - }); - }); -} +export { filesProxyRoute } from '../../proxy-core/surfaces/filesSurface.js'; diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts index afcfc94..c4c24bb 100644 --- a/src/server/routes/proxy/gemini.ts +++ b/src/server/routes/proxy/gemini.ts @@ -1,843 +1 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { TextDecoder } from 'node:util'; -import { fetch } from 'undici'; -import { and, eq } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; -import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; -import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { - buildUpstreamEndpointRequest, - resolveUpstreamEndpointCandidates, -} from './upstreamEndpoint.js'; -import { - geminiGenerateContentTransformer, -} from '../../transformers/gemini/generate-content/index.js'; -import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js'; -import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js'; -import { - createGeminiCliStreamReader, - unwrapGeminiCliPayload, - wrapGeminiCliRequest, -} from './geminiCliCompat.js'; -import { dispatchRuntimeRequest } from './runtimeExecutor.js'; - -const MAX_RETRIES = 2; -const GEMINI_MODEL_PROBES = [ - 'gemini-2.5-flash', - 'gemini-2.0-flash', - 'gemini-1.5-flash', - 'gemini-pro', -]; -const GEMINI_CLI_STATIC_MODELS = [ - { name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' }, - { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, - { name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' }, - { name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' }, - { name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' }, - { name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' }, - { name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' }, -]; -const EMPTY_PROXY_USAGE = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, -}; - -function isGeminiCliPlatform(platform: unknown): boolean { - return String(platform || '').trim().toLowerCase() === 'gemini-cli'; -} - -function isAntigravityPlatform(platform: unknown): boolean { - return String(platform || '').trim().toLowerCase() === 'antigravity'; -} - -function isInternalGeminiPlatform(platform: unknown): boolean { - return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform); -} - -function buildGeminiCliActionPath(input: { - isStreamAction: boolean; - isCountTokensAction: boolean; -}) { - if (input.isCountTokensAction) return '/v1internal:countTokens'; - if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse'; - return '/v1internal:generateContent'; -} - -function isDirectGeminiFamilyPlatform(platform: unknown): boolean { - const normalized = String(platform || '').trim().toLowerCase(); - return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity'; -} - -function omitGeminiCliModelField(body: unknown): Record { - if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; - const { model: _model, ...rest } = body as Record; - return rest; -} - -async function selectGeminiChannel(request: FastifyRequest) { - const policy = getDownstreamRoutingPolicy(request); - for (const candidate of GEMINI_MODEL_PROBES) { - const selected = await tokenRouter.selectChannel(candidate, policy); - if (selected) return selected; - } - return null; -} - -async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) { - const policy = getDownstreamRoutingPolicy(request); - for (const candidate of GEMINI_MODEL_PROBES) { - const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy); - if (selected) return selected; - } - return null; -} - -function resolveDownstreamPath(request: FastifyRequest): string { - const rawUrl = request.raw.url || request.url || ''; - const withoutQuery = rawUrl.split('?')[0] || rawUrl; - return withoutQuery || '/v1beta/models'; -} - -function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string { - const normalizedVersion = apiVersion.replace(/^\/+/, ''); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - return `/${normalizedVersion}/${normalizedAction}`; -} - -function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean { - const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; - const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; - return supportedModels.length > 0 || allowedRouteIds.length > 0; -} - -function extractGeminiListedModelName(item: unknown): string { - if (!item || typeof item !== 'object') return ''; - const rawName = typeof (item as { name?: unknown }).name === 'string' - ? (item as { name: string }).name.trim() - : ''; - if (!rawName) return ''; - return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName; -} - -async function filterGeminiListedModelsForPolicy( - payload: unknown, - request: FastifyRequest, -): Promise { - if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) { - return payload; - } - - const policy = getDownstreamRoutingPolicy(request); - if (!hasDownstreamModelRestrictions(policy)) { - return payload; - } - - const filteredModels: unknown[] = []; - for (const item of (payload as { models: unknown[] }).models) { - const modelName = extractGeminiListedModelName(item); - if (!modelName) continue; - if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; - const decision = await tokenRouter.explainSelection?.(modelName, [], policy); - if (decision && typeof decision.selectedChannelId !== 'number') continue; - filteredModels.push(item); - } - - return { - ...(payload as Record), - models: filteredModels, - }; -} - -async function readRouteAwareGeminiModels(request: FastifyRequest): Promise> { - const policy = getDownstreamRoutingPolicy(request); - const rows = await db.select({ modelName: schema.modelAvailability.modelName }) - .from(schema.modelAvailability) - .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where(and( - eq(schema.modelAvailability.available, true), - eq(schema.accounts.status, 'active'), - eq(schema.sites.status, 'active'), - )) - .all(); - const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName }) - .from(schema.tokenRoutes) - .where(eq(schema.tokenRoutes.enabled, true)) - .all(); - const deduped = Array.from(new Set([ - ...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean), - ...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean), - ])).sort(); - - const allowed: Array<{ name: string; displayName: string }> = []; - for (const modelName of deduped) { - if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; - const decision = await tokenRouter.explainSelection?.(modelName, [], policy); - if (decision && typeof decision.selectedChannelId !== 'number') continue; - allowed.push({ - name: `models/${modelName}`, - displayName: modelName, - }); - } - - return allowed; -} - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - upstreamPath: string | null, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - downstreamPath, - upstreamPath, - errorMessage, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel || modelRequested, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost: 0, - billingDetails: null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/gemini] failed to write proxy log', error); - } -} - -export async function geminiProxyRoute(app: FastifyInstance) { - const listModels = async (request: FastifyRequest, reply: FastifyReply) => { - const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( - request.params as { geminiApiVersion?: string } | undefined, - ); - const excludeChannelIds: number[] = []; - let retryCount = 0; - let lastStatus = 503; - let lastText = 'No available channels for Gemini models'; - let lastContentType = 'application/json'; - - while (retryCount <= MAX_RETRIES) { - const selected = retryCount === 0 - ? await selectGeminiChannel(request) - : await selectNextGeminiProbeChannel(request, excludeChannelIds); - if (!selected) { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - excludeChannelIds.push(selected.channel.id); - - try { - if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { - let models = await readRouteAwareGeminiModels(request); - if (models.length <= 0) { - await refreshModelsAndRebuildRoutes(); - models = await readRouteAwareGeminiModels(request); - } - return reply.code(200).send({ models }); - } - - if (isGeminiCliPlatform(selected.site.platform)) { - const filtered = await filterGeminiListedModelsForPolicy( - { models: GEMINI_CLI_STATIC_MODELS }, - request, - ); - return reply.code(200).send(filtered); - } - - const upstream = await fetch( - geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), - { method: 'GET' }, - ); - const text = await upstream.text(); - if (!upstream.ok) { - lastStatus = upstream.status; - lastText = text; - lastContentType = upstream.headers.get('content-type') || 'application/json'; - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - } - - try { - const parsed = JSON.parse(text); - const filtered = await filterGeminiListedModelsForPolicy(parsed, request); - return reply.code(upstream.status).send(filtered); - } catch { - return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); - } - } catch (error) { - await tokenRouter.recordFailure?.(selected.channel.id); - lastStatus = 502; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: error instanceof Error ? error.message : 'Gemini upstream request failed', - type: 'upstream_error', - }, - }); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - } - } - }; - - const handleGenerateContent = async ( - request: FastifyRequest, - reply: FastifyReply, - options?: { - downstreamProtocol?: 'gemini' | 'gemini-cli'; - action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; - }, - ) => { - const downstreamProtocol = options?.downstreamProtocol || 'gemini'; - const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli'; - const cliRequestedModel = isGeminiCliDownstream - ? (typeof (request.body as Record | null | undefined)?.model === 'string' - ? String((request.body as Record).model).trim() - : '') - : ''; - const parsedPath = isGeminiCliDownstream - ? { - apiVersion: 'v1beta', - modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`, - isStreamAction: options?.action === 'streamGenerateContent', - requestedModel: cliRequestedModel, - } - : geminiGenerateContentTransformer.parseProxyRequestPath({ - rawUrl: request.raw.url || request.url || '', - params: request.params as { geminiApiVersion?: string } | undefined, - }); - const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; - const isCountTokensAction = isGeminiCliDownstream - ? options?.action === 'countTokens' - : modelActionPath.endsWith(':countTokens'); - if (!requestedModel) { - return reply.code(400).send({ - error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, - }); - } - - const policy = getDownstreamRoutingPolicy(request); - const downstreamPath = resolveDownstreamPath(request); - const excludeChannelIds: number[] = []; - let retryCount = 0; - let lastStatus = 503; - let lastText = 'No available channels for this model'; - let lastContentType = 'application/json'; - - while (retryCount <= MAX_RETRIES) { - const selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, policy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy); - if (!selected) { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - excludeChannelIds.push(selected.channel.id); - - const actualModel = selected.actualModel || requestedModel; - const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest( - isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), - actualModel, - ); - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); - const isGeminiCli = isGeminiCliPlatform(selected.site.platform); - const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); - const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); - const startTime = Date.now(); - let upstreamPath = ''; - - try { - if (isDirectGeminiFamily) { - if (isGeminiCli && !oauth?.projectId) { - lastStatus = 500; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: 'Gemini CLI OAuth project is missing', - type: 'server_error', - }, - }); - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - const actualModelAction = modelActionPath.replace( - /^models\/[^:]+/, - `models/${actualModel}`, - ); - upstreamPath = isInternalGemini - ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) - : resolveUpstreamPath(apiVersion, actualModelAction); - const query = new URLSearchParams(request.query as Record).toString(); - const requestBody = isInternalGemini - ? ( - isCountTokensAction - ? { request: normalizedBody } - : wrapGeminiCliRequest({ - modelName: actualModel, - projectId: oauth?.projectId || '', - request: normalizedBody as Record, - }) - ) - : normalizedBody; - const requestHeaders = isInternalGemini - ? { - 'Content-Type': 'application/json', - ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), - Authorization: `Bearer ${selected.tokenValue}`, - ...buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, - downstreamHeaders: request.headers as Record, - }), - } - : { - 'Content-Type': 'application/json', - }; - const targetUrl = isInternalGemini - ? `${selected.site.url}${upstreamPath}` - : geminiGenerateContentTransformer.resolveActionUrl( - selected.site.url, - apiVersion, - actualModelAction, - selected.tokenValue, - query, - ); - const upstream = isInternalGemini - ? await dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: { - endpoint: 'chat', - path: upstreamPath, - headers: requestHeaders, - body: requestBody as Record, - runtime: { - executor: isGeminiCli ? 'gemini-cli' : 'antigravity', - modelName: actualModel, - stream: isStreamAction, - oauthProjectId: oauth?.projectId || null, - action: isCountTokensAction - ? 'countTokens' - : (isStreamAction ? 'streamGenerateContent' : 'generateContent'), - }, - }, - buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }) - : await fetch(targetUrl, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify(requestBody), - }); - const contentType = upstream.headers.get('content-type') || 'application/json'; - if (!upstream.ok) { - lastStatus = upstream.status; - lastContentType = contentType; - lastText = await upstream.text(); - await tokenRouter.recordFailure?.(selected.channel.id); - await logProxy( - selected, - requestedModel, - 'failed', - lastStatus, - Date.now() - startTime, - lastText, - retryCount, - downstreamPath, - upstreamPath, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - try { - return reply.code(lastStatus).send(JSON.parse(lastText)); - } catch { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - } - - if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { - reply.hijack(); - reply.raw.statusCode = upstream.status; - reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); - const upstreamReader = upstream.body?.getReader(); - const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader - ? createGeminiCliStreamReader(upstreamReader) - : upstreamReader; - if (!reader) { - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - ); - reply.raw.end(); - return; - } - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - const decoder = new TextDecoder(); - let rest = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - const chunkText = decoder.decode(value, { stream: true }); - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + chunkText, - ); - rest = consumed.rest; - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - const tail = decoder.decode(); - if (tail) { - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + tail, - ); - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } - const parsedUsage = parseProxyUsage(aggregateState); - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - ); - return; - } - - const text = await upstream.text(); - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - let parsedUsage = EMPTY_PROXY_USAGE; - try { - const parsed = JSON.parse(text); - const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream - ? unwrapGeminiCliPayload(parsed) - : parsed; - const responsePayload = isCountTokensAction - ? unwrappedPayload - : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( - aggregateState, - unwrappedPayload, - isStreamAction, - ); - parsedUsage = parseProxyUsage(aggregateState); - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - ); - return reply.code(upstream.status).send( - isGeminiCliDownstream && !isCountTokensAction - ? { response: responsePayload } - : responsePayload, - ); - } catch { - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - ); - return reply.code(upstream.status).type(contentType || 'application/json').send(text); - } - } - - if (isCountTokensAction) { - lastStatus = 501; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: 'Gemini countTokens compatibility is not implemented for this upstream', - type: 'invalid_request_error', - }, - }); - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ - body: normalizedBody as Record, - modelName: actualModel, - stream: isStreamAction, - }); - const endpointCandidates = await resolveUpstreamEndpointCandidates( - { - site: selected.site, - account: selected.account, - }, - actualModel, - 'openai', - requestedModel, - ); - const buildEndpointRequest = ( - endpoint: 'chat' | 'messages' | 'responses', - requestOptions: { forceNormalizeClaudeBody?: boolean } = {}, - ) => { - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName: actualModel, - stream: isStreamAction, - tokenValue: selected.tokenValue, - oauthProvider: oauth?.provider, - oauthProjectId: oauth?.projectId, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: openAiBody, - downstreamFormat: 'openai', - forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, - downstreamHeaders: request.headers as Record, - providerHeaders: buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, - downstreamHeaders: request.headers as Record, - }), - }); - return { - endpoint, - path: endpointRequest.path, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - runtime: endpointRequest.runtime, - }; - }; - const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( - dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: compatibilityRequest, - buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }) - ); - const endpointStrategy = createChatEndpointStrategy({ - downstreamFormat: 'openai', - endpointCandidates, - modelName: actualModel, - requestedModelHint: requestedModel, - sitePlatform: selected.site.platform, - isStream: isStreamAction, - buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( - endpoint, - { forceNormalizeClaudeBody }, - ), - dispatchRequest, - }); - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - endpointCandidates, - buildRequest: (endpoint) => buildEndpointRequest(endpoint), - dispatchRequest, - tryRecover: endpointStrategy.tryRecover, - shouldDowngrade: endpointStrategy.shouldDowngrade, - }); - if (!endpointResult.ok) { - lastStatus = endpointResult.status; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: endpointResult.errText, - type: 'upstream_error', - }, - }); - await tokenRouter.recordFailure?.(selected.channel.id); - await logProxy( - selected, - requestedModel, - 'failed', - lastStatus, - Date.now() - startTime, - endpointResult.errText, - retryCount, - downstreamPath, - null, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - - upstreamPath = endpointResult.upstreamPath; - const upstream = endpointResult.upstream; - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch {} - const parsedUsage = parseProxyUsage(upstreamData); - const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText); - const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({ - normalized: normalizedFinal, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - ); - return reply.code(upstream.status).send( - isGeminiCliDownstream - ? { response: geminiResponse } - : geminiResponse, - ); - } catch (error) { - lastStatus = 502; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: error instanceof Error ? error.message : 'Gemini upstream request failed', - type: 'upstream_error', - }, - }); - await tokenRouter.recordFailure?.(selected.channel.id); - await logProxy( - selected, - requestedModel, - 'failed', - 0, - Date.now() - startTime, - error instanceof Error ? error.message : 'Gemini upstream request failed', - retryCount, - downstreamPath, - upstreamPath || null, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - } - }; - - const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply); - const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { - downstreamProtocol: 'gemini-cli', - action: 'generateContent', - }); - const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { - downstreamProtocol: 'gemini-cli', - action: 'streamGenerateContent', - }); - const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { - downstreamProtocol: 'gemini-cli', - action: 'countTokens', - }); - - app.get('/v1beta/models', listModels); - app.get('/gemini/:geminiApiVersion/models', listModels); - app.post('/v1beta/models/*', generateContent); - app.post('/gemini/:geminiApiVersion/models/*', generateContent); - app.post('/v1internal::generateContent', geminiCliGenerateContent); - app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent); - app.post('/v1internal::countTokens', geminiCliCountTokens); -} +export { geminiProxyRoute } from '../../proxy-core/surfaces/geminiSurface.js'; diff --git a/src/server/routes/proxy/inputFiles.ts b/src/server/routes/proxy/inputFiles.ts index 46f3d9c..bee47e2 100644 --- a/src/server/routes/proxy/inputFiles.ts +++ b/src/server/routes/proxy/inputFiles.ts @@ -1,114 +1,4 @@ -import type { ProxyResourceOwner } from '../../middleware/auth.js'; -import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js'; -import { - inferInputFileMimeType, - normalizeInputFileBlock, - toOpenAiChatFileBlock, - toResponsesInputFileBlock, - type NormalizedInputFile, -} from '../../transformers/shared/inputFile.js'; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function isSupportedFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - -export class ProxyInputFileResolutionError extends Error { - statusCode: number; - payload: unknown; - - constructor(statusCode: number, payload: unknown) { - super(typeof payload === 'object' && payload && 'error' in (payload as Record) - ? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed') - : 'input file resolution failed'); - this.name = 'ProxyInputFileResolutionError'; - this.statusCode = statusCode; - this.payload = payload; - } -} - -function buildInvalidRequest(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(400, { - error: { - message, - type: 'invalid_request_error', - }, - }); -} - -function buildNotFound(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(404, { - error: { - message, - type: 'not_found_error', - }, - }); -} - -async function resolveInputFileBlock( - item: Record, - owner: ProxyResourceOwner, -): Promise> { - const normalized = normalizeInputFileBlock(item); - if (!normalized) return item; - - let resolved: NormalizedInputFile = { ...normalized }; - - if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) { - const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner); - if (!stored) { - throw buildNotFound(`file not found: ${resolved.fileId}`); - } - resolved = { - filename: stored.filename, - mimeType: stored.mimeType, - fileData: stored.contentBase64, - }; - } - - const inferredMimeType = inferInputFileMimeType(resolved); - if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) { - throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`); - } - if (inferredMimeType) { - resolved.mimeType = inferredMimeType; - } - - const type = typeof item.type === 'string' ? item.type : ''; - if (type === 'input_file') { - return toResponsesInputFileBlock(resolved); - } - if (type === 'file') { - return toOpenAiChatFileBlock(resolved); - } - return item; -} - -export async function inlineLocalInputFileReferences( - value: unknown, - owner: ProxyResourceOwner, -): Promise { - if (Array.isArray(value)) { - return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); - } - - if (!isRecord(value)) return value; - - const type = typeof value.type === 'string' ? value.type : ''; - if (type === 'input_file' || type === 'file') { - return resolveInputFileBlock(value, owner); - } - - const entries = await Promise.all( - Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const), - ); - return Object.fromEntries(entries); -} +export { + inlineLocalInputFileReferences, + ProxyInputFileResolutionError, +} from '../../proxy-core/surfaces/inputFilesSurface.js'; diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index eba96ea..708a7a3 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -1,67 +1,21 @@ import { FastifyInstance } from 'fastify'; +import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; -function isSearchPseudoModel(modelName: string): boolean { - const normalized = (modelName || '').trim().toLowerCase(); - if (!normalized) return false; - return normalized === '__search' || /^__.+_search$/.test(normalized); -} - export async function modelsProxyRoute(app: FastifyInstance) { app.get('/v1/models', async (request) => { const downstreamPolicy = getDownstreamRoutingPolicy(request); - - const readModels = async () => { - const deduped = Array.from(new Set(await tokenRouter.getAvailableModels())) - .filter((modelName) => !isSearchPseudoModel(modelName)) - .sort(); - const allowed: string[] = []; - for (const modelName of deduped) { - if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) { - continue; - } - const decision = await tokenRouter.explainSelection(modelName, [], downstreamPolicy); - if (typeof decision.selectedChannelId === 'number') { - allowed.push(modelName); - } - } - return allowed; - }; - - let models = await readModels(); - if (models.length === 0) { - await refreshModelsAndRebuildRoutes(); - models = await readModels(); - } - const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string' || typeof request.headers['x-api-key'] === 'string'; - if (wantsClaudeFormat) { - const data = models.map((id) => ({ - id, - type: 'model', - display_name: id, - created_at: new Date().toISOString(), - })); - return { - data, - first_id: data[0]?.id || null, - last_id: data[data.length - 1]?.id || null, - has_more: false, - }; - } - - return { - object: 'list', - data: models.map(id => ({ - id, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'metapi', - })), - }; + return listModelsSurface({ + downstreamPolicy, + responseFormat: wantsClaudeFormat ? 'claude' : 'openai', + tokenRouter, + refreshModelsAndRebuildRoutes, + isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes, + }); }); } diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index cc7d66a..9a4a6d1 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -467,4 +467,82 @@ describe('responses proxy codex oauth refresh', () => { }); expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed'); }); + + it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed')); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_nonstream_final', + object: 'response', + model: 'gpt-5.4', + status: 'completed', + output: [ + { + id: 'msg_nonstream_final', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'pong', + }, + ], + }, + ], + output_text: 'pong', + usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.completed'); + expect(response.body).toContain('"output_text":"pong"'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + }); + + it('does not retry or mark failure after streaming SSE success when post-stream usage accounting fails', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed')); + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream_ok","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream_ok","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.completed'); + expect(response.body).toContain('"id":"resp_codex_stream_ok"'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(recordFailureMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/server/routes/proxy/responses.compact-upstream.test.ts b/src/server/routes/proxy/responses.compact-upstream.test.ts index 4193f7d..d722cfe 100644 --- a/src/server/routes/proxy/responses.compact-upstream.test.ts +++ b/src/server/routes/proxy/responses.compact-upstream.test.ts @@ -182,4 +182,113 @@ describe('responses proxy compact upstream routing', () => { ], }); }); + + it('preserves native response.compaction payloads when the upstream compact surface closes via SSE', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.output_item.added', + `data: ${JSON.stringify({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + })}`, + '', + 'event: response.completed', + `data: ${JSON.stringify({ + type: 'response.completed', + response: { + id: 'cmp_123', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + usage: { + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + }, + }, + })}`, + '', + 'data: [DONE]', + '', + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: 'cmp_123', + object: 'response.compaction', + created_at: 1700000000, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-compact-payload', + }, + ], + usage: { + input_tokens: 1234, + output_tokens: 321, + total_tokens: 1555, + }, + }); + }); + + it('collects final payloads when non-stream compact upstreams still respond with SSE final payloads directly', async () => { + fetchMock.mockResolvedValue(new Response([ + 'event: response.completed', + 'data: {"id":"cmp_sse_123","object":"response.compaction","input_tokens":12,"output_tokens":3,"total_tokens":15,"output":[{"id":"rs_123","type":"compaction","encrypted_content":"enc-from-sse"}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses/compact', + payload: { + model: 'gpt-5.2', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: 'cmp_sse_123', + object: 'response.compaction', + input_tokens: 12, + output_tokens: 3, + total_tokens: 15, + output: [ + { + id: 'rs_123', + type: 'compaction', + encrypted_content: 'enc-from-sse', + }, + ], + }); + }); }); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 4ba2f58..a27395b 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -1,801 +1,12 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; -import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; -import { - buildUpstreamEndpointRequest, - resolveUpstreamEndpointCandidates, -} from './upstreamEndpoint.js'; -import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js'; -import { detectProxyFailure } from './proxyFailureJudge.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; -import { resolveProxyLogBilling } from './proxyBilling.js'; -import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; -import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js'; -import { dispatchRuntimeRequest } from './runtimeExecutor.js'; -import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; -import { - ProxyInputFileResolutionError, - hasNonImageFileInputInOpenAiBody, - resolveResponsesBodyInputFiles, -} from '../../services/proxyInputFileResolver.js'; -import { - buildOauthProviderHeaders, -} from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; -import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; -import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; -import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; -import { - createGeminiCliStreamReader, - unwrapGeminiCliPayload, -} from './geminiCliCompat.js'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { handleOpenAiResponsesSurfaceRequest } from '../../proxy-core/surfaces/openAiResponsesSurface.js'; import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js'; -const MAX_RETRIES = 2; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function normalizeIncludeList(value: unknown): string[] { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - if (!Array.isArray(value)) return []; - return value - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => item.length > 0); -} - -function hasExplicitInclude(body: Record): boolean { - return Object.prototype.hasOwnProperty.call(body, 'include'); -} - -function hasResponsesReasoningRequest(value: unknown): boolean { - if (!isRecord(value)) return false; - const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary']; - return relevantKeys.some((key) => { - const entry = value[key]; - if (typeof entry === 'string') return entry.trim().length > 0; - return entry !== undefined && entry !== null; - }); -} - -function carriesResponsesReasoningContinuity(value: unknown): boolean { - if (Array.isArray(value)) { - return value.some((item) => carriesResponsesReasoningContinuity(item)); - } - if (!isRecord(value)) return false; - - const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : ''; - if (type === 'reasoning') { - if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) { - return true; - } - if (Array.isArray(value.summary) && value.summary.length > 0) { - return true; - } - } - - if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) { - return true; - } - - return carriesResponsesReasoningContinuity(value.input) - || carriesResponsesReasoningContinuity(value.content); -} - -function wantsNativeResponsesReasoning(body: unknown): boolean { - if (!isRecord(body)) return false; - const include = normalizeIncludeList(body.include); - if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) { - return true; - } - if (carriesResponsesReasoningContinuity(body.input)) { - return true; - } - if (hasExplicitInclude(body)) { - return false; - } - 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; - -function deriveCodexExplicitSessionId(body: Record): string | null { - const promptCacheKey = typeof body.prompt_cache_key === 'string' - ? body.prompt_cache_key.trim() - : ''; - return promptCacheKey || null; -} - export async function responsesProxyRoute(app: FastifyInstance) { ensureResponsesWebsocketTransport(app); - const handleResponsesRequest = async ( - request: FastifyRequest, - reply: FastifyReply, - downstreamPath: string, - ) => { - 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; - const isCompactRequest = downstreamPath === '/v1/responses/compact'; - if (isCompactRequest && isStream) { - return reply.code(400).send({ - error: { - message: 'stream is not supported on /v1/responses/compact', - type: 'invalid_request_error', - }, - }); - } - if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; - const downstreamPolicy = getDownstreamRoutingPolicy(request); - const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; - const logDownstreamApiKeyId = downstreamApiKeyId !== null - && await hasProxyLogDownstreamApiKeyIdColumn(); - const excludeChannelIds: number[] = []; - let retryCount = 0; - - while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } - - if (!selected) { - await reportProxyAllFailed({ - model: requestedModel, - reason: 'No available channels after retries', - }); - return reply.code(503).send({ - error: { message: 'No available channels for this model', type: 'server_error' }, - }); - } - - excludeChannelIds.push(selected.channel.id); - - const modelName = selected.actualModel || requestedModel; - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); - const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; - const owner = getProxyResourceOwner(request); - let normalizedResponsesBody: Record = { - ...requestEnvelope.parsed.normalizedBody, - model: modelName, - stream: isStream, - }; - if (body.generate === false) { - normalizedResponsesBody.generate = false; - } - if (owner) { - try { - normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); - } catch (error) { - if (error instanceof ProxyInputFileResolutionError) { - return reply.code(error.statusCode).send(error.payload); - } - throw error; - } - } - const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody( - normalizedResponsesBody, - modelName, - isStream, - { defaultEncryptedReasoningInclude }, - ); - 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, - account: selected.account, - }, - modelName, - 'responses', - requestedModel, - { - hasNonImageFileInput, - wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, - }, - ); - if (requiresNativeResponsesFileUrl) { - endpointCandidates.splice(0, endpointCandidates.length, 'responses'); - } - if (endpointCandidates.length === 0) { - endpointCandidates.push('responses', 'chat', 'messages'); - } - const buildProviderHeaders = () => ( - buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, - downstreamHeaders: request.headers as Record, - }) - ); - const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { - const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); - const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody); - const endpointRequest = buildUpstreamEndpointRequest({ - endpoint, - modelName, - stream: upstreamStream, - tokenValue: selected.tokenValue, - oauthProvider: oauth?.provider, - oauthProjectId: oauth?.projectId, - sitePlatform: selected.site.platform, - siteUrl: selected.site.url, - openaiBody: openAiBody, - downstreamFormat: 'responses', - responsesOriginalBody: normalizedResponsesBody, - downstreamHeaders: request.headers as Record, - providerHeaders: buildProviderHeaders(), - codexExplicitSessionId, - }); - const upstreamPath = ( - isCompactRequest && endpoint === 'responses' - ? `${endpointRequest.path}/compact` - : endpointRequest.path - ); - return { - endpoint, - path: upstreamPath, - headers: endpointRequest.headers, - body: endpointRequest.body as Record, - runtime: endpointRequest.runtime, - }; - }; - const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( - dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: compatibilityRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }), - }) - ); - const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ - isStream: isStream || isCodexSite, - requiresNativeResponsesFileUrl, - dispatchRequest, - }); - const tryRecover = async (ctx: Parameters>[0]) => { - if (ctx.response.status === 401 && oauth) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; - const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; - const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); - if (refreshedResponse.ok) { - return { - upstream: refreshedResponse, - upstreamPath: refreshedRequest.path, - }; - } - ctx.request = refreshedRequest; - ctx.response = refreshedResponse; - ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); - } catch { - return endpointStrategy.tryRecover(ctx); - } - } - return endpointStrategy.tryRecover(ctx); - }; - - const startTime = Date.now(); - - try { - const endpointResult = await executeEndpointFlow({ - siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), - endpointCandidates, - buildRequest: (endpoint) => buildEndpointRequest(endpoint), - dispatchRequest, - tryRecover, - shouldDowngrade: endpointStrategy.shouldDowngrade, - onDowngrade: (ctx) => { - logProxy( - selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - }, - }); - - if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - const rawErrText = endpointResult.rawErrText || errText; - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - status, - Date.now() - startTime, - errText, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - await recordOauthQuotaResetHint({ - accountId: selected.account.id, - statusCode: status, - errorText: rawErrText, - }); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); - } - - const upstream = endpointResult.upstream; - const successfulUpstreamPath = endpointResult.upstreamPath; - - if (isStream) { - const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); - const startSseResponse = () => { - reply.hijack(); - reply.raw.statusCode = 200; - reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); - reply.raw.setHeader('Connection', 'keep-alive'); - reply.raw.setHeader('X-Accel-Buffering', 'no'); - }; - let parsedUsage: UsageSummary = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; - const writeLines = (lines: string[]) => { - for (const line of lines) reply.raw.write(line); - }; - const streamSession = openAiResponsesTransformer.proxyStream.createSession({ - modelName, - successfulUpstreamPath, - strictTerminalEvents: Object.entries(request.headers as Record) - .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' - && String(rawValue).trim() === '1'), - getUsage: () => parsedUsage, - onParsedPayload: (payload) => { - if (payload && typeof payload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); - } - }, - writeLines, - writeRaw: (chunk) => { - reply.raw.write(chunk); - }, - }); - - if (!upstreamContentType.includes('text/event-stream')) { - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { - upstreamData = rawText; - } - if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { - upstreamData = unwrapGeminiCliPayload(upstreamData); - } - - parsedUsage = parseProxyUsage(upstreamData); - const latency = Date.now() - startTime; - const failure = detectProxyFailure({ rawText, usage: parsedUsage }); - if (failure) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - failure.status, - latency, - failure.reason, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); - } - - startSseResponse(); - const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); - if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - startSseResponse(); - - const upstreamReader = upstream.body?.getReader(); - const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader - ? createGeminiCliStreamReader(upstreamReader) - : upstreamReader; - let rawText = ''; - const decoder = new TextDecoder(); - const reader = baseReader - ? { - async read() { - const result = await baseReader.read(); - if (result.value) { - rawText += decoder.decode(result.value, { stream: true }); - } - return result; - }, - async cancel(reason?: unknown) { - return baseReader.cancel(reason); - }, - releaseLock() { - return baseReader.releaseLock(); - }, - } - : baseReader; - const streamResult = await streamSession.run(reader, reply.raw); - rawText += decoder.decode(); - - const latency = Date.now() - startTime; - if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - // Once SSE has been hijacked and bytes may already be on the wire, we - // must not attempt to convert stream failures into a fresh HTTP error - // response or retry on another channel. Responses stream failures are - // handled in-band by the proxy stream session. - - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return; - } - - const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); - let rawText = ''; - let upstreamData: unknown; - if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { - const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); - rawText = collected.rawText; - upstreamData = collected.payload; - } else { - rawText = await upstream.text(); - upstreamData = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { - upstreamData = rawText; - } - } - if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { - upstreamData = unwrapGeminiCliPayload(upstreamData); - } - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(upstreamData); - const failure = detectProxyFailure({ rawText, usage: parsedUsage }); - if (failure) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - failure.status, - latency, - failure.reason, - retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); - } - const normalized = openAiResponsesTransformer.transformFinalResponse( - upstreamData, - modelName, - rawText, - ); - const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ - upstreamPayload: upstreamData, - normalized, - usage: parsedUsage, - serializationMode: isCompactRequest ? 'compact' : 'response', - }); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); - recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - return reply.send(downstreamData); - } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id); - logProxy( - selected, - requestedModel, - 'failed', - 0, - Date.now() - startTime, - err.message, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - logDownstreamApiKeyId ? downstreamApiKeyId : null, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - await reportProxyAllFailed({ - model: requestedModel, - reason: err.message || 'network failure', - }); - return reply.code(502).send({ - error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, - }); - } - } - }; app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => - handleResponsesRequest(request, reply, '/v1/responses')); + handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses')); app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) => reply.code(426).send({ error: { @@ -804,59 +15,5 @@ export async function responsesProxyRoute(app: FastifyInstance) { }, })); app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) => - handleResponsesRequest(request, reply, '/v1/responses/compact')); -} - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, - clientContext: DownstreamClientContext | null = null, - downstreamApiKeyId: number | 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, - }); - await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails: billingDetails ? JSON.stringify(billingDetails) : null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }).run(); - } catch (error) { - console.warn('[proxy/responses] failed to write proxy log', error); - } + handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses/compact')); } diff --git a/src/server/routes/proxy/responsesSseFinal.test.ts b/src/server/routes/proxy/responsesSseFinal.test.ts new file mode 100644 index 0000000..9db829c --- /dev/null +++ b/src/server/routes/proxy/responsesSseFinal.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; + +describe('collectResponsesFinalPayloadFromSse', () => { + it('treats event:error payloads as upstream failures', async () => { + const upstream = { + async text() { + return [ + 'event: error', + 'data: {"error":{"message":"quota exceeded"},"type":"error"}', + '', + 'data: [DONE]', + '', + ].join('\n'); + }, + }; + + await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4')) + .rejects + .toThrow('quota exceeded'); + }); +}); diff --git a/src/server/routes/proxy/responsesSseFinal.ts b/src/server/routes/proxy/responsesSseFinal.ts index 0eb7a0c..b22eb48 100644 --- a/src/server/routes/proxy/responsesSseFinal.ts +++ b/src/server/routes/proxy/responsesSseFinal.ts @@ -24,6 +24,14 @@ function getResponsesFailureMessage(payload: Record): string { return 'upstream stream failed'; } +function hasCompleteFinalResponsesPayload(payload: Record): boolean { + return ( + payload.object === 'response.compaction' + || Array.isArray(payload.output) + || Object.prototype.hasOwnProperty.call(payload, 'output_text') + ); +} + export async function collectResponsesFinalPayloadFromSse( upstream: { text(): Promise }, modelName: string, @@ -42,18 +50,36 @@ export async function collectResponsesFinalPayloadFromSse( }; let completedPayload: Record | null = null; - const captureCompletedPayload = (lines: string[]) => { + const captureCompletedPayloadFromEvent = ( + eventType: string, + payload: Record, + ) => { + if (completedPayload) return; + if (eventType === 'response.failed' || eventType === 'response.incomplete' || eventType === 'error') { + throw new Error(getResponsesFailureMessage(payload)); + } + if (eventType !== 'response.completed') { + return; + } + if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) { + completedPayload = payload.response; + return; + } + if (hasCompleteFinalResponsesPayload(payload)) { + completedPayload = payload; + } + }; + + const captureCompletedPayloadFromLines = (lines: string[]) => { if (completedPayload) return; const parsed = openAiResponsesTransformer.pullSseEvents(lines.join('')); for (const event of parsed.events) { if (event.data === '[DONE]') continue; const payload = parseResponsesSsePayload(event.data); if (!payload) continue; - if (payload.type === 'response.failed') { - throw new Error(getResponsesFailureMessage(payload)); - } - if (payload.type === 'response.completed' && isRecord(payload.response)) { - completedPayload = payload.response; + const payloadType = typeof payload.type === 'string' ? payload.type : ''; + captureCompletedPayloadFromEvent(payloadType || event.event, payload); + if (completedPayload) { return; } } @@ -64,13 +90,15 @@ export async function collectResponsesFinalPayloadFromSse( const payload = parseResponsesSsePayload(event.data); if (!payload) continue; - if (payload.type === 'response.failed') { - throw new Error(getResponsesFailureMessage(payload)); - } - + const payloadType = typeof payload.type === 'string' ? payload.type : ''; + const eventType = payloadType || event.event; usage = mergeProxyUsage(usage, parseProxyUsage(payload)); + captureCompletedPayloadFromEvent(eventType, payload); + if (completedPayload) { + continue; + } const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName); - captureCompletedPayload(openAiResponsesTransformer.aggregator.serialize({ + captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({ state: aggregateState, streamContext, event: normalizedEvent, @@ -79,7 +107,7 @@ export async function collectResponsesFinalPayloadFromSse( } if (!completedPayload) { - captureCompletedPayload(openAiResponsesTransformer.aggregator.complete( + captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.complete( aggregateState, streamContext, usage, diff --git a/src/server/routes/proxy/runtimeExecutor.test.ts b/src/server/routes/proxy/runtimeExecutor.test.ts index 12cd02a..eb83939 100644 --- a/src/server/routes/proxy/runtimeExecutor.test.ts +++ b/src/server/routes/proxy/runtimeExecutor.test.ts @@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetch } from 'undici'; import type { BuiltEndpointRequest } from './endpointFlow.js'; -vi.mock('undici', () => ({ - fetch: vi.fn(), -})); +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: vi.fn(), + }; +}); const fetchMock = vi.mocked(fetch); diff --git a/src/server/routes/proxy/runtimeExecutor.ts b/src/server/routes/proxy/runtimeExecutor.ts index 22bd563..80d8a98 100644 --- a/src/server/routes/proxy/runtimeExecutor.ts +++ b/src/server/routes/proxy/runtimeExecutor.ts @@ -1,346 +1,24 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { fetch, type RequestInit as UndiciRequestInit } from 'undici'; -import type { BuiltEndpointRequest } from './endpointFlow.js'; -import { buildUpstreamUrl } from './upstreamUrl.js'; - -const ANTIGRAVITY_RUNTIME_BASE_URLS = [ - 'https://daily-cloudcode-pa.googleapis.com', - 'https://daily-cloudcode-pa.sandbox.googleapis.com', -] as const; - -type RuntimeDispatchInput = { - siteUrl: string; - request: BuiltEndpointRequest; - targetUrl?: string; - buildInit: (requestUrl: string, request: BuiltEndpointRequest) => Promise | UndiciRequestInit; -}; - -function asTrimmedString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function withRequestBody( - request: BuiltEndpointRequest, - body: Record, - headers?: Record, -): BuiltEndpointRequest { - return { - ...request, - headers: headers ? { ...headers } : { ...request.headers }, - body, - }; -} - -async function performFetch( - input: RuntimeDispatchInput, - request: BuiltEndpointRequest, - requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path), -): Promise>> { - const init = await input.buildInit(requestUrl, request); - return fetch(requestUrl, init); -} - -async function materializeErrorResponse( - response: Awaited>, -): Promise>> { - if (response.ok) return response; - const text = await response.text().catch(() => ''); - return new Response(text, { - status: response.status, - headers: response.headers, - }) as unknown as Awaited>; -} - -function parseGeminiCliPreviewFallbackOrder(modelName: string): string[] { - switch (modelName.trim()) { - case 'gemini-2.5-pro': - return []; - case 'gemini-2.5-flash': - return []; - case 'gemini-2.5-flash-lite': - return []; - default: - return []; - } -} - -function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined { - const raw = asTrimmedString(userAgent); - if (!raw) return undefined; - return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `); -} - -function buildGeminiCliAttemptRequest(request: BuiltEndpointRequest, modelName: string): BuiltEndpointRequest { - const body = structuredClone(request.body); - const action = request.runtime?.action; - if (action === 'countTokens') { - delete body.model; - delete body.project; - } else { - body.model = modelName; - } - const headers = { ...request.headers }; - const nextUserAgent = replaceGeminiCliModelInUserAgent( - headers['User-Agent'] || headers['user-agent'], - modelName, - ); - if (nextUserAgent) { - headers['User-Agent'] = nextUserAgent; - delete headers['user-agent']; - } - return withRequestBody(request, body, headers); -} - -function antigravityRequestType(modelName: string): 'image_gen' | 'agent' { - return modelName.includes('image') ? 'image_gen' : 'agent'; -} - -function generateAntigravityProjectId(): string { - const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; - const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge']; - const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful'; - const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal'; - const suffix = Math.floor(100000 + Math.random() * 900000); - return `${adjective}-${noun}-${suffix}`; -} - -function extractFirstUserText(value: unknown): string { - if (!Array.isArray(value)) return ''; - for (const content of value) { - if (!content || typeof content !== 'object' || Array.isArray(content)) continue; - const record = content as Record; - if (asTrimmedString(record.role) !== 'user') continue; - const parts = Array.isArray(record.parts) ? record.parts : []; - for (const part of parts) { - if (!part || typeof part !== 'object' || Array.isArray(part)) continue; - const text = asTrimmedString((part as Record).text); - if (text) return text; - } - } - return ''; -} - -function generateStableAntigravitySessionId(payload: Record): string { - const firstUserText = extractFirstUserText( - payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request) - ? (payload.request as Record).contents - : undefined, - ); - if (!firstUserText) { - return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`; - } - const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16); - const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff'); - return `-${bigint.toString()}`; -} - -function renameParametersJsonSchema(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => renameParametersJsonSchema(item)); - } - if (!value || typeof value !== 'object') return value; - - const input = value as Record; - const output: Record = {}; - for (const [key, entry] of Object.entries(input)) { - const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key; - output[nextKey] = renameParametersJsonSchema(entry); - } - return output; -} - -function deleteNestedMaxOutputTokens(payload: Record): void { - const request = payload.request; - if (!request || typeof request !== 'object' || Array.isArray(request)) return; - const generationConfig = (request as Record).generationConfig; - if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return; - delete (generationConfig as Record).maxOutputTokens; -} - -function buildAntigravityRuntimeBody( - originalBody: Record, - modelName: string, - action?: NonNullable['action'], -): Record { - const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record; - if (action === 'countTokens') { - return payload; - } - const requestType = antigravityRequestType(modelName); - const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId(); - - payload.model = modelName; - payload.project = projectId; - payload.userAgent = 'antigravity'; - payload.requestType = requestType; - payload.requestId = requestType === 'image_gen' - ? `image_gen/${Date.now()}/${randomUUID()}/12` - : `agent-${randomUUID()}`; - - const request = payload.request; - if (request && typeof request === 'object' && !Array.isArray(request)) { - delete (request as Record).safetySettings; - if (requestType !== 'image_gen') { - (request as Record).sessionId = generateStableAntigravitySessionId(payload); - } - if (modelName.includes('claude')) { - const toolConfig = ( - (request as Record).toolConfig - && typeof (request as Record).toolConfig === 'object' - && !Array.isArray((request as Record).toolConfig) - ) - ? (request as Record).toolConfig as Record - : (((request as Record).toolConfig = {}) as Record); - toolConfig.functionCallingConfig = { mode: 'VALIDATED' }; - } else { - deleteNestedMaxOutputTokens(payload); - } - } - - return payload; -} - -function antigravityShouldRetryNoCapacity( - status: number, - responseText: string, -): boolean { - if (status !== 503) return false; - return responseText.toLowerCase().includes('no capacity available'); -} - -function antigravityNoCapacityRetryDelay(attempt: number): number { - const delay = Math.min((attempt + 1) * 250, 2000); - return delay; -} - -async function waitMs(ms: number): Promise { - if (ms <= 0) return; - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function dispatchGeminiCliRuntimeRequest( - input: RuntimeDispatchInput, -): Promise>> { - const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); - const models = [baseModel, ...parseGeminiCliPreviewFallbackOrder(baseModel)].filter(Boolean); - let lastResponse: Awaited> | null = null; - - for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) { - const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName); - const response = await performFetch(input, attemptRequest); - if (response.ok) return response; - - if (response.status === 429) { - lastResponse = await materializeErrorResponse(response); - continue; - } - - return materializeErrorResponse(response); - } - - return lastResponse || performFetch(input, input.request); -} - -async function dispatchCodexRuntimeRequest( - input: RuntimeDispatchInput, -): Promise>> { - return performFetch(input, input.request); -} - -async function dispatchClaudeRuntimeRequest( - input: RuntimeDispatchInput, -): Promise>> { - return performFetch(input, input.request); -} - -async function dispatchAntigravityRuntimeRequest( - input: RuntimeDispatchInput, -): Promise>> { - const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); - const runtimeBody = buildAntigravityRuntimeBody( - input.request.body, - modelName, - input.request.runtime?.action, - ); - const baseAttempts = 3; - let lastResponse: Awaited> | null = null; - - attemptLoop: - for (let attempt = 0; attempt < baseAttempts; attempt += 1) { - for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { - const requestUrl = `${baseUrl}${input.request.path}`; - const minimalHeaders: Record = { - Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', - 'Content-Type': 'application/json', - Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', - 'User-Agent': 'antigravity/1.19.6 darwin/arm64', - }; - let response: Awaited>; - try { - response = await performFetch( - input, - withRequestBody(input.request, runtimeBody, minimalHeaders), - requestUrl, - ); - } catch (error) { - if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { - continue; - } - if (attempt + 1 < baseAttempts) { - continue attemptLoop; - } - throw error; - } - if (response.ok) return response; - - const errorResponse = await materializeErrorResponse(response); - const errorText = await errorResponse.text().catch(() => ''); - lastResponse = new Response(errorText, { - status: errorResponse.status, - headers: errorResponse.headers, - }) as unknown as Awaited>; - - if (errorResponse.status === 429) { - continue; - } - - if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) { - if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { - continue; - } - if (attempt + 1 < baseAttempts) { - await waitMs(antigravityNoCapacityRetryDelay(attempt)); - continue attemptLoop; - } - } - - return lastResponse; - } - } - - return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, { - Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', - 'Content-Type': 'application/json', - Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', - 'User-Agent': 'antigravity/1.19.6 darwin/arm64', - })); -} +import { antigravityExecutor } from '../../proxy-core/executors/antigravityExecutor.js'; +import { claudeExecutor } from '../../proxy-core/executors/claudeExecutor.js'; +import { codexExecutor } from '../../proxy-core/executors/codexExecutor.js'; +import { geminiCliExecutor } from '../../proxy-core/executors/geminiCliExecutor.js'; +import type { RuntimeDispatchInput, RuntimeResponse } from '../../proxy-core/executors/types.js'; export async function dispatchRuntimeRequest( input: RuntimeDispatchInput, -): Promise>> { +): Promise { const executor = input.request.runtime?.executor || 'default'; if (executor === 'codex') { - return dispatchCodexRuntimeRequest(input); + return codexExecutor.dispatch(input); } if (executor === 'claude') { - return dispatchClaudeRuntimeRequest(input); + return claudeExecutor.dispatch(input); } if (executor === 'gemini-cli') { - return dispatchGeminiCliRuntimeRequest(input); + return geminiCliExecutor.dispatch(input); } if (executor === 'antigravity') { - return dispatchAntigravityRuntimeRequest(input); + return antigravityExecutor.dispatch(input); } - return performFetch(input, input.request); + return codexExecutor.dispatch(input); } diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 8ffdc25..660f33d 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; +import { resolveProviderProfile } from '../../proxy-core/providers/registry.js'; import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; import type { DownstreamFormat } from '../../transformers/shared/normalized.js'; import { @@ -11,7 +12,6 @@ import { } from '../../transformers/anthropic/messages/conversion.js'; import { buildGeminiGenerateContentRequestFromOpenAi, - wrapGeminiCliRequest, } from './geminiCliCompat.js'; export { buildMinimalJsonHeadersForCompatibility, @@ -790,6 +790,7 @@ export function buildUpstreamEndpointRequest(input: { }; } { const sitePlatform = normalizePlatformName(input.sitePlatform); + const providerProfile = resolveProviderProfile(sitePlatform); const isClaudeUpstream = sitePlatform === 'claude'; const isGeminiUpstream = sitePlatform === 'gemini'; const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; @@ -886,10 +887,6 @@ export function buildUpstreamEndpointRequest(input: { }; if (isInternalGeminiUpstream) { - const projectId = asTrimmedString(input.oauthProjectId); - if (isGeminiCliUpstream && !projectId) { - throw new Error('gemini-cli oauth project id missing'); - } const instructions = ( input.downstreamFormat === 'responses' && typeof input.responsesOriginalBody?.instructions === 'string' @@ -901,27 +898,22 @@ export function buildUpstreamEndpointRequest(input: { modelName: input.modelName, instructions, }); - const headers = isGeminiCliUpstream - ? buildGeminiCliRuntimeHeaders({ - baseHeaders: commonHeaders, - providerHeaders: input.providerHeaders, - modelName: input.modelName, - stream: input.stream, - }) - : buildAntigravityRuntimeHeaders({ - baseHeaders: commonHeaders, - stream: input.stream, - }); - return { - path: resolveEndpointPath(input.endpoint), - headers, - body: wrapGeminiCliRequest({ - modelName: input.modelName, - projectId: projectId || '', - request: geminiRequest, - }) as Record, - runtime, - }; + if (!providerProfile) { + throw new Error(`missing provider profile for platform: ${sitePlatform}`); + } + return providerProfile.prepareRequest({ + endpoint: input.endpoint, + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + providerHeaders: input.providerHeaders, + body: geminiRequest, + action: input.stream ? 'streamGenerateContent' : 'generateContent', + }); } if (input.endpoint === 'messages') { @@ -961,6 +953,21 @@ export function buildUpstreamEndpointRequest(input: { convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream), ); + if (providerProfile?.id === 'claude') { + return providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: commonHeaders, + claudeHeaders, + body: sanitizedBody, + }); + } + const headers = buildClaudeRuntimeHeaders({ baseHeaders: commonHeaders, claudeHeaders, @@ -1009,6 +1016,26 @@ export function buildUpstreamEndpointRequest(input: { sitePlatform, ); + if (providerProfile?.id === 'codex') { + return providerProfile.prepareRequest({ + endpoint: 'responses', + modelName: input.modelName, + stream: input.stream, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + oauthProjectId: input.oauthProjectId, + sitePlatform, + baseHeaders: { + ...commonHeaders, + ...responsesHeaders, + }, + providerHeaders: input.providerHeaders, + codexSessionCacheKey: input.codexSessionCacheKey, + codexExplicitSessionId: input.codexExplicitSessionId, + body, + }); + } + const headers = sitePlatform === 'codex' ? buildCodexRuntimeHeaders({ baseHeaders: { @@ -1078,9 +1105,7 @@ export function buildClaudeCountTokensUpstreamRequest(input: { }; } { const sitePlatform = normalizePlatformName(input.sitePlatform); - const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude'; const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders); - const anthropicVersion = claudeHeaders['anthropic-version'] || '2023-06-01'; const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({ ...input.claudeBody, model: input.modelName, @@ -1089,23 +1114,33 @@ export function buildClaudeCountTokensUpstreamRequest(input: { delete sanitizedBody.max_tokens; delete sanitizedBody.maxTokens; delete sanitizedBody.stream; + const providerProfile = resolveProviderProfile(sitePlatform); + if (providerProfile?.id !== 'claude') { + throw new Error(`missing claude provider profile for platform: ${sitePlatform || 'unknown'}`); + } - const headers = buildClaudeRuntimeHeaders({ + const prepared = providerProfile.prepareRequest({ + endpoint: 'messages', + modelName: input.modelName, + stream: false, + tokenValue: input.tokenValue, + oauthProvider: input.oauthProvider, + sitePlatform, baseHeaders: { 'Content-Type': 'application/json', }, - claudeHeaders, - anthropicVersion, - stream: false, - isClaudeOauthUpstream, - tokenValue: input.tokenValue, - extraBetas: betas, + claudeHeaders: { + ...claudeHeaders, + ...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}), + }, + body: sanitizedBody, + action: 'countTokens', }); return { - path: '/v1/messages/count_tokens?beta=true', - headers, - body: sanitizedBody, + path: prepared.path, + headers: prepared.headers, + body: prepared.body, runtime: { executor: 'claude', modelName: input.modelName, diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 7b5d926..75d8e93 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -235,6 +235,290 @@ describe('backupService', () => { expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); }); + it('imports ALL-API-Hub V2 backups into native offline connections and summaries', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockImplementation(async () => { + throw new Error('network access should not happen during offline import'); + }); + + try { + const payload = { + version: '2.0', + timestamp: Date.now(), + accounts: { + accounts: [ + { + id: 'managed-account', + site_url: 'https://newapi.example.com', + site_type: 'new-api', + site_name: 'Managed Site', + authType: 'access_token', + account_info: { + id: 7788, + username: 'managed-user', + access_token: 'managed-session-token', + quota: 100000, + today_quota_consumption: 50000, + }, + checkIn: { + autoCheckInEnabled: true, + }, + created_at: '2026-02-01T00:00:00.000Z', + updated_at: '2026-02-02T00:00:00.000Z', + }, + { + id: 'cookie-account', + site_url: 'https://onehub.example.com', + site_type: 'one-hub', + site_name: 'Cookie Site', + username: 'cookie-user', + authType: 'cookie', + cookieAuth: { + sessionCookie: 'sid=cookie-session', + }, + checkIn: { + autoCheckInEnabled: false, + }, + created_at: '2026-02-03T00:00:00.000Z', + updated_at: '2026-02-04T00:00:00.000Z', + }, + { + id: 'direct-openai-account', + site_url: 'https://api.openai.com', + site_type: 'openai', + site_name: 'OpenAI Direct', + username: 'openai-account', + authType: 'access_token', + account_info: { + username: 'openai-account', + access_token: 'sk-openai-account', + }, + created_at: '2026-02-05T00:00:00.000Z', + updated_at: '2026-02-06T00:00:00.000Z', + }, + { + id: 'sub2api-account', + site_url: 'https://sub2api.example.com', + site_type: 'sub2api', + site_name: 'Sub2API', + authType: 'access_token', + account_info: { + id: 99, + username: 'sub2-user', + access_token: 'sub2-session-token', + }, + sub2apiAuth: { + refreshToken: 'sub2-refresh-token', + tokenExpiresAt: 1735689600000, + }, + checkIn: { + autoCheckInEnabled: true, + }, + created_at: '2026-02-07T00:00:00.000Z', + updated_at: '2026-02-08T00:00:00.000Z', + }, + { + id: 'skipped-none-account', + site_url: 'https://skip-none.example.com', + site_type: 'new-api', + site_name: 'Skip None', + authType: 'none', + username: 'skip-none-user', + created_at: '2026-02-09T00:00:00.000Z', + updated_at: '2026-02-10T00:00:00.000Z', + }, + { + id: 'skipped-empty-account', + site_url: 'https://skip-empty.example.com', + site_type: 'new-api', + site_name: 'Skip Empty', + authType: 'access_token', + account_info: { + username: 'skip-empty-user', + }, + created_at: '2026-02-11T00:00:00.000Z', + updated_at: '2026-02-12T00:00:00.000Z', + }, + ], + bookmarks: [ + { + id: 'bookmark-1', + name: 'Ignored Bookmark', + url: 'https://bookmark.example.com', + }, + ], + pinnedAccountIds: ['direct-openai-account'], + orderedAccountIds: ['managed-account', 'cookie-account', 'direct-openai-account'], + last_updated: 1735689600000, + }, + preferences: { + language: 'zh-CN', + }, + channelConfigs: { + bySite: { + demo: { enabled: true }, + }, + }, + tagStore: { + version: 1, + tagsById: {}, + }, + apiCredentialProfiles: { + version: 2, + profiles: [ + { + id: 'profile-openai', + name: 'OpenAI Profile', + apiType: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-profile-openai', + tagIds: [], + notes: '', + createdAt: 1735689601000, + updatedAt: 1735689602000, + }, + { + id: 'profile-anthropic', + name: 'Claude Profile', + apiType: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + apiKey: 'sk-profile-claude', + tagIds: [], + notes: '', + createdAt: 1735689603000, + updatedAt: 1735689604000, + }, + { + id: 'profile-gemini', + name: 'Gemini Profile', + apiType: 'google', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + apiKey: 'gemini-profile-key', + tagIds: [], + notes: '', + createdAt: 1735689605000, + updatedAt: 1735689606000, + }, + { + id: 'profile-compat-fallback', + name: 'Compat Profile', + apiType: 'openai-compatible', + baseUrl: 'https://compat.example.com/v1', + apiKey: 'sk-compat-profile', + tagIds: [], + notes: '', + createdAt: 1735689607000, + updatedAt: 1735689608000, + }, + ], + lastUpdated: 1735689609000, + }, + } as Record; + + const result = await backupService.importBackup(payload); + const summary = (result as any).summary; + const warnings = (result as any).warnings; + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + expect(result.sections.preferences).toBe(true); + expect(summary).toMatchObject({ + importedAccounts: 4, + importedProfiles: 4, + importedApiKeyConnections: 5, + importedSites: 7, + skippedAccounts: 2, + }); + expect(summary.ignoredSections).toEqual( + expect.arrayContaining(['accounts.bookmarks', 'channelConfigs', 'tagStore']), + ); + expect(warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining('skipped-none-account'), + expect.stringContaining('skipped-empty-account'), + ]), + ); + + const sites = await db.select().from(schema.sites).all(); + const accounts = await db.select().from(schema.accounts).all(); + const accountTokens = await db.select().from(schema.accountTokens).all(); + const settings = await db.select().from(schema.settings).all(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sites).toHaveLength(7); + expect(accounts).toHaveLength(8); + expect(accountTokens).toHaveLength(5); + expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_channel_configs_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_tag_store_ref_v2')).toBe(true); + expect(settings.some((row) => row.key === 'legacy_api_credential_profiles_ref_v2')).toBe(false); + + const managedAccount = accounts.find((row) => row.username === 'managed-user'); + const cookieAccount = accounts.find((row) => row.username === 'cookie-user'); + const openAiAccount = accounts.find((row) => row.username === 'openai-account'); + const sub2apiAccount = accounts.find((row) => row.username === 'sub2-user'); + const openAiProfileAccount = accounts.find((row) => row.username === 'OpenAI Profile'); + const claudeProfileAccount = accounts.find((row) => row.username === 'Claude Profile'); + const geminiProfileAccount = accounts.find((row) => row.username === 'Gemini Profile'); + const compatProfileAccount = accounts.find((row) => row.username === 'Compat Profile'); + + expect(managedAccount?.accessToken).toBe('managed-session-token'); + expect(managedAccount?.apiToken).toBeNull(); + expect(managedAccount?.checkinEnabled).toBe(true); + expect(JSON.parse(managedAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + platformUserId: 7788, + }); + + expect(cookieAccount?.accessToken).toBe('sid=cookie-session'); + expect(cookieAccount?.checkinEnabled).toBe(false); + expect(JSON.parse(cookieAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + }); + + expect(openAiAccount?.accessToken).toBe(''); + expect(openAiAccount?.apiToken).toBe('sk-openai-account'); + expect(openAiAccount?.checkinEnabled).toBe(false); + expect(JSON.parse(openAiAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'apikey', + }); + + expect(sub2apiAccount?.accessToken).toBe('sub2-session-token'); + expect(JSON.parse(sub2apiAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + platformUserId: 99, + sub2apiAuth: { + refreshToken: 'sub2-refresh-token', + tokenExpiresAt: 1735689600000, + }, + }); + + expect(openAiProfileAccount?.accessToken).toBe(''); + expect(openAiProfileAccount?.apiToken).toBe('sk-profile-openai'); + expect(JSON.parse(openAiProfileAccount?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'apikey', + }); + expect(claudeProfileAccount?.apiToken).toBe('sk-profile-claude'); + expect(geminiProfileAccount?.apiToken).toBe('gemini-profile-key'); + expect(compatProfileAccount?.apiToken).toBe('sk-compat-profile'); + + const openAiSite = sites.find((row) => row.platform === 'openai' && row.url === 'https://api.openai.com'); + expect(openAiSite).toBeTruthy(); + expect(accounts.filter((row) => row.siteId === openAiSite?.id)).toHaveLength(2); + + expect(accountTokens.map((row) => row.token).sort()).toEqual([ + 'gemini-profile-key', + 'sk-compat-profile', + 'sk-openai-account', + 'sk-profile-claude', + 'sk-profile-openai', + ]); + expect(accountTokens.every((row) => row.name === 'default' && row.isDefault && row.source === 'legacy')).toBe(true); + } finally { + fetchSpy.mockRestore(); + } + }); + it('backfills oauth columns from extraConfig when importing older backups', async () => { const payload = { timestamp: Date.now(), diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 9e36a21..ac415ca 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm'; import cron from 'node-cron'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; +import { mergeAccountExtraConfig } from './accountExtraConfig.js'; import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; const BACKUP_VERSION = '2.0'; @@ -60,6 +61,15 @@ interface BackupImportResult { preferences: boolean; }; appliedSettings: Array<{ key: string; value: unknown }>; + summary?: { + importedSites: number; + importedAccounts: number; + importedProfiles: number; + importedApiKeyConnections: number; + skippedAccounts: number; + ignoredSections: string[]; + }; + warnings?: string[]; } const EXCLUDED_SETTING_KEYS = new Set([ @@ -67,6 +77,48 @@ const EXCLUDED_SETTING_KEYS = new Set([ 'auth_token', ]); +const DIRECT_API_PLATFORMS = new Set([ + 'openai', + 'claude', + 'gemini', + 'cliproxyapi', + 'codex', + 'gemini-cli', + 'antigravity', +]); + +const IMPORT_PLATFORM_ALIASES: Record = { + anyrouter: 'anyrouter', + 'wong-gongyi': 'new-api', + 'vo-api': 'new-api', + 'super-api': 'new-api', + 'rix-api': 'new-api', + 'neo-api': 'new-api', + newapi: 'new-api', + 'new api': 'new-api', + 'new-api': 'new-api', + oneapi: 'one-api', + 'one api': 'one-api', + 'one-api': 'one-api', + onehub: 'one-hub', + 'one-hub': 'one-hub', + donehub: 'done-hub', + 'done-hub': 'done-hub', + veloera: 'veloera', + sub2api: 'sub2api', + openai: 'openai', + anthropic: 'claude', + claude: 'claude', + google: 'gemini', + gemini: 'gemini', + cliproxyapi: 'cliproxyapi', + cpa: 'cliproxyapi', + 'cli-proxy-api': 'cliproxyapi', + codex: 'codex', + 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', +}; + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -141,6 +193,354 @@ function normalizeLegacyPlatform(raw: string): string { return 'new-api'; } +function normalizeOriginUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + return new URL(trimmed).origin; + } catch { + return trimmed.replace(/\/+$/, ''); + } +} + +function detectLocalPlatformByUrlHint(url: string): string | undefined { + const normalized = url.trim().toLowerCase(); + if (!normalized) return undefined; + + if (normalized.includes('api.openai.com')) return 'openai'; + if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; + if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; + if ( + normalized.includes('generativelanguage.googleapis.com') + || normalized.includes('googleapis.com/v1beta/openai') + || normalized.includes('gemini.google.com') + ) { + return 'gemini'; + } + if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; + if (normalized.includes('anyrouter')) return 'anyrouter'; + if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; + if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; + if (normalized.includes('veloera')) return 'veloera'; + if (normalized.includes('sub2api')) return 'sub2api'; + if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; + + return undefined; +} + +function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { + const normalizedPlatform = typeof rawPlatform === 'string' + ? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()] + : undefined; + if (normalizedPlatform) return normalizedPlatform; + + const urlHint = detectLocalPlatformByUrlHint(rawUrl); + if (urlHint) return urlHint; + + return normalizeLegacyPlatform(asString(rawPlatform)); +} + +function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): string { + const normalizedType = asString(apiType).toLowerCase(); + if (normalizedType === 'openai') return 'openai'; + if (normalizedType === 'anthropic') return 'claude'; + if (normalizedType === 'google') return 'gemini'; + if (normalizedType === 'openai-compatible') { + return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; + } + return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; +} + +function pushDefaultImportedToken( + rows: AccountTokenRow[], + nextId: () => number, + accountId: number, + token: string | null, + createdAt: string, + updatedAt: string, +) { + if (!token) return; + rows.push({ + id: nextId(), + accountId, + name: 'default', + token, + tokenGroup: 'default', + valueStatus: 'ready', + source: 'legacy', + enabled: true, + isDefault: true, + createdAt, + updatedAt, + }); +} + +function buildAllApiHubV2AccountsSection(data: RawBackupData): { + section: AccountsBackupSection; + summary: NonNullable; + warnings: string[]; +} | null { + const accountsContainer = isRecord(data.accounts) ? data.accounts : null; + if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null; + + const looksLikeV2 = + (typeof data.version === 'string' && data.version.startsWith('2')) + || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') + || Array.isArray(accountsContainer.bookmarks) + || Array.isArray(accountsContainer.pinnedAccountIds) + || Array.isArray(accountsContainer.orderedAccountIds) + || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)); + + if (!looksLikeV2) return null; + + const section: AccountsBackupSection = { + sites: [], + accounts: [], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + }; + const siteIdByKey = new Map(); + let nextSiteId = 1; + let nextAccountId = 1; + let nextTokenId = 1; + const warnings: string[] = []; + const ignoredSections: string[] = []; + let importedAccounts = 0; + let importedProfiles = 0; + let importedApiKeyConnections = 0; + let skippedAccounts = 0; + + const nextToken = () => nextTokenId++; + const ensureSite = (input: { + platform: string; + url: string; + name?: string; + createdAt: string; + updatedAt: string; + }) => { + const normalizedUrl = normalizeOriginUrl(input.url); + if (!normalizedUrl) return null; + const key = `${input.platform}::${normalizedUrl}`; + const existingId = siteIdByKey.get(key); + if (existingId) return existingId; + + const siteId = nextSiteId++; + siteIdByKey.set(key, siteId); + section.sites.push({ + id: siteId, + name: asString(input.name) || normalizedUrl, + url: normalizedUrl, + externalCheckinUrl: null, + platform: input.platform, + proxyUrl: null, + useSystemProxy: false, + customHeaders: null, + status: 'active', + isPinned: false, + sortOrder: section.sites.length, + globalWeight: 1, + apiKey: null, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + return siteId; + }; + + const addIgnoredSection = (name: string, active: boolean) => { + if (active && !ignoredSections.includes(name)) ignoredSections.push(name); + }; + + addIgnoredSection('accounts.bookmarks', Array.isArray(accountsContainer.bookmarks) && accountsContainer.bookmarks.length > 0); + addIgnoredSection('channelConfigs', isRecord(data.channelConfigs)); + addIgnoredSection('tagStore', isRecord(data.tagStore)); + + for (const row of accountsContainer.accounts) { + if (!isRecord(row)) continue; + + const createdAt = toIsoString(row.created_at); + const updatedAt = toIsoString(row.updated_at); + const siteUrl = normalizeOriginUrl(asString(row.site_url)); + const siteName = asString(row.site_name) || siteUrl; + const platform = resolveImportedPlatform(row.site_type, siteUrl); + const authType = asString(row.authType).toLowerCase(); + const accountInfo = isRecord(row.account_info) ? row.account_info : {}; + const cookieAuth = isRecord(row.cookieAuth) ? row.cookieAuth : {}; + const sub2apiAuth = isRecord(row.sub2apiAuth) ? row.sub2apiAuth : {}; + const rawAccountId = asString(row.id) || asString(row.username) || siteName || `account-${nextAccountId}`; + const username = asString(accountInfo.username) || asString(row.username) || rawAccountId; + const platformUserId = asNumber(accountInfo.id, 0); + const checkin = isRecord(row.checkIn) ? row.checkIn : {}; + const accessTokenCandidate = asString(accountInfo.access_token) || asString(row.access_token); + const cookieSession = asString(cookieAuth.sessionCookie); + const isDirectApiPlatform = DIRECT_API_PLATFORMS.has(platform); + + let accessToken = ''; + let apiToken: string | null = null; + let credentialMode: 'session' | 'apikey' | null = null; + + if (authType === 'cookie') { + if (!cookieSession) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:cookieAuth.sessionCookie 缺失`); + continue; + } + accessToken = cookieSession; + credentialMode = 'session'; + } else if (authType === 'access_token') { + if (!accessTokenCandidate) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:access_token 缺失`); + continue; + } + if (isDirectApiPlatform) { + accessToken = ''; + apiToken = accessTokenCandidate; + credentialMode = 'apikey'; + } else { + accessToken = accessTokenCandidate; + credentialMode = 'session'; + } + } else { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:authType=${authType || 'unknown'} 不支持离线迁移`); + continue; + } + + const siteId = ensureSite({ + platform, + url: siteUrl, + name: siteName, + createdAt, + updatedAt, + }); + if (!siteId) { + skippedAccounts += 1; + warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:site_url 无效`); + continue; + } + + const importedBalance = normalizeLegacyQuota(accountInfo.quota); + const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption); + const importedQuota = importedBalance + importedUsed; + const extraConfigPatch: Record = { + credentialMode, + source: 'all-api-hub', + }; + if (platformUserId > 0) { + extraConfigPatch.platformUserId = platformUserId; + } + const refreshToken = asString(sub2apiAuth.refreshToken); + const tokenExpiresAt = asNumber(sub2apiAuth.tokenExpiresAt, 0); + if (refreshToken) { + extraConfigPatch.sub2apiAuth = tokenExpiresAt > 0 + ? { refreshToken, tokenExpiresAt } + : { refreshToken }; + } + + const accountId = nextAccountId++; + section.accounts.push({ + id: accountId, + siteId, + username, + accessToken, + apiToken, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + balance: importedBalance, + balanceUsed: importedUsed, + quota: importedQuota > 0 ? importedQuota : importedBalance, + unitCost: null, + valueScore: 0, + status: asBoolean(row.disabled, false) ? 'disabled' : 'active', + isPinned: false, + sortOrder: section.accounts.length, + checkinEnabled: credentialMode === 'session' ? asBoolean(checkin.autoCheckInEnabled, true) : false, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: mergeAccountExtraConfig(undefined, extraConfigPatch), + createdAt, + updatedAt, + }); + pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiToken, createdAt, updatedAt); + if (credentialMode === 'apikey') importedApiKeyConnections += 1; + importedAccounts += 1; + } + + const profilesContainer = isRecord(data.apiCredentialProfiles) ? data.apiCredentialProfiles : null; + const profiles = Array.isArray(profilesContainer?.profiles) ? profilesContainer.profiles : []; + for (const profile of profiles) { + if (!isRecord(profile)) continue; + + const baseUrl = normalizeOriginUrl(asString(profile.baseUrl)); + const apiKey = asString(profile.apiKey); + if (!baseUrl || !apiKey) { + warnings.push(`跳过 ALL-API-Hub API 凭据 ${asString(profile.id) || asString(profile.name) || 'unknown'}:baseUrl 或 apiKey 缺失`); + continue; + } + + const createdAt = toIsoString(profile.createdAt); + const updatedAt = toIsoString(profile.updatedAt); + const platform = resolveImportedProfilePlatform(profile.apiType, asString(profile.baseUrl)); + const siteId = ensureSite({ + platform, + url: baseUrl, + name: baseUrl, + createdAt, + updatedAt, + }); + if (!siteId) continue; + + const accountId = nextAccountId++; + section.accounts.push({ + id: accountId, + siteId, + username: asString(profile.name) || asString(profile.id) || baseUrl, + accessToken: '', + apiToken: apiKey, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: section.accounts.length, + checkinEnabled: false, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: mergeAccountExtraConfig(undefined, { + credentialMode: 'apikey', + source: 'all-api-hub-profile', + importedProfileId: asString(profile.id) || undefined, + }), + createdAt, + updatedAt, + }); + pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiKey, createdAt, updatedAt); + importedApiKeyConnections += 1; + importedProfiles += 1; + } + + return { + section, + summary: { + importedSites: section.sites.length, + importedAccounts, + importedProfiles, + importedApiKeyConnections, + skippedAccounts, + ignoredSections, + }, + warnings, + }; +} + function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupSection | null { const accountsContainer = isRecord(data.accounts) ? data.accounts : null; const rows = Array.isArray(accountsContainer?.accounts) ? accountsContainer.accounts : null; @@ -285,9 +685,6 @@ function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesB if (isRecord(data.channelConfigs)) { settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs }); } - if (isRecord(data.apiCredentialProfiles)) { - settings.push({ key: 'legacy_api_credential_profiles_ref_v2', value: data.apiCredentialProfiles }); - } if (isRecord(data.tagStore)) { settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore }); } @@ -457,6 +854,9 @@ function detectAccountsSection(data: RawBackupData): AccountsBackupSection | nul if (legacyNested) return legacyNested; } + const allApiHubV2 = buildAllApiHubV2AccountsSection(data); + if (allApiHubV2) return allApiHubV2.section; + const refFormat = buildAccountsSectionFromRefBackup(data); if (refFormat) return refFormat; @@ -483,6 +883,18 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection return null; } +function detectImportMetadata(data: RawBackupData): { + summary?: BackupImportResult['summary']; + warnings?: string[]; +} { + const allApiHubV2 = buildAllApiHubV2AccountsSection(data); + if (!allApiHubV2) return {}; + return { + summary: allApiHubV2.summary, + warnings: allApiHubV2.warnings.length > 0 ? allApiHubV2.warnings : undefined, + }; +} + async function importAccountsSection(section: AccountsBackupSection): Promise { await db.transaction(async (tx) => { await tx.delete(schema.routeChannels).run(); @@ -634,6 +1046,7 @@ export async function importBackup(data: RawBackupData): Promise 1_000_000_000_000) { + const parsed = new Date(Math.round(raw)); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + if (raw > 1_000_000_000) { + const parsed = new Date(Math.round(raw * 1000)); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + return null; +} + +export function parseStoredUtcDateTime(raw: StoredUtcDateTimeInput): Date | null { + if (raw == null) return null; + if (raw instanceof Date) { + return Number.isNaN(raw.getTime()) ? null : new Date(raw.getTime()); + } + if (typeof raw === 'number') { + return parseEpochDateTime(raw); + } + if (typeof raw !== 'string') return null; + const text = raw.trim(); if (!text) return null; + const numeric = Number(text); + if (Number.isFinite(numeric)) { + const parsedNumeric = parseEpochDateTime(numeric); + if (parsedNumeric) return parsedNumeric; + } + let parsed: Date; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) { parsed = new Date(`${text.replace(' ', 'T')}Z`); @@ -36,7 +65,7 @@ export function parseStoredUtcDateTime(raw: string | null | undefined): Date | n return parsed; } -export function toLocalDayKeyFromStoredUtc(raw: string | null | undefined): string | null { +export function toLocalDayKeyFromStoredUtc(raw: StoredUtcDateTimeInput): string | null { const parsed = parseStoredUtcDateTime(raw); if (!parsed) return null; return formatLocalDate(parsed); diff --git a/src/server/services/modelAnalysisService.test.ts b/src/server/services/modelAnalysisService.test.ts index b1af10a..df57092 100644 --- a/src/server/services/modelAnalysisService.test.ts +++ b/src/server/services/modelAnalysisService.test.ts @@ -148,4 +148,35 @@ describe('buildModelAnalysis', () => { calls: 1, }); }); + + it('accepts Date-backed createdAt values from external database drivers', () => { + const logs = [ + { + createdAt: new Date('2026-02-24T12:30:00.000Z') as unknown as string, + modelActual: 'gpt-4o', + modelRequested: null, + status: 'success', + latencyMs: 250, + totalTokens: 800, + estimatedCost: 1.6, + }, + ]; + + const result = buildModelAnalysis(logs, { + now: new Date('2026-02-24T12:00:00.000Z'), + days: 1, + }); + + expect(result.totals).toEqual({ + calls: 1, + tokens: 800, + spend: 1.6, + }); + expect(result.callRanking[0]).toMatchObject({ + model: 'gpt-4o', + calls: 1, + successRate: 100, + avgLatencyMs: 250, + }); + }); }); diff --git a/src/server/services/modelAnalysisService.ts b/src/server/services/modelAnalysisService.ts index 9a172b9..b5b3a15 100644 --- a/src/server/services/modelAnalysisService.ts +++ b/src/server/services/modelAnalysisService.ts @@ -1,9 +1,9 @@ -import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js'; +import { formatLocalDate, parseStoredUtcDateTime, type StoredUtcDateTimeInput } from './localTimeService.js'; const DAY_MS = 24 * 60 * 60 * 1000; interface ProxyLogLike { - createdAt: string | null; + createdAt: StoredUtcDateTimeInput; modelActual: string | null; modelRequested: string | null; status: string | null; diff --git a/src/server/transformers/anthropic/messages/index.test.ts b/src/server/transformers/anthropic/messages/index.test.ts new file mode 100644 index 0000000..ab06c9e --- /dev/null +++ b/src/server/transformers/anthropic/messages/index.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { anthropicMessagesTransformer } from './index.js'; + +describe('anthropicMessagesTransformer protocol contract', () => { + it('parses native messages requests into canonical envelopes', () => { + const result = anthropicMessagesTransformer.parseRequest({ + model: 'claude-sonnet-4-5', + max_tokens: 256, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + ], + metadata: { + user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'anthropic-messages', + cliProfile: 'generic', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + }); + }); + + it('builds native messages requests from canonical envelopes', () => { + const body = anthropicMessagesTransformer.buildProtocolRequest({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'count these tokens' }], + }, + ], + tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], + }); + + expect(body).toMatchObject({ + model: 'claude-sonnet-4-5', + max_tokens: 4096, + messages: [ + { + role: 'user', + content: 'count these tokens', + }, + ], + tools: [ + { + name: 'lookup', + input_schema: { type: 'object' }, + }, + ], + }); + }); +}); diff --git a/src/server/transformers/anthropic/messages/index.ts b/src/server/transformers/anthropic/messages/index.ts index 78f9ae3..9154eb7 100644 --- a/src/server/transformers/anthropic/messages/index.ts +++ b/src/server/transformers/anthropic/messages/index.ts @@ -1,11 +1,18 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; 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 { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js'; import { anthropicMessagesOutbound } from './outbound.js'; import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; import { anthropicMessagesUsage } from './usage.js'; import { createAnthropicMessagesAggregateState } from './aggregator.js'; -import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js'; +import { + isMessagesRequiredError, + shouldRetryNormalizedMessagesBody, +} from './compatibility.js'; export { ANTHROPIC_RAW_SSE_EVENT_NAMES, consumeAnthropicSseEvent, @@ -30,6 +37,47 @@ export const anthropicMessagesTransformer = { aggregator: { createState: createAnthropicMessagesAggregateState, }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = anthropicMessagesInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid messages request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'anthropic-messages', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream); + }, transformRequest(body: unknown): ReturnType { return anthropicMessagesInbound.parse(body); }, diff --git a/src/server/transformers/canonical/attachments.test.ts b/src/server/transformers/canonical/attachments.test.ts new file mode 100644 index 0000000..3354d0a --- /dev/null +++ b/src/server/transformers/canonical/attachments.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalAttachmentFromInputFileBlock, + canonicalAttachmentToNormalizedInputFile, +} from './attachments.js'; + +describe('canonical attachment helpers', () => { + it('normalizes input_file blocks and prefers file_data over file_url and file_id', () => { + const attachment = canonicalAttachmentFromInputFileBlock({ + type: 'input_file', + file_data: 'data:application/pdf;base64,QUJD', + file_url: 'https://example.com/brief.pdf', + file_id: 'file_ignored', + filename: 'brief.pdf', + }); + + expect(attachment).toEqual({ + kind: 'file', + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,QUJD', + filename: 'brief.pdf', + mimeType: 'application/pdf', + }); + + expect(canonicalAttachmentToNormalizedInputFile(attachment!)).toEqual({ + sourceType: 'input_file', + fileData: 'data:application/pdf;base64,QUJD', + filename: 'brief.pdf', + mimeType: 'application/pdf', + hadDataUrl: true, + }); + }); + + it('preserves file_url when no file_data exists', () => { + const attachment = canonicalAttachmentFromInputFileBlock({ + type: 'file', + file: { + file_url: 'https://example.com/report.json', + filename: 'report.json', + mime_type: 'application/json', + }, + }); + + expect(attachment).toEqual({ + kind: 'file', + sourceType: 'file', + fileUrl: 'https://example.com/report.json', + filename: 'report.json', + mimeType: 'application/json', + }); + }); +}); diff --git a/src/server/transformers/canonical/attachments.ts b/src/server/transformers/canonical/attachments.ts new file mode 100644 index 0000000..fa36cb0 --- /dev/null +++ b/src/server/transformers/canonical/attachments.ts @@ -0,0 +1,57 @@ +import { + normalizeInputFileBlock, + type NormalizedInputFile, +} from '../shared/inputFile.js'; + +export type CanonicalAttachment = { + kind: 'file'; + sourceType?: 'file' | 'input_file'; + fileId?: string; + fileUrl?: string; + fileData?: string; + filename?: string; + mimeType?: string | null; +}; + +function cloneOptionalString(value: string | null | undefined): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function canonicalAttachmentFromNormalizedInputFile( + file: NormalizedInputFile, +): CanonicalAttachment { + return { + kind: 'file', + sourceType: file.sourceType, + ...(cloneOptionalString(file.fileId) ? { fileId: file.fileId } : {}), + ...(cloneOptionalString(file.fileUrl) ? { fileUrl: file.fileUrl } : {}), + ...(cloneOptionalString(file.fileData) ? { fileData: file.fileData } : {}), + ...(cloneOptionalString(file.filename) ? { filename: file.filename } : {}), + ...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}), + }; +} + +export function canonicalAttachmentFromInputFileBlock( + item: Record, +): CanonicalAttachment | null { + const normalized = normalizeInputFileBlock(item); + if (!normalized) return null; + return canonicalAttachmentFromNormalizedInputFile(normalized); +} + +export function canonicalAttachmentToNormalizedInputFile( + attachment: CanonicalAttachment, +): NormalizedInputFile { + const hadDataUrl = typeof attachment.fileData === 'string' + ? /^data:[^;,]+;base64,/i.test(attachment.fileData) + : undefined; + return { + ...(attachment.sourceType ? { sourceType: attachment.sourceType } : {}), + ...(cloneOptionalString(attachment.fileId) ? { fileId: attachment.fileId } : {}), + ...(cloneOptionalString(attachment.fileUrl) ? { fileUrl: attachment.fileUrl } : {}), + ...(cloneOptionalString(attachment.fileData) ? { fileData: attachment.fileData } : {}), + ...(cloneOptionalString(attachment.filename) ? { filename: attachment.filename } : {}), + ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), + ...(hadDataUrl !== undefined ? { hadDataUrl } : {}), + }; +} diff --git a/src/server/transformers/canonical/reasoning.test.ts b/src/server/transformers/canonical/reasoning.test.ts new file mode 100644 index 0000000..f35af87 --- /dev/null +++ b/src/server/transformers/canonical/reasoning.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeCanonicalReasoningRequest } from './reasoning.js'; + +describe('canonical reasoning helpers', () => { + it('normalizes reasoning config and keeps non-reasoning include entries in metadata', () => { + const normalized = normalizeCanonicalReasoningRequest({ + include: [' reasoning.encrypted_content ', '', 'message.input_image.image_url'], + reasoning: { + effort: 'high', + }, + reasoning_budget: '2048', + reasoning_summary: 'detailed', + }); + + expect(normalized.reasoning).toEqual({ + effort: 'high', + budgetTokens: 2048, + summary: 'detailed', + includeEncryptedContent: true, + }); + expect(normalized.metadata).toEqual({ + include: ['message.input_image.image_url'], + }); + }); + + it('returns undefined when no reasoning hints are present', () => { + expect(normalizeCanonicalReasoningRequest({})).toEqual({}); + }); +}); diff --git a/src/server/transformers/canonical/reasoning.ts b/src/server/transformers/canonical/reasoning.ts new file mode 100644 index 0000000..4d7cb17 --- /dev/null +++ b/src/server/transformers/canonical/reasoning.ts @@ -0,0 +1,98 @@ +import type { TransformerMetadata } from '../shared/normalized.js'; +import type { + CanonicalReasoningEffort, + CanonicalReasoningRequest, +} from './types.js'; + +type CanonicalReasoningNormalizationInput = { + include?: unknown; + reasoning?: unknown; + reasoning_effort?: unknown; + reasoning_budget?: unknown; + reasoning_summary?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toFiniteInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return Math.trunc(parsed); + } + return undefined; +} + +function normalizeReasoningEffort(value: unknown): CanonicalReasoningEffort | undefined { + const effort = asTrimmedString(value).toLowerCase(); + switch (effort) { + case 'none': + case 'low': + case 'medium': + case 'high': + case 'max': + return effort; + default: + return undefined; + } +} + +function normalizeIncludeEntries(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +export function normalizeCanonicalReasoningRequest( + input: CanonicalReasoningNormalizationInput, +): { + reasoning?: CanonicalReasoningRequest; + metadata?: Pick; +} { + const rawReasoning = isRecord(input.reasoning) ? input.reasoning : {}; + const include = normalizeIncludeEntries(input.include); + const filteredInclude = include.filter((item) => item !== 'reasoning.encrypted_content'); + + const effort = normalizeReasoningEffort(rawReasoning.effort ?? input.reasoning_effort); + const budgetTokens = toFiniteInteger(rawReasoning.budget_tokens ?? rawReasoning.budgetTokens ?? input.reasoning_budget); + const summary = asTrimmedString(rawReasoning.summary ?? input.reasoning_summary) || undefined; + const includeEncryptedContent = include.includes('reasoning.encrypted_content') || undefined; + + const reasoning: CanonicalReasoningRequest | undefined = ( + effort + || budgetTokens !== undefined + || summary + || includeEncryptedContent + ) + ? { + ...(effort ? { effort } : {}), + ...(budgetTokens !== undefined ? { budgetTokens } : {}), + ...(summary ? { summary } : {}), + ...(includeEncryptedContent ? { includeEncryptedContent } : {}), + } + : undefined; + + const metadata = filteredInclude.length > 0 + ? { include: filteredInclude } + : undefined; + + return { + ...(reasoning ? { reasoning } : {}), + ...(metadata ? { metadata } : {}), + }; +} diff --git a/src/server/transformers/canonical/request.test.ts b/src/server/transformers/canonical/request.test.ts new file mode 100644 index 0000000..bca8bd2 --- /dev/null +++ b/src/server/transformers/canonical/request.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { createCanonicalRequestEnvelope } from './request.js'; + +describe('canonical request helpers', () => { + it('normalizes a count_tokens request without provider-owned fields', () => { + const request = createCanonicalRequestEnvelope({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: ' claude-sonnet-4-5 ', + stream: false, + continuation: { + sessionId: ' session-1 ', + promptCacheKey: ' cache-1 ', + }, + }); + + expect(request).toEqual({ + operation: 'count_tokens', + surface: 'anthropic-messages', + cliProfile: 'claude_code', + requestedModel: 'claude-sonnet-4-5', + stream: false, + messages: [], + continuation: { + sessionId: 'session-1', + promptCacheKey: 'cache-1', + }, + }); + }); + + it('defaults generate requests to generic profile and empty collections', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5.2-codex', + surface: 'openai-responses', + }); + + expect(request).toEqual({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'generic', + requestedModel: 'gpt-5.2-codex', + stream: false, + messages: [], + }); + }); +}); diff --git a/src/server/transformers/canonical/request.ts b/src/server/transformers/canonical/request.ts new file mode 100644 index 0000000..74d58a1 --- /dev/null +++ b/src/server/transformers/canonical/request.ts @@ -0,0 +1,509 @@ +import { + canonicalAttachmentFromInputFileBlock, + canonicalAttachmentToNormalizedInputFile, + type CanonicalAttachment, +} from './attachments.js'; +import { normalizeCanonicalReasoningRequest } from './reasoning.js'; +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; +import type { + CanonicalContentPart, + CanonicalCliProfile, + CanonicalContinuation, + CanonicalMessage, + CanonicalMessageRole, + CanonicalOperation, + CanonicalReasoningRequest, + CanonicalRequestEnvelope, + CanonicalSurface, +} from './types.js'; +import { toOpenAiChatFileBlock } from '../shared/inputFile.js'; + +export type CreateCanonicalRequestEnvelopeInput = { + operation?: CanonicalOperation; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + requestedModel: string; + stream?: boolean; + messages?: CanonicalMessage[]; + reasoning?: CanonicalReasoningRequest; + tools?: CanonicalTool[]; + toolChoice?: CanonicalToolChoice; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + attachments?: CanonicalAttachment[]; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function normalizeCanonicalContinuation( + continuation: CanonicalContinuation | undefined, +): CanonicalContinuation | undefined { + if (!continuation) return undefined; + + const normalized: CanonicalContinuation = { + ...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}), + ...(asTrimmedString(continuation.previousResponseId) + ? { previousResponseId: asTrimmedString(continuation.previousResponseId) } + : {}), + ...(asTrimmedString(continuation.promptCacheKey) + ? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) } + : {}), + ...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}), + }; + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function createCanonicalRequestEnvelope( + input: CreateCanonicalRequestEnvelopeInput, +): CanonicalRequestEnvelope { + const requestedModel = asTrimmedString(input.requestedModel); + if (!requestedModel) { + throw new Error('canonical request requires requestedModel'); + } + + return { + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel, + stream: input.stream === true, + messages: Array.isArray(input.messages) ? input.messages : [], + ...(input.reasoning ? { reasoning: input.reasoning } : {}), + ...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}), + ...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}), + ...(normalizeCanonicalContinuation(input.continuation) + ? { continuation: normalizeCanonicalContinuation(input.continuation) } + : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...(input.passthrough ? { passthrough: input.passthrough } : {}), + }; +} + +type CanonicalRequestFromOpenAiBodyInput = { + body: Record; + surface: CanonicalSurface; + cliProfile?: CanonicalCliProfile; + operation?: CanonicalOperation; + metadata?: Record; + passthrough?: Record; + continuation?: CanonicalContinuation; +}; + +function normalizeRole(value: unknown): CanonicalMessageRole { + const role = asTrimmedString(value).toLowerCase(); + switch (role) { + case 'system': + case 'developer': + case 'assistant': + case 'tool': + return role; + default: + return 'user'; + } +} + +function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + + if (!Array.isArray(content)) return []; + + const parts: CanonicalContentPart[] = []; + for (const item of content) { + if (typeof item === 'string') { + if (item) parts.push({ type: 'text', text: item }); + continue; + } + if (!isRecord(item)) continue; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text' || type === 'input_text' || type === 'output_text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ type: 'text', text }); + continue; + } + if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') { + const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking); + if (text) parts.push({ type: 'text', text, thought: true }); + continue; + } + if (type === 'image_url' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_image' && isRecord(item.image_url)) { + const url = asTrimmedString(item.image_url.url); + if (url) parts.push({ type: 'image', url }); + continue; + } + if (type === 'input_file' || type === 'file') { + const attachment = canonicalAttachmentFromInputFileBlock(item); + if (attachment) { + parts.push({ + type: 'file', + ...(attachment.fileId ? { fileId: attachment.fileId } : {}), + ...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}), + ...(attachment.fileData ? { fileData: attachment.fileData } : {}), + ...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}), + ...(attachment.filename ? { filename: attachment.filename } : {}), + }); + } + continue; + } + } + + return parts; +} + +function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined { + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; + if (normalized === 'any') return 'required'; + return undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'auto' || type === 'none') return type; + if (type === 'any' || type === 'required') return 'required'; + if (type !== 'tool') return undefined; + + const name = asTrimmedString( + rawToolChoice.name + ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), + ); + return name ? { type: 'tool', name } : undefined; +} + +function parseTools(rawTools: unknown): CanonicalTool[] | undefined { + if (!Array.isArray(rawTools)) return undefined; + + const tools = rawTools + .flatMap((item) => { + if (!isRecord(item)) return []; + + if (asTrimmedString(item.type).toLowerCase() === 'function' && isRecord(item.function)) { + const name = asTrimmedString(item.function.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(item.function.description) + ? { description: asTrimmedString(item.function.description) } + : {}), + ...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}), + }]; + } + + if (Array.isArray(item.functionDeclarations)) { + return item.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + ...(isRecord(declaration.parametersJsonSchema) + ? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) } + : (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})), + }]; + }); + } + + return []; + }); + + return tools.length > 0 ? tools : undefined; +} + +export function canonicalRequestFromOpenAiBody( + input: CanonicalRequestFromOpenAiBodyInput, +): CanonicalRequestEnvelope { + const body = input.body; + const messages: CanonicalMessage[] = []; + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + + for (const rawMessage of rawMessages) { + if (!isRecord(rawMessage)) continue; + const role = normalizeRole(rawMessage.role); + + if (role === 'tool') { + const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); + const resultText = typeof rawMessage.content === 'string' + ? rawMessage.content + : safeJsonStringify(rawMessage.content ?? ''); + messages.push({ + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: toolCallId || 'tool', + ...(resultText ? { resultText } : {}), + }], + }); + continue; + } + + const parts = openAiContentToCanonicalParts(rawMessage.content); + const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const fn = isRecord(toolCall.function) ? toolCall.function : {}; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.name ?? fn.name); + const argumentsJson = typeof fn.arguments === 'string' + ? fn.arguments + : safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {}); + if (!name) continue; + parts.push({ + type: 'tool_call', + id: id || `tool_${parts.length}`, + name, + argumentsJson, + }); + } + + messages.push({ + role, + parts, + }); + } + + const reasoningResult = normalizeCanonicalReasoningRequest({ + include: body.include, + reasoning: body.reasoning, + reasoning_effort: body.reasoning_effort, + reasoning_budget: body.reasoning_budget, + reasoning_summary: body.reasoning_summary, + }); + + const continuation: CanonicalContinuation = { + ...(normalizeCanonicalContinuation(input.continuation) ?? {}), + ...(asTrimmedString(body.previous_response_id) + ? { previousResponseId: asTrimmedString(body.previous_response_id) } + : {}), + ...(asTrimmedString(body.prompt_cache_key) + ? { promptCacheKey: asTrimmedString(body.prompt_cache_key) } + : {}), + }; + + return createCanonicalRequestEnvelope({ + operation: input.operation ?? 'generate', + surface: input.surface, + cliProfile: input.cliProfile ?? 'generic', + requestedModel: asTrimmedString(body.model), + stream: body.stream === true, + messages, + ...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}), + ...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}), + ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), + ...(Object.keys(continuation).length > 0 ? { continuation } : {}), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...((reasoningResult.metadata || input.passthrough) + ? { + passthrough: { + ...(input.passthrough ?? {}), + ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), + }, + } + : {}), + }); +} + +function canonicalPartsToOpenAiContent( + role: CanonicalMessageRole, + parts: CanonicalContentPart[], +): { content: string | Array>; reasoning?: string; toolCalls?: Array> } { + const contentBlocks: Array> = []; + const toolCalls: Array> = []; + const visibleText: string[] = []; + const reasoningText: string[] = []; + + for (const part of parts) { + if (part.type === 'text') { + if (part.thought === true) { + reasoningText.push(part.text); + } else { + visibleText.push(part.text); + } + continue; + } + if (part.type === 'image') { + const url = asTrimmedString(part.url ?? part.dataUrl); + if (url) { + contentBlocks.push({ + type: 'image_url', + image_url: { url }, + }); + } + continue; + } + if (part.type === 'file') { + const normalizedFile = canonicalAttachmentToNormalizedInputFile({ + kind: 'file', + ...(part.fileId ? { fileId: part.fileId } : {}), + ...(part.fileUrl ? { fileUrl: part.fileUrl } : {}), + ...(part.fileData ? { fileData: part.fileData } : {}), + ...(part.filename ? { filename: part.filename } : {}), + ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}), + }); + contentBlocks.push(toOpenAiChatFileBlock(normalizedFile)); + continue; + } + if (part.type === 'tool_call') { + toolCalls.push({ + id: part.id, + type: 'function', + function: { + name: part.name, + arguments: part.argumentsJson, + }, + }); + continue; + } + if (part.type === 'tool_result' && role !== 'tool') { + const text = part.resultText ?? safeJsonStringify(part.resultJson ?? ''); + if (text) { + visibleText.push(text); + } + } + } + + if (contentBlocks.length <= 0) { + return { + content: visibleText.join(''), + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; + } + + if (visibleText.length > 0) { + contentBlocks.unshift({ + type: 'text', + text: visibleText.join(''), + }); + } + + return { + content: contentBlocks, + ...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; +} + +function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown { + if (!toolChoice) return undefined; + if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; + if (toolChoice === 'required') return 'required'; + return { + type: 'function', + function: { + name: toolChoice.name, + }, + }; +} + +export function canonicalRequestToOpenAiChatBody( + request: CanonicalRequestEnvelope, +): Record { + const messages: Array> = []; + + for (const message of request.messages) { + if (message.role === 'tool') { + for (const part of message.parts) { + if (part.type !== 'tool_result') continue; + messages.push({ + role: 'tool', + tool_call_id: part.toolCallId, + content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''), + }); + } + continue; + } + + const converted = canonicalPartsToOpenAiContent(message.role, message.parts); + const nextMessage: Record = { + role: message.role, + content: converted.content, + }; + if (message.role === 'assistant' && converted.reasoning) { + nextMessage.reasoning_content = converted.reasoning; + } + if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { + nextMessage.tool_calls = converted.toolCalls; + if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array).length <= 0) { + nextMessage.content = ''; + } + } + messages.push(nextMessage); + } + + const body: Record = { + model: request.requestedModel, + stream: request.stream, + messages, + }; + + if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort; + if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens; + if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary; + if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; + if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; + if (Array.isArray(request.tools) && request.tools.length > 0) { + body.tools = request.tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), + }, + })); + } + const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); + if (toolChoice !== undefined) body.tool_choice = toolChoice; + + if (isRecord(request.passthrough)) { + for (const [key, value] of Object.entries(request.passthrough)) { + if (key === 'transformerMetadata' || body[key] !== undefined) continue; + body[key] = cloneJsonValue(value); + } + } + + return body; +} + +export * from './attachments.js'; +export * from './reasoning.js'; +export * from './tools.js'; +export * from './types.js'; diff --git a/src/server/transformers/canonical/tools.ts b/src/server/transformers/canonical/tools.ts new file mode 100644 index 0000000..55b0be4 --- /dev/null +++ b/src/server/transformers/canonical/tools.ts @@ -0,0 +1,14 @@ +export type CanonicalTool = { + name: string; + description?: string; + inputSchema?: Record | null; +}; + +export type CanonicalToolChoice = + | 'auto' + | 'none' + | 'required' + | { + type: 'tool'; + name: string; + }; diff --git a/src/server/transformers/canonical/types.ts b/src/server/transformers/canonical/types.ts new file mode 100644 index 0000000..5577808 --- /dev/null +++ b/src/server/transformers/canonical/types.ts @@ -0,0 +1,108 @@ +import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; + +export type CanonicalOperation = + | 'generate' + | 'count_tokens'; + +export type CanonicalSurface = + | 'openai-chat' + | 'openai-responses' + | 'anthropic-messages' + | 'gemini-generate-content'; + +export type CanonicalCliProfile = + | 'generic' + | 'codex' + | 'claude_code' + | 'gemini_cli'; + +export type CanonicalMessageRole = + | 'system' + | 'developer' + | 'user' + | 'assistant' + | 'tool'; + +export type CanonicalTextPart = { + type: 'text'; + text: string; + thought?: boolean; +}; + +export type CanonicalImagePart = { + type: 'image'; + dataUrl?: string; + url?: string; + mimeType?: string | null; +}; + +export type CanonicalFilePart = { + type: 'file'; + fileId?: string; + fileUrl?: string; + fileData?: string; + mimeType?: string | null; + filename?: string; +}; + +export type CanonicalToolCallPart = { + type: 'tool_call'; + id: string; + name: string; + argumentsJson: string; +}; + +export type CanonicalToolResultPart = { + type: 'tool_result'; + toolCallId: string; + resultText?: string; + resultJson?: unknown; +}; + +export type CanonicalContentPart = + | CanonicalTextPart + | CanonicalImagePart + | CanonicalFilePart + | CanonicalToolCallPart + | CanonicalToolResultPart; + +export type CanonicalMessage = { + role: CanonicalMessageRole; + parts: CanonicalContentPart[]; +}; + +export type CanonicalReasoningEffort = + | 'none' + | 'low' + | 'medium' + | 'high' + | 'max'; + +export type CanonicalReasoningRequest = { + effort?: CanonicalReasoningEffort; + budgetTokens?: number; + summary?: string; + includeEncryptedContent?: boolean; +}; + +export type CanonicalContinuation = { + sessionId?: string; + previousResponseId?: string; + promptCacheKey?: string; + turnState?: string; +}; + +export type CanonicalRequestEnvelope = { + operation: CanonicalOperation; + surface: CanonicalSurface; + cliProfile: CanonicalCliProfile; + requestedModel: string; + stream: boolean; + messages: CanonicalMessage[]; + reasoning?: CanonicalReasoningRequest; + tools?: CanonicalTool[]; + toolChoice?: CanonicalToolChoice; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; +}; diff --git a/src/server/transformers/contracts.ts b/src/server/transformers/contracts.ts new file mode 100644 index 0000000..1ce6a82 --- /dev/null +++ b/src/server/transformers/contracts.ts @@ -0,0 +1,78 @@ +import type { + CanonicalCliProfile, + CanonicalContinuation, + CanonicalOperation, + CanonicalRequestEnvelope, +} from './canonical/types.js'; +import type { + ClaudeDownstreamContext, + NormalizedFinalResponse, + NormalizedStreamEvent, + StreamTransformContext, +} from './shared/normalized.js'; + +export type ProtocolParseContext = { + cliProfile?: CanonicalCliProfile; + operation?: CanonicalOperation; + continuation?: CanonicalContinuation; + metadata?: Record; + passthrough?: Record; + defaultEncryptedReasoningInclude?: boolean; +}; + +export type ProtocolBuildContext = { + cliProfile?: CanonicalCliProfile; +}; + +export type ProtocolResponseContext = { + modelName: string; + fallbackText?: string; +}; + +export type ProtocolStreamContext = { + modelName: string; + streamContext?: StreamTransformContext; +}; + +export type ProtocolSerializeContext = { + modelName: string; + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + }; + streamContext?: StreamTransformContext; + claudeContext?: ClaudeDownstreamContext; +}; + +export interface ProtocolTransformer { + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } }; + + buildProtocolRequest( + request: CanonicalRequestEnvelope, + ctx?: ProtocolBuildContext, + ): Record; + + normalizeFinal( + payload: unknown, + ctx: ProtocolResponseContext, + ): NormalizedFinalResponse; + + normalizeStreamEvent( + payload: unknown, + ctx: ProtocolStreamContext, + ): NormalizedStreamEvent; + + serializeFinal( + normalized: NormalizedFinalResponse, + ctx: ProtocolSerializeContext, + ): unknown; + + serializeStreamEvent( + normalized: NormalizedStreamEvent, + ctx: ProtocolSerializeContext, + ): string[] | unknown[]; +} diff --git a/src/server/transformers/final-hard-cut.architecture.test.ts b/src/server/transformers/final-hard-cut.architecture.test.ts index bd84ba3..9aab0a9 100644 --- a/src/server/transformers/final-hard-cut.architecture.test.ts +++ b/src/server/transformers/final-hard-cut.architecture.test.ts @@ -1,11 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; function readWorkspaceFile(relativePath: string): string { return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8'); } +function listTransformerFiles(directory: string): string[] { + const entries = readdirSync(directory); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(directory, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + files.push(...listTransformerFiles(fullPath)); + continue; + } + if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) { + files.push(fullPath); + } + } + return files; +} + describe('final transformer hard-cut architecture', () => { it('keeps shared normalized helpers independent from route chatFormats', () => { const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts'); @@ -37,4 +54,22 @@ describe('final transformer hard-cut architecture', () => { expect(geminiStream).not.toContain('passthrough'); expect(geminiAggregator).not.toContain('parts: unknown[]'); }); + + it('forbids transformer imports from routes, oauth, token router, runtime executor, and fastify', () => { + const transformerRoot = path.resolve(process.cwd(), 'src/server/transformers'); + const files = listTransformerFiles(transformerRoot); + + for (const file of files) { + const source = readFileSync(file, 'utf8'); + expect(source).not.toContain("from '../../routes/proxy/"); + expect(source).not.toContain("from '../routes/proxy/"); + expect(source).not.toContain("from '../../services/oauth/"); + expect(source).not.toContain("from '../services/oauth/"); + expect(source).not.toContain("from '../../services/tokenRouter.js'"); + expect(source).not.toContain("from '../services/tokenRouter.js'"); + expect(source).not.toContain("from '../../routes/proxy/runtimeExecutor.js'"); + expect(source).not.toContain("from '../routes/proxy/runtimeExecutor.js'"); + expect(source).not.toContain("from 'fastify'"); + } + }); }); diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index a9c7a49..e9afd9f 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -15,6 +15,86 @@ import { } from './stream.js'; describe('geminiGenerateContentTransformer.inbound', () => { + it('parses native Gemini requests into canonical envelopes', () => { + const result = geminiGenerateContentTransformer.parseRequest({ + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'generic', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + reasoning: { + budgetTokens: 512, + }, + }); + }); + + it('builds native Gemini requests from canonical envelopes', () => { + const body = geminiGenerateContentTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'gemini-generate-content', + cliProfile: 'gemini_cli', + requestedModel: 'gemini-2.5-pro', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + budgetTokens: 512, + }, + tools: [{ name: 'lookup', inputSchema: { type: 'object' } }], + toolChoice: 'required', + }); + + expect(body).toMatchObject({ + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: 'lookup', + parameters: { type: 'object' }, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { + mode: 'ANY', + }, + }, + generationConfig: { + thinkingConfig: { + thinkingBudget: 512, + }, + }, + }); + }); + it('preserves native Gemini request fields through normalization', () => { const body = geminiGenerateContentTransformer.inbound.normalizeRequest({ contents: [ diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index ab706ee..4d71fbc 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,3 +1,6 @@ +import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js'; +import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; function normalizeBaseUrl(baseUrl: string): string { return (baseUrl || '').replace(/\/+$/, ''); } @@ -10,6 +13,10 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { const normalized = normalizeBaseUrl(baseUrl); if (baseIncludesVersion(normalized)) return normalized; @@ -85,6 +92,189 @@ import { geminiGenerateContentUsage } from './usage.js'; import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js'; +function parseJsonString(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw }; + } +} + +function parseDataUrl(value: string): { mimeType: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim()); + if (!match) return null; + return { + mimeType: match[1], + data: match[2], + }; +} + +function canonicalPartToGeminiPart(part: CanonicalContentPart): Record | null { + if (part.type === 'text') { + return { + text: part.text, + ...(part.thought === true ? { thought: true } : {}), + }; + } + + if (part.type === 'image') { + const source = typeof part.dataUrl === 'string' && part.dataUrl.trim() + ? part.dataUrl + : (typeof part.url === 'string' ? part.url : ''); + if (!source) return null; + + const dataUrl = parseDataUrl(source); + if (dataUrl) { + return { + inlineData: { + mimeType: dataUrl.mimeType, + data: dataUrl.data, + }, + }; + } + + return { + fileData: { + fileUri: source, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'file') { + if (part.fileData) { + return { + inlineData: { + mimeType: part.mimeType || 'application/octet-stream', + data: part.fileData, + }, + }; + } + + const fileUri = part.fileUrl || part.fileId; + if (!fileUri) return null; + return { + fileData: { + fileUri, + ...(part.mimeType ? { mimeType: part.mimeType } : {}), + }, + }; + } + + if (part.type === 'tool_call') { + return { + functionCall: { + id: part.id, + name: part.name, + args: parseJsonString(part.argumentsJson), + }, + }; + } + + if (part.type === 'tool_result') { + return { + functionResponse: { + name: part.toolCallId, + response: part.resultJson ?? parseJsonString(part.resultText ?? ''), + }, + }; + } + + return null; +} + +function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record { + const contents: Array> = []; + const systemParts: Array> = []; + + for (const message of request.messages) { + if (message.role === 'system' || message.role === 'developer') { + systemParts.push( + ...message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part), + ); + continue; + } + + const parts = message.parts + .map((part) => canonicalPartToGeminiPart(part)) + .filter((part): part is Record => !!part); + + if (parts.length <= 0) continue; + + if (message.role === 'tool') { + contents.push({ + role: 'user', + parts, + }); + continue; + } + + contents.push({ + role: message.role === 'assistant' ? 'model' : 'user', + parts, + }); + } + + const payload: Record = { + contents, + }; + + if (systemParts.length > 0) { + payload.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + if (request.reasoning?.budgetTokens !== undefined) { + generationConfig.thinkingConfig = { + thinkingBudget: request.reasoning.budgetTokens, + }; + } else if (request.reasoning?.effort) { + generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig( + request.requestedModel, + request.reasoning.effort, + ); + } + if (Object.keys(generationConfig).length > 0) { + payload.generationConfig = generationConfig; + } + + if (Array.isArray(request.tools) && request.tools.length > 0) { + payload.tools = [{ + functionDeclarations: request.tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), + })), + }]; + } + + if (request.toolChoice) { + if (request.toolChoice === 'none') { + payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } }; + } else if (request.toolChoice === 'auto') { + payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; + } else if (request.toolChoice === 'required') { + payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; + } else { + payload.toolConfig = { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [request.toolChoice.name], + }, + }; + } + } + + return payload; +} + export const geminiGenerateContentTransformer = { protocol: 'gemini/generate-content' as const, inbound: geminiGenerateContentInbound, @@ -108,6 +298,52 @@ export const geminiGenerateContentTransformer = { resolveBaseUrl: resolveGeminiNativeBaseUrl, resolveModelsUrl: resolveGeminiModelsUrl, resolveActionUrl: resolveGeminiGenerateContentUrl, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const rawBody = isRecord(body) ? body : {}; + const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel); + if (!requestedModel) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + const stream = rawBody.stream === true || ctx?.metadata?.stream === true; + const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel); + const openAiBody = buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody, + modelName: requestedModel, + stream, + }); + + return { + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'gemini-generate-content', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + return buildGeminiRequestFromCanonical(request); + }, }; export { diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts index 5c0198c..64f99bc 100644 --- a/src/server/transformers/openai/chat/index.test.ts +++ b/src/server/transformers/openai/chat/index.test.ts @@ -11,6 +11,66 @@ function parseSsePayloads(lines: string[]): Array> { } describe('openAiChatTransformer.inbound', () => { + it('parses chat requests into canonical envelopes', () => { + const result = openAiChatTransformer.parseRequest({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'high', + reasoning_budget: 1024, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: true, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + continuation: { + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + budgetTokens: 1024, + }, + }); + }); + + it('builds chat requests from canonical envelopes', () => { + const body = openAiChatTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + reasoning: { + effort: 'medium', + budgetTokens: 512, + }, + continuation: { + promptCacheKey: 'cache-key', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + prompt_cache_key: 'cache-key', + reasoning_effort: 'medium', + reasoning_budget: 512, + }); + }); + it('captures chat request metadata fields without changing upstream body', () => { const result = openAiChatTransformer.transformRequest({ model: 'gpt-5', diff --git a/src/server/transformers/openai/chat/index.ts b/src/server/transformers/openai/chat/index.ts index 4bff17f..9191ddc 100644 --- a/src/server/transformers/openai/chat/index.ts +++ b/src/server/transformers/openai/chat/index.ts @@ -1,3 +1,6 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js'; import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js'; import { openAiChatInbound } from './inbound.js'; @@ -28,6 +31,46 @@ export const openAiChatTransformer = { proxyStream: { createSession: createChatProxyStreamSession, }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiChatInbound.parse(body); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid chat request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + return { + value: canonicalRequestFromOpenAiBody({ + body: parsed.value.parsed.upstreamBody, + surface: 'openai-chat', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + return canonicalRequestToOpenAiChatBody(request); + }, transformRequest(body: unknown): ReturnType { return openAiChatInbound.parse(body); }, diff --git a/src/server/transformers/openai/responses/index.test.ts b/src/server/transformers/openai/responses/index.test.ts index b6a6052..6f0267e 100644 --- a/src/server/transformers/openai/responses/index.test.ts +++ b/src/server/transformers/openai/responses/index.test.ts @@ -3,6 +3,75 @@ import { describe, expect, it } from 'vitest'; import { openAiResponsesTransformer } from './index.js'; describe('openAiResponsesTransformer.inbound', () => { + it('parses responses requests into canonical envelopes', () => { + const result = openAiResponsesTransformer.parseRequest({ + model: 'gpt-5', + input: 'hello', + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + reasoning: { + effort: 'high', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.value).toMatchObject({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: false, + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + messages: [ + { + role: 'user', + parts: [{ type: 'text', text: 'hello' }], + }, + ], + reasoning: { + effort: 'high', + }, + }); + }); + + it('builds responses requests from canonical envelopes', () => { + const body = openAiResponsesTransformer.buildProtocolRequest({ + operation: 'generate', + surface: 'openai-responses', + cliProfile: 'codex', + requestedModel: 'gpt-5', + stream: true, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + continuation: { + previousResponseId: 'resp_prev_1', + promptCacheKey: 'cache-key', + }, + reasoning: { + effort: 'high', + }, + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + stream: true, + previous_response_id: 'resp_prev_1', + prompt_cache_key: 'cache-key', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + reasoning: { + effort: 'high', + }, + }); + }); + it('returns a protocol request envelope with a normalized responses body', () => { const result = openAiResponsesTransformer.transformRequest({ model: 'gpt-5', diff --git a/src/server/transformers/openai/responses/index.ts b/src/server/transformers/openai/responses/index.ts index 27c691f..5f93480 100644 --- a/src/server/transformers/openai/responses/index.ts +++ b/src/server/transformers/openai/responses/index.ts @@ -1,3 +1,6 @@ +import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js'; +import type { CanonicalRequestEnvelope } from '../../canonical/types.js'; +import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { type StreamTransformContext } from '../../shared/normalized.js'; import { convertOpenAiBodyToResponsesBody, @@ -61,6 +64,64 @@ export const openAiResponsesTransformer = { proxyStream: { createSession: createResponsesProxyStreamSession, }, + parseRequest( + body: unknown, + ctx?: ProtocolParseContext, + ): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } { + const parsed = openAiResponsesInbound.parse(body, { + defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude, + }); + if (parsed.error) { + return { error: parsed.error }; + } + if (!parsed.value) { + return { + error: { + statusCode: 400, + payload: { + error: { + message: 'invalid responses request', + type: 'invalid_request_error', + }, + }, + }, + }; + } + + const responsesBody = parsed.value.parsed.normalizedBody; + const openAiBody = convertResponsesBodyToOpenAiBody( + responsesBody, + typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model, + responsesBody.stream === true, + { defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude }, + ); + + return { + value: canonicalRequestFromOpenAiBody({ + body: openAiBody, + surface: 'openai-responses', + cliProfile: ctx?.cliProfile, + operation: ctx?.operation, + metadata: ctx?.metadata, + passthrough: ctx?.passthrough, + continuation: ctx?.continuation, + }), + }; + }, + buildProtocolRequest( + request: CanonicalRequestEnvelope, + _ctx?: ProtocolBuildContext, + ): Record { + const openAiBody = canonicalRequestToOpenAiChatBody(request); + if (request.reasoning) { + openAiBody.reasoning = { + ...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}), + ...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}), + ...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}), + }; + } + return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream); + }, transformRequest( body: unknown, options?: { defaultEncryptedReasoningInclude?: boolean }, diff --git a/src/server/transformers/shared/chatEndpointStrategy.ts b/src/server/transformers/shared/chatEndpointStrategy.ts index 00d7ed8..c83f679 100644 --- a/src/server/transformers/shared/chatEndpointStrategy.ts +++ b/src/server/transformers/shared/chatEndpointStrategy.ts @@ -1,5 +1,5 @@ +import type { Response as UndiciResponse } from 'undici'; import type { DownstreamFormat } from './normalized.js'; -import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js'; import { buildMinimalJsonHeadersForCompatibility, isEndpointDispatchDeniedError, @@ -13,6 +13,18 @@ import { shouldRetryNormalizedMessagesBody, } from '../anthropic/messages/compatibility.js'; +type EndpointAttemptContext = { + request: CompatibilityRequest; + targetUrl: string; + response: UndiciResponse; + rawErrText: string; +}; + +type EndpointRecoverResult = { + upstream: UndiciResponse; + upstreamPath: string; +} | null; + type CompatibilityRequest = { endpoint: CompatibilityEndpoint; path: string; diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx new file mode 100644 index 0000000..bb9deed --- /dev/null +++ b/src/web/pages/ImportExport.test.tsx @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import ImportExport from './ImportExport.js'; + +const { apiMock, toastMock } = vi.hoisted(() => ({ + apiMock: { + exportBackup: vi.fn(), + importBackup: vi.fn(), + }, + toastMock: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + toast: vi.fn(), + }, +})); + +vi.mock('../api.js', () => ({ + api: apiMock, +})); + +vi.mock('../components/Toast.js', () => ({ + ToastProvider: ({ children }: { children: React.ReactNode }) => children, + useToast: () => toastMock, +})); + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +const allApiHubV2Payload = JSON.stringify({ + version: '2.0', + timestamp: 1735689600000, + accounts: { + accounts: [ + { + id: 'managed-account', + site_url: 'https://newapi.example.com', + site_type: 'new-api', + site_name: 'Managed Site', + authType: 'access_token', + account_info: { + id: 7788, + username: 'managed-user', + access_token: 'managed-session-token', + }, + }, + { + id: 'direct-account', + site_url: 'https://api.openai.com', + site_type: 'openai', + site_name: 'OpenAI Direct', + authType: 'access_token', + account_info: { + username: 'openai-user', + access_token: 'sk-openai-account', + }, + }, + ], + bookmarks: [ + { + id: 'bookmark-1', + name: 'Ignored Bookmark', + url: 'https://bookmark.example.com', + }, + ], + pinnedAccountIds: ['direct-account'], + orderedAccountIds: ['managed-account', 'direct-account'], + last_updated: 1735689601000, + }, + preferences: { + language: 'zh-CN', + }, + channelConfigs: { + bySite: { + demo: { enabled: true }, + }, + }, + tagStore: { + version: 1, + tagsById: {}, + }, + apiCredentialProfiles: { + version: 2, + profiles: [ + { + id: 'profile-openai', + name: 'OpenAI Profile', + apiType: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'sk-profile-openai', + tagIds: [], + notes: '', + createdAt: 1735689602000, + updatedAt: 1735689603000, + }, + { + id: 'profile-gemini', + name: 'Gemini Profile', + apiType: 'google', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + apiKey: 'gemini-profile-key', + tagIds: [], + notes: '', + createdAt: 1735689604000, + updatedAt: 1735689605000, + }, + ], + lastUpdated: 1735689606000, + }, +}); + +describe('ImportExport', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('window', { + confirm: vi.fn(() => true), + }); + apiMock.importBackup.mockResolvedValue({ + allImported: true, + sections: { + accounts: true, + preferences: true, + }, + appliedSettings: [], + summary: { + importedSites: 3, + importedAccounts: 2, + importedProfiles: 2, + importedApiKeyConnections: 3, + skippedAccounts: 1, + ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'], + }, + warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'], + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + , + ); + }); + + const textarea = root!.root.findByType('textarea'); + await act(async () => { + textarea.props.onChange({ target: { value: allApiHubV2Payload } }); + }); + await flushMicrotasks(); + + const rendered = collectText(root!.root); + expect(rendered).toContain('ALL-API-Hub V2'); + expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2'); + expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore'); + } finally { + root?.unmount(); + } + }); + + it('uses backend import summary in the completion toast', async () => { + const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm); + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + + const textarea = root!.root.findByType('textarea'); + await act(async () => { + textarea.props.onChange({ target: { value: allApiHubV2Payload } }); + }); + await flushMicrotasks(); + + const importButton = root!.root.findAll((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('导入') + )).at(-1); + + expect(importButton).toBeTruthy(); + + await act(async () => { + importButton!.props.onClick(); + }); + await flushMicrotasks(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(apiMock.importBackup).toHaveBeenCalledTimes(1); + expect(toastMock.success).toHaveBeenCalledWith( + expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'), + ); + expect(toastMock.success).toHaveBeenCalledWith( + expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'), + ); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index c588d14..d506c8c 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -12,12 +12,16 @@ type ParsedSummary = { hasAccounts: boolean; hasPreferences: boolean; hasLegacyData: boolean; + isAllApiHubV2: boolean; sitesCount: number; accountsCount: number; + bookmarksCount: number; + profilesCount: number; tokensCount: number; routesCount: number; channelsCount: number; settingsCount: number; + ignoredSections: string[]; }; function downloadJsonFile(data: unknown, filename: string) { @@ -35,23 +39,29 @@ function downloadJsonFile(data: unknown, filename: string) { function parseImportSummary(raw: string): ParsedSummary | null { if (!raw.trim()) return null; + const invalidSummary = (): ParsedSummary => ({ + valid: false, + version: '-', + timestampLabel: '未知', + hasAccounts: false, + hasPreferences: false, + hasLegacyData: false, + isAllApiHubV2: false, + sitesCount: 0, + accountsCount: 0, + bookmarksCount: 0, + profilesCount: 0, + tokensCount: 0, + routesCount: 0, + channelsCount: 0, + settingsCount: 0, + ignoredSections: [], + }); + try { const data = JSON.parse(raw) as any; if (!data || typeof data !== 'object' || Array.isArray(data)) { - return { - valid: false, - version: '-', - timestampLabel: '未知', - hasAccounts: false, - hasPreferences: false, - hasLegacyData: false, - sitesCount: 0, - accountsCount: 0, - tokensCount: 0, - routesCount: 0, - channelsCount: 0, - settingsCount: 0, - }; + return invalidSummary(); } const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts)) @@ -63,6 +73,20 @@ function parseImportSummary(raw: string): ParsedSummary | null { const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts)); const legacyPrefs = Boolean(data.data?.preferences); + const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0; + const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0; + const isAllApiHubV2 = Boolean( + accountsSection + && Array.isArray(accountsSection.accounts) + && ( + (typeof data.version === 'string' && data.version.startsWith('2')) + || 'last_updated' in accountsSection + || Array.isArray(accountsSection.bookmarks) + || Array.isArray(accountsSection.pinnedAccountIds) + || Array.isArray(accountsSection.orderedAccountIds) + || profilesCount > 0 + ) + ); const hasAccounts = Boolean( data.type === 'accounts' @@ -74,6 +98,10 @@ function parseImportSummary(raw: string): ParsedSummary | null { || preferencesSection || legacyPrefs, ); + const ignoredSections: string[] = []; + if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks'); + if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs'); + if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore'); const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0); @@ -89,31 +117,52 @@ function parseImportSummary(raw: string): ParsedSummary | null { hasAccounts, hasPreferences, hasLegacyData: legacyAccounts || legacyPrefs, + isAllApiHubV2, sitesCount: toCount(accountsSection?.sites), accountsCount: toCount(accountsSection?.accounts), + bookmarksCount, + profilesCount, tokensCount: toCount(accountsSection?.accountTokens), routesCount: toCount(accountsSection?.tokenRoutes), channelsCount: toCount(accountsSection?.routeChannels), settingsCount: toCount(preferencesSection?.settings), + ignoredSections, }; } catch { - return { - valid: false, - version: '-', - timestampLabel: '未知', - hasAccounts: false, - hasPreferences: false, - hasLegacyData: false, - sitesCount: 0, - accountsCount: 0, - tokensCount: 0, - routesCount: 0, - channelsCount: 0, - settingsCount: 0, - }; + return invalidSummary(); } } +function buildImportSuccessMessage(result: any): string { + const sections: string[] = []; + if (result?.sections?.accounts) sections.push('账号与路由'); + if (result?.sections?.preferences) sections.push('系统设置'); + + const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`]; + if (result?.summary) { + const summary = result.summary; + parts.push( + [ + `站点 ${summary.importedSites ?? 0}`, + `账号 ${summary.importedAccounts ?? 0}`, + `API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`, + `跳过 ${summary.skippedAccounts ?? 0}`, + ].join(' / '), + ); + + if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) { + parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`); + } + } + + if (Array.isArray(result?.warnings) && result.warnings.length > 0) { + const preview = result.warnings.slice(0, 2).join(';'); + parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`); + } + + return parts.join(';'); +} + export default function ImportExport() { const toast = useToast(); const [exportingType, setExportingType] = useState(''); @@ -193,7 +242,10 @@ export default function ImportExport() { toast.error('当前 JSON 结构无法识别'); return; } - if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) { + const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' + ? true + : window.confirm('导入会覆盖账号/路由或系统设置,确认继续?'); + if (!confirmed) { return; } @@ -201,10 +253,7 @@ export default function ImportExport() { try { const parsed = JSON.parse(importData); const result = await api.importBackup(parsed); - const sections: string[] = []; - if (result?.sections?.accounts) sections.push('账号与路由'); - if (result?.sections?.preferences) sections.push('系统设置'); - toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`); + toast.success(buildImportSuccessMessage(result)); setImportData(''); setSelectedFileName(''); } catch (err: any) { @@ -379,6 +428,17 @@ export default function ImportExport() { <>
结构有效,版本:{summary.version},时间:{summary.timestampLabel}
包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}
+ {summary.isAllApiHubV2 ? ( + <> +
检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。
+
+ 统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount} +
+ {summary.ignoredSections.length ? ( +
不会原生导入:{summary.ignoredSections.join('、')}
+ ) : null} + + ) : null} {(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount} From 36e9a856646140f662f915955117fbfd0b729894 Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 17:14:07 +0800 Subject: [PATCH 3/8] fix backup import preview and refresh race (#167) --- src/server/services/backupService.test.ts | 2 + src/server/services/backupService.ts | 28 +++++-- .../services/modelService.discovery.test.ts | 64 ++++++++++++++ src/server/services/modelService.ts | 22 ++++- src/web/pages/ImportExport.test.tsx | 83 +++++++++++++++++++ src/web/pages/ImportExport.tsx | 18 ++++ 6 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 75d8e93..2026af2 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -148,6 +148,8 @@ describe('backupService', () => { expect(result.allImported).toBe(true); expect(result.sections.accounts).toBe(true); + expect(result.summary).toBeUndefined(); + expect(result.warnings).toBeUndefined(); const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index ac415ca..07f1bcb 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -283,13 +283,29 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): { const accountsContainer = isRecord(data.accounts) ? data.accounts : null; if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null; + if (coerceAccountsSection(accountsContainer)) return null; + + const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => ( + isRecord(row) && ( + Object.prototype.hasOwnProperty.call(row, 'site_url') + || Object.prototype.hasOwnProperty.call(row, 'site_type') + || Object.prototype.hasOwnProperty.call(row, 'account_info') + || Object.prototype.hasOwnProperty.call(row, 'cookieAuth') + || Object.prototype.hasOwnProperty.call(row, 'authType') + || Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth') + ) + )); + const looksLikeV2 = - (typeof data.version === 'string' && data.version.startsWith('2')) - || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') - || Array.isArray(accountsContainer.bookmarks) - || Array.isArray(accountsContainer.pinnedAccountIds) - || Array.isArray(accountsContainer.orderedAccountIds) - || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)); + looksLikeLegacyAccountRow + && ( + (typeof data.version === 'string' && data.version.startsWith('2')) + || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') + || Array.isArray(accountsContainer.bookmarks) + || Array.isArray(accountsContainer.pinnedAccountIds) + || Array.isArray(accountsContainer.orderedAccountIds) + || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)) + ); if (!looksLikeV2) return null; diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 715c2ca..904126c 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -26,6 +26,7 @@ describe('refreshModelsForAccount credential discovery', () => { let db: DbModule['db']; let schema: DbModule['schema']; let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount']; + let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes']; let dataDir = ''; beforeAll(async () => { @@ -39,6 +40,7 @@ describe('refreshModelsForAccount credential discovery', () => { db = dbModule.db; schema = dbModule.schema; refreshModelsForAccount = modelService.refreshModelsForAccount; + refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes; }); beforeEach(async () => { @@ -142,6 +144,68 @@ describe('refreshModelsForAccount credential discovery', () => { expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']); }); + it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => { + getApiTokenMock.mockResolvedValue(null); + + let releaseGate: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + getModelsMock.mockImplementation(async () => { + await gate; + return ['gpt-5-nano']; + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-concurrent-refresh', + url: 'https://site-concurrent-refresh.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'concurrent-refresh-user', + accessToken: 'shared-credential', + apiToken: 'shared-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'shared-credential', + source: 'manual', + enabled: true, + isDefault: true, + }).run(); + + const firstRefresh = refreshModelsAndRebuildRoutes(); + const secondRefresh = refreshModelsAndRebuildRoutes(); + await Promise.resolve(); + await Promise.resolve(); + releaseGate?.(); + + const results = await Promise.allSettled([firstRefresh, secondRefresh]); + expect(results.every((item) => item.status === 'fulfilled')).toBe(true); + expect(getModelsMock).toHaveBeenCalledTimes(2); + + const modelRows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + + const token = await db.select().from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + }); + it('marks runtime health unhealthy when model discovery fails', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token')); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index aa085cf..d47ad94 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -59,6 +59,10 @@ const GEMINI_CLI_STATIC_MODELS = [ 'gemini-3-flash-preview', 'gemini-3.1-flash-lite-preview', ]; +let inFlightRefreshModelsAndRebuildRoutes: Promise<{ + refresh: ModelRefreshResult[]; + rebuild: Awaited>; +}> | null = null; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; @@ -1172,8 +1176,24 @@ export async function rebuildTokenRoutesFromAvailability() { }; } -export async function refreshModelsAndRebuildRoutes() { +async function runRefreshModelsAndRebuildRoutes() { const refresh = await refreshModelsForAllActiveAccounts(); const rebuild = await rebuildTokenRoutesFromAvailability(); return { refresh, rebuild }; } + +export async function refreshModelsAndRebuildRoutes() { + if (inFlightRefreshModelsAndRebuildRoutes) { + return inFlightRefreshModelsAndRebuildRoutes; + } + + inFlightRefreshModelsAndRebuildRoutes = (async () => { + try { + return await runRefreshModelsAndRebuildRoutes(); + } finally { + inFlightRefreshModelsAndRebuildRoutes = null; + } + })(); + + return inFlightRefreshModelsAndRebuildRoutes; +} diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index bb9deed..28fad0f 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -121,6 +121,64 @@ const allApiHubV2Payload = JSON.stringify({ }, }); +const nativeMetapiPayload = JSON.stringify({ + version: '2.0', + timestamp: 1735689600000, + accounts: { + sites: [ + { + id: 1, + name: 'Native Site', + url: 'https://native.example.com', + platform: 'new-api', + }, + ], + accounts: [ + { + id: 1, + siteId: 1, + username: 'native-user', + accessToken: 'session-token', + apiToken: 'api-token', + status: 'active', + }, + ], + accountTokens: [ + { + id: 1, + accountId: 1, + name: 'default', + token: 'sk-native', + enabled: true, + isDefault: true, + }, + ], + tokenRoutes: [ + { + id: 1, + modelPattern: 'gpt-5-nano', + enabled: true, + }, + ], + routeChannels: [ + { + id: 1, + routeId: 1, + accountId: 1, + tokenId: 1, + enabled: true, + manualOverride: false, + }, + ], + routeGroupSources: [], + }, + preferences: { + settings: [ + { key: 'locale', value: 'zh-CN' }, + ], + }, +}); + describe('ImportExport', () => { beforeEach(() => { vi.clearAllMocks(); @@ -178,6 +236,31 @@ describe('ImportExport', () => { } }); + it('does not label native metapi backups as ALL-API-Hub V2', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + , + ); + }); + + const textarea = root!.root.findByType('textarea'); + await act(async () => { + textarea.props.onChange({ target: { value: nativeMetapiPayload } }); + }); + await flushMicrotasks(); + + const rendered = collectText(root!.root); + expect(rendered).not.toContain('ALL-API-Hub V2'); + expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1'); + } finally { + root?.unmount(); + } + }); + it('uses backend import summary in the completion toast', async () => { const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm); let root: ReturnType | null = null; diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index d506c8c..12aa9a9 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -75,8 +75,26 @@ function parseImportSummary(raw: string): ParsedSummary | null { const legacyPrefs = Boolean(data.data?.preferences); const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0; const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0; + const isNativeMetapiBackup = Boolean( + accountsSection + && Array.isArray(accountsSection.sites) + && Array.isArray(accountsSection.accountTokens) + && Array.isArray(accountsSection.tokenRoutes) + && Array.isArray(accountsSection.routeChannels) + ); + const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts) + && accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && ( + 'site_url' in row + || 'site_type' in row + || 'account_info' in row + || 'cookieAuth' in row + || 'authType' in row + || 'sub2apiAuth' in row + )); const isAllApiHubV2 = Boolean( accountsSection + && !isNativeMetapiBackup + && hasLegacyAccountRows && Array.isArray(accountsSection.accounts) && ( (typeof data.version === 'string' && data.version.startsWith('2')) From 8f0b5c81f4bd94790eedf141fa7accec527107b3 Mon Sep 17 00:00:00 2001 From: cita Date: Thu, 19 Mar 2026 20:30:01 +0800 Subject: [PATCH 4/8] fix responses eof false success logging --- .../proxy/responses.codex-oauth.test.ts | 33 +++++++++++++++++++ .../routes/proxy/responses.websocket.test.ts | 11 ++++--- .../openai/responses/proxyStream.ts | 30 +++++++++++++---- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index 9a4a6d1..3e692ec 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -468,6 +468,39 @@ describe('responses proxy codex oauth refresh', () => { expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed'); }); + it('does not record success when a native responses stream closes before response.completed', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_truncated","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_truncated","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_truncated","delta":"partial"}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('event: response.failed'); + expect(response.body).not.toContain('event: response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('stream closed before response.completed'); + }); + it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => { resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed')); fetchMock.mockResolvedValue(new Response(JSON.stringify({ diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts index a88c896..540c6f3 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -526,7 +526,7 @@ describe('responses websocket transport', () => { const socket = new WebSocket(`${baseUrl}/v1/responses`); await waitForSocketOpen(socket); - const messagesPromise = waitForSocketMessages(socket, 3, 400); + const messagesPromise = waitForSocketMessages(socket, 6, 400); socket.send(JSON.stringify({ type: 'response.create', @@ -546,9 +546,12 @@ describe('responses websocket transport', () => { expect(messages.map((message) => message.type)).toEqual([ 'response.created', 'response.output_text.delta', - 'error', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.failed', ]); - expect(messages[2]?.status).toBe(408); - expect(messages[2]?.error?.message).toContain('stream closed before response.completed'); + expect(messages[5]?.response?.status).toBe('failed'); + expect(messages[5]?.error?.message).toContain('stream closed before response.completed'); }); }); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index d27191d..b938bed 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -58,6 +58,9 @@ function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { const streamContext = openAiResponsesStream.createContext(input.modelName); const responsesState = createOpenAiResponsesAggregateState(input.modelName); + const requiresExplicitTerminalEvent = input.strictTerminalEvents + || input.successfulUpstreamPath.endsWith('/responses') + || input.successfulUpstreamPath.endsWith('/responses/compact'); let finalized = false; let terminalResult: ResponsesProxyStreamResult = { status: 'completed', @@ -84,14 +87,24 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), payload)); }; + const complete = () => { + if (finalized) return; + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + }; + const closeOut = () => { if (finalized) return; - if (input.strictTerminalEvents) { - finalized = true; - terminalResult = { - status: 'failed', - errorMessage: 'stream closed before response.completed', - }; + if (requiresExplicitTerminalEvent) { + fail({ + type: 'response.failed', + error: { + message: 'stream closed before response.completed', + }, + }, 'stream closed before response.completed'); return; } finalize(); @@ -120,8 +133,10 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const isFailureEvent = ( eventBlock.event === 'error' || eventBlock.event === 'response.failed' + || eventBlock.event === 'response.incomplete' || payloadType === 'error' || payloadType === 'response.failed' + || payloadType === 'response.incomplete' ); if (isFailureEvent) { fail(parsedPayload); @@ -136,6 +151,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes event: normalizedEvent, usage: input.getUsage(), })); + if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') { + complete(); + } return false; } From 6c3a09da860e1469de70885c0e373f5db07a9abc Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 20:49:21 +0800 Subject: [PATCH 5/8] fix: harden mac release architecture verification --- .github/workflows/release.yml | 860 +++++++++++++++++----------------- 1 file changed, 417 insertions(+), 443 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0860aca..b95a5d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,443 +1,417 @@ -name: Release - -on: - push: - tags: ['v*'] - workflow_dispatch: - -permissions: - contents: write - packages: write - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - verify: - name: Verify - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Build - run: npm run build - - - name: Upload built dist - uses: actions/upload-artifact@v7 - with: - name: metapi-dist-${{ github.sha }} - path: dist - if-no-files-found: error - - build-packages: - name: Build Desktop Package (${{ matrix.platform }}) - needs: verify - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - platform: linux - runner: ubuntu-latest - artifactLabel: linux-x64 - - platform: windows - runner: windows-latest - artifactLabel: windows-x64 - - platform: mac-arm64 - runner: macos-15 - artifactLabel: mac-arm64 - packageCommand: npm run package:desktop -- --mac --arm64 - expectedMacArch: arm64 - - platform: mac-x64 - runner: macos-15-intel - artifactLabel: mac-x64 - packageCommand: npm run package:desktop:mac:intel - expectedMacArch: x64 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - - name: Restore Electron cache (Windows) - if: runner.os == 'Windows' - uses: actions/cache@v5 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ${{ env.LOCALAPPDATA }}\electron\Cache - ${{ env.LOCALAPPDATA }}\electron-builder\Cache - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Restore Electron cache (Linux) - if: runner.os == 'Linux' - uses: actions/cache@v5 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ${{ runner.temp }}/.cache/appimage-tools - ~/.cache/electron - ~/.cache/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Restore Electron cache (macOS) - if: runner.os == 'macOS' - uses: actions/cache@v5 - with: - path: | - ${{ runner.temp }}/.cache/electron - ${{ runner.temp }}/.cache/electron-builder - ~/Library/Caches/electron - ~/Library/Caches/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - - name: Install dependencies - run: npm ci - - - name: Download built dist - uses: actions/download-artifact@v8 - with: - name: metapi-dist-${{ github.sha }} - path: dist - - - name: Build desktop artifacts (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }} - WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }} - run: | - if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT } - if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } - for ($attempt = 1; $attempt -le 2; $attempt++) { - Write-Host "Desktop build attempt $attempt/2" - npm run package:desktop - if ($LASTEXITCODE -eq 0) { - break - } - if ($attempt -eq 2) { - exit $LASTEXITCODE - } - Start-Sleep -Seconds 90 - } - - - name: Smoke test packaged native modules (Windows) - if: runner.os == 'Windows' - shell: pwsh - env: - ELECTRON_RUN_AS_NODE: 1 - run: | - $appRoot = Join-Path $PWD 'release\win-unpacked\resources\app' - $exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe' - Push-Location $appRoot - try { - & $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - } finally { - Pop-Location - } - - - name: Build desktop artifacts (Linux) - if: runner.os == 'Linux' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - run: | - set -euo pipefail - for attempt in 1 2; do - echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - - - name: Build desktop artifacts (macOS) - if: startsWith(matrix.platform, 'mac-') - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_cache: ${{ runner.temp }}/.npm - ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron - ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder - ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} - PACKAGE_COMMAND: ${{ matrix.packageCommand }} - run: | - set -euo pipefail - if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi - if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi - if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi - for attempt in 1 2; do - echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" - if eval "${PACKAGE_COMMAND}"; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - - - name: Verify packaged macOS architecture - if: startsWith(matrix.platform, 'mac-') - shell: bash - env: - expectedMacArch: ${{ matrix.expectedMacArch }} - run: | - set -euo pipefail - mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi') - if [ "${#app_binaries[@]}" -eq 0 ]; then - echo "No packaged macOS app binary found under release/" - exit 1 - fi - - for binary in "${app_binaries[@]}"; do - archs="$(lipo -archs "$binary" 2>/dev/null | xargs)" - echo "$binary => ${archs:-unknown}" - if [ -z "${archs}" ]; then - echo "Unable to detect architecture via lipo" - exit 1 - fi - - if [ "${expectedMacArch}" = "x64" ]; then - [ "${archs}" = "x86_64" ] || { - echo "Expected x86_64-only binary but got: ${archs}" - exit 1 - } - else - [ "${archs}" = "arm64" ] || { - echo "Expected arm64-only binary but got: ${archs}" - exit 1 - } - fi - done - - - name: Upload desktop package - uses: actions/upload-artifact@v7 - with: - name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }} - path: | - release/*.exe - release/*.dmg - release/*.zip - release/*.AppImage - release/*.deb - release/*.blockmap - release/latest*.yml - release/latest*.json - release/builder-debug.yml - if-no-files-found: error - - publish-release: - name: Publish GitHub Release - needs: build-packages - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Download package artifacts - uses: actions/download-artifact@v8 - with: - path: release-assets - merge-multiple: true - - - name: Show release files - run: ls -la release-assets - - - name: Upload Release Assets - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - generate_release_notes: true - files: release-assets/* - - publish-docker-arch: - name: Publish Docker Image (${{ matrix.arch }}) - needs: build-packages - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - if: startsWith(github.ref, 'refs/tags/') - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-latest - - arch: arm64 - platform: linux/arm64 - runner: ubuntu-24.04-arm - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Ensure Docker Hub secrets are configured - run: | - if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then - echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" - exit 1 - fi - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest - - - name: Add architecture suffix to tags - id: arch-tags - shell: bash - run: | - set -euo pipefail - rm -f tags.txt - while IFS= read -r tag; do - [ -n "$tag" ] || continue - echo "${tag}-${{ matrix.arch }}" >> tags.txt - done <<< "${{ steps.meta.outputs.tags }}" - { - echo 'tags<> "$GITHUB_OUTPUT" - - - name: Build and push ${{ matrix.platform }} image - uses: docker/build-push-action@v7 - with: - context: . - file: docker/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: ${{ steps.arch-tags.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=release-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} - - publish-docker: - name: Publish Docker Manifests - needs: publish-docker-arch - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Ensure Docker Hub secrets are configured - run: | - if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then - echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" - exit 1 - fi - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest - - - name: Create multi-arch manifests - shell: bash - run: | - set -euo pipefail - while IFS= read -r tag; do - [ -n "$tag" ] || continue - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" - done <<< "${{ steps.meta.outputs.tags }}" +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + packages: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Verify + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + - name: Upload built dist + uses: actions/upload-artifact@v7 + with: + name: metapi-dist-${{ github.sha }} + path: dist + if-no-files-found: error + + build-packages: + name: Build Desktop Package (${{ matrix.platform }}) + needs: verify + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux + runner: ubuntu-latest + artifactLabel: linux-x64 + - platform: windows + runner: windows-latest + artifactLabel: windows-x64 + - platform: mac-arm64 + runner: macos-15 + artifactLabel: mac-arm64 + packageCommand: npm run package:desktop -- --mac --arm64 + expectedMacArch: arm64 + - platform: mac-x64 + runner: macos-15-intel + artifactLabel: mac-x64 + packageCommand: npm run package:desktop:mac:intel + expectedMacArch: x64 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Restore Electron cache (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ${{ env.LOCALAPPDATA }}\electron\Cache + ${{ env.LOCALAPPDATA }}\electron-builder\Cache + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Restore Electron cache (Linux) + if: runner.os == 'Linux' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ${{ runner.temp }}/.cache/appimage-tools + ~/.cache/electron + ~/.cache/electron-builder + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Restore Electron cache (macOS) + if: runner.os == 'macOS' + uses: actions/cache@v5 + with: + path: | + ${{ runner.temp }}/.cache/electron + ${{ runner.temp }}/.cache/electron-builder + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} + restore-keys: | + electron-cache-${{ runner.os }}- + + - name: Install dependencies + run: npm ci + + - name: Download built dist + uses: actions/download-artifact@v8 + with: + name: metapi-dist-${{ github.sha }} + path: dist + + - name: Build desktop artifacts (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }} + WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT } + if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } + for ($attempt = 1; $attempt -le 2; $attempt++) { + Write-Host "Desktop build attempt $attempt/2" + npm run package:desktop + if ($LASTEXITCODE -eq 0) { + break + } + if ($attempt -eq 2) { + exit $LASTEXITCODE + } + Start-Sleep -Seconds 90 + } + + - name: Smoke test packaged native modules (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + ELECTRON_RUN_AS_NODE: 1 + run: | + $appRoot = Join-Path $PWD 'release\win-unpacked\resources\app' + $exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe' + Push-Location $appRoot + try { + & $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } finally { + Pop-Location + } + + - name: Build desktop artifacts (Linux) + if: runner.os == 'Linux' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + run: | + set -euo pipefail + for attempt in 1 2; do + echo "Desktop build attempt ${attempt}/2" + if npm run package:desktop; then + break + fi + if [ "$attempt" -eq 2 ]; then + exit 1 + fi + sleep 90 + done + + - name: Build desktop artifacts (macOS) + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_cache: ${{ runner.temp }}/.npm + ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron + ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder + ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ + APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} + PACKAGE_COMMAND: ${{ matrix.packageCommand }} + run: | + set -euo pipefail + if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi + if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi + if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi + for attempt in 1 2; do + echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" + if eval "${PACKAGE_COMMAND}"; then + break + fi + if [ "$attempt" -eq 2 ]; then + exit 1 + fi + sleep 90 + done + + - name: Verify packaged macOS architecture + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + expectedMacArch: ${{ matrix.expectedMacArch }} + run: | + node scripts/desktop/verifyMacArchitecture.mjs --release-dir release --expected-arch "$expectedMacArch" + + - name: Upload desktop package + uses: actions/upload-artifact@v7 + with: + name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }} + path: | + release/*.exe + release/*.dmg + release/*.zip + release/*.AppImage + release/*.deb + release/*.blockmap + release/latest*.yml + release/latest*.json + release/builder-debug.yml + if-no-files-found: error + + publish-release: + name: Publish GitHub Release + needs: build-packages + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download package artifacts + uses: actions/download-artifact@v8 + with: + path: release-assets + merge-multiple: true + + - name: Show release files + run: ls -la release-assets + + - name: Upload Release Assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + generate_release_notes: true + files: release-assets/* + + publish-docker-arch: + name: Publish Docker Image (${{ matrix.arch }}) + needs: build-packages + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + if: startsWith(github.ref, 'refs/tags/') + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Ensure Docker Hub secrets are configured + run: | + if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then + echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + 1467078763/metapi + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Add architecture suffix to tags + id: arch-tags + shell: bash + run: | + set -euo pipefail + rm -f tags.txt + while IFS= read -r tag; do + [ -n "$tag" ] || continue + echo "${tag}-${{ matrix.arch }}" >> tags.txt + done <<< "${{ steps.meta.outputs.tags }}" + { + echo 'tags<> "$GITHUB_OUTPUT" + + - name: Build and push ${{ matrix.platform }} image + uses: docker/build-push-action@v7 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: true + tags: ${{ steps.arch-tags.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=release-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} + + publish-docker: + name: Publish Docker Manifests + needs: publish-docker-arch + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Ensure Docker Hub secrets are configured + run: | + if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then + echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" + exit 1 + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + 1467078763/metapi + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Create multi-arch manifests + shell: bash + run: | + set -euo pipefail + while IFS= read -r tag; do + [ -n "$tag" ] || continue + docker buildx imagetools create \ + --tag "$tag" \ + "${tag}-amd64" \ + "${tag}-arm64" + done <<< "${{ steps.meta.outputs.tags }}" From 1607928b6cd56e27f9a9300140de0a487a568797 Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 20:49:22 +0800 Subject: [PATCH 6/8] fix: harden mac release architecture verification --- scripts/desktop/release.workflow.test.ts | 32 ++++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/desktop/release.workflow.test.ts b/scripts/desktop/release.workflow.test.ts index 23b37c3..e8a4b6f 100644 --- a/scripts/desktop/release.workflow.test.ts +++ b/scripts/desktop/release.workflow.test.ts @@ -1,16 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -describe('release workflow', () => { - it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { - const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); - - expect(workflow).toContain('runner: macos-15-intel'); - expect(workflow).toContain('runner: macos-15'); - expect(workflow).toContain('expectedMacArch: x64'); - expect(workflow).toContain('expectedMacArch: arm64'); - expect(workflow).toContain('Verify packaged macOS architecture'); - expect(workflow).toContain('lipo -archs'); - }); -}); +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('release workflow', () => { + it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { + const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(workflow).toContain('runner: macos-15-intel'); + expect(workflow).toContain('runner: macos-15'); + expect(workflow).toContain('expectedMacArch: x64'); + expect(workflow).toContain('expectedMacArch: arm64'); + expect(workflow).toContain('Verify packaged macOS architecture'); + expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs'); + }); +}); From e656179044282dbcdfdb064ae52aa58fa54abfdb Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 20:49:23 +0800 Subject: [PATCH 7/8] fix: harden mac release architecture verification --- scripts/desktop/verifyMacArchitecture.mjs | 122 ++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 scripts/desktop/verifyMacArchitecture.mjs diff --git a/scripts/desktop/verifyMacArchitecture.mjs b/scripts/desktop/verifyMacArchitecture.mjs new file mode 100644 index 0000000..650609a --- /dev/null +++ b/scripts/desktop/verifyMacArchitecture.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi']; + +function walkDirectories(rootDir) { + const queue = [rootDir]; + const files = []; + + while (queue.length > 0) { + const currentDir = queue.shift(); + if (!currentDir) { + continue; + } + + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + const entryPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + queue.push(entryPath); + continue; + } + + if (entry.isFile()) { + files.push(entryPath); + } + } + } + + return files; +} + +export function findPackagedMacBinaries(releaseDir) { + const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/'); + + return walkDirectories(releaseDir).filter((filePath) => + filePath.replaceAll('\\', '/').endsWith(normalizedSuffix), + ); +} + +export function normalizeExpectedMacArch(expectedArch) { + if (expectedArch === 'x64') { + return 'x86_64'; + } + + if (expectedArch === 'arm64') { + return 'arm64'; + } + + throw new Error(`Unsupported expected mac architecture: ${expectedArch}`); +} + +export function inspectBinaryArchsWithLipo(binaryPath) { + try { + return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`); + } +} + +export function verifyMacArchitecture({ + releaseDir, + expectedArch, + inspectBinaryArchs = inspectBinaryArchsWithLipo, +}) { + const binaries = findPackagedMacBinaries(releaseDir); + if (binaries.length === 0) { + throw new Error(`No packaged macOS app binary found under ${releaseDir}`); + } + + const expectedBinaryArch = normalizeExpectedMacArch(expectedArch); + + return binaries.map((binaryPath) => { + const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' '); + if (!archs) { + throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`); + } + + if (archs !== expectedBinaryArch) { + throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`); + } + + return { binaryPath, archs }; + }); +} + +function parseArgValue(args, name) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + + return args[index + 1]; +} + +function main() { + const args = process.argv.slice(2); + const releaseDirArg = parseArgValue(args, '--release-dir'); + const expectedArch = parseArgValue(args, '--expected-arch'); + + if (!releaseDirArg || !expectedArch) { + throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir --expected-arch '); + } + + const releaseDir = resolve(releaseDirArg); + const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch }); + + for (const { binaryPath, archs } of verifiedBinaries) { + console.log(`${binaryPath} => ${archs}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} From 882c9dee5addbc28a6b974f87fcfc9d48e6238fd Mon Sep 17 00:00:00 2001 From: Cita Date: Thu, 19 Mar 2026 20:49:24 +0800 Subject: [PATCH 8/8] fix: harden mac release architecture verification --- scripts/desktop/verifyMacArchitecture.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/desktop/verifyMacArchitecture.test.ts diff --git a/scripts/desktop/verifyMacArchitecture.test.ts b/scripts/desktop/verifyMacArchitecture.test.ts new file mode 100644 index 0000000..f02cb28 --- /dev/null +++ b/scripts/desktop/verifyMacArchitecture.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { verifyMacArchitecture } from './verifyMacArchitecture.mjs'; + +const tempDirs: string[] = []; + +function createReleaseBinary() { + const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-')); + tempDirs.push(releaseDir); + + const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi'); + mkdirSync(dirname(binaryPath), { recursive: true }); + writeFileSync(binaryPath, 'binary'); + + return { releaseDir, binaryPath }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('verifyMacArchitecture', () => { + it('accepts x64 binaries when lipo reports x86_64', () => { + const { releaseDir, binaryPath } = createReleaseBinary(); + + expect( + verifyMacArchitecture({ + releaseDir, + expectedArch: 'x64', + inspectBinaryArchs: () => 'x86_64', + }), + ).toEqual([{ archs: 'x86_64', binaryPath }]); + }); + + it('rejects x64 binaries when lipo reports arm64', () => { + const { releaseDir } = createReleaseBinary(); + + expect(() => + verifyMacArchitecture({ + releaseDir, + expectedArch: 'x64', + inspectBinaryArchs: () => 'arm64', + }), + ).toThrow(/Expected x86_64-only binary but got: arm64/); + }); +});