Merge pull request #212 from Brucents/fix/proxy-upstream-resolution
fix(proxy): 修正代理上游解析并补齐系统代理测试能力
This commit is contained in:
@@ -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 cron from 'node-cron';
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
} from '../../services/databaseMigrationService.js';
|
||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.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 { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||
@@ -70,6 +71,10 @@ interface DatabaseMigrationBody {
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
interface SystemProxyTestBody {
|
||||
proxyUrl?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeDatabaseConfig = {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
@@ -80,6 +85,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||
const DB_URL_SETTING_KEY = 'db_url';
|
||||
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 {
|
||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||
@@ -118,6 +125,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
|
||||
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[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -524,6 +619,43 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
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) => {
|
||||
const body = request.body || {};
|
||||
const changedLabels: string[] = [];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
||||
estimatedCostFromQuota: 0,
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
@@ -60,6 +61,10 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
@@ -107,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
@@ -130,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
|
||||
actualModel: 'gpt-5.4',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||
accessToken: 'fresh-access-token',
|
||||
accountId: 33,
|
||||
accountKey: 'chatgpt-account-123',
|
||||
});
|
||||
});
|
||||
|
||||
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('"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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<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> {
|
||||
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)
|
||||
|
||||
@@ -214,6 +214,69 @@ describe('responses proxy codex oauth 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 () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_codex_no_system',
|
||||
|
||||
Reference in New Issue
Block a user