fix(proxy): avoid duplicated /v1 when joining upstream url
This commit is contained in:
@@ -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,40 @@ 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('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({
|
||||||
|
|||||||
@@ -3,6 +3,28 @@ 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';
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type BuiltEndpointRequest = {
|
export type BuiltEndpointRequest = {
|
||||||
endpoint: UpstreamEndpoint;
|
endpoint: UpstreamEndpoint;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -66,7 +88,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 = normalizeUpstreamTargetUrl(input.siteUrl, request.path);
|
||||||
|
|
||||||
let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user