forked from boos/BoosAPI
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:
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 地址');
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user