feat: add proxy first-byte timeout and log badges (#383)

* add proxy first-byte timeout and log badges

* fix first-byte timeout review feedback
This commit is contained in:
Cita
2026-04-02 10:00:11 +08:00
committed by GitHub
parent bcd758edac
commit 180d17adfc
43 changed files with 4049 additions and 91 deletions
@@ -65,6 +65,7 @@ describe('settings and auth events', () => {
(config as any).proxyDebugRetentionHours = 24;
(config as any).proxyDebugMaxBodyBytes = 262144;
config.routingFallbackUnitCost = 1;
(config as any).proxyFirstByteTimeoutSec = 0;
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
(config as any).disableCrossProtocolFallback = false;
(config as any).telegramEnabled = false;
@@ -508,6 +509,32 @@ describe('settings and auth events', () => {
expect(saved?.value).toBe(JSON.stringify(thirtyDaysSec));
});
it('persists and returns first-byte timeout from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyFirstByteTimeoutSec: 7,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { proxyFirstByteTimeoutSec?: number };
expect(updated.proxyFirstByteTimeoutSec).toBe(7);
expect((config as any).proxyFirstByteTimeoutSec).toBe(7);
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_first_byte_timeout_sec')).get();
expect(saved?.value).toBe(JSON.stringify(7));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { proxyFirstByteTimeoutSec?: number };
expect(runtime.proxyFirstByteTimeoutSec).toBe(7);
});
it('persists and returns disable cross protocol fallback from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+21
View File
@@ -95,6 +95,7 @@ interface RuntimeSettingsBody {
notifyCooldownSec?: number;
adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number;
proxyFirstByteTimeoutSec?: number;
tokenRouterFailureCooldownMaxSec?: number;
routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
@@ -681,6 +682,12 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.routingFallbackUnitCost = Math.max(1e-6, n);
return;
}
case 'proxy_first_byte_timeout_sec': {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return;
config.proxyFirstByteTimeoutSec = Math.max(0, Math.trunc(n));
return;
}
case 'token_router_failure_cooldown_max_sec': {
const normalized = normalizeTokenRouterFailureCooldownMaxSec(value);
if (normalized == null) return;
@@ -717,6 +724,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
proxyDebugRetentionHours: config.proxyDebugRetentionHours,
proxyDebugMaxBodyBytes: config.proxyDebugMaxBodyBytes,
routingFallbackUnitCost: config.routingFallbackUnitCost,
proxyFirstByteTimeoutSec: config.proxyFirstByteTimeoutSec,
tokenRouterFailureCooldownMaxSec: config.tokenRouterFailureCooldownMaxSec,
routingWeights: config.routingWeights,
webhookUrl: config.webhookUrl,
@@ -1639,6 +1647,19 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('routing_fallback_unit_cost', normalized);
}
if (body.proxyFirstByteTimeoutSec !== undefined) {
const nextProxyFirstByteTimeoutSec = Number(body.proxyFirstByteTimeoutSec);
if (!Number.isFinite(nextProxyFirstByteTimeoutSec) || nextProxyFirstByteTimeoutSec < 0) {
return reply.code(400).send({ success: false, message: '首字超时必须是大于等于 0 的数字(秒)' });
}
const normalized = Math.max(0, Math.trunc(nextProxyFirstByteTimeoutSec));
if (normalized !== config.proxyFirstByteTimeoutSec) {
changedLabels.push(`首字超时(${config.proxyFirstByteTimeoutSec}s -> ${normalized}s)`);
}
config.proxyFirstByteTimeoutSec = normalized;
upsertSetting('proxy_first_byte_timeout_sec', normalized);
}
if (body.tokenRouterFailureCooldownMaxSec !== undefined) {
const normalized = normalizeTokenRouterFailureCooldownMaxSec(body.tokenRouterFailureCooldownMaxSec);
if (normalized == null) {
@@ -75,6 +75,8 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
isStream: 1,
firstByteLatencyMs: 45,
clientFamily: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
@@ -92,6 +94,8 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
isStream: 0,
firstByteLatencyMs: 12,
clientFamily: 'codex',
promptTokens: 8,
completionTokens: 2,
@@ -105,6 +109,8 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4.1',
modelActual: 'gpt-4.1',
status: 'retried',
isStream: 1,
firstByteLatencyMs: 30,
promptTokens: 20,
completionTokens: 4,
totalTokens: 24,
@@ -117,6 +123,8 @@ describe('stats proxy logs routes', () => {
modelRequested: 'claude-3-7-sonnet',
modelActual: 'claude-3-7-sonnet',
status: 'success',
isStream: 1,
firstByteLatencyMs: 88,
promptTokens: 40,
completionTokens: 10,
totalTokens: 50,
@@ -163,6 +171,8 @@ describe('stats proxy logs routes', () => {
expect(body.items[0]?.clientAppId).toBe(null);
expect(body.items[0]?.clientAppName).toBe(null);
expect(body.items[0]?.clientConfidence).toBe(null);
expect(body.items[0]?.isStream).toBe(false);
expect(body.items[0]?.firstByteLatencyMs).toBe(12);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.clientOptions).toEqual([
{ value: 'family:codex', label: '协议 · Codex' },
@@ -204,6 +214,8 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
isStream: 1,
firstByteLatencyMs: 64,
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
@@ -238,6 +250,8 @@ describe('stats proxy logs routes', () => {
clientAppId: string | null;
clientAppName: string | null;
clientConfidence: string | null;
isStream: boolean | null;
firstByteLatencyMs: number | null;
billingDetails: Record<string, unknown> | null;
};
@@ -251,6 +265,8 @@ describe('stats proxy logs routes', () => {
expect(body.clientAppId).toBe('cherry_studio');
expect(body.clientAppName).toBe('Cherry Studio');
expect(body.clientConfidence).toBe('exact');
expect(body.isStream).toBe(true);
expect(body.firstByteLatencyMs).toBe(64);
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
+4
View File
@@ -497,6 +497,10 @@ function mapProxyLogRow(
const legacyMeta = parseProxyLogMessageMeta(typeof row.proxy_logs.errorMessage === 'string' ? row.proxy_logs.errorMessage : '');
return {
...row.proxy_logs,
isStream: row.proxy_logs.isStream == null ? null : Boolean(row.proxy_logs.isStream),
firstByteLatencyMs: typeof row.proxy_logs.firstByteLatencyMs === 'number'
? row.proxy_logs.firstByteLatencyMs
: null,
...(options?.includeBillingDetails
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
: {}),