@jeffusion/bungee-core 6.1.1 → 6.2.0

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.
Files changed (38) hide show
  1. package/dist/main.js +94 -65
  2. package/dist/observability-worker.js +21 -1
  3. package/dist/plugins/anthropic-request-sanitizer/index.js.map +3 -3
  4. package/dist/plugins/anthropic-tool-name-transformer/index.js.map +3 -3
  5. package/dist/plugins/chatgpt-oauth/control.js.map +1 -1
  6. package/dist/plugins/chatgpt-oauth/index.js.map +3 -3
  7. package/dist/plugins/codex-router/control.js.map +1 -1
  8. package/dist/plugins/codex-router/index.js.map +3 -3
  9. package/dist/plugins/deepseek-reasoning-fix/index.js.map +3 -3
  10. package/dist/plugins/key-access/control.js.map +1 -1
  11. package/dist/plugins/key-access/index.js.map +3 -3
  12. package/dist/plugins/key-access/ingress.js.map +1 -1
  13. package/dist/plugins/key-rate-limit/index.js.map +3 -3
  14. package/dist/plugins/llm-protocol-adapter/control.js.map +1 -1
  15. package/dist/plugins/llm-protocol-adapter/index.js.map +4 -4
  16. package/dist/plugins/llm-tps/control.js +212 -0
  17. package/dist/plugins/llm-tps/control.js.map +13 -0
  18. package/dist/plugins/llm-tps/index.js +286 -0
  19. package/dist/plugins/llm-tps/index.js.map +27 -0
  20. package/dist/plugins/llm-tps/manifest.json +115 -0
  21. package/dist/plugins/llm-tps/ui/TpsEndpoints.svelte +9 -0
  22. package/dist/plugins/llm-tps/ui/TpsOverview.svelte +9 -0
  23. package/dist/plugins/llm-tps/ui/TpsWidget.svelte +115 -0
  24. package/dist/plugins/llm-tps/ui/presentation.ts +16 -0
  25. package/dist/plugins/llm-tps/ui/stats-resource.ts +77 -0
  26. package/dist/plugins/llm-tps/ui/stats.ts +7 -0
  27. package/dist/plugins/model-mapping/index.js.map +3 -3
  28. package/dist/plugins/models-dev/index.js.map +3 -3
  29. package/dist/plugins/signature-repair/index.js.map +3 -3
  30. package/dist/plugins/token-budget/control.js.map +1 -1
  31. package/dist/plugins/token-budget/index.js.map +3 -3
  32. package/dist/plugins/token-metering/index.js +138 -11
  33. package/dist/plugins/token-metering/index.js.map +8 -7
  34. package/dist/plugins/token-stats/control.js.map +1 -1
  35. package/dist/plugins/token-stats/index.js.map +3 -3
  36. package/dist/plugins/user-auth/control.js.map +1 -1
  37. package/dist/plugins/user-auth/index.js.map +3 -3
  38. package/package.json +2 -2
