From a66cbafeb83b493d558c9c9c14d20e6eb9741e68 Mon Sep 17 00:00:00 2001 From: Brucents Date: Fri, 20 Mar 2026 17:40:02 +0800 Subject: [PATCH 1/7] fix(proxy): separate upstream target resolution from proxy dispatch and add site-level proxy URLs --- src/server/proxy-core/surfaces/chatSurface.ts | 3 +- .../surfaces/openAiResponsesSurface.ts | 3 +- src/server/routes/api/sites.proxyUrl.test.ts | 30 ++++++++++++++--- src/server/routes/api/sites.ts | 16 ++++++++-- src/server/routes/proxy/endpointFlow.test.ts | 17 +--------- src/server/routes/proxy/endpointFlow.ts | 12 +------ src/server/services/siteProxy.test.ts | 32 +++++++++++++++++++ src/server/services/siteProxy.ts | 9 +++++- src/web/pages/Sites.tsx | 11 +++++++ src/web/pages/helpers/sitesEditor.test.ts | 11 ++++++- src/web/pages/helpers/sitesEditor.ts | 7 +++- 11 files changed, 111 insertions(+), 40 deletions(-) diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index d64ecaf..b69d6f3 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { buildClaudeCountTokensUpstreamRequest, @@ -263,7 +263,6 @@ export async function handleChatSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 4ed49ae..d824ca3 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { buildUpstreamEndpointRequest, @@ -364,7 +364,6 @@ export async function handleOpenAiResponsesSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index 428544c..ae38ffb 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs'; type DbModule = typeof import('../../db/index.js'); -describe('sites system proxy settings', () => { +describe('sites proxy settings', () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; @@ -36,7 +36,7 @@ describe('sites system proxy settings', () => { delete process.env.DATA_DIR; }); - it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => { + it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => { const response = await app.inject({ method: 'POST', url: '/api/sites', @@ -44,6 +44,7 @@ describe('sites system proxy settings', () => { name: 'proxy-site', url: 'https://proxy-site.example.com', platform: 'new-api', + proxyUrl: 'socks5://127.0.0.1:1080', useSystemProxy: true, customHeaders: JSON.stringify({ 'cf-access-client-id': 'site-client-id', @@ -56,11 +57,13 @@ describe('sites system proxy settings', () => { expect(response.statusCode).toBe(200); const payload = response.json() as { + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; globalWeight?: number; }; + expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080'); expect(payload.useSystemProxy).toBe(true); expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}'); expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); @@ -83,6 +86,22 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy'); }); + it('rejects invalid proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'proxy-site', + url: 'https://proxy-site.example.com', + platform: 'new-api', + proxyUrl: 'not-a-proxy', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl'); + }); + it('rejects invalid site global weight', async () => { const response = await app.inject({ method: 'POST', @@ -115,7 +134,7 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl'); }); - it('updates useSystemProxy for an existing site', async () => { + it('updates per-site proxy settings for an existing site', async () => { const created = await app.inject({ method: 'POST', url: '/api/sites', @@ -133,12 +152,15 @@ describe('sites system proxy settings', () => { method: 'PUT', url: `/api/sites/${site.id}`, payload: { + proxyUrl: 'http://127.0.0.1:8080', useSystemProxy: true, }, }); expect(response.statusCode).toBe(200); - expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true); + const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean }; + expect(payload.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(payload.useSystemProxy).toBe(true); }); it('rejects invalid custom headers json', async () => { diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index d433b8a..294379d 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, eq } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; -import { invalidateSiteProxyCache } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; @@ -222,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) { name: string; url: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; @@ -230,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) { sortOrder?: number; globalWeight?: number; } }>('/api/sites', async (request, reply) => { - const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; + const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; const normalizedStatus = normalizeSiteStatus(status); if (status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -239,6 +240,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -275,6 +280,7 @@ export async function sitesRoutes(app: FastifyInstance) { name, url: url.replace(/\/+$/, ''), platform: detectedPlatform, + proxyUrl: normalizedProxyUrl.proxyUrl, useSystemProxy: normalizedUseSystemProxy ?? false, customHeaders: normalizedCustomHeaders.customHeaders, externalCheckinUrl: normalizedExternalCheckinUrl.url, @@ -300,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) { name?: string; url?: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; @@ -328,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -352,6 +363,7 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.name !== undefined) updates.name = body.name; if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.platform !== undefined) updates.platform = body.platform; + if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 7513406..8dbd13a 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => { expect(result.ok).toBe(true); expect(dispatchRequest).toHaveBeenCalledTimes(1); + expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses'); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -219,22 +220,6 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => { - fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }))); - - await executeEndpointFlow({ - siteUrl: 'https://example.com', - proxyUrl: 'https://proxy.internal/base', - endpointCandidates: ['responses'], - buildRequest: () => requestFor('/v1/responses'), - }); - - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); - }); - it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index bd5b808..41588fa 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -59,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string { type ExecuteEndpointFlowInput = { siteUrl: string; - proxyUrl?: string | null; endpointCandidates: UpstreamEndpoint[]; buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest; dispatchRequest?: ( @@ -73,12 +72,6 @@ type ExecuteEndpointFlowInput = { onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise; }; -function buildAbsoluteUrl(base: string, path: string): string { - const normalizedBase = base.replace(/\/+$/, ''); - const normalizedPath = path.replace(/^\/+/, ''); - return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase; -} - export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise { const endpointCount = input.endpointCandidates.length; if (endpointCount <= 0) { @@ -96,10 +89,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 defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); - const targetUrl = input.proxyUrl - ? buildAbsoluteUrl(input.proxyUrl, request.path) - : defaultTarget; + const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); let response = input.dispatchRequest ? await input.dispatchRequest(request, targetUrl) diff --git a/src/server/services/siteProxy.test.ts b/src/server/services/siteProxy.test.ts index ec5258f..ae327e6 100644 --- a/src/server/services/siteProxy.test.ts +++ b/src/server/services/siteProxy.test.ts @@ -57,6 +57,22 @@ describe('siteProxy', () => { .toBeNull(); }); + it('prefers site-specific proxy url over the shared system proxy', async () => { + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'socks5://127.0.0.1:1080', 1) + `); + + const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js'); + expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/models')) + .toBe('socks5://127.0.0.1:1080'); + }); + it('injects dispatcher when a site opts into the configured system proxy', async () => { await db.insert(schema.settings).values({ key: 'system_proxy_url', @@ -75,6 +91,20 @@ describe('siteProxy', () => { expect('dispatcher' in requestInit).toBe(true); }); + it('injects dispatcher when a site defines its own proxy url', async () => { + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'http://127.0.0.1:7890', 0) + `); + + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', { + method: 'POST', + }); + + expect('dispatcher' in requestInit).toBe(true); + }); + it('injects a working dispatcher for socks5 system proxies', async () => { const upstreamServer = createServer((_request, response) => { response.writeHead(200, { 'content-type': 'application/json' }); @@ -170,6 +200,7 @@ describe('siteProxy', () => { it('merges site custom headers from site records even without cache lookup', async () => { const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); const requestInit = withSiteRecordProxyRequestInit({ + proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false, customHeaders: JSON.stringify({ 'x-site-scope': 'site-level', @@ -184,5 +215,6 @@ describe('siteProxy', () => { expect(headers.get('x-site-scope')).toBe('site-level'); expect(headers.get('x-request-id')).toBe('req-1'); + expect('dispatcher' in requestInit).toBe(true); }); }); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index c7da18d..75b0949 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -31,6 +31,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000; type SiteProxyRow = { siteUrl: string; + proxyUrl: string | null; useSystemProxy: boolean; customHeaders: string | null; }; @@ -42,6 +43,7 @@ type ParsedSiteProxyInput = { }; type SiteProxyConfigLike = { + proxyUrl?: string | null; useSystemProxy?: boolean | null; customHeaders?: string | null; }; @@ -103,6 +105,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise ({ siteUrl: normalizeSiteUrl(row.siteUrl), + proxyUrl: normalizeSiteProxyUrl(row.proxyUrl), useSystemProxy: !!row.useSystemProxy, customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null, })), @@ -368,7 +372,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise const rows = await getCachedSiteProxyRows(); const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl); - const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null; + const proxyUrl = matchedRow?.proxyUrl + || (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null); return { proxyUrl: proxyUrl || null, customHeaders: matchedRow?.customHeaders ?? null, @@ -425,6 +430,8 @@ export function withExplicitProxyRequestInit( } export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null { + const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl); + if (explicitProxyUrl) return explicitProxyUrl; if (!site?.useSystemProxy) return null; return normalizeSiteProxyUrl(config.systemProxyUrl); } diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 125d789..cfd5fdf 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -40,6 +40,7 @@ type SiteRow = { externalCheckinUrl?: string | null; platform?: string; status?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; globalWeight?: number; @@ -382,6 +383,7 @@ export default function Sites() { url: form.url.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(), platform: form.platform.trim(), + proxyUrl: form.proxyUrl.trim(), useSystemProxy: !!form.useSystemProxy, customHeaders: serializedCustomHeaders.customHeaders, globalWeight: Number(parsedGlobalWeight.toFixed(3)), @@ -918,6 +920,15 @@ export default function Sites() { )} + setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + style={formInputStyle} + /> +
+ 留空则直连;填写后优先于系统代理。 +