Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/proxy/completions.ts
#	src/server/services/siteProxy.ts
This commit is contained in:
Hureru
2026-03-19 00:00:10 +08:00
20 changed files with 1747 additions and 137 deletions
+2
View File
@@ -94,6 +94,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
routingWeights: {
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
+10
View File
@@ -129,6 +129,16 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const proxyErrorKeywords = parseSettingFromMap<string[] | string>(settingsMap, 'proxy_error_keywords');
if (proxyErrorKeywords !== undefined) {
config.proxyErrorKeywords = toStringList(proxyErrorKeywords);
}
const proxyEmptyContentFailEnabled = parseSettingFromMap<boolean>(settingsMap, 'proxy_empty_content_fail_enabled');
if (typeof proxyEmptyContentFailEnabled === 'boolean') {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
}
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
@@ -12,6 +12,8 @@ const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn();
type AccountTokenServiceModule = typeof import('../../services/accountTokenService.js');
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
@@ -28,6 +30,7 @@ describe('account tokens sync routes with site status', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let maskToken: AccountTokenServiceModule['maskToken'];
let dataDir = '';
let seedId = 0;
@@ -63,9 +66,11 @@ describe('account tokens sync routes with site status', () => {
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenServiceModule = await import('../../services/accountTokenService.js');
const routesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
maskToken = accountTokenServiceModule.maskToken;
app = Fastify();
await app.register(routesModule.accountTokensRoutes);
@@ -196,6 +201,247 @@ describe('account tokens sync routes with site status', () => {
]);
});
it('reuses an existing ready token when upstream only returns the matching masked token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('removes matching masked_pending placeholders after reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
const maskedToken = maskToken(fullToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: maskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not reuse a different ready token when another logical token shares the same masked value', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const firstFullToken = 'sk-real-token-1234';
const secondFullToken = 'sk-real-zzzzz-1234';
const sharedMaskedToken = maskToken(firstFullToken);
expect(maskToken(secondFullToken)).toBe(sharedMaskedToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'second-token',
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'second-token', key: sharedMaskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === firstFullToken)).toMatchObject({
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
const maskedRow = tokenRows.find((row) => row.name === 'second-token');
expect(maskedRow).toMatchObject({
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('keeps fully ambiguous short masks as masked_pending instead of reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-abcd';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'short-token',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'short-token', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
created: 1,
updated: 0,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === fullToken)).toBeDefined();
const maskedRow = tokenRows.find((row) => row.token === 'sk-***');
expect(maskedRow).toMatchObject({
name: 'short-token',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts)
@@ -273,6 +273,60 @@ describe('settings and auth events', () => {
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
});
it('splits proxy error keywords on newlines and commas when saving runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyErrorKeywords: 'quota exceeded\nbad gateway,too many requests',
proxyEmptyContentFailEnabled: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(updated.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(updated.proxyEmptyContentFailEnabled).toBe(true);
expect(config.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(config.proxyEmptyContentFailEnabled).toBe(true);
const rows = await db.select().from(schema.settings).all();
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
expect(settingsMap.get('proxy_error_keywords')).toBe(JSON.stringify([
'quota exceeded',
'bad gateway',
'too many requests',
]));
expect(settingsMap.get('proxy_empty_content_fail_enabled')).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(runtime.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
});
it('persists and returns log cleanup settings from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+83
View File
@@ -55,6 +55,8 @@ interface RuntimeSettingsBody {
adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number;
routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
}
interface DatabaseMigrationBody {
@@ -127,6 +129,33 @@ function toStringList(value: unknown): string[] {
return [];
}
function parseProxyErrorKeywords(value: unknown): string[] {
const splitKeywords = (input: string): string[] => input
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (Array.isArray(value)) {
const keywords = value.flatMap((item) => {
if (typeof item !== 'string') return [];
return splitKeywords(item);
});
return keywords;
}
if (typeof value === 'string') {
const keywords = splitKeywords(value);
return keywords;
}
throw new Error('上游错误关键词格式无效:需要 string 或 string[]');
}
function parseBooleanFlag(value: unknown, label: string): boolean {
if (typeof value === 'boolean') return value;
throw new Error(`${label}格式无效:需要 boolean`);
}
function isValidHttpUrl(raw: string): boolean {
const value = String(raw || '').trim();
if (!value) return false;
@@ -197,6 +226,22 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'proxy_error_keywords': {
try {
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
} catch {
return;
}
return;
}
case 'proxy_empty_content_fail_enabled': {
try {
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
} catch {
return;
}
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
@@ -347,6 +392,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp,
systemProxyUrl: config.systemProxyUrl,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken),
};
}
@@ -603,6 +650,42 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
}
if (body.proxyErrorKeywords !== undefined) {
let nextKeywords: string[] = [];
try {
nextKeywords = parseProxyErrorKeywords(body.proxyErrorKeywords);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '上游错误关键词格式无效',
});
}
if (JSON.stringify(nextKeywords) !== JSON.stringify(config.proxyErrorKeywords || [])) {
changedLabels.push('上游错误关键词');
}
config.proxyErrorKeywords = nextKeywords;
upsertSetting('proxy_error_keywords', config.proxyErrorKeywords);
}
if (body.proxyEmptyContentFailEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.proxyEmptyContentFailEnabled, '空内容判定失败开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '空内容判定失败开关格式无效',
});
}
if (nextValue !== config.proxyEmptyContentFailEnabled) {
changedLabels.push('空内容判定失败');
}
config.proxyEmptyContentFailEnabled = nextValue;
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');
+139
View File
@@ -1,5 +1,6 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -109,6 +110,8 @@ describe('chat proxy stream behavior', () => {
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
});
afterAll(async () => {
@@ -150,6 +153,77 @@ describe('chat proxy stream behavior', () => {
expect(response.body).toContain('data: [DONE]');
});
it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-empty',
object: 'chat.completion',
created: 1_706_000_000,
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 6, completion_tokens: 0, 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: 'hi' }],
},
});
expect(response.statusCode).toBe(502);
expect(response.json()?.error?.type).toBe('upstream_error');
expect(response.json()?.error?.message).toContain('empty content');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns HTTP upstream_error instead of hijacking when streamed chat requests receive empty non-SSE payloads', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-empty-stream',
object: 'chat.completion',
created: 1_706_000_000,
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 4, completion_tokens: 0, total_tokens: 4 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
},
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).not.toContain('text/event-stream');
expect(response.json()?.error?.type).toBe('upstream_error');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => {
const response = await app.inject({
method: 'POST',
@@ -1989,6 +2063,71 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/responses');
});
it('returns upstream_error for empty non-stream /v1/responses payloads when empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp-empty',
object: 'response',
model: 'gpt-5.4',
status: 'completed',
output: [],
output_text: '',
usage: { input_tokens: 3, output_tokens: 0, total_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
},
});
expect(response.statusCode).toBe(502);
expect(response.json()?.error?.type).toBe('upstream_error');
expect(response.json()?.error?.message).toContain('empty content');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns HTTP upstream_error instead of hijacking when streamed /v1/responses receives empty non-SSE payloads', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp-empty-stream',
object: 'response',
model: 'gpt-5.4',
status: 'completed',
output: [],
output_text: '',
usage: { input_tokens: 2, output_tokens: 0, total_tokens: 2 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
stream: true,
},
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).not.toContain('text/event-stream');
expect(response.json()?.error?.type).toBe('upstream_error');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
+142 -82
View File
@@ -21,7 +21,8 @@ import {
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
@@ -343,12 +344,15 @@ async function handleChatProxyRequest(
const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) {
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
const startSseResponse = () => {
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
};
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
@@ -378,10 +382,11 @@ async function handleChatProxyRequest(
reply.raw.write(chunk);
},
});
let rawText = '';
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
if (!upstreamContentType.includes('text/event-stream')) {
const fallbackText = await upstream.text();
rawText = fallbackText;
let fallbackData: unknown = null;
try {
fallbackData = JSON.parse(fallbackText);
@@ -391,7 +396,96 @@ async function handleChatProxyRequest(
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
fallbackData = unwrapGeminiCliPayload(fallbackData);
}
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: failure.reason, type: 'upstream_error' },
});
}
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
} else {
startSseResponse();
// Preserve real SSE streaming instead of buffering the whole upstream body.
const upstreamReader = upstream.body?.getReader();
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
const decoder = new TextDecoder();
const reader = baseReader
? {
async read() {
const result = await baseReader.read();
if (result.value) {
rawText += decoder.decode(result.value, { stream: true });
}
return result;
},
async cancel(reason?: unknown) {
return baseReader.cancel(reason);
},
releaseLock() {
return baseReader.releaseLock();
},
}
: baseReader;
const streamResult = await streamSession.run(reader, reply.raw);
rawText += decoder.decode();
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
@@ -417,83 +511,13 @@ async function handleChatProxyRequest(
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
// Once SSE has been hijacked and streamed downstream, we can no longer
// safely fall back to an HTTP error response or retry by switching the
// channel mid-flight. Stream-level failures must be handled in-band by
// the proxy stream session itself.
}
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
const streamResult = await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -563,6 +587,42 @@ async function handleChatProxyRequest(
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: failure.reason, type: 'upstream_error' },
});
}
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
+39 -2
View File
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
@@ -70,7 +71,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
'Authorization': `Bearer ${selected.tokenValue}`,
},
body: JSON.stringify(forwardBody),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig)));
}));
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
@@ -182,9 +183,45 @@ export async function completionsProxyRoute(app: FastifyInstance) {
return;
}
const data = await upstream.json() as any;
const rawText = await upstream.text();
let data: any = rawText;
try {
data = JSON.parse(rawText);
} catch {
data = rawText;
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
const errText = failure.reason;
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
errText,
retryCount,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: errText, type: 'upstream_error' },
});
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it } from 'vitest';
import { config } from '../../config.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
describe('detectProxyFailure (empty content)', () => {
const originalEmptyFail = config.proxyEmptyContentFailEnabled;
const originalKeywords = Array.isArray(config.proxyErrorKeywords) ? [...config.proxyErrorKeywords] : config.proxyErrorKeywords;
afterEach(() => {
config.proxyEmptyContentFailEnabled = originalEmptyFail;
config.proxyErrorKeywords = originalKeywords as any;
});
it('flags empty assistant content even when total tokens > 0', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 12, completionTokens: 0, totalTokens: 12 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('does not flag when output exists even if usage is missing', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_has_output',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: 'hi' },
finish_reason: 'stop',
}],
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('does not treat tool call payloads as empty content', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'resp_tool',
object: 'response',
status: 'completed',
output: [{
type: 'function_call',
id: 'fc_1',
call_id: 'call_abc',
name: 'Glob',
arguments: '{"pattern":"README*"}',
}],
output_text: '',
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('flags empty SSE streams that contain no content deltas', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: {"id":"evt_1","choices":[{"delta":{}}]}',
'',
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('treats DONE-only SSE as no output and flags failure', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('truncates fractional token counts before empty-content detection', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_fractional_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 3.9, completion_tokens: 0.6, total_tokens: 4.5 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 3.9, completionTokens: 0.6, totalTokens: 4.5 } as any,
});
expect(failure).toMatchObject({ status: 502 });
});
});
@@ -0,0 +1,203 @@
import { config } from '../../config.js';
import { pullSseDataEvents } from '../../services/proxyUsageParser.js';
type FailureResult = {
status: number;
reason: string;
};
type UsageSummary = {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
function normalizeKeywords(values: string[]): string[] {
return values
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0)
.map((item) => item.toLowerCase());
}
function toNonNegativeInt(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.trunc(n));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function hasToolCallLike(value: unknown): boolean {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
if (isRecord(value)) return Object.keys(value).length > 0;
return false;
}
function hasCompletionContentFromChoice(choice: any): boolean {
if (hasNonEmptyString(choice?.text)) return true;
if (hasNonEmptyString(choice?.completion)) return true;
if (hasNonEmptyString(choice?.output_text)) return true;
const message = choice?.message;
if (hasNonEmptyString(message?.content)) return true;
if (Array.isArray(message?.content)) {
for (const part of message.content) {
if (hasNonEmptyString(part?.text) || hasNonEmptyString(part?.output_text) || hasNonEmptyString(part?.content)) {
return true;
}
}
}
if (hasNonEmptyString(message?.refusal)) return true;
if (hasToolCallLike(message?.tool_calls)) return true;
if (hasToolCallLike(message?.toolCalls)) return true;
if (hasToolCallLike(message?.function_call)) return true;
if (hasToolCallLike(message?.functionCall)) return true;
if (hasToolCallLike(choice?.tool_calls)) return true;
if (hasToolCallLike(choice?.toolCalls)) return true;
if (hasToolCallLike(choice?.function_call)) return true;
if (hasToolCallLike(choice?.functionCall)) return true;
const delta = choice?.delta;
if (hasNonEmptyString(delta?.content)) return true;
if (hasNonEmptyString(delta?.refusal)) return true;
if (hasToolCallLike(delta?.tool_calls)) return true;
if (hasToolCallLike(delta?.toolCalls)) return true;
if (hasToolCallLike(delta?.function_call)) return true;
if (hasToolCallLike(delta?.functionCall)) return true;
return false;
}
function hasCompletionContentFromPayload(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') return false;
const obj: any = payload;
if (Array.isArray(obj?.choices)) {
for (const choice of obj.choices) {
if (hasCompletionContentFromChoice(choice)) return true;
}
if (hasCompletionContentFromChoice(obj)) return true;
}
if (hasNonEmptyString(obj?.output_text)) return true;
if (hasNonEmptyString(obj?.outputText)) return true;
if (Array.isArray(obj?.output)) {
for (const item of obj.output) {
if (!isRecord(item)) continue;
const type = String((item as any).type || '').toLowerCase();
if (type.includes('function_call') || type.includes('tool_call')) return true;
if (hasNonEmptyString((item as any).text) || hasNonEmptyString((item as any).output_text)) return true;
if (Array.isArray((item as any).content)) {
for (const part of (item as any).content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasToolCallLike((item as any).tool_calls) || hasToolCallLike((item as any).toolCalls)) return true;
if (hasToolCallLike((item as any).function_call) || hasToolCallLike((item as any).functionCall)) return true;
}
}
if (Array.isArray(obj?.content)) {
for (const part of obj.content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasNonEmptyString(obj?.delta)) return true;
if (hasNonEmptyString(obj?.text)) return true;
if (hasToolCallLike(obj?.tool_calls) || hasToolCallLike(obj?.toolCalls)) return true;
if (hasToolCallLike(obj?.function_call) || hasToolCallLike(obj?.functionCall)) return true;
return false;
}
function detectHasUpstreamOutput(rawText: string): boolean {
const text = typeof rawText === 'string' ? rawText : '';
const trimmed = text.trim();
if (!trimmed) return false;
try {
const parsed = JSON.parse(trimmed);
return hasCompletionContentFromPayload(parsed);
} catch {
// Important: don't trim before SSE parsing, otherwise `data: [DONE]\n\n` can be lost.
const pulled = pullSseDataEvents(text);
if (pulled.events.length > 0) {
for (const event of pulled.events) {
const payload = event.trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsedEvent = JSON.parse(payload);
if (hasCompletionContentFromPayload(parsedEvent)) return true;
} catch {
// Non-JSON payload still counts as upstream output.
return true;
}
}
// SSE payloads exist but none contain output.
return false;
}
// Looks like SSE but contains no non-DONE payloads.
if (text.includes('data:')) return false;
// Not JSON and not SSE: assume it's plain text output.
return true;
}
}
export function detectProxyFailure(input: {
rawText: string;
usage?: UsageSummary | null;
// Backward-compatible fields (older call sites)
completionTokens?: number;
totalTokens?: number;
}): FailureResult | null {
const rawText = typeof input.rawText === 'string' ? input.rawText : '';
const keywords = normalizeKeywords(config.proxyErrorKeywords || []);
if (keywords.length > 0) {
const normalizedText = rawText.toLowerCase();
const matched = keywords.find((keyword) => normalizedText.includes(keyword));
if (matched) {
return {
status: 502,
reason: `Upstream response matched failure keyword: ${matched}`,
};
}
}
if (config.proxyEmptyContentFailEnabled) {
const completionTokens = toNonNegativeInt(input.usage?.completionTokens ?? input.completionTokens);
const hasOutput = detectHasUpstreamOutput(rawText);
if (!hasOutput && completionTokens <= 0) {
return {
status: 502,
reason: 'Upstream returned empty content',
};
}
}
return null;
}
+190 -7
View File
@@ -17,6 +17,7 @@ import {
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
@@ -425,15 +426,140 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) {
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
const startSseResponse = () => {
reply.hijack();
reply.raw.statusCode = 200;
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
};
if (!upstreamContentType.includes('text/event-stream')) {
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const parsedUsage = parseProxyUsage(upstreamData);
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
}
const normalized = openAiResponsesTransformer.transformFinalResponse(
upstreamData,
modelName,
rawText,
);
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
upstreamPayload: upstreamData,
normalized,
usage: parsedUsage,
serializationMode: 'response',
});
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
const streamPayload = isRecord(downstreamData) ? downstreamData : {
id: `resp_${Date.now()}`,
object: 'response',
status: 'completed',
model: modelName,
output: [],
output_text: '',
usage: {
input_tokens: parsedUsage.promptTokens,
output_tokens: parsedUsage.completionTokens,
total_tokens: parsedUsage.totalTokens,
},
};
const createdPayload = {
...streamPayload,
status: 'in_progress',
output: [],
output_text: '',
};
startSseResponse();
reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`);
reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`);
reply.raw.write('data: [DONE]\n\n');
reply.raw.end();
return;
}
startSseResponse();
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
let parsedUsage: UsageSummary = {
@@ -444,6 +570,25 @@ export async function responsesProxyRoute(app: FastifyInstance) {
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let rawText = '';
const decoder = new TextDecoder();
const reader = baseReader
? {
async read() {
const result = await baseReader.read();
if (result.value) {
rawText += decoder.decode(result.value, { stream: true });
}
return result;
},
async cancel(reason?: unknown) {
return baseReader.cancel(reason);
},
releaseLock() {
return baseReader.releaseLock();
},
}
: baseReader;
const writeLines = (lines: string[]) => {
for (const line of lines) reply.raw.write(line);
};
@@ -465,6 +610,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
},
});
const streamResult = await streamSession.run(reader, reply.raw);
rawText += decoder.decode();
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
@@ -490,6 +636,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return;
}
// Once SSE has been hijacked and bytes may already be on the wire, we
// must not attempt to convert stream failures into a fresh HTTP error
// response or retry on another channel. Responses stream failures are
// handled in-band by the proxy stream session.
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -545,6 +696,38 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
}
const normalized = openAiResponsesTransformer.transformFinalResponse(
upstreamData,
modelName,
@@ -60,6 +60,41 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•');
}
function normalizeMaskedTokenForCompare(token: string | null | undefined): string {
return normalizeTokenForDisplay(token).replace(/•/g, '*');
}
function matchesMaskedTokenValue(
fullToken: string | null | undefined,
maskedToken: string | null | undefined,
): boolean {
const normalizedFull = normalizeTokenForDisplay(fullToken);
const normalizedMasked = normalizeMaskedTokenForCompare(maskedToken);
if (!normalizedFull || !normalizedMasked) return false;
if (!isMaskedTokenValue(normalizedMasked)) {
return normalizedFull === normalizedMasked;
}
const firstMaskIndex = normalizedMasked.search(/[\*]/);
const lastMaskIndex = Math.max(
normalizedMasked.lastIndexOf('*'),
normalizedMasked.lastIndexOf('•'),
);
if (firstMaskIndex < 0 || lastMaskIndex < firstMaskIndex) {
return normalizedFull === normalizedMasked;
}
const prefix = normalizedMasked.slice(0, firstMaskIndex);
const suffix = normalizedMasked.slice(lastMaskIndex + 1);
const visiblePrefix = prefix.replace(/^sk-/i, '');
if (!visiblePrefix && !suffix) return false;
if (normalizedFull.length < prefix.length + suffix.length) return false;
if (prefix && !normalizedFull.startsWith(prefix)) return false;
if (suffix && !normalizedFull.endsWith(suffix)) return false;
return true;
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
@@ -306,6 +341,62 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue;
}
const matchingReadyByMaskedValue = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? existing.filter((row) => (
resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& matchesMaskedTokenValue(row.token, tokenValue)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
))
: [];
const readyMaskedMatch = matchingReadyByMaskedValue.length === 1
? matchingReadyByMaskedValue[0]
: null;
if (readyMaskedMatch) {
const staleMaskedPlaceholders = existing.filter((row) => (
row.id !== readyMaskedMatch.id
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
&& matchesMaskedTokenValue(row.token, tokenValue)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, readyMaskedMatch.id))
.run();
readyMaskedMatch.name = tokenName;
readyMaskedMatch.tokenGroup = tokenGroup;
readyMaskedMatch.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
readyMaskedMatch.enabled = enabled;
readyMaskedMatch.source = 'sync';
readyMaskedMatch.updatedAt = now;
if (staleMaskedPlaceholders.length > 0) {
for (const placeholder of staleMaskedPlaceholders) {
await db.delete(schema.accountTokens)
.where(eq(schema.accountTokens.id, placeholder.id))
.run();
}
for (const placeholder of staleMaskedPlaceholders) {
const placeholderIndex = existing.findIndex((row) => row.id === placeholder.id);
if (placeholderIndex >= 0) {
existing.splice(placeholderIndex, 1);
}
}
}
updated++;
continue;
}
const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row)
&& row.name === tokenName
+72 -2
View File
@@ -1,9 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { once } from 'node:events';
import { mkdtempSync } from 'node:fs';
import { createServer } from 'node:http';
import { connect as connectSocket } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { Headers } from 'undici';
import { SocksClient } from 'socks';
import { Headers, fetch } from 'undici';
type DbModule = typeof import('../db/index.js');
@@ -71,6 +75,72 @@ describe('siteProxy', () => {
expect('dispatcher' in requestInit).toBe(true);
});
it('injects a working dispatcher for socks5 system proxies', async () => {
const upstreamServer = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true }));
});
upstreamServer.listen(0, '127.0.0.1');
await once(upstreamServer, 'listening');
const upstreamAddress = upstreamServer.address();
if (!upstreamAddress || typeof upstreamAddress === 'string') {
throw new Error('Failed to determine upstream server address');
}
const requestUrl = `http://proxy-site.example.com:${upstreamAddress.port}/v1/chat/completions`;
await db.insert(schema.settings).values({
key: 'system_proxy_url',
value: JSON.stringify('socks5h://127.0.0.1:1080'),
}).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, use_system_proxy)
VALUES ('proxy-site', ${`http://proxy-site.example.com:${upstreamAddress.port}`}, 'new-api', 1)
`);
const createConnectionSpy = vi.spyOn(SocksClient, 'createConnection').mockImplementation(async () => {
const socket = connectSocket(upstreamAddress.port, '127.0.0.1');
await once(socket, 'connect');
return { socket } as Awaited<ReturnType<typeof SocksClient.createConnection>>;
});
try {
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = await withSiteProxyRequestInit(requestUrl, {
method: 'GET',
});
expect('dispatcher' in requestInit).toBe(true);
const response = await fetch(requestUrl, requestInit);
expect(response.status).toBe(200);
expect(createConnectionSpy).toHaveBeenCalledTimes(1);
expect(createConnectionSpy).toHaveBeenCalledWith(expect.objectContaining({
command: 'connect',
proxy: expect.objectContaining({
host: '127.0.0.1',
port: 1080,
type: 5,
}),
destination: expect.objectContaining({
host: 'proxy-site.example.com',
port: upstreamAddress.port,
}),
}));
} finally {
createConnectionSpy.mockRestore();
await new Promise<void>((resolve, reject) => {
upstreamServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => {
await db.insert(schema.sites).values({
name: 'headers-site',
+164 -2
View File
@@ -2,8 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { db, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
import { lookup as dnsLookup } from 'node:dns';
import { isIP, type Socket } from 'node:net';
import { connect as tlsConnect, type TLSSocket } from 'node:tls';
import { SocksClient } from 'socks';
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { ProxyAgent } from 'undici';
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
@@ -17,6 +21,15 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
'socks5:',
'socks5h:',
]);
const SOCKS_PROXY_PROTOCOLS = new Set([
'socks:',
'socks4:',
'socks4a:',
'socks5:',
'socks5h:',
]);
const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 10_000;
const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
type SiteProxyRow = {
siteUrl: string;
@@ -58,6 +71,27 @@ export function withAccountProxyOverride<T>(
return accountProxyOverride.run(normalized, fn);
}
type ParsedSocksProxyConfig = {
shouldLookup: boolean;
proxy: {
host: string;
port: number;
type: 4 | 5;
userId?: string;
password?: string;
};
};
type UndiciConnectOptions = {
hostname: string;
host?: string;
protocol: string;
port: string;
servername?: string;
localAddress?: string | null;
httpSocket?: Socket;
};
function normalizeSiteUrl(value: string): string {
const trimmed = (value || '').trim();
if (!trimmed) return '';
@@ -128,7 +162,10 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
if (cached) return cached;
try {
const dispatcher = new ProxyAgent(normalized);
const parsedProxyUrl = new URL(normalized);
const dispatcher = SOCKS_PROXY_PROTOCOLS.has(parsedProxyUrl.protocol.toLowerCase())
? createSocksDispatcher(parsedProxyUrl)
: new ProxyAgent(normalized);
dispatcherCache.set(normalized, dispatcher);
return dispatcher;
} catch {
@@ -136,6 +173,131 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
}
}
function parseSocksProxyUrl(proxyUrl: URL): ParsedSocksProxyConfig {
let shouldLookup = false;
let type: 4 | 5 = 5;
switch (proxyUrl.protocol.toLowerCase()) {
case 'socks4:':
shouldLookup = true;
type = 4;
break;
case 'socks4a:':
type = 4;
break;
case 'socks5:':
shouldLookup = true;
type = 5;
break;
case 'socks:':
case 'socks5h:':
type = 5;
break;
default:
throw new TypeError(`Unsupported SOCKS proxy protocol: ${proxyUrl.protocol}`);
}
const proxy: ParsedSocksProxyConfig['proxy'] = {
host: proxyUrl.hostname,
port: Number.parseInt(proxyUrl.port, 10) || 1080,
type,
};
if (proxyUrl.username) {
proxy.userId = decodeURIComponent(proxyUrl.username);
}
if (proxyUrl.password) {
proxy.password = decodeURIComponent(proxyUrl.password);
}
return { shouldLookup, proxy };
}
function applySocketDefaults(socket: Socket | TLSSocket) {
socket.setNoDelay(true);
socket.setKeepAlive(true, DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS);
}
async function resolveSocksDestinationHost(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
dnsLookup(hostname, {}, (error, address) => {
if (error) {
reject(error);
return;
}
resolve(address);
});
});
}
async function createSocksSocket(
connectOptions: UndiciConnectOptions,
socksProxy: ParsedSocksProxyConfig,
): Promise<Socket | TLSSocket> {
if (!connectOptions.hostname) {
throw new Error('Missing hostname for SOCKS proxy request');
}
const destinationHost = socksProxy.shouldLookup
? await resolveSocksDestinationHost(connectOptions.hostname)
: connectOptions.hostname;
const destinationPort = Number.parseInt(connectOptions.port, 10)
|| (connectOptions.protocol === 'https:' ? 443 : 80);
const { socket } = await SocksClient.createConnection({
proxy: socksProxy.proxy,
destination: {
host: destinationHost,
port: destinationPort,
},
command: 'connect',
timeout: DEFAULT_PROXY_CONNECT_TIMEOUT_MS,
socket_options: connectOptions.localAddress
? { localAddress: connectOptions.localAddress } as any
: undefined,
});
applySocketDefaults(socket);
if (connectOptions.protocol !== 'https:') {
return socket;
}
return await new Promise<TLSSocket>((resolve, reject) => {
const tlsSocket = tlsConnect({
socket,
host: connectOptions.hostname,
servername: connectOptions.servername || (!isIP(connectOptions.hostname) ? connectOptions.hostname : undefined),
ALPNProtocols: ['http/1.1'],
});
const cleanup = (error: Error) => {
socket.destroy();
tlsSocket.destroy();
reject(error);
};
tlsSocket.once('secureConnect', () => {
tlsSocket.off('error', cleanup);
applySocketDefaults(tlsSocket);
resolve(tlsSocket);
});
tlsSocket.once('error', cleanup);
});
}
function createSocksDispatcher(proxyUrl: URL): Dispatcher {
const socksProxy = parseSocksProxyUrl(proxyUrl);
return new UndiciAgent({
connect: (connectOptions, callback) => {
void createSocksSocket(connectOptions, socksProxy)
.then((socket) => callback(null, socket))
.catch((error) => {
callback(error instanceof Error ? error : new Error(String(error)), null as any);
});
},
});
}
export function normalizeSiteProxyUrl(input: unknown): string | null {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
+60 -10
View File
@@ -1413,6 +1413,8 @@ export default function ModelTester() {
setActiveDebugTab(DEBUG_TABS.RESPONSE);
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
@@ -1447,6 +1449,12 @@ export default function ModelTester() {
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
@@ -1457,21 +1465,38 @@ export default function ModelTester() {
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]),
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
setPendingPayload(null);
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
@@ -1539,6 +1564,8 @@ export default function ModelTester() {
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
@@ -1571,6 +1598,12 @@ export default function ModelTester() {
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
@@ -1581,20 +1614,37 @@ export default function ModelTester() {
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]),
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '代理流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
+79
View File
@@ -34,6 +34,8 @@ type RuntimeSettings = {
routingFallbackUnitCost: number;
routingWeights: RoutingWeights;
systemProxyUrl: string;
proxyErrorKeywords: string[];
proxyEmptyContentFailEnabled: boolean;
proxyTokenMasked?: string;
adminIpAllowlist?: string[];
currentAdminIp?: string;
@@ -193,13 +195,17 @@ export default function Settings() {
routingFallbackUnitCost: 1,
routingWeights: defaultWeights,
systemProxyUrl: '',
proxyErrorKeywords: [],
proxyEmptyContentFailEnabled: false,
});
const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
const [proxyErrorKeywordsText, setProxyErrorKeywordsText] = useState('');
const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false);
@@ -406,12 +412,21 @@ export default function Settings() {
...(runtimeInfo.routingWeights || {}),
},
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
proxyErrorKeywords: Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string')
: [],
proxyEmptyContentFailEnabled: !!runtimeInfo.proxyEmptyContentFailEnabled,
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [],
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
});
setProxyErrorKeywordsText(
Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
: '',
);
setAdminIpAllowlistText(
Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.join('\n')
@@ -463,6 +478,11 @@ export default function Settings() {
return compact;
};
const parseProxyErrorKeywords = (raw: string) => raw
.split(/\r?\n|,/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
const saveSchedule = async () => {
setSavingSchedule(true);
try {
@@ -521,6 +541,33 @@ export default function Settings() {
}
};
const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true);
try {
const keywords = parseProxyErrorKeywords(proxyErrorKeywordsText);
const res = await api.updateRuntimeSettings({
proxyErrorKeywords: keywords,
proxyEmptyContentFailEnabled: runtime.proxyEmptyContentFailEnabled,
});
const nextKeywords = Array.isArray(res?.proxyErrorKeywords)
? res.proxyErrorKeywords
: keywords;
setRuntime((prev) => ({
...prev,
proxyErrorKeywords: nextKeywords,
proxyEmptyContentFailEnabled: typeof res?.proxyEmptyContentFailEnabled === 'boolean'
? res.proxyEmptyContentFailEnabled
: prev.proxyEmptyContentFailEnabled,
}));
setProxyErrorKeywordsText(nextKeywords.join('\n'));
toast.success('代理失败规则已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingProxyFailureRules(false);
}
};
const resetDownstreamForm = () => {
setEditingDownstreamId(null);
setDownstreamCreate({
@@ -1007,6 +1054,38 @@ export default function Settings() {
</button>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
命中任一关键词或空内容时判定失败,可触发重试。
</div>
<textarea
value={proxyErrorKeywordsText}
onChange={(e) => setProxyErrorKeywordsText(e.target.value)}
placeholder="一行一个关键词,或逗号分隔"
style={{
...inputStyle,
fontFamily: 'var(--font-mono)',
minHeight: 96,
resize: 'vertical',
marginBottom: 12,
}}
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
<input
type="checkbox"
checked={runtime.proxyEmptyContentFailEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, proxyEmptyContentFailEnabled: e.target.checked }))}
/>
空内容(completion=0,即使 prompt 有 token 也算)判定失败
</label>
<div>
<button onClick={saveProxyFailureRules} disabled={savingProxyFailureRules} className="btn btn-primary">
{savingProxyFailureRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存失败规则'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>