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:
@@ -387,6 +387,7 @@ export type RuntimeSettingsPayload = {
|
||||
notifyCooldownSec?: number;
|
||||
adminIpAllowlist?: string[] | string;
|
||||
routingFallbackUnitCost?: number;
|
||||
proxyFirstByteTimeoutSec?: number;
|
||||
tokenRouterFailureCooldownMaxSec?: number;
|
||||
routingWeights?: RuntimeRoutingWeightsPayload;
|
||||
proxyErrorKeywords?: string[] | string;
|
||||
@@ -437,6 +438,8 @@ export type ProxyLogListItem = {
|
||||
modelActual: string;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
isStream?: boolean | null;
|
||||
firstByteLatencyMs?: number | null;
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number | null;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user