forked from boos/BoosAPI
fix(proxy): add system proxy connectivity test and align proxy UI copy
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||||
@@ -241,7 +242,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||||
};
|
};
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
if (refreshedResponse.ok) {
|
if (refreshedResponse.ok) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
|
|||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
@@ -341,7 +342,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||||
};
|
};
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
if (refreshedResponse.ok) {
|
if (refreshedResponse.ok) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
|
||||||
|
describe('settings system proxy test route', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let config: ConfigModule['config'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const configModule = await import('../../config.js');
|
||||||
|
const settingsRoutesModule = await import('./settings.js');
|
||||||
|
|
||||||
|
config = configModule.config;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(settingsRoutesModule.settingsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
config.systemProxyUrl = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tests the provided system proxy url and returns latency', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/system-proxy/test',
|
||||||
|
payload: {
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
probeUrl: 'https://www.gstatic.com/generate_204',
|
||||||
|
finalUrl: 'https://www.gstatic.com/generate_204',
|
||||||
|
reachable: true,
|
||||||
|
ok: true,
|
||||||
|
statusCode: 204,
|
||||||
|
});
|
||||||
|
expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
|
||||||
|
expect(url).toBe('https://www.gstatic.com/generate_204');
|
||||||
|
expect(requestInit.method).toBe('GET');
|
||||||
|
expect(requestInit.dispatcher).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the saved system proxy url when request body is empty', async () => {
|
||||||
|
config.systemProxyUrl = 'socks5://127.0.0.1:1080';
|
||||||
|
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/system-proxy/test',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing system proxy url', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/system-proxy/test',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the proxy probe request fails', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('fetch failed', {
|
||||||
|
cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/settings/system-proxy/test',
|
||||||
|
payload: {
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(502);
|
||||||
|
expect((response.json() as { message?: string }).message).toContain('连接被拒绝');
|
||||||
|
expect((response.json() as { message?: string }).message).not.toContain('fetch failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
} from '../../services/databaseMigrationService.js';
|
} from '../../services/databaseMigrationService.js';
|
||||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
||||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||||
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||||
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||||
@@ -70,6 +71,10 @@ interface DatabaseMigrationBody {
|
|||||||
ssl?: unknown;
|
ssl?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SystemProxyTestBody {
|
||||||
|
proxyUrl?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeDatabaseConfig = {
|
type RuntimeDatabaseConfig = {
|
||||||
dialect: MigrationDialect;
|
dialect: MigrationDialect;
|
||||||
connectionString: string;
|
connectionString: string;
|
||||||
@@ -80,6 +85,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
|
|||||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||||
const DB_URL_SETTING_KEY = 'db_url';
|
const DB_URL_SETTING_KEY = 'db_url';
|
||||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
const DB_SSL_SETTING_KEY = 'db_ssl';
|
||||||
|
const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204';
|
||||||
|
const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
function isValidProxyToken(value: string): boolean {
|
function isValidProxyToken(value: string): boolean {
|
||||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||||
@@ -118,6 +125,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractNestedErrorMessages(error: unknown): string[] {
|
||||||
|
const messages: string[] = [];
|
||||||
|
const visited = new Set<unknown>();
|
||||||
|
let current: any = error;
|
||||||
|
|
||||||
|
while (current && !visited.has(current)) {
|
||||||
|
visited.add(current);
|
||||||
|
const message = typeof current?.message === 'string' ? current.message.trim() : '';
|
||||||
|
if (message) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
current = current?.cause;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSystemProxyTestFailure(error: unknown): string {
|
||||||
|
const messages = extractNestedErrorMessages(error);
|
||||||
|
const detail = messages.find((message) => message && message !== 'fetch failed')
|
||||||
|
|| messages[0]
|
||||||
|
|| '未知错误';
|
||||||
|
|
||||||
|
if (/ECONNREFUSED/i.test(detail)) {
|
||||||
|
return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) {
|
||||||
|
return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) {
|
||||||
|
return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/ECONNRESET/i.test(detail)) {
|
||||||
|
return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/407/.test(detail) || /proxy authentication/i.test(detail)) {
|
||||||
|
return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `系统代理测试失败:${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSystemProxyConnectivity(proxyUrl: string) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
SYSTEM_PROXY_TEST_PROBE_URL,
|
||||||
|
withExplicitProxyRequestInit(proxyUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'user-agent': 'metapi-system-proxy-tester/1.0',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await response.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// Ignore body drain failures; reachability is determined by receiving a response.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reachable: true,
|
||||||
|
ok: response.ok,
|
||||||
|
statusCode: response.status,
|
||||||
|
latencyMs: Math.max(1, Date.now() - startedAt),
|
||||||
|
probeUrl: SYSTEM_PROXY_TEST_PROBE_URL,
|
||||||
|
finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`);
|
||||||
|
}
|
||||||
|
throw new Error(describeSystemProxyTestFailure(error));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toStringList(value: unknown): string[] {
|
function toStringList(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -524,6 +619,43 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
return getRuntimeSettingsResponse(currentAdminIp);
|
return getRuntimeSettingsResponse(currentAdminIp);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||||
|
const rawProxyUrl = request.body?.proxyUrl === undefined
|
||||||
|
? config.systemProxyUrl
|
||||||
|
: String(request.body.proxyUrl || '').trim();
|
||||||
|
const normalizedProxyUrl = rawProxyUrl
|
||||||
|
? normalizeSiteProxyUrl(rawProxyUrl)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!rawProxyUrl) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: '请先填写系统代理地址',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedProxyUrl) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testSystemProxyConnectivity(normalizedProxyUrl);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
proxyUrl: normalizedProxyUrl,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
success: false,
|
||||||
|
message: error?.message || '系统代理测试失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
||||||
const body = request.body || {};
|
const body = request.body || {};
|
||||||
const changedLabels: string[] = [];
|
const changedLabels: string[] = [];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
}));
|
}));
|
||||||
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||||
values: () => ({
|
values: () => ({
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
@@ -60,6 +61,10 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
|||||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||||
|
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../db/index.js', () => ({
|
vi.mock('../../db/index.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
@@ -107,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
reportProxyAllFailedMock.mockReset();
|
reportProxyAllFailedMock.mockReset();
|
||||||
reportTokenExpiredMock.mockReset();
|
reportTokenExpiredMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
@@ -130,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
actualModel: 'gpt-5.4',
|
actualModel: 'gpt-5.4',
|
||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'fresh-access-token',
|
||||||
|
accountId: 33,
|
||||||
|
accountKey: 'chatgpt-account-123',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -281,4 +292,73 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
expect(response.body).not.toContain('event: response.created');
|
expect(response.body).not.toContain('event: response.created');
|
||||||
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries oauth chat requests with a normalized upstream URL after refresh', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
username: 'oauth-user@example.com',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'chatgpt-account-123',
|
||||||
|
email: 'oauth-user@example.com',
|
||||||
|
planType: 'plus',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'expired-access-token',
|
||||||
|
actualModel: 'gpt-4o-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: { message: 'expired token', type: 'invalid_request_error' },
|
||||||
|
}), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl_refreshed',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1706000000,
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'ok after refresh' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [{ role: 'user', content: 'hello oauth' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toBe('https://gateway.example.com/v1/responses');
|
||||||
|
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
|
||||||
|
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
|
||||||
|
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
|
||||||
|
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -214,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
expect(response.json()?.output_text).toContain('ok after codex token refresh');
|
expect(response.json()?.output_text).toContain('ok after codex token refresh');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries oauth responses requests with a normalized upstream URL after refresh', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
username: 'oauth-user@example.com',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'chatgpt-account-123',
|
||||||
|
email: 'oauth-user@example.com',
|
||||||
|
planType: 'plus',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'expired-access-token',
|
||||||
|
actualModel: 'gpt-4.1-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: { message: 'expired token', type: 'invalid_request_error' },
|
||||||
|
}), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_openai_refreshed',
|
||||||
|
object: 'response',
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
status: 'completed',
|
||||||
|
output_text: 'ok after refresh',
|
||||||
|
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
input: 'hello oauth',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
expect(firstUrl).toBe('https://gateway.example.com/v1/responses');
|
||||||
|
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
|
||||||
|
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
|
||||||
|
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
|
||||||
|
expect(response.json()?.output_text).toBe('ok after refresh');
|
||||||
|
});
|
||||||
|
|
||||||
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
|
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
id: 'resp_codex_no_system',
|
id: 'resp_codex_no_system',
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ export type ProxyTestJobResponse = {
|
|||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemProxyTestRequest = {
|
||||||
|
proxyUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemProxyTestResponse = {
|
||||||
|
success: true;
|
||||||
|
proxyUrl: string;
|
||||||
|
probeUrl: string;
|
||||||
|
finalUrl: string;
|
||||||
|
reachable: true;
|
||||||
|
ok: boolean;
|
||||||
|
statusCode: number;
|
||||||
|
latencyMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||||
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
|
||||||
|
|
||||||
@@ -511,6 +526,11 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
}),
|
||||||
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
||||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||||
request('/api/settings/database/runtime', {
|
request('/api/settings/database/runtime', {
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ type RuntimeSettings = {
|
|||||||
currentAdminIp?: string;
|
currentAdminIp?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SystemProxyTestState =
|
||||||
|
| { kind: 'success'; text: string }
|
||||||
|
| { kind: 'error'; text: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
type DownstreamApiKeyItem = {
|
type DownstreamApiKeyItem = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -222,6 +227,8 @@ export default function Settings() {
|
|||||||
const [testingCheckin, setTestingCheckin] = useState(false);
|
const [testingCheckin, setTestingCheckin] = useState(false);
|
||||||
const [savingToken, setSavingToken] = useState(false);
|
const [savingToken, setSavingToken] = useState(false);
|
||||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||||
|
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||||
|
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||||
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
|
||||||
const [savingRouting, setSavingRouting] = useState(false);
|
const [savingRouting, setSavingRouting] = useState(false);
|
||||||
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
|
||||||
@@ -576,6 +583,31 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testSystemProxy = async () => {
|
||||||
|
const proxyUrl = runtime.systemProxyUrl.trim();
|
||||||
|
if (!proxyUrl) {
|
||||||
|
const message = '请先填写系统代理地址';
|
||||||
|
setSystemProxyTestState({ kind: 'error', text: message });
|
||||||
|
toast.info(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestingSystemProxy(true);
|
||||||
|
setSystemProxyTestState(null);
|
||||||
|
try {
|
||||||
|
const res = await api.testSystemProxy({ proxyUrl });
|
||||||
|
const summary = `连通成功,延迟 ${res.latencyMs} ms`;
|
||||||
|
setSystemProxyTestState({ kind: 'success', text: summary });
|
||||||
|
toast.success(`系统代理测试成功(${res.latencyMs} ms)`);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || '系统代理测试失败';
|
||||||
|
setSystemProxyTestState({ kind: 'error', text: message });
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setTestingSystemProxy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveProxyFailureRules = async () => {
|
const saveProxyFailureRules = async () => {
|
||||||
setSavingProxyFailureRules(true);
|
setSavingProxyFailureRules(true);
|
||||||
try {
|
try {
|
||||||
@@ -1114,13 +1146,39 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
value={runtime.systemProxyUrl}
|
value={runtime.systemProxyUrl}
|
||||||
onChange={(e) => setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))}
|
onChange={(e) => {
|
||||||
|
setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }));
|
||||||
|
setSystemProxyTestState(null);
|
||||||
|
}}
|
||||||
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
|
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
|
||||||
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
|
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={testSystemProxy}
|
||||||
|
disabled={testingSystemProxy}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
{testingSystemProxy ? <><span className="spinner spinner-sm" /> 测试中...</> : '测试系统代理'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{systemProxyTestState && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
color: systemProxyTestState.kind === 'success'
|
||||||
|
? 'var(--color-success)'
|
||||||
|
: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{systemProxyTestState.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
||||||
|
|||||||
@@ -921,13 +921,13 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
placeholder="站点代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||||
value={form.proxyUrl}
|
value={form.proxyUrl}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
style={formInputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
留空则直连;填写后优先于系统代理。
|
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||||
</div>
|
</div>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
getRoutesLite: vi.fn(),
|
getRoutesLite: vi.fn(),
|
||||||
getRuntimeDatabaseConfig: vi.fn(),
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
updateRuntimeSettings: vi.fn(),
|
updateRuntimeSettings: vi.fn(),
|
||||||
|
testSystemProxy: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -67,6 +68,16 @@ describe('Settings system proxy', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
systemProxyUrl: 'http://127.0.0.1:7890',
|
systemProxyUrl: 'http://127.0.0.1:7890',
|
||||||
});
|
});
|
||||||
|
apiMock.testSystemProxy.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
probeUrl: 'https://www.gstatic.com/generate_204',
|
||||||
|
finalUrl: 'https://www.gstatic.com/generate_204',
|
||||||
|
reachable: true,
|
||||||
|
ok: true,
|
||||||
|
statusCode: 204,
|
||||||
|
latencyMs: 321,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -112,4 +123,45 @@ describe('Settings system proxy', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tests system proxy from settings', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const proxyInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const testButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '测试系统代理'
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
testButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.testSystemProxy).toHaveBeenCalledWith({
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
});
|
||||||
|
expect(collectText(root.root)).toContain('连通成功,延迟 321 ms');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user