Merge pull request #88 from DeliciousBuding/fix/normalize-upstream-v1

fix(proxy): baseUrl 已含 /v1 时避免重复拼接
This commit is contained in:
Cita
2026-03-15 15:58:17 +08:00
committed by GitHub
8 changed files with 140 additions and 18 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
@@ -52,7 +53,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id); excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/completions`; const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/completions');
const forwardBody = { ...body, model: selected.actualModel }; const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now(); const startTime = Date.now();
+2 -1
View File
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
@@ -49,7 +50,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id); excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/embeddings`; const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/embeddings');
const forwardBody = { ...body, model: selected.actualModel }; const forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now(); const startTime = Date.now();
+59 -1
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetch } from 'undici'; import { fetch } from 'undici';
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; import type { BuiltEndpointRequest } from './endpointFlow.js';
vi.mock('undici', () => ({ vi.mock('undici', () => ({
fetch: vi.fn(), fetch: vi.fn(),
})); }));
vi.mock('../../services/siteProxy.js', () => ({
withSiteProxyRequestInit: async (_targetUrl: string, init: RequestInit) => init,
}));
const fetchMock = vi.mocked(fetch); const fetchMock = vi.mocked(fetch);
function requestFor(path: string): BuiltEndpointRequest { function requestFor(path: string): BuiltEndpointRequest {
@@ -22,6 +26,14 @@ function toUndiciResponse(response: Response): Awaited<ReturnType<typeof fetch>>
} }
describe('executeEndpointFlow', () => { describe('executeEndpointFlow', () => {
let executeEndpointFlow: (input: any) => Promise<any>;
beforeEach(async () => {
if (!executeEndpointFlow) {
({ executeEndpointFlow } = await import('./endpointFlow.js'));
}
});
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); fetchMock.mockReset();
}); });
@@ -42,9 +54,55 @@ describe('executeEndpointFlow', () => {
if (result.ok) { if (result.ok) {
expect(result.upstreamPath).toBe('/v1/responses'); expect(result.upstreamPath).toBe('/v1/responses');
} }
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/v1/responses');
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
}); });
it('avoids duplicated /v1 when base url already ends with /v1', 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',
endpointCandidates: ['chat'],
buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }),
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.example.com/v1/chat/completions');
});
it('avoids duplicated /v1 when base url already ends with /api/v1', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
await executeEndpointFlow({
siteUrl: 'https://openrouter.ai/api/v1',
endpointCandidates: ['chat'],
buildRequest: () => ({ ...requestFor('/v1/chat/completions'), endpoint: 'chat' }),
});
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 () => { it('downgrades to next endpoint when policy allows', async () => {
fetchMock fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
+2 -1
View File
@@ -2,6 +2,7 @@ import { fetch } from 'undici';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { summarizeUpstreamError } from './upstreamError.js'; import { summarizeUpstreamError } from './upstreamError.js';
import type { UpstreamEndpoint } from './upstreamEndpoint.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
export type BuiltEndpointRequest = { export type BuiltEndpointRequest = {
endpoint: UpstreamEndpoint; endpoint: UpstreamEndpoint;
@@ -66,7 +67,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex); const request = input.buildRequest(endpoint, endpointIndex);
const targetUrl = `${input.siteUrl}${request.path}`; const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'POST', method: 'POST',
+3 -2
View File
@@ -12,6 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
@@ -48,7 +49,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
excludeChannelIds.push(selected.channel.id); 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 forwardBody = { ...body, model: selected.actualModel };
const startTime = Date.now(); const startTime = Date.now();
@@ -154,7 +155,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
} }
excludeChannelIds.push(selected.channel.id); 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(); const startTime = Date.now();
try { try {
+2 -1
View File
@@ -10,6 +10,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
const DEFAULT_SEARCH_MODEL = '__search'; const DEFAULT_SEARCH_MODEL = '__search';
@@ -75,7 +76,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
} }
excludeChannelIds.push(selected.channel.id); excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/search`; const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/search');
const forwardBody = { const forwardBody = {
...body, ...body,
max_results: maxResults, max_results: maxResults,
+58
View File
@@ -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}`;
}
}
+4 -3
View File
@@ -9,6 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { import {
deleteProxyVideoTaskByPublicId, deleteProxyVideoTaskByPublicId,
getProxyVideoTaskByPublicId, getProxyVideoTaskByPublicId,
@@ -70,7 +71,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
} }
excludeChannelIds.push(selected.channel.id); excludeChannelIds.push(selected.channel.id);
const targetUrl = `${selected.site.url}/v1/videos`; const targetUrl = buildUpstreamUrl(selected.site.url, '/v1/videos');
const startTime = Date.now(); const startTime = Date.now();
try { 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, { const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'GET', method: 'GET',
headers: { 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, { const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {