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:
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user