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
@@ -58,6 +58,8 @@ function buildListResponse(overrides?: Partial<{
modelActual: 'gpt-4o',
status: 'success',
latencyMs: 120,
firstByteLatencyMs: 35,
isStream: true,
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
@@ -146,6 +148,8 @@ describe('ProxyLogs server-driven page', () => {
modelActual: 'gpt-4o',
status: 'success',
latencyMs: 120,
firstByteLatencyMs: 35,
isStream: true,
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
@@ -264,6 +268,8 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('Codex');
expect(text).toContain('推测');
expect(text).toContain('下游 Key: 移动端灰度');
expect(text).toContain('流式');
expect(text).toContain('首字');
} finally {
await act(async () => {
root?.unmount();
@@ -801,6 +807,8 @@ describe('ProxyLogs server-driven page', () => {
modelActual: 'gpt-4o',
status: 'success',
latencyMs: 120,
firstByteLatencyMs: 22,
isStream: false,
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
+74
View File
@@ -265,6 +265,28 @@ function latencyBgColor(ms: number) {
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
function firstByteColor(ms: number) {
if (ms >= 3000) return 'var(--color-danger)';
if (ms >= 1000) return 'var(--color-warning)';
return 'var(--color-primary)';
}
function firstByteBgColor(ms: number) {
if (ms >= 3000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
if (ms >= 1000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
return 'color-mix(in srgb, var(--color-primary) 12%, transparent)';
}
function formatStreamModeLabel(isStream: boolean | null | undefined) {
if (isStream == null) return null;
return isStream ? '流式' : '非流';
}
function formatFirstByteLabel(ms: number | null | undefined) {
if (!Number.isFinite(ms) || typeof ms !== 'number' || ms < 0) return null;
return `首字 ${formatLatency(ms)}`;
}
function formatCompactNumber(value: number, digits = 6) {
if (!Number.isFinite(value)) return '0';
const formatted = value.toFixed(digits).replace(/\.?0+$/, '');
@@ -1877,6 +1899,8 @@ export default function ProxyLogs() {
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const isExpanded = expanded === log.id;
const clientDisplay = resolveProxyLogClientDisplay(detailLog);
const streamModeLabel = formatStreamModeLabel(detailLog.isStream);
const firstByteLabel = formatFirstByteLabel(detailLog.firstByteLatencyMs);
return (
<MobileCard
@@ -1911,6 +1935,24 @@ export default function ProxyLogs() {
{clientDisplay.secondary}
</span>
) : null}
{streamModeLabel ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{streamModeLabel}
</span>
) : null}
{firstByteLabel ? (
<span
className="badge"
style={{
fontSize: 10,
color: firstByteColor(detailLog.firstByteLatencyMs ?? 0),
background: firstByteBgColor(detailLog.firstByteLatencyMs ?? 0),
borderColor: 'transparent',
}}
>
{firstByteLabel}
</span>
) : null}
</div>
<div className="mobile-summary-grid">
<div className="mobile-summary-metric">
@@ -1934,6 +1976,8 @@ export default function ProxyLogs() {
<div className="mobile-card-extra">
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
{streamModeLabel ? <MobileField label="模式" value={streamModeLabel} /> : null}
{firstByteLabel ? <MobileField label="首字" value={firstByteLabel.replace(/^首字\s*/, '')} /> : null}
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
<MobileField label="用量来源" value={formatProxyLogUsageSource(detailLog.usageSource ?? pathMeta.usageSource) || '--'} />
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
@@ -1983,6 +2027,8 @@ export default function ProxyLogs() {
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const streamModeLabel = formatStreamModeLabel(detailLog.isStream);
const firstByteLabel = formatFirstByteLabel(detailLog.firstByteLatencyMs);
return (
<React.Fragment key={log.id}>
@@ -2015,6 +2061,28 @@ export default function ProxyLogs() {
{downstreamKeySummary}
</div>
) : null}
{(streamModeLabel || firstByteLabel) ? (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{streamModeLabel ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{streamModeLabel}
</span>
) : null}
{firstByteLabel ? (
<span
className="badge"
style={{
fontSize: 10,
color: firstByteColor(detailLog.firstByteLatencyMs ?? 0),
background: firstByteBgColor(detailLog.firstByteLatencyMs ?? 0),
borderColor: 'transparent',
}}
>
{firstByteLabel}
</span>
) : null}
</div>
) : null}
</div>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
@@ -2084,6 +2152,12 @@ export default function ProxyLogs() {
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.modelActual}</strong></>
)}
,状态: <strong style={{ color: detailLog.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{detailLog.status === 'success' ? '成功' : '失败'}</strong>
{streamModeLabel && (
<>,模式: <strong style={{ color: 'var(--color-text-primary)' }}>{streamModeLabel}</strong></>
)}
{firstByteLabel && (
<>,首字: <strong style={{ color: firstByteColor(detailLog.firstByteLatencyMs ?? 0) }}>{formatLatency(detailLog.firstByteLatencyMs ?? 0)}</strong></>
)}
,用时: <strong style={{ color: latencyColor(detailLog.latencyMs) }}>{formatLatency(detailLog.latencyMs)}</strong>
{detail && (
<>
+34
View File
@@ -66,6 +66,7 @@ type RuntimeSettings = {
proxySessionChannelConcurrencyLimit: number;
proxySessionChannelQueueWaitMs: number;
routingFallbackUnitCost: number;
proxyFirstByteTimeoutSec: number;
routeFailureCooldownMaxValue: number;
routeFailureCooldownMaxUnit: RouteCooldownUnit;
routingWeights: RoutingWeights;
@@ -244,6 +245,7 @@ export default function Settings() {
proxySessionChannelConcurrencyLimit: 2,
proxySessionChannelQueueWaitMs: 1500,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routeFailureCooldownMaxValue: 30,
routeFailureCooldownMaxUnit: 'day',
routingWeights: defaultWeights,
@@ -496,6 +498,9 @@ export default function Settings() {
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost)
: 1,
proxyFirstByteTimeoutSec: Number(runtimeInfo.proxyFirstByteTimeoutSec) >= 0
? Math.trunc(Number(runtimeInfo.proxyFirstByteTimeoutSec))
: 0,
routeFailureCooldownMaxValue: routeCooldownInput.value,
routeFailureCooldownMaxUnit: routeCooldownInput.unit,
routingWeights: {
@@ -942,6 +947,9 @@ export default function Settings() {
await api.updateRuntimeSettings({
routingWeights: runtime.routingWeights,
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
proxyFirstByteTimeoutSec: Number.isFinite(runtime.proxyFirstByteTimeoutSec)
? Math.max(0, Math.trunc(runtime.proxyFirstByteTimeoutSec))
: 0,
tokenRouterFailureCooldownMaxSec: toRouteCooldownSeconds(
runtime.routeFailureCooldownMaxValue,
runtime.routeFailureCooldownMaxUnit,
@@ -1739,6 +1747,32 @@ export default function Settings() {
</span>
</label>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
首字超时(无首包 / 首 token)
</div>
<input
type="number"
min={0}
step={1}
aria-label="首字超时秒数"
value={runtime.proxyFirstByteTimeoutSec}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
proxyFirstByteTimeoutSec: Number.isFinite(nextValue) && nextValue >= 0
? Math.trunc(nextValue)
: prev.proxyFirstByteTimeoutSec,
}));
}}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7, marginTop: 6 }}>
`0` 表示关闭。只有在指定时间内完全没有任何首包 / 首 token 返回时才切换,已经开始输出的请求不会被这项超时打断。
</div>
</div>
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
@@ -57,6 +57,7 @@ describe('Settings route cooldown cap', () => {
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
adminIpAllowlist: [],
@@ -127,6 +128,7 @@ describe('Settings route cooldown cap', () => {
usageWeight: 0.3,
},
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
tokenRouterFailureCooldownMaxSec: 10,
disableCrossProtocolFallback: false,
});
@@ -146,6 +148,7 @@ describe('Settings route cooldown cap', () => {
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 0,
routingWeights: {},
tokenRouterFailureCooldownMaxSec: 10,
adminIpAllowlist: [],
@@ -181,4 +184,57 @@ describe('Settings route cooldown cap', () => {
root?.unmount();
}
});
it('saves the first-byte timeout seconds alongside other routing runtime settings', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const firstByteInput = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'number'
&& node.props['aria-label'] === '首字超时秒数'
));
await act(async () => {
firstByteInput.props.onChange({ target: { value: '7' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存路由策略'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
routingWeights: {
baseWeightFactor: 0.5,
valueScoreFactor: 0.5,
costWeight: 0.4,
balanceWeight: 0.3,
usageWeight: 0.3,
},
routingFallbackUnitCost: 1,
proxyFirstByteTimeoutSec: 7,
tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60,
disableCrossProtocolFallback: false,
});
} finally {
root?.unmount();
}
});
});