diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index b029566..968763e 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetch } from 'undici'; -import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; +import type { BuiltEndpointRequest } from './endpointFlow.js'; vi.mock('undici', () => ({ fetch: vi.fn(), })); +vi.mock('../../services/siteProxy.js', () => ({ + withSiteProxyRequestInit: async (_targetUrl: string, init: RequestInit) => init, +})); + const fetchMock = vi.mocked(fetch); function requestFor(path: string): BuiltEndpointRequest { @@ -22,6 +26,14 @@ function toUndiciResponse(response: Response): Awaited> } describe('executeEndpointFlow', () => { + let executeEndpointFlow: (input: any) => Promise; + + beforeEach(async () => { + if (!executeEndpointFlow) { + ({ executeEndpointFlow } = await import('./endpointFlow.js')); + } + }); + beforeEach(() => { fetchMock.mockReset(); }); @@ -42,9 +54,40 @@ describe('executeEndpointFlow', () => { if (result.ok) { expect(result.upstreamPath).toBe('/v1/responses'); } + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/v1/responses'); 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 () => { fetchMock .mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index 97115f2..b017b3c 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -3,6 +3,28 @@ 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}`; +} + export type BuiltEndpointRequest = { endpoint: UpstreamEndpoint; path: string; @@ -66,7 +88,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 = `${input.siteUrl}${request.path}`; + const targetUrl = normalizeUpstreamTargetUrl(input.siteUrl, request.path); let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'POST',