@@ -0,0 +1,115 @@
1
+ {
2
+ "name": "llm-tps",
3
+ "version": "1.0.0",
4
+ "schemaVersion": 3,
5
+ "artifactKind": "runtime-plugin",
6
+ "main": "index.js",
7
+ "capabilities": [
8
+ "hooks",
9
+ "api",
10
+ "nativeWidgetsStatic",
11
+ "dynamicRuntimeLoad",
12
+ "controlPlane"
13
+ ],
14
+ "uiExtensionMode": "native-static",
15
+ "engines": {
16
+ "bungee": "^6.0.0"
17
+ },
18
+ "runtimeScope": "scoped",
19
+ "control": {
20
+ "entry": "control.js",
21
+ "rpc": []
22
+ },
23
+ "builtin": true,
24
+ "author": "Bungee Team",
25
+ "ui": {
26
+ "components": [
27
+ {
28
+ "name": "TpsOverview",
29
+ "entry": "ui/TpsOverview.svelte"
30
+ },
31
+ {
32
+ "name": "TpsEndpoints",
33
+ "entry": "ui/TpsEndpoints.svelte"
34
+ }
35
+ ]
36
+ },
37
+ "dependencies": {
38
+ "token-metering": "^1.0.0"
39
+ },
40
+ "services": {
41
+ "provides": [],
42
+ "consumes": [
43
+ {
44
+ "id": "token-metering.v1",
45
+ "version": 1,
46
+ "process": "worker",
47
+ "plugin": "token-metering"
48
+ }
49
+ ]
50
+ },
51
+ "contributes": {
52
+ "nativeWidgets": [
53
+ {
54
+ "id": "llm-tps-overview",
55
+ "title": "widgets.overview.title",
56
+ "size": "medium",
57
+ "component": "TpsOverview",
58
+ "presentation": "kpi"
59
+ },
60
+ {
61
+ "id": "llm-tps-endpoints",
62
+ "title": "widgets.endpoints.title",
63
+ "size": "full",
64
+ "component": "TpsEndpoints"
65
+ }
66
+ ],
67
+ "api": [
68
+ {
69
+ "path": "/stats",
70
+ "methods": [
71
+ "GET"
72
+ ],
73
+ "handler": "getStats",
74
+ "execution": "control",
75
+ "capability": "logs.read"
76
+ }
77
+ ]
78
+ },
79
+ "metadata": {
80
+ "name": "metadata.name",
81
+ "description": "plugin.description",
82
+ "icon": "speed"
83
+ },
84
+ "configSchema": [],
85
+ "translations": {
86
+ "en": {
87
+ "metadata.name": "LLM Generation Speed",
88
+ "plugin.description": "Measure streamed output generation speed for routes with this plugin bound.",
89
+ "widgets.overview.title": "LLM Generation Speed",
90
+ "widgets.endpoints.title": "Endpoint Generation Speed",
91
+ "ui.refresh": "Refresh",
92
+ "ui.loading": "Loading generation speed",
93
+ "ui.loadFailed": "Unable to load generation speed. Refresh to retry.",
94
+ "ui.noData": "No valid streaming samples in this range",
95
+ "ui.weightedHint": "Time-weighted generation speed: output tokens divided by total time from first valid output to generation end.",
96
+ "ui.samples": "Valid samples: {count} · estimated: {estimated}",
97
+ "ui.ranking": "Generation speed by route and endpoint, fastest first",
98
+ "ui.incomplete": "Reporting is incomplete. Some samples may be missing."
99
+ },
100
+ "zh-CN": {
101
+ "metadata.name": "LLM 生成速度",
102
+ "plugin.description": "统计绑定此插件的路由上的流式输出生成速度。",
103
+ "widgets.overview.title": "LLM 生成速度",
104
+ "widgets.endpoints.title": "端点生成速度",
105
+ "ui.refresh": "刷新",
106
+ "ui.loading": "正在加载生成速度",
107
+ "ui.loadFailed": "生成速度加载失败,请刷新重试。",
108
+ "ui.noData": "所选范围暂无有效流式样本",
109
+ "ui.weightedHint": "耗时加权平均生成速度:输出 Token 总量除以从首个有效输出到生成结束的总耗时。",
110
+ "ui.samples": "有效样本:{count} · 其中估算:{estimated}",
111
+ "ui.ranking": "按路由与端点统计生成速度,由高到低排列",
112
+ "ui.incomplete": "上报不完整,部分样本可能缺失。"
113
+ }
114
+ }
115
+ }
@@ -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="endpoints" />
@@ -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="overview" />
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ import { onMount, untrack } from 'svelte';
3
+ import { isLoading } from 'svelte-i18n';
4
+ import { _, type TimeRange } from '@bungee/plugin-sdk';
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
+ import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
10
+ import type { TpsRange } from '../contract';
11
+ import type { TpsResource, TpsState } from './stats-resource';
12
+ import { getTpsResource } from './stats';
13
+ import { endpointIdentity, formatTps, rankedEndpoints } from './presentation';
14
+
15
+ let { pluginName, selectedRange, onHeaderChange, mode }:
16
+ { pluginName: string; selectedRange: TimeRange; onHeaderChange?: NativeWidgetHeaderChange; mode: 'overview' | 'endpoints' } = $props();
17
+ let state: TpsState = $state({ data: null, busy: true, error: false });
18
+ let resource: TpsResource | undefined = $state();
19
+ const range: TpsRange = $derived(selectedRange === '1h' || selectedRange === '12h' ? selectedRange : '24h');
20
+ const t = (key: string, values?: Record<string, string | number>) => $isLoading ? '' : getPluginText(key, pluginName,
21
+ (id, options) => $_(id, { ...options, values }));
22
+ const rows = $derived(rankedEndpoints(state.data?.endpoints ?? []));
23
+ 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
+ $effect(() => {
38
+ const next = getTpsResource(pluginName, range);
39
+ resource = next;
40
+ return next.subscribe(value => { state = value; });
41
+ });
42
+ $effect(() => {
43
+ const report = onHeaderChange;
44
+ const header = { summary: range, refresh: { label: t('ui.refresh'), busy: state.busy,
45
+ disabled: !resource, run: () => { void resource?.refresh(); } } };
46
+ untrack(() => report?.(header));
47
+ });
48
+ onMount(() => {
49
+ const report = onHeaderChange;
50
+ return () => report?.(null);
51
+ });
52
+ </script>
53
+
54
+ <div class="tps-content min-w-0 w-full" data-testid={`tps-${mode}`} aria-busy={state.busy}>
55
+ {#if $isLoading}
56
+ <span class="nx-metric text-zinc-50">—</span>
57
+ {:else}
58
+ {#if mode === 'overview'}
59
+ <div class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1.5">
60
+ <span class="nx-metric tps-value text-zinc-50" data-testid="tps-total-value">{formatTps(state.data?.total.sampleCount ? state.data.total.tps : null)}</span>
61
+ <span class="font-mono text-sm text-zinc-400">tokens/s</span>
62
+ </div>
63
+ <p class="mt-3 text-sm text-zinc-400">{t('ui.weightedHint')}</p>
64
+ {/if}
65
+ {#if state.error}
66
+ <p role="alert" class="text-sm text-red-300" data-testid="tps-error">{t('ui.loadFailed')}</p>
67
+ {:else if state.busy && !state.data}
68
+ <p role="status" class="text-sm text-zinc-400">{t('ui.loading')}</p>
69
+ {:else if state.data}
70
+ {#if !state.data.total.sampleCount}
71
+ <p class="text-sm text-zinc-400" data-testid="tps-empty">{t('ui.noData')}</p>
72
+ {:else if mode === 'overview'}
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}
76
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex (A scrolling region needs keyboard focus so every endpoint remains reachable.) -->
77
+ <div class="endpoint-list" role="region" aria-label={t('ui.ranking')} data-testid="tps-endpoint-list" tabindex="0">
78
+ <ol class="endpoint-items">
79
+ {#each rows as row (JSON.stringify([row.routeId, row.endpointId]))}
80
+ {@const label = labels.get(JSON.stringify([row.routeId, row.endpointId]))}
81
+ <li class="endpoint-row" data-testid="tps-endpoint-row" data-route-id={row.routeId} data-endpoint-id={endpointIdentity(row.endpointId)}>
82
+ <div class="endpoint-heading">
83
+ <div class="min-w-0">
84
+ <p class="endpoint-label text-sm text-zinc-200">{label?.endpoint ?? endpointIdentity(row.endpointId)}</p>
85
+ <p class="endpoint-label mt-1 text-xs text-zinc-400">{label?.route ?? row.routeId}</p>
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>
88
+ </div>
89
+ <div class="mt-2 h-1.5 bg-carbon-700" aria-hidden="true"><div class="h-full bg-nexus-500" style:width={`${maximum && row.tps !== null ? Math.max(0, row.tps / maximum * 100) : 0}%`}></div></div>
90
+ <p class="mt-2 font-mono text-xs text-zinc-400">{t('ui.samples', { count: row.sampleCount, estimated: row.estimatedSampleCount })}</p>
91
+ </li>
92
+ {/each}
93
+ </ol>
94
+ </div>
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
+ {/if}
100
+ {/if}
101
+ </div>
102
+
103
+ <style>
104
+ .tps-content { display: flex; flex-direction: column; gap: 6px; }
105
+ .tps-value { font-size: clamp(28px, 4cqw, 42px); overflow-wrap: anywhere; }
106
+ .endpoint-list { max-height: 360px; overflow-y: auto; overscroll-behavior: contain; }
107
+ .endpoint-items { list-style: none; margin: 0; padding: 0; }
108
+ .endpoint-row { padding: 12px 0; border-bottom: 1px solid var(--nx-edge); }
109
+ .endpoint-row:first-child { padding-top: 0; }
110
+ .endpoint-row:last-child { border-bottom: 0; }
111
+ .endpoint-heading { display: flex; flex-wrap: wrap; align-items: start; justify-content: space-between; gap: 6px 18px; }
112
+ .endpoint-heading > div { flex: 1 1 180px; }
113
+ .endpoint-heading > p { flex: 0 1 auto; overflow-wrap: anywhere; }
114
+ .endpoint-label { overflow-wrap: anywhere; }
115
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { TpsEndpointStats } from '../contract';
2
+
3
+ export function formatTps(value: number | null | undefined): string {
4
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
5
+ ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value) : '—';
6
+ }
7
+
8
+ /** Host-issued opaque identities are safe fallbacks; URL-shaped values are never labels. */
9
+ export function endpointIdentity(id: string): string {
10
+ return /^[a-zA-Z0-9_-]+$/.test(id) ? id : 'endpoint';
11
+ }
12
+
13
+ export function rankedEndpoints(rows: readonly TpsEndpointStats[]): TpsEndpointStats[] {
14
+ return [...rows].sort((a, b) => (b.tps ?? -1) - (a.tps ?? -1)
15
+ || a.routeId.localeCompare(b.routeId) || a.endpointId.localeCompare(b.endpointId));
16
+ }
@@ -0,0 +1,77 @@
1
+ import { writable } from 'svelte/store';
2
+ import type { TpsAggregate, TpsRange, TpsStatsResponse } from '../contract';
3
+
4
+ export type TpsState = { data: TpsStatsResponse | null; busy: boolean; error: boolean };
5
+ type Load = (plugin: string, range: TpsRange, signal: AbortSignal) => Promise<TpsStatsResponse>;
6
+ type Poll = (refresh: () => void) => () => void;
7
+
8
+ function validAggregate(value: TpsAggregate): boolean {
9
+ return !!value && (value.tps === null || typeof value.tps === 'number' && Number.isFinite(value.tps) && value.tps >= 0)
10
+ && [value.outputTokens, value.generationDurationMs].every(n => typeof n === 'number' && Number.isFinite(n) && n >= 0)
11
+ && [value.sampleCount, value.estimatedSampleCount].every(n => Number.isSafeInteger(n) && n >= 0)
12
+ && value.estimatedSampleCount <= value.sampleCount;
13
+ }
14
+
15
+ export function validateStats(data: TpsStatsResponse, range: TpsRange): TpsStatsResponse {
16
+ if (!data || data.range !== range || !Number.isFinite(data.asOfMs) || !validAggregate(data.total)
17
+ || typeof data.reportingIncomplete !== 'boolean' || !Array.isArray(data.endpoints)
18
+ || !data.endpoints.every(row => validAggregate(row) && typeof row.routeId === 'string' && typeof row.endpointId === 'string')) {
19
+ throw new Error('invalid_tps_stats');
20
+ }
21
+ return data;
22
+ }
23
+
24
+ /** One active request and polling timer per plugin/range; no snapshots survive the last subscriber. */
25
+ export function createTpsResources(load: Load, poll: Poll = refresh => {
26
+ const timer = setInterval(refresh, 60_000);
27
+ return () => clearInterval(timer);
28
+ }) {
29
+ const resources = new Map<string, TpsResource>();
30
+ function get(plugin: string, range: TpsRange): TpsResource {
31
+ const key = JSON.stringify([plugin, range]);
32
+ const cached = resources.get(key);
33
+ if (cached) return cached;
34
+ let active = false;
35
+ let generation = 0;
36
+ let controller: AbortController | undefined;
37
+ let pending: Promise<void> | undefined;
38
+ const state = writable<TpsState>({ data: null, busy: false, error: false }, () => {
39
+ active = true;
40
+ state.set({ data: null, busy: true, error: false });
41
+ void refresh();
42
+ const stopPoll = poll(() => { void refresh(); });
43
+ return () => {
44
+ active = false;
45
+ generation++;
46
+ controller?.abort();
47
+ pending = undefined;
48
+ stopPoll();
49
+ if (resources.get(key) === resource) resources.delete(key);
50
+ };
51
+ });
52
+ function refresh(): Promise<void> {
53
+ if (!active) return Promise.resolve();
54
+ if (pending) return pending;
55
+ const version = ++generation;
56
+ const request = new AbortController();
57
+ controller = request;
58
+ state.update(current => ({ ...current, busy: true }));
59
+ pending = Promise.resolve().then(() => load(plugin, range, request.signal)).then(data => {
60
+ const valid = validateStats(data, range);
61
+ if (active && version === generation) state.set({ data: valid, busy: false, error: false });
62
+ }).catch(() => {
63
+ if (active && version === generation && !request.signal.aborted) state.set({ data: null, busy: false, error: true });
64
+ }).finally(() => { if (version === generation) pending = undefined; });
65
+ return pending;
66
+ }
67
+ const resource: TpsResource = { subscribe: state.subscribe, refresh };
68
+ resources.set(key, resource);
69
+ return resource;
70
+ }
71
+ return { get };
72
+ }
73
+
74
+ export type TpsResource = {
75
+ subscribe: ReturnType<typeof writable<TpsState>>['subscribe'];
76
+ refresh: () => Promise<void>;
77
+ };
@@ -0,0 +1,7 @@
1
+ import { requestPluginControl } from '@bungee/plugin-sdk';
2
+ import type { TpsStatsResponse } from '../contract';
3
+ import { createTpsResources } from './stats-resource';
4
+
5
+ const resources = createTpsResources((plugin, range, signal) =>
6
+ requestPluginControl<TpsStatsResponse>(plugin, `/stats?range=${range}`, 'GET', undefined, signal));
7
+ export const getTpsResource = resources.get;