@jeffusion/bungee-core 6.2.1 → 6.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +46 -45
- package/dist/observability-worker.js +24 -1
- package/dist/plugins/anthropic-request-sanitizer/index.js.map +1 -1
- package/dist/plugins/anthropic-tool-name-transformer/index.js.map +1 -1
- package/dist/plugins/chatgpt-oauth/control.js.map +1 -1
- package/dist/plugins/chatgpt-oauth/index.js.map +1 -1
- package/dist/plugins/chatgpt-oauth/ui/AccountsPage.svelte +1 -1
- package/dist/plugins/codex-router/control.js.map +1 -1
- package/dist/plugins/codex-router/index.js.map +1 -1
- package/dist/plugins/deepseek-reasoning-fix/index.js.map +1 -1
- package/dist/plugins/key-access/control.js.map +1 -1
- package/dist/plugins/key-access/index.js.map +1 -1
- package/dist/plugins/key-access/ingress.js.map +1 -1
- package/dist/plugins/key-rate-limit/index.js.map +1 -1
- package/dist/plugins/llm-protocol-adapter/control.js.map +1 -1
- package/dist/plugins/llm-protocol-adapter/index.js.map +3 -3
- package/dist/plugins/llm-tps/control.js +41 -11
- package/dist/plugins/llm-tps/control.js.map +5 -5
- package/dist/plugins/llm-tps/index.js +20 -1
- package/dist/plugins/llm-tps/index.js.map +6 -6
- package/dist/plugins/llm-tps/manifest.json +20 -6
- package/dist/plugins/llm-tps/ui/TpsModels.svelte +9 -0
- package/dist/plugins/llm-tps/ui/TpsWidget.svelte +63 -58
- package/dist/plugins/llm-tps/ui/presentation.ts +6 -6
- package/dist/plugins/llm-tps/ui/stats-resource.ts +7 -3
- package/dist/plugins/model-mapping/index.js.map +1 -1
- package/dist/plugins/models-dev/index.js.map +1 -1
- package/dist/plugins/signature-repair/index.js.map +1 -1
- package/dist/plugins/token-budget/control.js.map +1 -1
- package/dist/plugins/token-budget/index.js.map +2 -2
- package/dist/plugins/token-metering/index.js +7 -3
- package/dist/plugins/token-metering/index.js.map +5 -5
- package/dist/plugins/token-stats/control.js.map +1 -1
- package/dist/plugins/token-stats/index.js.map +2 -2
- package/dist/plugins/token-stats/ui/TokenStatsPage.svelte +2 -2
- package/dist/plugins/user-auth/control.js.map +1 -1
- package/dist/plugins/user-auth/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
{
|
|
32
32
|
"name": "TpsEndpoints",
|
|
33
33
|
"entry": "ui/TpsEndpoints.svelte"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "TpsModels",
|
|
37
|
+
"entry": "ui/TpsModels.svelte"
|
|
34
38
|
}
|
|
35
39
|
]
|
|
36
40
|
},
|
|
@@ -62,6 +66,12 @@
|
|
|
62
66
|
"title": "widgets.endpoints.title",
|
|
63
67
|
"size": "full",
|
|
64
68
|
"component": "TpsEndpoints"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"id": "llm-tps-models",
|
|
72
|
+
"title": "widgets.models.title",
|
|
73
|
+
"size": "full",
|
|
74
|
+
"component": "TpsModels"
|
|
65
75
|
}
|
|
66
76
|
],
|
|
67
77
|
"api": [
|
|
@@ -88,28 +98,32 @@
|
|
|
88
98
|
"plugin.description": "Measure streamed output generation speed for routes with this plugin bound.",
|
|
89
99
|
"widgets.overview.title": "LLM Generation Speed",
|
|
90
100
|
"widgets.endpoints.title": "Endpoint Generation Speed",
|
|
101
|
+
"widgets.models.title": "Model Generation Speed",
|
|
91
102
|
"ui.refresh": "Refresh",
|
|
92
103
|
"ui.loading": "Loading generation speed",
|
|
93
104
|
"ui.loadFailed": "Unable to load generation speed. Refresh to retry.",
|
|
94
|
-
"ui.noData": "No
|
|
95
|
-
"ui.
|
|
105
|
+
"ui.noData": "No data in this range",
|
|
106
|
+
"ui.sampleCount": "Samples",
|
|
107
|
+
"ui.estimatedCount": "Estimated",
|
|
96
108
|
"ui.samples": "Valid samples: {count} · estimated: {estimated}",
|
|
97
109
|
"ui.ranking": "Generation speed by route and endpoint, fastest first",
|
|
98
|
-
"ui.
|
|
110
|
+
"ui.modelRanking": "Generation speed by route and model, fastest first"
|
|
99
111
|
},
|
|
100
112
|
"zh-CN": {
|
|
101
113
|
"metadata.name": "LLM 生成速度",
|
|
102
114
|
"plugin.description": "统计绑定此插件的路由上的流式输出生成速度。",
|
|
103
115
|
"widgets.overview.title": "LLM 生成速度",
|
|
104
116
|
"widgets.endpoints.title": "端点生成速度",
|
|
117
|
+
"widgets.models.title": "模型生成速度",
|
|
105
118
|
"ui.refresh": "刷新",
|
|
106
119
|
"ui.loading": "正在加载生成速度",
|
|
107
120
|
"ui.loadFailed": "生成速度加载失败,请刷新重试。",
|
|
108
|
-
"ui.noData": "
|
|
109
|
-
"ui.
|
|
121
|
+
"ui.noData": "所选范围暂无数据",
|
|
122
|
+
"ui.sampleCount": "样本",
|
|
123
|
+
"ui.estimatedCount": "估算",
|
|
110
124
|
"ui.samples": "有效样本:{count} · 其中估算:{estimated}",
|
|
111
125
|
"ui.ranking": "按路由与端点统计生成速度,由高到低排列",
|
|
112
|
-
"ui.
|
|
126
|
+
"ui.modelRanking": "按路由与模型统计生成速度,由高到低排列"
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { TimeRange } from '@bungee/plugin-sdk';
|
|
3
|
+
import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
|
|
4
|
+
import TpsWidget from './TpsWidget.svelte';
|
|
5
|
+
let { pluginName = 'llm-tps', selectedRange = '24h', onHeaderChange }:
|
|
6
|
+
{ pluginName?: string; selectedRange?: TimeRange; onHeaderChange?: NativeWidgetHeaderChange } = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<TpsWidget {pluginName} {selectedRange} {onHeaderChange} mode="models" />
|
|
@@ -1,39 +1,30 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, untrack } from 'svelte';
|
|
3
3
|
import { isLoading } from 'svelte-i18n';
|
|
4
|
-
import { _, type TimeRange } from '@bungee/plugin-sdk';
|
|
4
|
+
import { _, formatCompactNumber, type TimeRange } from '@bungee/plugin-sdk';
|
|
5
5
|
import { getPluginText } from '$utils/plugin-i18n';
|
|
6
|
-
import { configStore, routesStore } from '$stores/config';
|
|
7
|
-
import { resolveRouteEndpoints } from '$api/routes';
|
|
8
|
-
import { toEditorRoute, toEditorService } from '$api/config-adapters';
|
|
9
6
|
import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
|
|
10
7
|
import type { TpsRange } from '../contract';
|
|
11
8
|
import type { TpsResource, TpsState } from './stats-resource';
|
|
12
9
|
import { getTpsResource } from './stats';
|
|
13
|
-
import {
|
|
10
|
+
import { formatTps, rankedEndpoints, rankedModels } from './presentation';
|
|
11
|
+
import Sparkline from '$components/dashboard/Sparkline.svelte';
|
|
12
|
+
import { BKpiMetric } from '$components/industrial';
|
|
14
13
|
|
|
15
14
|
let { pluginName, selectedRange, onHeaderChange, mode }:
|
|
16
|
-
{ pluginName: string; selectedRange: TimeRange; onHeaderChange?: NativeWidgetHeaderChange; mode: 'overview' | 'endpoints' } = $props();
|
|
15
|
+
{ pluginName: string; selectedRange: TimeRange; onHeaderChange?: NativeWidgetHeaderChange; mode: 'overview' | 'endpoints' | 'models' } = $props();
|
|
17
16
|
let state: TpsState = $state({ data: null, busy: true, error: false });
|
|
18
17
|
let resource: TpsResource | undefined = $state();
|
|
19
18
|
const range: TpsRange = $derived(selectedRange === '1h' || selectedRange === '12h' ? selectedRange : '24h');
|
|
20
19
|
const t = (key: string, values?: Record<string, string | number>) => $isLoading ? '' : getPluginText(key, pluginName,
|
|
21
20
|
(id, options) => $_(id, { ...options, values }));
|
|
22
|
-
const
|
|
21
|
+
const sampleCount = $derived(state.data?.total.sampleCount ?? 0);
|
|
22
|
+
const estimatedCount = $derived(state.data?.total.estimatedSampleCount ?? 0);
|
|
23
|
+
const trendValues = $derived((state.data?.history ?? []).map(point => point.tps ?? 0));
|
|
24
|
+
const rows = $derived(mode === 'models'
|
|
25
|
+
? rankedModels(state.data?.models ?? []).map(row => ({ ...row, key: row.model, label: row.model }))
|
|
26
|
+
: rankedEndpoints(state.data?.endpoints ?? []).map(row => ({ ...row, key: row.upstreamHost, label: row.upstreamHost })));
|
|
23
27
|
const maximum = $derived(rows.reduce((max, row) => Math.max(max, row.tps ?? 0), 0));
|
|
24
|
-
const services = $derived($configStore?.services ?? []);
|
|
25
|
-
const labels = $derived.by(() => {
|
|
26
|
-
const result = new Map<string, { route: string; endpoint: string }>();
|
|
27
|
-
const editorServices = services.map(toEditorService);
|
|
28
|
-
for (const route of $routesStore) {
|
|
29
|
-
const endpoints = resolveRouteEndpoints(toEditorRoute(route, services), editorServices);
|
|
30
|
-
for (const endpoint of endpoints) if (endpoint._uid) result.set(JSON.stringify([route.id, endpoint._uid]), {
|
|
31
|
-
route: 'name' in route && typeof route.name === 'string' ? route.name.trim() || route.path || route.id : route.path || route.id,
|
|
32
|
-
endpoint: endpoint.description?.trim() || endpointIdentity(endpoint._uid),
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
return result;
|
|
36
|
-
});
|
|
37
28
|
$effect(() => {
|
|
38
29
|
const next = getTpsResource(pluginName, range);
|
|
39
30
|
resource = next;
|
|
@@ -42,7 +33,9 @@
|
|
|
42
33
|
$effect(() => {
|
|
43
34
|
const report = onHeaderChange;
|
|
44
35
|
const header = { summary: range, refresh: { label: t('ui.refresh'), busy: state.busy,
|
|
45
|
-
disabled: !resource, run: () => { void resource?.refresh(); } }
|
|
36
|
+
disabled: !resource, run: () => { void resource?.refresh(); } },
|
|
37
|
+
...(mode !== 'overview' ? { actions: rankingUnit } : {}),
|
|
38
|
+
...(mode === 'overview' && state.data?.total.sampleCount ? { footer: sampleFooter } : {}) };
|
|
46
39
|
untrack(() => report?.(header));
|
|
47
40
|
});
|
|
48
41
|
onMount(() => {
|
|
@@ -51,65 +44,77 @@
|
|
|
51
44
|
});
|
|
52
45
|
</script>
|
|
53
46
|
|
|
54
|
-
|
|
47
|
+
{#snippet rankingUnit()}
|
|
48
|
+
<span class="ranking-unit">TOKENS/S</span>
|
|
49
|
+
{/snippet}
|
|
50
|
+
|
|
51
|
+
{#snippet sampleFooter()}
|
|
52
|
+
{#if sampleCount}
|
|
53
|
+
<div class="tps-samples" data-testid="tps-total-samples">
|
|
54
|
+
<span>{t('ui.sampleCount')} <b class="nx-display">{sampleCount.toLocaleString()}</b></span>
|
|
55
|
+
{#if estimatedCount > 0}<span>{t('ui.estimatedCount')} <b class="nx-display">{estimatedCount.toLocaleString()}</b></span>{/if}
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
<div class="tps-content min-w-0 w-full" class:tps-overview={mode === 'overview'} data-testid={`tps-${mode}`} aria-busy={state.busy}>
|
|
55
61
|
{#if $isLoading}
|
|
56
62
|
<span class="nx-metric text-zinc-50">—</span>
|
|
57
63
|
{:else}
|
|
58
64
|
{#if mode === 'overview'}
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
</
|
|
63
|
-
<p class="mt-3 text-sm text-zinc-400">{t('ui.weightedHint')}</p>
|
|
65
|
+
<BKpiMetric value={formatCompactNumber(state.data?.total.sampleCount ? state.data.total.tps : null)}
|
|
66
|
+
valueTitle={`${formatTps(state.data?.total.sampleCount ? state.data.total.tps : null)} TOKENS/S`} unit="TOKENS/S" valueTestId="tps-total-value">
|
|
67
|
+
{#snippet icon()}<Sparkline values={trendValues} tone="orange" wide />{/snippet}
|
|
68
|
+
</BKpiMetric>
|
|
64
69
|
{/if}
|
|
65
70
|
{#if state.error}
|
|
66
71
|
<p role="alert" class="text-sm text-red-300" data-testid="tps-error">{t('ui.loadFailed')}</p>
|
|
67
72
|
{:else if state.busy && !state.data}
|
|
68
73
|
<p role="status" class="text-sm text-zinc-400">{t('ui.loading')}</p>
|
|
69
74
|
{:else if state.data}
|
|
70
|
-
{#if !state.data.total.sampleCount}
|
|
75
|
+
{#if mode === 'overview' ? !state.data.total.sampleCount : !rows.length}
|
|
71
76
|
<p class="text-sm text-zinc-400" data-testid="tps-empty">{t('ui.noData')}</p>
|
|
72
|
-
{:else if mode
|
|
73
|
-
<p class="mt-3 font-mono text-sm text-zinc-300" data-testid="tps-total-samples">{t('ui.samples', {
|
|
74
|
-
count: state.data.total.sampleCount, estimated: state.data.total.estimatedSampleCount })}</p>
|
|
75
|
-
{:else}
|
|
77
|
+
{:else if mode !== 'overview'}
|
|
76
78
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (A scrolling region needs keyboard focus so every endpoint remains reachable.) -->
|
|
77
|
-
<div class="
|
|
78
|
-
<ol class="
|
|
79
|
-
{#each rows as row (JSON.stringify([row.routeId, row.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
</div>
|
|
87
|
-
<p class="font-mono text-sm text-zinc-50">{formatTps(row.sampleCount ? row.tps : null)} <span class="text-xs text-zinc-400">tokens/s</span></p>
|
|
79
|
+
<div class="ranking-list" role="region" aria-label={t(mode === 'models' ? 'ui.modelRanking' : 'ui.ranking')} data-testid={mode === 'models' ? 'tps-model-list' : 'tps-endpoint-list'} tabindex="0">
|
|
80
|
+
<ol class="ranking-items">
|
|
81
|
+
{#each rows as row, index (JSON.stringify([row.routeId, row.key]))}
|
|
82
|
+
<li class="ranking-row" data-testid={mode === 'models' ? 'tps-model-row' : 'tps-endpoint-row'} data-route-id={row.routeId}
|
|
83
|
+
data-upstream-host={mode === 'endpoints' ? row.key : undefined} data-model={mode === 'models' ? row.key : undefined}>
|
|
84
|
+
<span class="ranking-position nx-display" aria-hidden="true">{index + 1}</span>
|
|
85
|
+
<div class="ranking-identity">
|
|
86
|
+
<p class="ranking-name">{row.label}</p>
|
|
87
|
+
<p class="ranking-route">{row.routeId}</p>
|
|
88
88
|
</div>
|
|
89
|
-
<
|
|
90
|
-
<
|
|
89
|
+
<span class="ranking-value nx-display" title={`${formatTps(row.sampleCount ? row.tps : null)} TOKENS/S · ${t('ui.samples', { count: row.sampleCount, estimated: row.estimatedSampleCount })}`}>{formatCompactNumber(row.sampleCount ? row.tps : null)}</span>
|
|
90
|
+
<div class="ranking-track" aria-hidden="true"><span class="ranking-bar" style:width={`${maximum > 0 ? (row.tps ?? 0) / maximum * 100 : 0}%`}></span></div>
|
|
91
91
|
</li>
|
|
92
92
|
{/each}
|
|
93
93
|
</ol>
|
|
94
94
|
</div>
|
|
95
95
|
{/if}
|
|
96
|
-
{#if state.data.reportingIncomplete}
|
|
97
|
-
<p class="mt-3 text-sm text-amber-400" role="status" data-testid="tps-incomplete">{t('ui.incomplete')}</p>
|
|
98
|
-
{/if}
|
|
99
96
|
{/if}
|
|
100
97
|
{/if}
|
|
101
98
|
</div>
|
|
102
99
|
|
|
103
100
|
<style>
|
|
104
101
|
.tps-content { display: flex; flex-direction: column; gap: 6px; }
|
|
105
|
-
.tps-
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
.
|
|
114
|
-
.
|
|
102
|
+
.tps-content:not(.tps-overview) { height: 100%; min-height: 0; }
|
|
103
|
+
.tps-overview { justify-content: center; }
|
|
104
|
+
.tps-samples { display: flex; flex-wrap: wrap; gap: 6px 18px; font-family: var(--font-mono, 'DM Mono', monospace); font-size: 10px; letter-spacing: .08em; color: var(--nx-text-mute); }
|
|
105
|
+
.tps-samples b { margin-left: 4px; font-weight: 400; font-variant-numeric: tabular-nums; color: var(--nx-text); }
|
|
106
|
+
.ranking-unit { white-space: nowrap; font: 10px var(--font-mono, 'DM Mono', monospace); letter-spacing: .08em; color: var(--nx-text-dim); }
|
|
107
|
+
.ranking-list { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none; }
|
|
108
|
+
.ranking-list::-webkit-scrollbar { display: none; }
|
|
109
|
+
.ranking-items { display: grid; grid-template-columns: 20px minmax(0, 1fr) max-content; column-gap: 8px; list-style: none; margin: 0; padding: 0; }
|
|
110
|
+
.ranking-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; gap: 4px 8px; padding: 6px 0; border-bottom: 1px solid var(--nx-edge); }
|
|
111
|
+
.ranking-row:first-child { padding-top: 0; }
|
|
112
|
+
.ranking-row:last-child { border-bottom: 0; }
|
|
113
|
+
.ranking-position { grid-column: 1; grid-row: 1 / 3; color: var(--nx-text-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
114
|
+
.ranking-identity { min-width: 0; }
|
|
115
|
+
.ranking-name { margin: 0; font-size: 14px; font-weight: 500; line-height: 1.4; color: var(--nx-text); overflow-wrap: anywhere; }
|
|
116
|
+
.ranking-route { margin: 3px 0 0; font: 12px/1.4 var(--font-mono, 'DM Mono', monospace); color: var(--nx-text-dim); overflow-wrap: anywhere; }
|
|
117
|
+
.ranking-value { grid-column: 3; grid-row: 1 / 3; text-align: right; font-size: 14px; font-variant-numeric: tabular-nums; color: var(--nx-text); }
|
|
118
|
+
.ranking-track { grid-column: 2; height: 6px; }
|
|
119
|
+
.ranking-bar { display: block; height: 100%; background: var(--nx-accent); }
|
|
115
120
|
</style>
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { TpsEndpointStats } from '../contract';
|
|
1
|
+
import type { TpsEndpointStats, TpsModelStats } from '../contract';
|
|
2
2
|
|
|
3
3
|
export function formatTps(value: number | null | undefined): string {
|
|
4
4
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
5
5
|
? new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value) : '—';
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export function rankedEndpoints(rows: readonly TpsEndpointStats[]): TpsEndpointStats[] {
|
|
9
|
+
return [...rows].sort((a, b) => (b.tps ?? -1) - (a.tps ?? -1)
|
|
10
|
+
|| a.routeId.localeCompare(b.routeId) || a.upstreamHost.localeCompare(b.upstreamHost));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function
|
|
13
|
+
export function rankedModels(rows: readonly TpsModelStats[]): TpsModelStats[] {
|
|
14
14
|
return [...rows].sort((a, b) => (b.tps ?? -1) - (a.tps ?? -1)
|
|
15
|
-
|| a.routeId.localeCompare(b.routeId) || a.
|
|
15
|
+
|| a.routeId.localeCompare(b.routeId) || a.model.localeCompare(b.model));
|
|
16
16
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writable } from 'svelte/store';
|
|
2
|
-
import type
|
|
2
|
+
import { isUpstreamHost, type TpsAggregate, type TpsRange, type TpsStatsResponse } from '../contract';
|
|
3
3
|
|
|
4
4
|
export type TpsState = { data: TpsStatsResponse | null; busy: boolean; error: boolean };
|
|
5
5
|
type Load = (plugin: string, range: TpsRange, signal: AbortSignal) => Promise<TpsStatsResponse>;
|
|
@@ -14,8 +14,12 @@ function validAggregate(value: TpsAggregate): boolean {
|
|
|
14
14
|
|
|
15
15
|
export function validateStats(data: TpsStatsResponse, range: TpsRange): TpsStatsResponse {
|
|
16
16
|
if (!data || data.range !== range || !Number.isFinite(data.asOfMs) || !validAggregate(data.total)
|
|
17
|
-
|| typeof data.reportingIncomplete !== 'boolean' || !Array.isArray(data.endpoints)
|
|
18
|
-
|| !data.
|
|
17
|
+
|| typeof data.reportingIncomplete !== 'boolean' || !Array.isArray(data.endpoints) || !Array.isArray(data.models) || !Array.isArray(data.history)
|
|
18
|
+
|| !data.history.every((row, index) => validAggregate(row) && Number.isSafeInteger(row.bucketStartMs) && row.bucketStartMs >= 0
|
|
19
|
+
&& (index === 0 || row.bucketStartMs > data.history[index - 1]!.bucketStartMs))
|
|
20
|
+
|| !data.endpoints.every(row => validAggregate(row) && typeof row.routeId === 'string' && !!row.routeId.trim() && isUpstreamHost(row.upstreamHost))
|
|
21
|
+
|| !data.models.every(row => validAggregate(row) && typeof row.routeId === 'string' && !!row.routeId.trim()
|
|
22
|
+
&& typeof row.model === 'string' && !!row.model.trim())) {
|
|
19
23
|
throw new Error('invalid_tps_stats');
|
|
20
24
|
}
|
|
21
25
|
return data;
|