fix(proxy): separate upstream target resolution from proxy dispatch and add site-level proxy URLs
This commit is contained in:
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.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 { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
buildClaudeCountTokensUpstreamRequest,
|
buildClaudeCountTokensUpstreamRequest,
|
||||||
@@ -263,7 +263,6 @@ export async function handleChatSurfaceRequest(
|
|||||||
try {
|
try {
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.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 { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
@@ -364,7 +364,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
try {
|
try {
|
||||||
const endpointResult = await executeEndpointFlow({
|
const endpointResult = await executeEndpointFlow({
|
||||||
siteUrl: selected.site.url,
|
siteUrl: selected.site.url,
|
||||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
describe('sites system proxy settings', () => {
|
describe('sites proxy settings', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
|
|||||||
delete process.env.DATA_DIR;
|
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({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
|
|||||||
name: 'proxy-site',
|
name: 'proxy-site',
|
||||||
url: 'https://proxy-site.example.com',
|
url: 'https://proxy-site.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
customHeaders: JSON.stringify({
|
customHeaders: JSON.stringify({
|
||||||
'cf-access-client-id': 'site-client-id',
|
'cf-access-client-id': 'site-client-id',
|
||||||
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const payload = response.json() as {
|
const payload = response.json() as {
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
};
|
};
|
||||||
|
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
|
||||||
expect(payload.useSystemProxy).toBe(true);
|
expect(payload.useSystemProxy).toBe(true);
|
||||||
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
|
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
|
||||||
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
|
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');
|
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 () => {
|
it('rejects invalid site global weight', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
|
|||||||
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
|
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({
|
const created = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: `/api/sites/${site.id}`,
|
url: `/api/sites/${site.id}`,
|
||||||
payload: {
|
payload: {
|
||||||
|
proxyUrl: 'http://127.0.0.1:8080',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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 () => {
|
it('rejects invalid custom headers json', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
@@ -222,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
@@ -230,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
} }>('/api/sites', async (request, reply) => {
|
} }>('/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);
|
const normalizedStatus = normalizeSiteStatus(status);
|
||||||
if (status !== undefined && !normalizedStatus) {
|
if (status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
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) {
|
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
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);
|
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
|
||||||
if (!normalizedExternalCheckinUrl.valid) {
|
if (!normalizedExternalCheckinUrl.valid) {
|
||||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
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,
|
name,
|
||||||
url: url.replace(/\/+$/, ''),
|
url: url.replace(/\/+$/, ''),
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||||
@@ -300,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
@@ -328,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
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);
|
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
|
||||||
if (!normalizedExternalCheckinUrl.valid) {
|
if (!normalizedExternalCheckinUrl.valid) {
|
||||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
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.name !== undefined) updates.name = body.name;
|
||||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||||
if (body.platform !== undefined) updates.platform = body.platform;
|
if (body.platform !== undefined) updates.platform = body.platform;
|
||||||
|
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(dispatchRequest).toHaveBeenCalledTimes(1);
|
expect(dispatchRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses');
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,22 +220,6 @@ describe('executeEndpointFlow', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('returns normalized final error when all endpoints fail', async () => {
|
||||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string {
|
|||||||
|
|
||||||
type ExecuteEndpointFlowInput = {
|
type ExecuteEndpointFlowInput = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
proxyUrl?: string | null;
|
|
||||||
endpointCandidates: UpstreamEndpoint[];
|
endpointCandidates: UpstreamEndpoint[];
|
||||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||||
dispatchRequest?: (
|
dispatchRequest?: (
|
||||||
@@ -73,12 +72,6 @@ type ExecuteEndpointFlowInput = {
|
|||||||
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
|
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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<EndpointFlowResult> {
|
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
|
||||||
const endpointCount = input.endpointCandidates.length;
|
const endpointCount = input.endpointCandidates.length;
|
||||||
if (endpointCount <= 0) {
|
if (endpointCount <= 0) {
|
||||||
@@ -96,10 +89,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 defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
|
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
|
||||||
const targetUrl = input.proxyUrl
|
|
||||||
? buildAbsoluteUrl(input.proxyUrl, request.path)
|
|
||||||
: defaultTarget;
|
|
||||||
|
|
||||||
let response = input.dispatchRequest
|
let response = input.dispatchRequest
|
||||||
? await input.dispatchRequest(request, targetUrl)
|
? await input.dispatchRequest(request, targetUrl)
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ describe('siteProxy', () => {
|
|||||||
.toBeNull();
|
.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 () => {
|
it('injects dispatcher when a site opts into the configured system proxy', async () => {
|
||||||
await db.insert(schema.settings).values({
|
await db.insert(schema.settings).values({
|
||||||
key: 'system_proxy_url',
|
key: 'system_proxy_url',
|
||||||
@@ -75,6 +91,20 @@ describe('siteProxy', () => {
|
|||||||
expect('dispatcher' in requestInit).toBe(true);
|
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 () => {
|
it('injects a working dispatcher for socks5 system proxies', async () => {
|
||||||
const upstreamServer = createServer((_request, response) => {
|
const upstreamServer = createServer((_request, response) => {
|
||||||
response.writeHead(200, { 'content-type': 'application/json' });
|
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 () => {
|
it('merges site custom headers from site records even without cache lookup', async () => {
|
||||||
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
|
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
|
||||||
const requestInit = withSiteRecordProxyRequestInit({
|
const requestInit = withSiteRecordProxyRequestInit({
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
customHeaders: JSON.stringify({
|
customHeaders: JSON.stringify({
|
||||||
'x-site-scope': 'site-level',
|
'x-site-scope': 'site-level',
|
||||||
@@ -184,5 +215,6 @@ describe('siteProxy', () => {
|
|||||||
|
|
||||||
expect(headers.get('x-site-scope')).toBe('site-level');
|
expect(headers.get('x-site-scope')).toBe('site-level');
|
||||||
expect(headers.get('x-request-id')).toBe('req-1');
|
expect(headers.get('x-request-id')).toBe('req-1');
|
||||||
|
expect('dispatcher' in requestInit).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
|
|||||||
|
|
||||||
type SiteProxyRow = {
|
type SiteProxyRow = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
|
proxyUrl: string | null;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
customHeaders: string | null;
|
customHeaders: string | null;
|
||||||
};
|
};
|
||||||
@@ -42,6 +43,7 @@ type ParsedSiteProxyInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SiteProxyConfigLike = {
|
type SiteProxyConfigLike = {
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean | null;
|
useSystemProxy?: boolean | null;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
};
|
};
|
||||||
@@ -103,6 +105,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
siteUrl: schema.sites.url,
|
siteUrl: schema.sites.url,
|
||||||
|
proxyUrl: schema.sites.proxyUrl,
|
||||||
useSystemProxy: schema.sites.useSystemProxy,
|
useSystemProxy: schema.sites.useSystemProxy,
|
||||||
customHeaders: schema.sites.customHeaders,
|
customHeaders: schema.sites.customHeaders,
|
||||||
})
|
})
|
||||||
@@ -129,6 +132,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
|||||||
loadedAt: nowMs,
|
loadedAt: nowMs,
|
||||||
rows: rows.map((row) => ({
|
rows: rows.map((row) => ({
|
||||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||||
|
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||||
useSystemProxy: !!row.useSystemProxy,
|
useSystemProxy: !!row.useSystemProxy,
|
||||||
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
|
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
|
||||||
})),
|
})),
|
||||||
@@ -368,7 +372,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise
|
|||||||
|
|
||||||
const rows = await getCachedSiteProxyRows();
|
const rows = await getCachedSiteProxyRows();
|
||||||
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
|
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
|
||||||
const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null;
|
const proxyUrl = matchedRow?.proxyUrl
|
||||||
|
|| (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null);
|
||||||
return {
|
return {
|
||||||
proxyUrl: proxyUrl || null,
|
proxyUrl: proxyUrl || null,
|
||||||
customHeaders: matchedRow?.customHeaders ?? null,
|
customHeaders: matchedRow?.customHeaders ?? null,
|
||||||
@@ -425,6 +430,8 @@ export function withExplicitProxyRequestInit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
|
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
|
||||||
|
const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl);
|
||||||
|
if (explicitProxyUrl) return explicitProxyUrl;
|
||||||
if (!site?.useSystemProxy) return null;
|
if (!site?.useSystemProxy) return null;
|
||||||
return normalizeSiteProxyUrl(config.systemProxyUrl);
|
return normalizeSiteProxyUrl(config.systemProxyUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type SiteRow = {
|
|||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
@@ -382,6 +383,7 @@ export default function Sites() {
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||||
platform: form.platform.trim(),
|
platform: form.platform.trim(),
|
||||||
|
proxyUrl: form.proxyUrl.trim(),
|
||||||
useSystemProxy: !!form.useSystemProxy,
|
useSystemProxy: !!form.useSystemProxy,
|
||||||
customHeaders: serializedCustomHeaders.customHeaders,
|
customHeaders: serializedCustomHeaders.customHeaders,
|
||||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||||
@@ -918,6 +920,15 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="站点代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||||
|
value={form.proxyUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
style={formInputStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
留空则直连;填写后优先于系统代理。
|
||||||
|
</div>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://a.example.com/',
|
url: 'https://a.example.com/',
|
||||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||||
customHeaders: '{"x-site-token":"alpha"}',
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
globalWeight: 1.2,
|
globalWeight: 1.2,
|
||||||
@@ -29,6 +30,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://a.example.com/',
|
url: 'https://a.example.com/',
|
||||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||||
customHeaders: '{"x-site-token":"alpha"}',
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
globalWeight: 1.2,
|
globalWeight: 1.2,
|
||||||
@@ -44,6 +46,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://b.example.com',
|
url: 'https://b.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: 'one-api',
|
platform: 'one-api',
|
||||||
|
proxyUrl: '',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
customHeaders: '',
|
customHeaders: '',
|
||||||
globalWeight: 0.8,
|
globalWeight: 0.8,
|
||||||
@@ -58,6 +61,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://b.example.com',
|
url: 'https://b.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: 'one-api',
|
platform: 'one-api',
|
||||||
|
proxyUrl: '',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
customHeaders: '',
|
customHeaders: '',
|
||||||
globalWeight: 0.8,
|
globalWeight: 0.8,
|
||||||
@@ -74,6 +78,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://c.example.com',
|
url: 'https://c.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: '',
|
platform: '',
|
||||||
|
proxyUrl: '',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
customHeaders: '',
|
customHeaders: '',
|
||||||
globalWeight: 1,
|
globalWeight: 1,
|
||||||
@@ -85,16 +90,20 @@ describe('buildSiteSaveAction', () => {
|
|||||||
it('does not expose deprecated apiKey in site editor state', () => {
|
it('does not expose deprecated apiKey in site editor state', () => {
|
||||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||||
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||||
|
expect(emptySiteForm().proxyUrl).toBe('');
|
||||||
expect(siteFormFromSite({
|
expect(siteFormFromSite({
|
||||||
name: 'site-d',
|
name: 'site-d',
|
||||||
url: 'https://d.example.com',
|
url: 'https://d.example.com',
|
||||||
externalCheckinUrl: null,
|
externalCheckinUrl: null,
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
proxyUrl: null,
|
proxyUrl: 'http://127.0.0.1:8080',
|
||||||
customHeaders: '{"x-site-token":"alpha"}',
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
globalWeight: 1,
|
globalWeight: 1,
|
||||||
apiKey: 'sk-legacy-site-key',
|
apiKey: 'sk-legacy-site-key',
|
||||||
})).not.toHaveProperty('apiKey');
|
})).not.toHaveProperty('apiKey');
|
||||||
|
expect(siteFormFromSite({
|
||||||
|
proxyUrl: 'http://127.0.0.1:8080',
|
||||||
|
}).proxyUrl).toBe('http://127.0.0.1:8080');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses custom headers json into key value rows', () => {
|
it('parses custom headers json into key value rows', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type SiteForm = {
|
|||||||
url: string;
|
url: string;
|
||||||
externalCheckinUrl: string;
|
externalCheckinUrl: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
proxyUrl: string;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
customHeaders: SiteCustomHeaderField[];
|
customHeaders: SiteCustomHeaderField[];
|
||||||
globalWeight: string;
|
globalWeight: string;
|
||||||
@@ -22,6 +23,7 @@ export type SiteSavePayload = {
|
|||||||
url: string;
|
url: string;
|
||||||
externalCheckinUrl: string;
|
externalCheckinUrl: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
proxyUrl: string;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
customHeaders: string;
|
customHeaders: string;
|
||||||
globalWeight: number;
|
globalWeight: number;
|
||||||
@@ -45,6 +47,7 @@ export function emptySiteForm(): SiteForm {
|
|||||||
url: '',
|
url: '',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: '',
|
platform: '',
|
||||||
|
proxyUrl: '',
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
customHeaders: [emptySiteCustomHeader()],
|
customHeaders: [emptySiteCustomHeader()],
|
||||||
globalWeight: '1',
|
globalWeight: '1',
|
||||||
@@ -76,8 +79,9 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
|
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean | null;
|
useSystemProxy?: boolean | null;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
globalWeight?: number | string | null;
|
globalWeight?: number | string | null;
|
||||||
@@ -89,6 +93,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
|
|||||||
url: site.url ?? '',
|
url: site.url ?? '',
|
||||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||||
platform: site.platform ?? '',
|
platform: site.platform ?? '',
|
||||||
|
proxyUrl: site.proxyUrl ?? '',
|
||||||
useSystemProxy: !!site.useSystemProxy,
|
useSystemProxy: !!site.useSystemProxy,
|
||||||
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||||
globalWeight,
|
globalWeight,
|
||||||
|
|||||||
Reference in New Issue
Block a user