fix(proxy): normalize upstream url join
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user