From a6e6be9460df8b961f23a57e4754898b97f98ee2 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Sun, 15 Mar 2026 15:54:48 +0800 Subject: [PATCH] fix(proxy): normalize upstream url join --- src/server/routes/proxy/completions.ts | 11 ++-- src/server/routes/proxy/embeddings.ts | 11 ++-- src/server/routes/proxy/endpointFlow.test.ts | 15 +++++ src/server/routes/proxy/endpointFlow.ts | 25 +-------- src/server/routes/proxy/images.ts | 5 +- src/server/routes/proxy/search.ts | 3 +- src/server/routes/proxy/upstreamUrl.ts | 58 ++++++++++++++++++++ src/server/routes/proxy/videos.ts | 7 ++- 8 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 src/server/routes/proxy/upstreamUrl.ts diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 01d6930..17686e3 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -50,11 +51,11 @@ export async function completionsProxyRoute(app: FastifyInstance) { }); } - excludeChannelIds.push(selected.channel.id); - - const targetUrl = `${selected.site.url}/v1/completions`; - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/completions'); + const forwardBody = { ...body, model: selected.actualModel }; + const startTime = Date.now(); try { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 6f29e01..2299512 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -47,11 +48,11 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { return reply.code(503).send({ error: { message: 'No available channels', type: 'server_error' } }); } - excludeChannelIds.push(selected.channel.id); - - const targetUrl = `${selected.site.url}/v1/embeddings`; - const forwardBody = { ...body, model: selected.actualModel }; - const startTime = Date.now(); + excludeChannelIds.push(selected.channel.id); + + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings'); + const forwardBody = { ...body, model: selected.actualModel }; + const startTime = Date.now(); try { const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 968763e..1ec37d1 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -88,6 +88,21 @@ describe('executeEndpointFlow', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe('https://openrouter.ai/api/v1/chat/completions'); }); + it('keeps url well-formed when base url includes query/hash', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://api.example.com/v1?foo=1#keep', + endpointCandidates: ['chat'], + buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.example.com/v1/chat/completions?foo=1#keep'); + }); + it('downgrades to next endpoint when policy allows', async () => { fetchMock .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index b017b3c..c1e9336 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -2,28 +2,7 @@ import { fetch } from 'undici'; import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { summarizeUpstreamError } from './upstreamError.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js'; - -function normalizeUpstreamTargetUrl(siteUrl: string, requestPath: string): string { - const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : ''; - const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : ''; - const base = baseRaw.replace(/\/+$/, ''); - let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`; - - try { - const parsed = new URL(base); - const basePath = parsed.pathname.replace(/\/+$/, ''); - const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath); - if (baseHasVersionSuffix && path.startsWith('/v1/')) { - path = path.slice('/v1'.length) || '/'; - } - } catch { - // Ignore URL parsing errors and fall back to naive join. - } - - if (!base) return path || '/'; - if (!path || path === '/') return base; - return `${base}${path}`; -} +import { buildUpstreamUrl } from './upstreamUrl.js'; export type BuiltEndpointRequest = { endpoint: UpstreamEndpoint; @@ -88,7 +67,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const request = input.buildRequest(endpoint, endpointIndex); - const targetUrl = normalizeUpstreamTargetUrl(input.siteUrl, request.path); + const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'POST', diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 1b87a17..27ca200 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -12,6 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -48,7 +49,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/images/generations`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/generations'); const forwardBody = { ...body, model: selected.actualModel }; const startTime = Date.now(); @@ -154,7 +155,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/images/edits`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/images/edits'); const startTime = Date.now(); try { diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 2f04ca9..4a3c8b0 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -10,6 +10,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; const DEFAULT_SEARCH_MODEL = '__search'; @@ -75,7 +76,7 @@ export async function searchProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/search`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/search'); const forwardBody = { ...body, max_results: maxResults, diff --git a/src/server/routes/proxy/upstreamUrl.ts b/src/server/routes/proxy/upstreamUrl.ts new file mode 100644 index 0000000..97ae8c8 --- /dev/null +++ b/src/server/routes/proxy/upstreamUrl.ts @@ -0,0 +1,58 @@ +function formatUrlOrigin(url: URL): string { + // URL.origin drops credentials; preserve them if present. + const username = url.username ? encodeURIComponent(url.username) : ''; + const password = url.password ? encodeURIComponent(url.password) : ''; + const auth = username + ? `${username}${password ? `:${password}` : ''}@` + : ''; + + return `${url.protocol}//${auth}${url.host}`; +} + +function joinPath(basePath: string, requestPath: string): string { + const base = basePath.replace(/\/+$/, ''); + const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; + + if (!base || base === '/') return path || '/'; + if (!path || path === '/') return base; + return `${base}${path}`; +} + +export function buildUpstreamUrl(siteUrl: string, requestPath: string): string { + const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : ''; + const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : ''; + const fallbackBase = baseRaw.replace(/\/+$/, ''); + let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`; + + if (!fallbackBase) return path || '/'; + if (!path || path === '/') return fallbackBase; + + try { + const parsed = new URL(baseRaw); + const basePath = parsed.pathname.replace(/\/+$/, ''); + const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath); + if (baseHasVersionSuffix) { + if (path === '/v1') { + path = '/'; + } else if (path.startsWith('/v1/')) { + path = path.slice('/v1'.length) || '/'; + } + } + + const joinedPath = joinPath(basePath, path); + return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`; + } catch { + // Ignore URL parsing errors and fall back to naive join. + const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase); + if (baseHasVersionSuffix) { + if (path === '/v1') { + path = '/'; + } else if (path.startsWith('/v1/')) { + path = path.slice('/v1'.length) || '/'; + } + } + + return `${fallbackBase}${path}`; + } +} + diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index 00ada07..a5b1438 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -9,6 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; import { deleteProxyVideoTaskByPublicId, getProxyVideoTaskByPublicId, @@ -70,7 +71,7 @@ export async function videosProxyRoute(app: FastifyInstance) { } excludeChannelIds.push(selected.channel.id); - const targetUrl = `${selected.site.url}/v1/videos`; + const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/videos'); const startTime = Date.now(); try { @@ -180,7 +181,7 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`; + const targetUrl = buildUpstreamUrl(mapping.siteUrl, `/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`); const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'GET', headers: { @@ -211,7 +212,7 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`; + const targetUrl = buildUpstreamUrl(mapping.siteUrl, `/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`); const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'DELETE', headers: {