Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28049bce2c | |||
| e8276cde3f | |||
| cb55e2d24a | |||
| 0f64a4cd85 | |||
| 227e5b12da | |||
| 4cae96500e | |||
| 1f1514b5da | |||
| fd37fd276a | |||
| 43664819d4 | |||
| 96c9b963b4 | |||
| 472e289198 | |||
| f6a032f81c | |||
| 3bcac29fa1 | |||
| 5b8b91d942 | |||
| c3b743ccbd | |||
| dee98a88b4 | |||
| 7d42d208b5 | |||
| 308ed58467 | |||
| fd67e481ec | |||
| 6b0e37cff7 | |||
| 3fcc017c0c | |||
| aa40e0236c | |||
| 7f574a2f22 | |||
| 7d44a70498 | |||
| fcc4fe22cc | |||
| 7cef8635bd | |||
| 2bac7ad6a4 | |||
| d8e0fda594 | |||
| df8293494f | |||
| ebeeb17170 | |||
| 8021297e8b | |||
| eacf024057 | |||
| bed925ef4c | |||
| 48455d94e8 | |||
| a1c3de04d5 | |||
| dfafd9a826 | |||
| 3c6f836a7e | |||
| b1780667f0 | |||
| 68d1635289 | |||
| f8c603fafb | |||
| 6d78e5198c | |||
| 451e87a826 |
54 changed files with 3007 additions and 523 deletions
|
|
@ -17,6 +17,9 @@ ADMIN_API_TOKEN_TTL_DAYS=30
|
|||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
ADMIN_TRUSTED_PROXY_IPS=
|
||||
|
||||
# Model routing
|
||||
MODEL_LIST_INCLUDE_ROUTING_METADATA=false
|
||||
|
||||
# OpenID Connect
|
||||
OIDC_ISSUER_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -80,11 +80,12 @@ jobs:
|
|||
|
||||
docker_args=(
|
||||
build
|
||||
.
|
||||
--file Dockerfile
|
||||
--progress=plain
|
||||
--target app
|
||||
--label "org.opencontainers.image.source=${REPOSITORY_URL}"
|
||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
.
|
||||
)
|
||||
|
||||
for tag in "${tags[@]}"; do
|
||||
|
|
@ -96,5 +97,3 @@ jobs:
|
|||
for tag in "${tags[@]}"; do
|
||||
docker push "${tag}"
|
||||
done
|
||||
|
||||
# The single OCI image contains the public API and the admin dashboard runtime.
|
||||
|
|
|
|||
|
|
@ -78,9 +78,11 @@ pnpm run bench # 벤치마크 실행
|
|||
| `OIDC_ALLOWED_EMAILS` | empty | 관리자 접근을 허용할 이메일 목록 |
|
||||
| `OIDC_SCOPES` | `openid profile email` | OIDC authorization scope |
|
||||
| `MODEL_CATALOG_REFRESH_MIN_MS` | `300000` 예시 | 모델 카탈로그 refresh 최소 간격(ms) |
|
||||
| `MODEL_LIST_INCLUDE_ROUTING_METADATA` | `false` | `true`이면 `/v1/models` model object에 비표준 `kyush_router` routing metadata 추가 |
|
||||
| `DETAIL_STREAM_LOG_MODE` | `compact` | 상세 로그에서 stream response body 저장 방식 (`compact`, `raw`, `both`, `off`) |
|
||||
|
||||
## Detailed Docs
|
||||
관련 기능을 수정하기 전에 해당 문서를 반드시 먼저 읽으세요.
|
||||
관련 기능을 수정하기 전에 해당 문서를 반드시 UTF-8로 먼저 읽으세요.
|
||||
|
||||
클라이언트 중심
|
||||
- [docs/client.md](docs/client.md) — 클라이언트 구조, `/dashboard` 라우팅, 관리자 UI 동작
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const api = {
|
|||
users: {
|
||||
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
|
||||
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
|
||||
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean }): Promise<User> =>
|
||||
create: (data: { name: string; email?: string; api_key?: string; detail_logging?: boolean; copy_reasoning_to_reasoning_content?: boolean }): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<User>): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from 'solid-js';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { formatDurationMs } from '../ui/lib/format';
|
||||
import {
|
||||
BoxPlotChart,
|
||||
ChartLegend,
|
||||
|
|
@ -8,11 +10,11 @@ import {
|
|||
CommandBar,
|
||||
CommandBarGroup,
|
||||
HistogramChart,
|
||||
MetaCluster,
|
||||
PageHeader,
|
||||
Panel,
|
||||
Select,
|
||||
SummaryStrip,
|
||||
Switch,
|
||||
TimeSeriesChart,
|
||||
} from '../ui';
|
||||
|
||||
|
|
@ -32,10 +34,15 @@ export const Analytics: Component = () => {
|
|||
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenResponseSeries, setHiddenResponseSeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
|
||||
const [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
|
||||
const [refreshInterval, setRefreshInterval] = createSignal('10');
|
||||
const [refreshKey, setRefreshKey] = createSignal(0);
|
||||
const [dailyVolumeScale, setDailyVolumeScale] = createSignal<'linear' | 'log'>('linear');
|
||||
|
||||
const filters = createMemo(() => ({
|
||||
days: Number(days()),
|
||||
backendId: backendFilter() === 'all' ? undefined : Number(backendFilter()),
|
||||
key: refreshKey(),
|
||||
}));
|
||||
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
|
|
@ -44,6 +51,19 @@ export const Analytics: Component = () => {
|
|||
const [modelTrends] = createResource(filters, (params) => api.analytics.getModelTrends({ backendId: params.backendId, days: params.days, limit: 8 }));
|
||||
const [histogram] = createResource(filters, (params) => api.analytics.getResponseLengthHistogram({ backendId: params.backendId, days: params.days, bins: 20 }));
|
||||
const [boxPlot] = createResource(filters, (params) => api.analytics.getResponseLengthBoxPlot(params.backendId, params.days));
|
||||
const currentDailyTotals = createMemo(() => dailyTotals.latest ?? dailyTotals());
|
||||
const currentBackendQuality = createMemo(() => backendQuality.latest ?? backendQuality());
|
||||
const currentModelTrends = createMemo(() => modelTrends.latest ?? modelTrends());
|
||||
const currentHistogram = createMemo(() => histogram.latest ?? histogram());
|
||||
const currentBoxPlot = createMemo(() => boxPlot.latest ?? boxPlot());
|
||||
const analyticsLoading = createMemo(() => dailyTotals.loading || backendQuality.loading || modelTrends.loading || histogram.loading || boxPlot.loading);
|
||||
|
||||
createEffect(() => {
|
||||
if (!isAutoRefresh()) return;
|
||||
const ms = Number(refreshInterval()) * 1000;
|
||||
const id = setInterval(() => setRefreshKey((key) => key + 1), ms);
|
||||
onCleanup(() => clearInterval(id));
|
||||
});
|
||||
|
||||
const backendOptions = createMemo(() => [
|
||||
{ value: 'all', label: 'All Backends' },
|
||||
|
|
@ -62,7 +82,7 @@ export const Analytics: Component = () => {
|
|||
});
|
||||
|
||||
const dailyVolumeRows = createMemo(() =>
|
||||
(dailyTotals() ?? []).map((row) => ({
|
||||
(currentDailyTotals() ?? []).map((row) => ({
|
||||
date: row.date,
|
||||
requests: row.total_requests,
|
||||
tokens: row.total_tokens,
|
||||
|
|
@ -71,7 +91,7 @@ export const Analytics: Component = () => {
|
|||
|
||||
const responseTimeRows = createMemo(() => {
|
||||
const grouped = new Map<string, AnalyticsChartRow>();
|
||||
for (const row of backendQuality() ?? []) {
|
||||
for (const row of currentBackendQuality() ?? []) {
|
||||
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
|
||||
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
|
||||
grouped.set(row.date, entry);
|
||||
|
|
@ -80,7 +100,7 @@ export const Analytics: Component = () => {
|
|||
});
|
||||
|
||||
const responseTimeSeries = createMemo(() => {
|
||||
const ids = Array.from(new Set((backendQuality() ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
|
||||
const ids = Array.from(new Set((currentBackendQuality() ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
|
||||
return ids.map((backendId, index) => ({
|
||||
key: `backend_${backendId}`,
|
||||
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
|
||||
|
|
@ -90,7 +110,7 @@ export const Analytics: Component = () => {
|
|||
|
||||
const reliabilityRows = createMemo(() => {
|
||||
const grouped = new Map<string, { requests: number; errors: number }>();
|
||||
for (const row of backendQuality() ?? []) {
|
||||
for (const row of currentBackendQuality() ?? []) {
|
||||
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
|
||||
entry.requests += row.total_requests;
|
||||
entry.errors += row.error_count;
|
||||
|
|
@ -108,7 +128,7 @@ export const Analytics: Component = () => {
|
|||
|
||||
const modelTrendRows = createMemo(() => {
|
||||
const grouped = new Map<string, AnalyticsChartRow>();
|
||||
for (const row of modelTrends() ?? []) {
|
||||
for (const row of currentModelTrends() ?? []) {
|
||||
const entry: AnalyticsChartRow = grouped.get(row.date) ?? { date: row.date };
|
||||
entry[`model_${row.model}`] = row.request_count;
|
||||
grouped.set(row.date, entry);
|
||||
|
|
@ -117,7 +137,7 @@ export const Analytics: Component = () => {
|
|||
});
|
||||
|
||||
const modelTrendSeries = createMemo(() => {
|
||||
const models = Array.from(new Set((modelTrends() ?? []).map((row) => row.model)));
|
||||
const models = Array.from(new Set((currentModelTrends() ?? []).map((row) => row.model)));
|
||||
return models.map((model, index) => ({
|
||||
key: `model_${model}`,
|
||||
label: model,
|
||||
|
|
@ -126,7 +146,7 @@ export const Analytics: Component = () => {
|
|||
});
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const totals = (dailyTotals() ?? []).reduce(
|
||||
const totals = (currentDailyTotals() ?? []).reduce(
|
||||
(acc, row) => {
|
||||
acc.requests += row.total_requests;
|
||||
acc.tokens += row.total_tokens;
|
||||
|
|
@ -134,15 +154,15 @@ export const Analytics: Component = () => {
|
|||
},
|
||||
{ requests: 0, tokens: 0 }
|
||||
);
|
||||
const qualityRows = backendQuality() ?? [];
|
||||
const qualityRows = currentBackendQuality() ?? [];
|
||||
const avgLatency =
|
||||
qualityRows.length === 0 ? 0 : qualityRows.reduce((sum, row) => sum + row.avg_response_time_ms, 0) / qualityRows.length;
|
||||
const errorCount = qualityRows.reduce((sum, row) => sum + row.error_count, 0);
|
||||
|
||||
return [
|
||||
{ label: 'Requests', value: formatInteger.format(totals.requests), hint: `Last ${days()} days` },
|
||||
{ label: 'Tokens', value: formatInteger.format(totals.tokens), hint: 'Aggregated daily total tokens' },
|
||||
{ label: 'Avg Response', value: `${avgLatency.toFixed(1)}ms`, hint: 'Across visible backend series' },
|
||||
{ label: 'Tokens', value: formatInteger.format(totals.tokens), hint: `Selected ${days()}-day window total` },
|
||||
{ label: 'Avg Response', value: formatDurationMs(avgLatency), hint: 'Across visible backend series' },
|
||||
{ label: 'Errors', value: formatInteger.format(errorCount), hint: 'Absolute backend error count' },
|
||||
];
|
||||
});
|
||||
|
|
@ -175,6 +195,37 @@ export const Analytics: Component = () => {
|
|||
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
|
||||
<Select label="Backend" value={backendFilter()} options={backendOptions()} onChange={setBackendFilter} />
|
||||
</CommandBarGroup>
|
||||
<CommandBarGroup>
|
||||
<Switch
|
||||
label="Auto refresh"
|
||||
checked={isAutoRefresh()}
|
||||
onChange={setIsAutoRefresh}
|
||||
/>
|
||||
<Select
|
||||
label="Refresh Interval"
|
||||
value={refreshInterval()}
|
||||
options={[
|
||||
{ value: '5', label: 'Every 5s' },
|
||||
{ value: '10', label: 'Every 10s' },
|
||||
{ value: '30', label: 'Every 30s' },
|
||||
{ value: '60', label: 'Every 60s' },
|
||||
{ value: '600', label: 'Every 10m' },
|
||||
]}
|
||||
onChange={setRefreshInterval}
|
||||
/>
|
||||
<div class="ui-divider--vertical" />
|
||||
<button
|
||||
class="ui-button analytics__refresh-button"
|
||||
classList={{ 'ui-button--loading': analyticsLoading() }}
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((key) => key + 1)}
|
||||
disabled={analyticsLoading()}
|
||||
aria-busy={analyticsLoading()}
|
||||
>
|
||||
<RefreshCw />
|
||||
{analyticsLoading() ? 'Refreshing' : 'Refresh'}
|
||||
</button>
|
||||
</CommandBarGroup>
|
||||
</CommandBar>
|
||||
|
||||
<SummaryStrip items={summaryItems()} />
|
||||
|
|
@ -184,14 +235,25 @@ export const Analytics: Component = () => {
|
|||
title="Daily Volume"
|
||||
description="Daily request and token totals on shared time axis."
|
||||
actions={
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||
]}
|
||||
mutedKeys={hiddenDailySeries()}
|
||||
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
|
||||
/>
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||
]}
|
||||
mutedKeys={hiddenDailySeries()}
|
||||
onToggle={(key) => toggleHiddenKey(setHiddenDailySeries, key)}
|
||||
/>
|
||||
<Select
|
||||
label="Scale"
|
||||
value={dailyVolumeScale()}
|
||||
options={[
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
]}
|
||||
onChange={setDailyVolumeScale}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TimeSeriesChart
|
||||
|
|
@ -208,6 +270,7 @@ export const Analytics: Component = () => {
|
|||
formatLeftValue={(value) => new Intl.NumberFormat('en-US').format(Math.round(value))}
|
||||
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
|
||||
tooltipTitle="Daily request and token totals"
|
||||
yScaleType={dailyVolumeScale()}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
|
|
@ -252,8 +315,8 @@ export const Analytics: Component = () => {
|
|||
showLegend={false}
|
||||
hiddenKeys={hiddenResponseSeries()}
|
||||
onToggleLegend={(key) => toggleHiddenKey(setHiddenResponseSeries, key)}
|
||||
yLeftLabel="Milliseconds"
|
||||
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
|
||||
yLeftLabel="Response time"
|
||||
formatLeftValue={formatDurationMs}
|
||||
tooltipTitle="Average backend response time"
|
||||
/>
|
||||
</Panel>
|
||||
|
|
@ -283,22 +346,16 @@ export const Analytics: Component = () => {
|
|||
</div>
|
||||
|
||||
<div class="ui-section-grid analytics__grid--spread-wide">
|
||||
<Panel title="Response Length Distribution" description="Histogram of completion token lengths across the selected window.">
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'Metric', value: 'completion_tokens' },
|
||||
]}
|
||||
<Panel title="Response Length Distribution" description="Log-scaled completion_tokens histogram across the selected window.">
|
||||
<HistogramChart
|
||||
data={currentHistogram() ?? []}
|
||||
xTickUnit="tok"
|
||||
yTickUnit="req"
|
||||
/>
|
||||
<HistogramChart data={histogram() ?? []} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="Daily Response Length Spread" description="Completion token box plot by day using min / q1 / median / q3 / max summary.">
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'Outliers', value: 'Hidden in this view' },
|
||||
]}
|
||||
/>
|
||||
<BoxPlotChart data={boxPlot() ?? []} />
|
||||
<Panel title="Daily Response Length Spread" description="Log-scaled daily completion_tokens spread; outliers are hidden.">
|
||||
<BoxPlotChart data={currentBoxPlot() ?? []} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import { For, createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import Pencil from 'lucide-solid/icons/pencil';
|
||||
import Plus from 'lucide-solid/icons/plus';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
|
|
@ -39,6 +39,7 @@ const emptyForm = (): BackendFormState => ({
|
|||
|
||||
export const Backends: Component = () => {
|
||||
const [backends, { refetch }] = createResource(() => api.backends.getAll());
|
||||
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
|
||||
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
const [editingBackend, setEditingBackend] = createSignal<Backend | null>(null);
|
||||
|
|
@ -202,11 +203,11 @@ export const Backends: Component = () => {
|
|||
|
||||
<Panel title="Backend catalog" description="Operational list with overflow-safe URL presentation and compact actions.">
|
||||
<Show
|
||||
when={!backends.loading || (backends()?.length ?? 0) > 0}
|
||||
when={!backends.loading || (currentBackends()?.length ?? 0) > 0}
|
||||
fallback={<EmptyState title="Loading backends" description="Reading upstream routing targets from the admin API." />}
|
||||
>
|
||||
<Show
|
||||
when={(backends()?.length ?? 0) > 0}
|
||||
when={(currentBackends()?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No backends yet"
|
||||
|
|
@ -216,7 +217,7 @@ export const Backends: Component = () => {
|
|||
}
|
||||
>
|
||||
<DataGrid
|
||||
rows={backends() ?? []}
|
||||
rows={currentBackends() ?? []}
|
||||
columns={[
|
||||
{ id: 'id', header: 'ID', mono: true, cell: (backend) => <span>{backend.id}</span> },
|
||||
{ id: 'name', header: 'Name', cell: (backend) => <span>{backend.name}</span> },
|
||||
|
|
@ -248,7 +249,7 @@ export const Backends: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(backend) => backend.id}
|
||||
loading={backends.loading}
|
||||
loading={backends.loading && (currentBackends()?.length ?? 0) === 0}
|
||||
rowActions={(backend) => (
|
||||
<div class="ui-row-actions">
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
|
||||
import { Show, createMemo, createResource, createSignal, type Component } from 'solid-js';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, type Component } from 'solid-js';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { formatDurationMs } from '../ui/lib/format';
|
||||
import {
|
||||
ChartLegend,
|
||||
ComboChart,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
Panel,
|
||||
Select,
|
||||
SummaryStrip,
|
||||
Switch,
|
||||
TimeSeriesChart,
|
||||
} from '../ui';
|
||||
|
||||
|
|
@ -32,10 +34,23 @@ export const Dashboard: Component = () => {
|
|||
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenLatencySeries, setHiddenLatencySeries] = createSignal<Set<string>>(new Set());
|
||||
const [hiddenModelSeries, setHiddenModelSeries] = createSignal<Set<string>>(new Set());
|
||||
const [isAutoRefresh, setIsAutoRefresh] = createSignal(false);
|
||||
const [refreshInterval, setRefreshInterval] = createSignal('10');
|
||||
const [refreshKey, setRefreshKey] = createSignal(0);
|
||||
const [trafficVolumeScale, setTrafficVolumeScale] = createSignal<'linear' | 'log'>('linear');
|
||||
|
||||
const windowDays = createMemo(() => Number(days()));
|
||||
const [summary, { refetch }] = createResource(windowDays, (value) => api.dashboard.getSummary(value));
|
||||
const summarySource = createMemo(() => ({ days: windowDays(), key: refreshKey() }));
|
||||
const [summary] = createResource(summarySource, (value) => api.dashboard.getSummary(value.days));
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
const currentSummary = createMemo(() => summary.latest ?? summary());
|
||||
|
||||
createEffect(() => {
|
||||
if (!isAutoRefresh()) return;
|
||||
const ms = Number(refreshInterval()) * 1000;
|
||||
const id = setInterval(() => setRefreshKey((k) => k + 1), ms);
|
||||
onCleanup(() => clearInterval(id));
|
||||
});
|
||||
|
||||
const backendNameById = createMemo(() => {
|
||||
const entries = new Map<number, string>();
|
||||
|
|
@ -46,7 +61,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const trafficRows = createMemo(() =>
|
||||
(summary()?.series.daily_totals ?? []).map((row) => ({
|
||||
(currentSummary()?.series.daily_totals ?? []).map((row) => ({
|
||||
date: row.date,
|
||||
requests: row.total_requests,
|
||||
tokens: row.total_tokens,
|
||||
|
|
@ -55,7 +70,7 @@ export const Dashboard: Component = () => {
|
|||
|
||||
const reliabilityRows = createMemo(() => {
|
||||
const grouped = new Map<string, { requests: number; errors: number }>();
|
||||
for (const row of summary()?.series.backend_quality ?? []) {
|
||||
for (const row of currentSummary()?.series.backend_quality ?? []) {
|
||||
const entry = grouped.get(row.date) ?? { requests: 0, errors: 0 };
|
||||
entry.requests += row.total_requests;
|
||||
entry.errors += row.error_count;
|
||||
|
|
@ -73,7 +88,7 @@ export const Dashboard: Component = () => {
|
|||
|
||||
const latencyRows = createMemo(() => {
|
||||
const grouped = new Map<string, DashboardChartRow>();
|
||||
for (const row of summary()?.series.backend_quality ?? []) {
|
||||
for (const row of currentSummary()?.series.backend_quality ?? []) {
|
||||
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
|
||||
entry[`backend_${row.backend_id}`] = row.avg_response_time_ms;
|
||||
grouped.set(row.date, entry);
|
||||
|
|
@ -82,7 +97,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const latencySeries = createMemo(() => {
|
||||
const ids = Array.from(new Set((summary()?.series.backend_quality ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
|
||||
const ids = Array.from(new Set((currentSummary()?.series.backend_quality ?? []).map((row) => row.backend_id))).sort((left, right) => left - right);
|
||||
return ids.map((backendId, index) => ({
|
||||
key: `backend_${backendId}`,
|
||||
label: backendNameById().get(backendId) ?? `Backend ${backendId}`,
|
||||
|
|
@ -92,7 +107,7 @@ export const Dashboard: Component = () => {
|
|||
|
||||
const modelRows = createMemo(() => {
|
||||
const grouped = new Map<string, DashboardChartRow>();
|
||||
for (const row of summary()?.series.model_trends ?? []) {
|
||||
for (const row of currentSummary()?.series.model_trends ?? []) {
|
||||
const entry: DashboardChartRow = grouped.get(row.date) ?? { date: row.date };
|
||||
entry[`model_${row.model}`] = row.request_count;
|
||||
grouped.set(row.date, entry);
|
||||
|
|
@ -101,7 +116,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const modelSeries = createMemo(() => {
|
||||
const models = Array.from(new Set((summary()?.series.model_trends ?? []).map((row) => row.model)));
|
||||
const models = Array.from(new Set((currentSummary()?.series.model_trends ?? []).map((row) => row.model)));
|
||||
return models.map((model, index) => ({
|
||||
key: `model_${model}`,
|
||||
label: model,
|
||||
|
|
@ -110,7 +125,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
const payload = currentSummary();
|
||||
const latestTraffic = payload?.series.daily_totals[payload.series.daily_totals.length - 1];
|
||||
|
||||
return [
|
||||
|
|
@ -122,7 +137,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const cacheStateItems = createMemo(() => {
|
||||
const counts = summary()?.health.cache_state_counts;
|
||||
const counts = currentSummary()?.health.cache_state_counts;
|
||||
if (!counts) return [];
|
||||
return [
|
||||
{ key: 'Ready', value: String(counts.ready) },
|
||||
|
|
@ -133,7 +148,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const scriptItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
const payload = currentSummary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
|
|
@ -144,7 +159,7 @@ export const Dashboard: Component = () => {
|
|||
});
|
||||
|
||||
const accessItems = createMemo(() => {
|
||||
const payload = summary();
|
||||
const payload = currentSummary();
|
||||
if (!payload) return [];
|
||||
|
||||
return [
|
||||
|
|
@ -176,13 +191,43 @@ export const Dashboard: Component = () => {
|
|||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Operations cockpit for router health, traffic shape, and the configuration context behind current behavior."
|
||||
actions={<button class="ui-button" type="button" onClick={() => void refetch()}><RefreshCcw />Refresh</button>}
|
||||
/>
|
||||
|
||||
<CommandBar class="analytics__filters">
|
||||
<CommandBarGroup>
|
||||
<Select label="Window" value={days()} options={dayOptions} onChange={setDays} />
|
||||
</CommandBarGroup>
|
||||
<CommandBarGroup>
|
||||
<Switch
|
||||
label="Auto refresh"
|
||||
checked={isAutoRefresh()}
|
||||
onChange={setIsAutoRefresh}
|
||||
/>
|
||||
<Select
|
||||
label="Refresh Interval"
|
||||
value={refreshInterval()}
|
||||
options={[
|
||||
{ value: '5', label: 'Every 5s' },
|
||||
{ value: '10', label: 'Every 10s' },
|
||||
{ value: '30', label: 'Every 30s' },
|
||||
{ value: '60', label: 'Every 60s' },
|
||||
{ value: '600', label: 'Every 10m' },
|
||||
]}
|
||||
onChange={setRefreshInterval}
|
||||
/>
|
||||
<div class="ui-divider--vertical" />
|
||||
<button
|
||||
class="ui-button dashboard__refresh-button"
|
||||
classList={{ 'ui-button--loading': summary.loading }}
|
||||
type="button"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
disabled={summary.loading}
|
||||
aria-busy={summary.loading}
|
||||
>
|
||||
<RefreshCw />
|
||||
{summary.loading ? 'Refreshing' : 'Refresh'}
|
||||
</button>
|
||||
</CommandBarGroup>
|
||||
</CommandBar>
|
||||
|
||||
<SummaryStrip items={summaryItems()} />
|
||||
|
|
@ -193,14 +238,25 @@ export const Dashboard: Component = () => {
|
|||
title="Traffic Volume"
|
||||
description="Daily request and token totals for the selected window."
|
||||
actions={
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||
]}
|
||||
mutedKeys={hiddenTrafficSeries()}
|
||||
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
|
||||
/>
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ key: 'requests', label: 'Requests', color: '#2357d8' },
|
||||
{ key: 'tokens', label: 'Tokens', color: '#1f7a45' },
|
||||
]}
|
||||
mutedKeys={hiddenTrafficSeries()}
|
||||
onToggle={(key) => toggleHiddenKey(setHiddenTrafficSeries, key)}
|
||||
/>
|
||||
<Select
|
||||
label="Scale"
|
||||
value={trafficVolumeScale()}
|
||||
options={[
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
]}
|
||||
onChange={setTrafficVolumeScale}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TimeSeriesChart
|
||||
|
|
@ -217,6 +273,7 @@ export const Dashboard: Component = () => {
|
|||
formatLeftValue={(value) => formatInteger.format(Math.round(value))}
|
||||
formatRightValue={(value) => new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)}
|
||||
tooltipTitle="Traffic volume"
|
||||
yScaleType={trafficVolumeScale()}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
|
|
@ -248,8 +305,8 @@ export const Dashboard: Component = () => {
|
|||
showLegend={false}
|
||||
hiddenKeys={hiddenLatencySeries()}
|
||||
onToggleLegend={(key) => toggleHiddenKey(setHiddenLatencySeries, key)}
|
||||
yLeftLabel="Milliseconds"
|
||||
formatLeftValue={(value) => `${value.toFixed(0)}ms`}
|
||||
yLeftLabel="Latency"
|
||||
formatLeftValue={formatDurationMs}
|
||||
tooltipTitle="Backend latency"
|
||||
/>
|
||||
</Panel>
|
||||
|
|
@ -276,11 +333,11 @@ export const Dashboard: Component = () => {
|
|||
<Panel title="Backend Health" description="Cache readiness, liveness, and sync drift indicators for current backends.">
|
||||
<MetaCluster items={cacheStateItems()} />
|
||||
<Show
|
||||
when={(summary()?.health.stale_backends.length ?? 0) > 0}
|
||||
when={(currentSummary()?.health.stale_backends.length ?? 0) > 0}
|
||||
fallback={<EmptyState title="No stale backend syncs" description="All active backends synced within the freshness window." />}
|
||||
>
|
||||
<div class="dashboard__status-list">
|
||||
{summary()?.health.stale_backends.map((backend) => (
|
||||
{currentSummary()?.health.stale_backends.map((backend) => (
|
||||
<div class="dashboard__status-item">
|
||||
<div>
|
||||
<strong>{backend.name}</strong>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show, type Component } from 'solid-js';
|
||||
import RefreshCcw from 'lucide-solid/icons/refresh-ccw';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import type { RequestLog } from '../types';
|
||||
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, hasRenderableConversation } from '../ui';
|
||||
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, extractAssistantConversationPreview, hasRenderableConversation } from '../ui';
|
||||
|
||||
interface FilterState {
|
||||
month: string;
|
||||
|
|
@ -15,6 +15,7 @@ interface FilterState {
|
|||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
|
||||
const emptyFilters = (): FilterState => ({
|
||||
month: '',
|
||||
|
|
@ -25,32 +26,6 @@ const emptyFilters = (): FilterState => ({
|
|||
endpoint: '',
|
||||
});
|
||||
|
||||
function extractAssistantPreview(responseBody?: string): string {
|
||||
if (!responseBody) return '-';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody) as {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: unknown;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const content = parsed.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') return '-';
|
||||
|
||||
const normalized = content
|
||||
.replace(/\r/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!normalized) return '-';
|
||||
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function prettyPrint(value?: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
|
|
@ -63,6 +38,7 @@ function prettyPrint(value?: string): string {
|
|||
|
||||
export const DetailLogs: Component = () => {
|
||||
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
|
||||
const [searchDraft, setSearchDraft] = createSignal('');
|
||||
const [page, setPage] = createSignal(1);
|
||||
const [pageSize, setPageSize] = createSignal(25);
|
||||
const [selectedLogId, setSelectedLogId] = createSignal<number | null>(null);
|
||||
|
|
@ -88,9 +64,32 @@ export const DetailLogs: Component = () => {
|
|||
})
|
||||
);
|
||||
|
||||
const requestPage = createMemo(() => logs());
|
||||
const updateFilter = (key: keyof FilterState, value: string) => {
|
||||
let changed = false;
|
||||
setFilters((current) => {
|
||||
if (current[key] === value) return current;
|
||||
changed = true;
|
||||
return { ...current, [key]: value };
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
setPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const nextQuery = searchDraft();
|
||||
const id = window.setTimeout(() => updateFilter('q', nextQuery), SEARCH_DEBOUNCE_MS);
|
||||
onCleanup(() => window.clearTimeout(id));
|
||||
});
|
||||
|
||||
const requestPage = createMemo(() => (logs.state === 'ready' || logs.state === 'refreshing' ? logs.latest : undefined));
|
||||
const requestRows = createMemo(() => requestPage()?.rows ?? []);
|
||||
const totalRows = createMemo(() => requestPage()?.total ?? 0);
|
||||
const logsError = createMemo(() => {
|
||||
if (!logs.error) return null;
|
||||
return logs.error instanceof Error ? logs.error.message : 'Failed to load detailed logs.';
|
||||
});
|
||||
const pageCount = createMemo(() => Math.max(1, Math.ceil(totalRows() / pageSize())));
|
||||
const rangeStart = createMemo(() => (totalRows() === 0 ? 0 : (page() - 1) * pageSize() + 1));
|
||||
const rangeEnd = createMemo(() => Math.min(totalRows(), page() * pageSize()));
|
||||
|
|
@ -140,7 +139,7 @@ export const DetailLogs: Component = () => {
|
|||
const assistantPreviewById = createMemo(() => {
|
||||
const previews = new Map<number, string>();
|
||||
for (const row of requestRows()) {
|
||||
previews.set(row.id, extractAssistantPreview(row.response_body));
|
||||
previews.set(row.id, extractAssistantConversationPreview(row.response_body));
|
||||
}
|
||||
return previews;
|
||||
});
|
||||
|
|
@ -165,22 +164,29 @@ export const DetailLogs: Component = () => {
|
|||
];
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchDraft('');
|
||||
setFilters(emptyFilters());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const updateFilter = (key: keyof FilterState, value: string) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div class="ui-app-page">
|
||||
<PageHeader
|
||||
title="Detail Logs"
|
||||
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
|
||||
actions={<Button onClick={() => void refetch()}><RefreshCcw />Refresh</Button>}
|
||||
actions={
|
||||
<Button
|
||||
class="detail-logs__refresh-button"
|
||||
classList={{ 'ui-button--loading': logs.loading }}
|
||||
onClick={() => void refetch()}
|
||||
disabled={logs.loading}
|
||||
aria-busy={logs.loading}
|
||||
>
|
||||
<RefreshCcw />
|
||||
{logs.loading ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<SummaryStrip
|
||||
|
|
@ -195,9 +201,9 @@ export const DetailLogs: Component = () => {
|
|||
<CommandBarGroup>
|
||||
<TextField
|
||||
label="Search"
|
||||
value={filters().q}
|
||||
value={searchDraft()}
|
||||
placeholder="Search body, headers, models, errors"
|
||||
onInput={(event) => updateFilter('q', event.currentTarget.value)}
|
||||
onInput={(event) => setSearchDraft(event.currentTarget.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Month"
|
||||
|
|
@ -254,7 +260,8 @@ export const DetailLogs: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
loading={logs.loading}
|
||||
loading={logs.loading && requestRows().length === 0}
|
||||
error={logsError()}
|
||||
emptyMessage="No detailed logs matched the current filters."
|
||||
onRowClick={(row) => setSelectedLogId(row.id)}
|
||||
pagination={{
|
||||
|
|
@ -269,7 +276,7 @@ export const DetailLogs: Component = () => {
|
|||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}}
|
||||
/>
|
||||
{!logs.loading && requestRows().length === 0 && (
|
||||
{!logs.loading && !logsError() && requestRows().length === 0 && (
|
||||
<EmptyState title="No logs found" description="Try a different month, date, or search term." />
|
||||
)}
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ export const Models: Component = () => {
|
|||
const [overview, { refetch: refetchOverview }] = createResource(() => api.modelCache.getOverview());
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
const [rules, { refetch: refetchRules }] = createResource(() => api.modelRewrites.getAll());
|
||||
const currentOverview = createMemo(() => overview.state === 'ready' || overview.state === 'refreshing' ? overview.latest : undefined);
|
||||
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
|
||||
const currentRules = createMemo(() => rules.state === 'ready' || rules.state === 'refreshing' ? rules.latest : undefined);
|
||||
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
const [editingRule, setEditingRule] = createSignal<ModelRewriteRule | null>(null);
|
||||
|
|
@ -50,7 +53,7 @@ export const Models: Component = () => {
|
|||
const [notice, setNotice] = createSignal<{ tone: 'success' | 'danger'; message: string } | null>(null);
|
||||
const backendNameById = createMemo(() => {
|
||||
const names = new Map<number, string>();
|
||||
for (const backend of backends() ?? []) {
|
||||
for (const backend of currentBackends() ?? []) {
|
||||
names.set(backend.id, backend.name);
|
||||
}
|
||||
return names;
|
||||
|
|
@ -58,7 +61,7 @@ export const Models: Component = () => {
|
|||
|
||||
const getBackendName = (backendId: number) => backendNameById().get(backendId) ?? `Backend ${backendId}`;
|
||||
const modelCatalogRows = createMemo(() =>
|
||||
(overview()?.models ?? []).map((entry) => ({
|
||||
(currentOverview()?.models ?? []).map((entry) => ({
|
||||
...entry,
|
||||
backend_names: entry.backend_ids.map((backendId) => getBackendName(backendId)).join(', '),
|
||||
}))
|
||||
|
|
@ -145,15 +148,15 @@ export const Models: Component = () => {
|
|||
<div class="ui-app-page">
|
||||
<PageHeader
|
||||
title="Models"
|
||||
description="Inspect cached backend model catalogs and manage global model rewrite rules."
|
||||
description="Inspect cached backend model catalogs and manage chained global model rewrite rules."
|
||||
actions={<Button onClick={() => void Promise.all([refetchOverview(), refetchRules()])}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
<SummaryStrip
|
||||
items={[
|
||||
{ label: 'Catalog Models', value: overview()?.models.length ?? 0, hint: 'Unique models across active backends' },
|
||||
{ label: 'Tracked Backends', value: overview()?.backends.length ?? 0, hint: 'Memory cache status by backend' },
|
||||
{ label: 'Rewrite Rules', value: rules()?.length ?? 0, hint: 'Global source -> target mappings' },
|
||||
{ label: 'Catalog Models', value: currentOverview()?.models.length ?? 0, hint: 'Unique models across active backends' },
|
||||
{ label: 'Tracked Backends', value: currentOverview()?.backends.length ?? 0, hint: 'Memory cache status by backend' },
|
||||
{ label: 'Rewrite Rules', value: currentRules()?.length ?? 0, hint: 'Global source -> target mappings' },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
|
@ -164,11 +167,11 @@ export const Models: Component = () => {
|
|||
<div class="ui-section-grid">
|
||||
<Panel title="Backend Cache Status" description="Memory-backed backend cache state used by request routing and `/v1/models`.">
|
||||
<Show
|
||||
when={(overview()?.backends.length ?? 0) > 0}
|
||||
when={(currentOverview()?.backends.length ?? 0) > 0 || overview.loading}
|
||||
fallback={<EmptyState title="No backend cache yet" description="Backend model states appear here after the server has seen active backends." />}
|
||||
>
|
||||
<DataGrid
|
||||
rows={overview()?.backends ?? []}
|
||||
rows={currentOverview()?.backends ?? []}
|
||||
columns={[
|
||||
{
|
||||
id: 'backend_id',
|
||||
|
|
@ -182,14 +185,14 @@ export const Models: Component = () => {
|
|||
{ id: 'last_error', header: 'Last Error', cell: (item) => <span title={item.last_error ?? '-'}>{item.last_error ?? '-'}</span> },
|
||||
]}
|
||||
getRowKey={(item) => item.backend_id}
|
||||
loading={overview.loading}
|
||||
loading={overview.loading && (currentOverview()?.backends.length ?? 0) === 0}
|
||||
/>
|
||||
</Show>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Model Catalog" description="Unique models and the backend names currently advertising each one.">
|
||||
<Show
|
||||
when={modelCatalogRows().length > 0}
|
||||
when={modelCatalogRows().length > 0 || overview.loading}
|
||||
fallback={<EmptyState title="No cached models yet" description="Model catalog entries appear here after backend model snapshots are available." />}
|
||||
>
|
||||
<DataGrid
|
||||
|
|
@ -216,7 +219,7 @@ export const Models: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(item) => item.model_id}
|
||||
loading={overview.loading}
|
||||
loading={overview.loading && modelCatalogRows().length === 0}
|
||||
/>
|
||||
</Show>
|
||||
</Panel>
|
||||
|
|
@ -224,16 +227,16 @@ export const Models: Component = () => {
|
|||
|
||||
<Panel
|
||||
title="Model Rewrite Rules"
|
||||
description="Force rules always rewrite. Fallback rules rewrite only when the original model has no usable backend."
|
||||
description="Force rules always rewrite and continue through the chain. Fallback rules continue only when the current model has no usable backend."
|
||||
actions={<IconButton variant="primary" icon={<Plus />} label="Add Rule" onClick={openCreateDialog} />}
|
||||
>
|
||||
<div class="ui-stack ui-stack--tight">
|
||||
<Show
|
||||
when={(rules()?.length ?? 0) > 0}
|
||||
when={(currentRules()?.length ?? 0) > 0 || rules.loading}
|
||||
fallback={<EmptyState title="No rewrite rules" description="Requests currently route using the original model name." />}
|
||||
>
|
||||
<DataGrid
|
||||
rows={rules() ?? []}
|
||||
rows={currentRules() ?? []}
|
||||
columns={[
|
||||
{ id: 'source_model', header: 'Source', cell: (rule) => <span>{rule.source_model}</span> },
|
||||
{ id: 'target_model', header: 'Target', cell: (rule) => <span>{rule.target_model}</span> },
|
||||
|
|
@ -242,7 +245,7 @@ export const Models: Component = () => {
|
|||
{ id: 'note', header: 'Note', cell: (rule) => <span title={rule.note ?? '-'}>{rule.note ?? '-'}</span> },
|
||||
]}
|
||||
getRowKey={(rule) => rule.id}
|
||||
loading={rules.loading}
|
||||
loading={rules.loading && (currentRules()?.length ?? 0) === 0}
|
||||
rowActions={(rule) => (
|
||||
<div class="ui-row-actions">
|
||||
<IconButton icon={<Pencil />} label="Edit" onClick={() => openEditDialog(rule)} />
|
||||
|
|
@ -266,7 +269,7 @@ export const Models: Component = () => {
|
|||
open={dialogOpen()}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={editingRule() ? 'Edit Model Rule' : 'Add Model Rule'}
|
||||
description="Choose whether the target model should always replace the source, or only act as a fallback when the source is unavailable."
|
||||
description="Choose whether the target model should always replace the source, or only continue the chain when the current model is unavailable."
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDialogOpen(false)} disabled={submitting()}>Cancel</Button>
|
||||
|
|
@ -282,7 +285,7 @@ export const Models: Component = () => {
|
|||
<TextField label="Note" value={form().note} onInput={(event) => setForm((current) => ({ ...current, note: event.currentTarget.value }))} />
|
||||
<Checkbox
|
||||
label="Always force rewrite"
|
||||
description="When enabled, requests always route to the target model. When disabled, the target model is only used as a fallback."
|
||||
description="When enabled, requests always continue to the target model. When disabled, the target is used only if the current model has no backend."
|
||||
checked={form().force}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, force: checked }))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ export const Scripts: Component = () => {
|
|||
const [scripts, { refetch: refetchScripts }] = createResource(() => api.scripts.getAll());
|
||||
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
|
||||
const [backends, { refetch: refetchBackends }] = createResource(() => api.backends.getAll());
|
||||
const currentScripts = createMemo(() => scripts.state === 'ready' || scripts.state === 'refreshing' ? scripts.latest : undefined);
|
||||
const currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
|
||||
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
|
||||
const [form, setForm] = createSignal<ScriptFormState>(emptyForm());
|
||||
const [selectedScriptId, setSelectedScriptId] = createSignal<number | null>(null);
|
||||
const [pendingDeleteScript, setPendingDeleteScript] = createSignal<UserScript | null>(null);
|
||||
|
|
@ -116,11 +119,11 @@ export const Scripts: Component = () => {
|
|||
const [testResult, setTestResult] = createSignal<{ success: boolean; error?: string; executionTime?: number } | null>(null);
|
||||
const [testing, setTesting] = createSignal(false);
|
||||
|
||||
const userOptions = createMemo(() => (users() ?? []).map((user) => ({ value: String(user.id), label: user.name })));
|
||||
const backendOptions = createMemo(() => (backends() ?? []).map((backend) => ({ value: String(backend.id), label: backend.name })));
|
||||
const userOptions = createMemo(() => (currentUsers() ?? []).map((user) => ({ value: String(user.id), label: user.name })));
|
||||
const backendOptions = createMemo(() => (currentBackends() ?? []).map((backend) => ({ value: String(backend.id), label: backend.name })));
|
||||
|
||||
const activeCount = createMemo(() => (scripts() ?? []).filter((script) => script.is_active).length);
|
||||
const selectedScript = createMemo(() => (scripts() ?? []).find((script) => script.id === selectedScriptId()) ?? null);
|
||||
const activeCount = createMemo(() => (currentScripts() ?? []).filter((script) => script.is_active).length);
|
||||
const selectedScript = createMemo(() => (currentScripts() ?? []).find((script) => script.id === selectedScriptId()) ?? null);
|
||||
|
||||
const syncForm = (script?: UserScript | null) => {
|
||||
if (!script) {
|
||||
|
|
@ -144,8 +147,8 @@ export const Scripts: Component = () => {
|
|||
};
|
||||
|
||||
const getTargetLabel = (script: Pick<UserScript, 'script_type' | 'target_user_id' | 'target_backend_id'>) => {
|
||||
const user = (users() ?? []).find((item) => item.id === script.target_user_id);
|
||||
const backend = (backends() ?? []).find((item) => item.id === script.target_backend_id);
|
||||
const user = (currentUsers() ?? []).find((item) => item.id === script.target_user_id);
|
||||
const backend = (currentBackends() ?? []).find((item) => item.id === script.target_backend_id);
|
||||
|
||||
if (script.script_type === 'per-user-backend') {
|
||||
return {
|
||||
|
|
@ -275,8 +278,8 @@ export const Scripts: Component = () => {
|
|||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.scripts.test(current.id, {
|
||||
user: users()?.[0] || undefined,
|
||||
backend: backends()?.[0] || undefined,
|
||||
user: currentUsers()?.[0] || undefined,
|
||||
backend: currentBackends()?.[0] || undefined,
|
||||
request: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
|
|
@ -329,11 +332,11 @@ export const Scripts: Component = () => {
|
|||
bodyClass="ui-stack ui-stack--tight"
|
||||
>
|
||||
<Show
|
||||
when={!scripts.loading || (scripts()?.length ?? 0) > 0}
|
||||
when={!scripts.loading || (currentScripts()?.length ?? 0) > 0}
|
||||
fallback={<EmptyState title="Loading scripts" description="Reading middleware definitions and target mappings." />}
|
||||
>
|
||||
<Show
|
||||
when={(scripts()?.length ?? 0) > 0}
|
||||
when={(currentScripts()?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No scripts yet"
|
||||
|
|
@ -345,7 +348,7 @@ export const Scripts: Component = () => {
|
|||
}
|
||||
>
|
||||
<DataGrid
|
||||
rows={scripts() ?? []}
|
||||
rows={currentScripts() ?? []}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -377,7 +380,7 @@ export const Scripts: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(script) => script.id}
|
||||
loading={scripts.loading}
|
||||
loading={scripts.loading && (currentScripts()?.length ?? 0) === 0}
|
||||
onRowClick={(script) => syncForm(script)}
|
||||
rowActions={(script) => (
|
||||
<div class="ui-row-actions">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface UserFormState {
|
|||
api_key: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): UserFormState => ({
|
||||
|
|
@ -46,6 +47,7 @@ const emptyForm = (): UserFormState => ({
|
|||
api_key: '',
|
||||
is_active: true,
|
||||
detail_logging: false,
|
||||
copy_reasoning_to_reasoning_content: false,
|
||||
});
|
||||
|
||||
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
|
||||
|
|
@ -54,6 +56,9 @@ export const Users: Component = () => {
|
|||
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
const [permissions, { refetch: refetchPermissions }] = createResource(() => api.permissions.getAll());
|
||||
const currentUsers = createMemo(() => users.state === 'ready' || users.state === 'refreshing' ? users.latest : undefined);
|
||||
const currentBackends = createMemo(() => backends.state === 'ready' || backends.state === 'refreshing' ? backends.latest : undefined);
|
||||
const currentPermissions = createMemo(() => permissions.state === 'ready' || permissions.state === 'refreshing' ? permissions.latest : undefined);
|
||||
const [query, setQuery] = createSignal('');
|
||||
const [dialogOpen, setDialogOpen] = createSignal(false);
|
||||
const [userDeleteConfirmOpen, setUserDeleteConfirmOpen] = createSignal(false);
|
||||
|
|
@ -70,7 +75,7 @@ export const Users: Component = () => {
|
|||
|
||||
const filteredUsers = createMemo(() => {
|
||||
const value = query().trim().toLowerCase();
|
||||
const list = users() ?? [];
|
||||
const list = currentUsers() ?? [];
|
||||
if (!value) return list;
|
||||
return list.filter((user) => {
|
||||
const haystack = [user.name, user.email ?? '', user.api_key].join(' ').toLowerCase();
|
||||
|
|
@ -78,29 +83,29 @@ export const Users: Component = () => {
|
|||
});
|
||||
});
|
||||
|
||||
const activeCount = createMemo(() => (users() ?? []).filter((user) => user.is_active).length);
|
||||
const selectedUser = createMemo(() => (users() ?? []).find((user) => user.id === selectedUserId()) ?? null);
|
||||
const activeCount = createMemo(() => (currentUsers() ?? []).filter((user) => user.is_active).length);
|
||||
const selectedUser = createMemo(() => (currentUsers() ?? []).find((user) => user.id === selectedUserId()) ?? null);
|
||||
const permissionsForSelectedUser = createMemo(() => {
|
||||
const currentUserId = selectedUserId();
|
||||
if (!currentUserId) return [];
|
||||
return (permissions() ?? []).filter((permission) => permission.user_id === currentUserId);
|
||||
return (currentPermissions() ?? []).filter((permission) => permission.user_id === currentUserId);
|
||||
});
|
||||
const assignedBackendIds = createMemo(() => new Set(permissionsForSelectedUser().map((permission) => permission.backend_id)));
|
||||
const availableBackendOptions = createMemo(() =>
|
||||
(backends() ?? [])
|
||||
(currentBackends() ?? [])
|
||||
.filter((backend) => !assignedBackendIds().has(backend.id))
|
||||
.map((backend) => ({ value: String(backend.id), label: backend.name }))
|
||||
);
|
||||
const backendNameById = createMemo(() => {
|
||||
const names = new Map<number, string>();
|
||||
for (const backend of backends() ?? []) {
|
||||
for (const backend of currentBackends() ?? []) {
|
||||
names.set(backend.id, backend.name);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const list = users() ?? [];
|
||||
const list = currentUsers() ?? [];
|
||||
const currentSelectedUserId = selectedUserId();
|
||||
|
||||
if (list.length === 0) {
|
||||
|
|
@ -129,6 +134,7 @@ export const Users: Component = () => {
|
|||
api_key: user.api_key,
|
||||
is_active: user.is_active,
|
||||
detail_logging: user.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: user.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
|
@ -151,6 +157,7 @@ export const Users: Component = () => {
|
|||
api_key: current.api_key.trim() || undefined,
|
||||
is_active: current.is_active,
|
||||
detail_logging: current.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User updated.' });
|
||||
} else {
|
||||
|
|
@ -159,6 +166,7 @@ export const Users: Component = () => {
|
|||
email: current.email.trim() || undefined,
|
||||
api_key: current.api_key.trim() || undefined,
|
||||
detail_logging: current.detail_logging,
|
||||
copy_reasoning_to_reasoning_content: current.copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User created.' });
|
||||
}
|
||||
|
|
@ -362,6 +370,11 @@ export const Users: Component = () => {
|
|||
header: 'Detail Log',
|
||||
cell: (user) => <StatusBadge tone={user.detail_logging ? 'warning' : 'neutral'}>{user.detail_logging ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'reasoning_compat',
|
||||
header: 'Reasoning Compat',
|
||||
cell: (user) => <StatusBadge tone={user.copy_reasoning_to_reasoning_content ? 'success' : 'neutral'}>{user.copy_reasoning_to_reasoning_content ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
|
|
@ -369,7 +382,7 @@ export const Users: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(user) => user.id}
|
||||
loading={users.loading}
|
||||
loading={users.loading && filteredUsers().length === 0}
|
||||
emptyMessage="No users match the current search."
|
||||
onRowClick={(user) => setSelectedUserId(user.id)}
|
||||
rowActions={(user) => (
|
||||
|
|
@ -455,7 +468,7 @@ export const Users: Component = () => {
|
|||
},
|
||||
]}
|
||||
getRowKey={(permission) => `${permission.user_id}-${permission.backend_id}`}
|
||||
loading={permissions.loading || backends.loading}
|
||||
loading={(permissions.loading || backends.loading) && permissionsForSelectedUser().length === 0}
|
||||
rowActions={(permission) => (
|
||||
<IconButton
|
||||
variant="danger"
|
||||
|
|
@ -526,6 +539,12 @@ export const Users: Component = () => {
|
|||
checked={form().detail_logging}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Copy reasoning to reasoning_content"
|
||||
description="Enable for clients that only display thinking from reasoning_content."
|
||||
checked={form().copy_reasoning_to_reasoning_content}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, copy_reasoning_to_reasoning_content: checked }))}
|
||||
/>
|
||||
</form>
|
||||
</FormDialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type User = {
|
|||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
|
|||
21
client/src/ui/lib/format.ts
Normal file
21
client/src/ui/lib/format.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const durationFormatters = {
|
||||
seconds: new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }),
|
||||
minutes: new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }),
|
||||
};
|
||||
|
||||
export function formatDurationMs(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0ms';
|
||||
}
|
||||
|
||||
const absoluteValue = Math.abs(value);
|
||||
if (absoluteValue < 1000) {
|
||||
return `${Math.round(value)}ms`;
|
||||
}
|
||||
|
||||
if (absoluteValue < 60_000) {
|
||||
return `${durationFormatters.seconds.format(value / 1000)}s`;
|
||||
}
|
||||
|
||||
return `${durationFormatters.minutes.format(value / 60_000)}m`;
|
||||
}
|
||||
|
|
@ -99,10 +99,71 @@ function formatCompactNumber(value: number): string {
|
|||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
function formatNumberWithUnit(value: number, unit?: string): string {
|
||||
const formatted = formatCompactNumber(value);
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getSymlogMax(value: number): number {
|
||||
return value <= 0 ? 1 : value * 1.1;
|
||||
}
|
||||
|
||||
function roundSymlogTick(value: number, max: number): number {
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value < 10) {
|
||||
return Math.min(max, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
const power = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / power;
|
||||
const niceNormalized = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return Math.min(max, niceNormalized * power);
|
||||
}
|
||||
|
||||
function getSymlogTicks(maxValue: number, intervals: number = 4): number[] {
|
||||
const max = Math.max(1, Math.round(maxValue));
|
||||
const transformedMax = Math.log1p(max);
|
||||
const safeIntervals = Math.max(2, intervals);
|
||||
const ticks = new Set<number>();
|
||||
|
||||
for (let index = 0; index <= safeIntervals; index += 1) {
|
||||
const rawTick = index === safeIntervals ? max : Math.expm1((transformedMax / safeIntervals) * index);
|
||||
ticks.add(roundSymlogTick(rawTick, max));
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function getSymlogTicksInRange(minValue: number, maxValue: number, intervals: number = 4): number[] {
|
||||
const min = Math.max(0, minValue);
|
||||
const max = Math.max(min + 1, Math.round(maxValue));
|
||||
const transformedMin = Math.log1p(min);
|
||||
const transformedMax = Math.log1p(max);
|
||||
const safeIntervals = Math.max(2, intervals);
|
||||
const ticks = new Set<number>();
|
||||
|
||||
for (let index = 0; index <= safeIntervals; index += 1) {
|
||||
const rawTick = index === 0
|
||||
? min
|
||||
: index === safeIntervals
|
||||
? max
|
||||
: Math.expm1(transformedMin + ((transformedMax - transformedMin) / safeIntervals) * index);
|
||||
const tick = roundSymlogTick(rawTick, max);
|
||||
if (tick >= min && tick <= max) {
|
||||
ticks.add(tick);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ticks).sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function getDateTicks(values: Date[], width: number): Date[] {
|
||||
if (values.length <= 7) {
|
||||
return values;
|
||||
|
|
@ -115,6 +176,13 @@ function getDateTicks(values: Date[], width: number): Date[] {
|
|||
return scale.ticks(Math.max(2, Math.floor(width / 120)));
|
||||
}
|
||||
|
||||
function getSvgPointerX(event: PointerEvent): number {
|
||||
const target = event.currentTarget as SVGGraphicsElement;
|
||||
const container = target.ownerSVGElement ?? target;
|
||||
const [x] = d3.pointer(event, container);
|
||||
return x;
|
||||
}
|
||||
|
||||
interface TimeSeriesChartSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -146,6 +214,7 @@ interface TimeSeriesChartProps {
|
|||
formatLeftValue?: (value: number) => string;
|
||||
formatRightValue?: (value: number) => string;
|
||||
tooltipTitle?: string;
|
||||
yScaleType?: 'linear' | 'log';
|
||||
}
|
||||
|
||||
interface ChartLegendProps {
|
||||
|
|
@ -183,6 +252,7 @@ export function ChartLegend(props: ChartLegendProps) {
|
|||
type ParsedTimeSeriesDatum = TimeSeriesDatum & { parsedDate: Date };
|
||||
type ParsedComboDatum = { date: string; lineValue: number; barValue: number; parsedDate: Date };
|
||||
type ParsedBoxPlotDatum = { date: string; min: number; q1: number; median: number; q3: number; max: number; parsedDate: Date };
|
||||
type HistogramBin = { bin_start: number; bin_end: number; count: number };
|
||||
|
||||
export function TimeSeriesChart(props: TimeSeriesChartProps) {
|
||||
const env = createChartEnvironment();
|
||||
|
|
@ -218,32 +288,82 @@ export function TimeSeriesChart(props: TimeSeriesChartProps) {
|
|||
const leftSeries = createMemo(() => visibleSeries().filter((series) => series.axis !== 'right'));
|
||||
const rightSeries = createMemo(() => visibleSeries().filter((series) => series.axis === 'right'));
|
||||
|
||||
const isLogScale = () => props.yScaleType === 'log';
|
||||
|
||||
const leftScale = createMemo(() => {
|
||||
const maxValue =
|
||||
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
|
||||
d3.max(leftSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
) ?? 0;
|
||||
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
const values = points().flatMap((point: ParsedTimeSeriesDatum) =>
|
||||
leftSeries().map((series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
);
|
||||
const maxValue = d3.max(values) ?? 0;
|
||||
const range = [dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop];
|
||||
|
||||
if (isLogScale()) {
|
||||
const positiveValues = values.filter((v) => v > 0);
|
||||
const minLog = positiveValues.length > 0 ? d3.min(positiveValues) ?? 1 : 1;
|
||||
const maxLog = maxValue === 0 ? 10 : maxValue * 1.1;
|
||||
return d3.scaleLog().domain([minLog, maxLog]).range(range);
|
||||
}
|
||||
|
||||
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range(range);
|
||||
});
|
||||
|
||||
const rightScale = createMemo(() => {
|
||||
const maxValue =
|
||||
d3.max(points(), (point: ParsedTimeSeriesDatum) =>
|
||||
d3.max(rightSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
) ?? 0;
|
||||
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
const values = points().flatMap((point: ParsedTimeSeriesDatum) =>
|
||||
rightSeries().map((series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
);
|
||||
const maxValue = d3.max(values) ?? 0;
|
||||
const range = [dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop];
|
||||
|
||||
if (isLogScale()) {
|
||||
const positiveValues = values.filter((v) => v > 0);
|
||||
const minLog = positiveValues.length > 0 ? d3.min(positiveValues) ?? 1 : 1;
|
||||
const maxLog = maxValue === 0 ? 10 : maxValue * 1.1;
|
||||
return d3.scaleLog().domain([minLog, maxLog]).range(range);
|
||||
}
|
||||
|
||||
return d3.scaleLinear().domain([0, maxValue === 0 ? 1 : maxValue * 1.1]).nice().range(range);
|
||||
});
|
||||
|
||||
const leftTicks = createMemo(() => leftScale().ticks(4));
|
||||
const rightTicks = createMemo(() => rightSeries().length > 0 ? rightScale().ticks(4) : []);
|
||||
const leftTicks = createMemo(() => {
|
||||
if (isLogScale()) {
|
||||
const maxValue = d3.max(points(), (point: ParsedTimeSeriesDatum) =>
|
||||
d3.max(leftSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
) ?? 0;
|
||||
return getSymlogTicks(maxValue, 8).filter((tick) => tick > 0);
|
||||
}
|
||||
return leftScale().ticks(4);
|
||||
});
|
||||
const rightTicks = createMemo(() => {
|
||||
if (rightSeries().length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isLogScale()) {
|
||||
const maxValue = d3.max(points(), (point: ParsedTimeSeriesDatum) =>
|
||||
d3.max(rightSeries(), (series: TimeSeriesChartSeries) => Number(point[series.key] ?? 0))
|
||||
) ?? 0;
|
||||
return getSymlogTicks(maxValue, 8).filter((tick) => tick > 0);
|
||||
}
|
||||
return rightScale().ticks(4);
|
||||
});
|
||||
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 120))));
|
||||
const dateTicks = createMemo(() => getDateTicks(points().map((point) => point.parsedDate), getInnerWidth(dimensions())));
|
||||
|
||||
const linePath = (series: TimeSeriesChartSeries) =>
|
||||
d3.line()
|
||||
.defined((point: ParsedTimeSeriesDatum) => typeof point[series.key] === 'number')
|
||||
const linePath = (series: TimeSeriesChartSeries) => {
|
||||
const scale = series.axis === 'right' ? rightScale() : leftScale();
|
||||
return d3.line()
|
||||
.defined((point: ParsedTimeSeriesDatum) => {
|
||||
const value = Number(point[series.key] ?? 0);
|
||||
if (!Number.isFinite(value)) {
|
||||
return false;
|
||||
}
|
||||
if (isLogScale() && value <= 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.x((point: ParsedTimeSeriesDatum) => xScale()(point.parsedDate))
|
||||
.y((point: ParsedTimeSeriesDatum) => (series.axis === 'right' ? rightScale() : leftScale())(Number(point[series.key] ?? 0)))(points()) ?? '';
|
||||
.y((point: ParsedTimeSeriesDatum) => scale(Number(point[series.key] ?? 0)))(points()) ?? '';
|
||||
};
|
||||
|
||||
const hoveredPoint = createMemo(() => {
|
||||
const index = hoverIndex();
|
||||
|
|
@ -257,7 +377,15 @@ export function TimeSeriesChart(props: TimeSeriesChartProps) {
|
|||
...series,
|
||||
value: Number(hoveredPoint()?.[series.key] ?? 0),
|
||||
}))
|
||||
.filter((series) => Number.isFinite(series.value))
|
||||
.filter((series) => {
|
||||
if (!Number.isFinite(series.value)) {
|
||||
return false;
|
||||
}
|
||||
if (isLogScale() && series.value <= 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
: []
|
||||
);
|
||||
|
||||
|
|
@ -279,9 +407,7 @@ export function TimeSeriesChart(props: TimeSeriesChartProps) {
|
|||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const hoveredDate = xScale().invert(offsetX);
|
||||
const hoveredDate = xScale().invert(getSvgPointerX(event));
|
||||
const nearestIndex = d3.leastIndex(points(), (point: ParsedTimeSeriesDatum) => Math.abs(point.parsedDate.getTime() - hoveredDate.getTime()));
|
||||
setHoverIndex(nearestIndex ?? null);
|
||||
};
|
||||
|
|
@ -486,9 +612,7 @@ export function ComboChart(props: ComboChartProps) {
|
|||
});
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const hoveredDate = xScale().invert(offsetX);
|
||||
const hoveredDate = xScale().invert(getSvgPointerX(event));
|
||||
const nearestIndex = d3.leastIndex(points(), (point: ParsedComboDatum) => Math.abs(point.parsedDate.getTime() - hoveredDate.getTime()));
|
||||
setHoverIndex(nearestIndex ?? null);
|
||||
};
|
||||
|
|
@ -628,34 +752,69 @@ export function ComboChart(props: ComboChartProps) {
|
|||
}
|
||||
|
||||
interface HistogramChartProps {
|
||||
data: Array<{ bin_start: number; bin_end: number; count: number }>;
|
||||
data: HistogramBin[];
|
||||
height?: number;
|
||||
xTickUnit?: string;
|
||||
yTickUnit?: string;
|
||||
}
|
||||
|
||||
export function HistogramChart(props: HistogramChartProps) {
|
||||
const env = createChartEnvironment();
|
||||
const [hoverIndex, setHoverIndex] = createSignal<number | null>(null);
|
||||
const theme = createMemo(() => {
|
||||
env.themeVersion();
|
||||
return readChartTheme();
|
||||
});
|
||||
const dimensions = createMemo(() => buildChartDimensions(env.width(), props.height ?? 200, 20));
|
||||
const xDomain = createMemo(() => {
|
||||
const min = d3.min(props.data, (bin: HistogramBin) => bin.bin_start) ?? 0;
|
||||
const max = d3.max(props.data, (bin: HistogramBin) => bin.bin_end) ?? 1;
|
||||
return {
|
||||
min: Math.max(0, min),
|
||||
max,
|
||||
};
|
||||
});
|
||||
const xScale = createMemo(() => {
|
||||
const min = d3.min(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_start) ?? 0;
|
||||
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.bin_end) ?? 1;
|
||||
return d3.scaleLinear().domain([min, max]).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
|
||||
const domain = xDomain();
|
||||
return d3.scaleSymlog().domain([domain.min, getSymlogMax(domain.max)]).range([dimensions().marginLeft, dimensions().marginLeft + getInnerWidth(dimensions())]);
|
||||
});
|
||||
const yScale = createMemo(() => {
|
||||
const max = d3.max(props.data, (bin: { bin_start: number; bin_end: number; count: number }) => bin.count) ?? 0;
|
||||
return d3.scaleLinear().domain([0, max === 0 ? 1 : max * 1.1]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
const max = d3.max(props.data, (bin: HistogramBin) => bin.count) ?? 0;
|
||||
return d3.scaleSymlog().domain([0, getSymlogMax(max)]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
});
|
||||
const xTicks = createMemo(() => xScale().ticks(Math.max(2, Math.floor(getInnerWidth(dimensions()) / 100))));
|
||||
const yTicks = createMemo(() => getSymlogTicks(d3.max(props.data, (bin: HistogramBin) => bin.count) ?? 0));
|
||||
const xTicks = createMemo(() => {
|
||||
const domain = xDomain();
|
||||
return getSymlogTicksInRange(domain.min, domain.max);
|
||||
});
|
||||
const hoveredBin = createMemo(() => {
|
||||
const index = hoverIndex();
|
||||
return index === null ? null : props.data[index] ?? null;
|
||||
});
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const x = getSvgPointerX(event);
|
||||
const hoveredValue = xScale().invert(x);
|
||||
const index = props.data.findIndex((bin) => hoveredValue >= bin.bin_start && hoveredValue <= bin.bin_end);
|
||||
|
||||
if (index >= 0) {
|
||||
setHoverIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
const nearestIndex = d3.leastIndex(props.data, (bin: HistogramBin) => {
|
||||
const center = (xScale()(bin.bin_start) + xScale()(bin.bin_end)) / 2;
|
||||
return Math.abs(center - x);
|
||||
});
|
||||
setHoverIndex(nearestIndex ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="ui-chart">
|
||||
<div class="ui-chart__frame" ref={env.rootRef}>
|
||||
<Show when={env.width() > 0 && props.data.length > 0} fallback={<div class="ui-chart__empty">No histogram data available.</div>}>
|
||||
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Histogram">
|
||||
<For each={yScale().ticks(4)}>
|
||||
<For each={yTicks()}>
|
||||
{(tick) => (
|
||||
<>
|
||||
<line
|
||||
|
|
@ -667,7 +826,7 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
stroke-dasharray="3 4"
|
||||
/>
|
||||
<text x={dimensions().marginLeft - 8} y={yScale()(tick)} fill={theme().textMuted} text-anchor="end" dominant-baseline="middle" class="ui-chart__tick">
|
||||
{formatCompactNumber(tick)}
|
||||
{formatNumberWithUnit(tick, props.yTickUnit)}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -681,7 +840,9 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
width={Math.max(2, xScale()(bin.bin_end) - xScale()(bin.bin_start) - 2)}
|
||||
height={dimensions().marginTop + getInnerHeight(dimensions()) - yScale()(bin.count)}
|
||||
fill={theme().warning}
|
||||
opacity="0.8"
|
||||
opacity={hoveredBin() === bin ? '0.95' : '0.8'}
|
||||
stroke={hoveredBin() === bin ? theme().text : 'transparent'}
|
||||
stroke-width={hoveredBin() === bin ? '1.5' : '0'}
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -696,11 +857,40 @@ export function HistogramChart(props: HistogramChartProps) {
|
|||
text-anchor="middle"
|
||||
class="ui-chart__tick"
|
||||
>
|
||||
{formatCompactNumber(tick)}
|
||||
{formatNumberWithUnit(tick, props.xTickUnit)}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<rect
|
||||
x={dimensions().marginLeft}
|
||||
y={dimensions().marginTop}
|
||||
width={getInnerWidth(dimensions())}
|
||||
height={getInnerHeight(dimensions())}
|
||||
fill="transparent"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => setHoverIndex(null)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Show when={hoveredBin()}>
|
||||
{(bin) => (
|
||||
<div class="ui-chart__tooltip">
|
||||
<div class="ui-chart__tooltip-title">Completion tokens</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().warning }} />
|
||||
<span>Range</span>
|
||||
<strong>
|
||||
{formatNumberWithUnit(bin().bin_start, props.xTickUnit)} - {formatNumberWithUnit(bin().bin_end, props.xTickUnit)}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().accent }} />
|
||||
<span>Requests</span>
|
||||
<strong>{formatNumberWithUnit(bin().count, props.yTickUnit)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -714,6 +904,7 @@ interface BoxPlotChartProps {
|
|||
|
||||
export function BoxPlotChart(props: BoxPlotChartProps) {
|
||||
const env = createChartEnvironment();
|
||||
const [hoverIndex, setHoverIndex] = createSignal<number | null>(null);
|
||||
const theme = createMemo(() => {
|
||||
env.themeVersion();
|
||||
return readChartTheme();
|
||||
|
|
@ -731,15 +922,29 @@ export function BoxPlotChart(props: BoxPlotChartProps) {
|
|||
});
|
||||
const yScale = createMemo(() => {
|
||||
const max = d3.max(points(), (point: ParsedBoxPlotDatum) => point.max) ?? 0;
|
||||
return d3.scaleLinear().domain([0, max === 0 ? 1 : max * 1.1]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
return d3.scaleSymlog().domain([0, getSymlogMax(max)]).range([dimensions().marginTop + getInnerHeight(dimensions()), dimensions().marginTop]);
|
||||
});
|
||||
const yTicks = createMemo(() => getSymlogTicks(d3.max(points(), (point: ParsedBoxPlotDatum) => point.max) ?? 0));
|
||||
const hoveredPoint = createMemo(() => {
|
||||
const index = hoverIndex();
|
||||
return index === null ? null : points()[index] ?? null;
|
||||
});
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const x = getSvgPointerX(event);
|
||||
const nearestIndex = d3.leastIndex(points(), (point: ParsedBoxPlotDatum) => {
|
||||
const center = (xScale()(point.date) ?? dimensions().marginLeft) + xScale().bandwidth() / 2;
|
||||
return Math.abs(center - x);
|
||||
});
|
||||
setHoverIndex(nearestIndex ?? null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="ui-chart">
|
||||
<div class="ui-chart__frame" ref={env.rootRef}>
|
||||
<Show when={env.width() > 0 && points().length > 0} fallback={<div class="ui-chart__empty">No box plot data available.</div>}>
|
||||
<svg viewBox={`0 0 ${dimensions().width} ${dimensions().height}`} class="ui-chart__svg" role="img" aria-label="Box plot chart">
|
||||
<For each={yScale().ticks(4)}>
|
||||
<For each={yTicks()}>
|
||||
{(tick) => (
|
||||
<>
|
||||
<line
|
||||
|
|
@ -769,8 +974,9 @@ export function BoxPlotChart(props: BoxPlotChartProps) {
|
|||
width={xScale().bandwidth()}
|
||||
height={Math.max(2, yScale()(point.q1) - yScale()(point.q3))}
|
||||
fill={theme().accent}
|
||||
opacity="0.25"
|
||||
opacity={hoveredPoint() === point ? '0.4' : '0.25'}
|
||||
stroke={theme().accent}
|
||||
stroke-width={hoveredPoint() === point ? '2' : '1'}
|
||||
/>
|
||||
<line x1={xScale()(point.date)} x2={(xScale()(point.date) ?? 0) + xScale().bandwidth()} y1={yScale()(point.median)} y2={yScale()(point.median)} stroke={theme().accent} stroke-width="2" />
|
||||
<text
|
||||
|
|
@ -786,7 +992,61 @@ export function BoxPlotChart(props: BoxPlotChartProps) {
|
|||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={hoveredPoint()}>
|
||||
{(point) => (
|
||||
<line
|
||||
x1={(xScale()(point().date) ?? dimensions().marginLeft) + xScale().bandwidth() / 2}
|
||||
x2={(xScale()(point().date) ?? dimensions().marginLeft) + xScale().bandwidth() / 2}
|
||||
y1={dimensions().marginTop}
|
||||
y2={dimensions().marginTop + getInnerHeight(dimensions())}
|
||||
stroke={theme().textMuted}
|
||||
stroke-dasharray="4 4"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<rect
|
||||
x={dimensions().marginLeft}
|
||||
y={dimensions().marginTop}
|
||||
width={getInnerWidth(dimensions())}
|
||||
height={getInnerHeight(dimensions())}
|
||||
fill="transparent"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => setHoverIndex(null)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Show when={hoveredPoint()}>
|
||||
{(point) => (
|
||||
<div class="ui-chart__tooltip">
|
||||
<div class="ui-chart__tooltip-title">{formatDate(point().parsedDate)}</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().textMuted }} />
|
||||
<span>Max</span>
|
||||
<strong>{formatNumberWithUnit(point().max, 'tok')}</strong>
|
||||
</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().accent }} />
|
||||
<span>Q3</span>
|
||||
<strong>{formatNumberWithUnit(point().q3, 'tok')}</strong>
|
||||
</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().accent }} />
|
||||
<span>Median</span>
|
||||
<strong>{formatNumberWithUnit(point().median, 'tok')}</strong>
|
||||
</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().accent }} />
|
||||
<span>Q1</span>
|
||||
<strong>{formatNumberWithUnit(point().q1, 'tok')}</strong>
|
||||
</div>
|
||||
<div class="ui-chart__tooltip-row">
|
||||
<span class="ui-chart__legend-swatch" style={{ background: theme().textMuted }} />
|
||||
<span>Min</span>
|
||||
<strong>{formatNumberWithUnit(point().min, 'tok')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { MetaCluster } from './MetaCluster';
|
|||
import { StatusBadge, type StatusTone } from './StatusBadge';
|
||||
|
||||
type KnownChatRole = 'system' | 'user' | 'assistant';
|
||||
const COMPACT_CHAT_STREAM_FORMAT = 'kyush.chat_stream.compact.v1';
|
||||
const RAW_CHAT_STREAM_FORMAT = 'kyush.chat_stream.raw.v1';
|
||||
|
||||
interface ParsedMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
toolCalls?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +20,74 @@ interface ConversationTimelineProps {
|
|||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
interface ParsedStreamResponse {
|
||||
messages: ParsedMessage[];
|
||||
model?: string;
|
||||
created?: number;
|
||||
usage?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StreamChoiceState {
|
||||
index: number;
|
||||
role?: string;
|
||||
content: string[];
|
||||
reasoning: string[];
|
||||
toolCalls: Map<number, StreamToolCallState>;
|
||||
finishReason?: string;
|
||||
stopReason?: string;
|
||||
}
|
||||
|
||||
interface StreamToolCallState {
|
||||
index: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringifyValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function hasMeaningfulToolCall(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === 'string') return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.some((item) => hasMeaningfulToolCall(item));
|
||||
if (isRecord(value)) {
|
||||
return Object.entries(value).some(([key, item]) => key !== 'index' && hasMeaningfulToolCall(item));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeToolCalls(value: unknown): string | undefined {
|
||||
if (!hasMeaningfulToolCall(value)) return undefined;
|
||||
|
||||
const rendered = prettyJson(value).trim();
|
||||
if (!rendered || rendered === '[]' || rendered === '{}') return undefined;
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function normalizePayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
|
||||
|
|
@ -31,6 +103,49 @@ function normalizePayload(value: unknown): Record<string, unknown> | null {
|
|||
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function normalizeCompactStreamResponse(payload: Record<string, unknown> | null): ParsedStreamResponse | null {
|
||||
const compactPayload = payload?.format === RAW_CHAT_STREAM_FORMAT && isRecord(payload.compact)
|
||||
? payload.compact
|
||||
: payload;
|
||||
|
||||
if (compactPayload?.format !== COMPACT_CHAT_STREAM_FORMAT) return null;
|
||||
|
||||
const rawChoices = compactPayload.choices;
|
||||
const messages = Array.isArray(rawChoices)
|
||||
? rawChoices
|
||||
.filter((choice): choice is Record<string, unknown> => isRecord(choice))
|
||||
.map((choice) => {
|
||||
const metadata = [
|
||||
choice.finish_reason !== undefined && choice.finish_reason !== null
|
||||
? { key: 'Finish', value: String(choice.finish_reason) }
|
||||
: null,
|
||||
choice.stop_reason !== undefined && choice.stop_reason !== null
|
||||
? { key: 'Stop Reason', value: String(choice.stop_reason) }
|
||||
: null,
|
||||
choice.matched_stop !== undefined && choice.matched_stop !== null
|
||||
? { key: 'Matched Stop', value: String(choice.matched_stop) }
|
||||
: null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
|
||||
return {
|
||||
role: typeof choice.role === 'string' ? choice.role : 'assistant',
|
||||
content: stringifyValue(choice.content),
|
||||
reasoning: stringifyValue(choice.reasoning).trim() || undefined,
|
||||
toolCalls: normalizeToolCalls(choice.tool_calls),
|
||||
metadata,
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content || message.reasoning || message.toolCalls || (message.metadata?.length ?? 0) > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
messages,
|
||||
model: typeof compactPayload.model === 'string' ? compactPayload.model : undefined,
|
||||
created: typeof compactPayload.created === 'number' ? compactPayload.created : undefined,
|
||||
usage: isRecord(compactPayload.usage) ? compactPayload.usage : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
|
||||
const messages = payload?.messages;
|
||||
if (!Array.isArray(messages)) return [];
|
||||
|
|
@ -39,7 +154,7 @@ function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessa
|
|||
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
role: typeof item.role === 'string' ? item.role : 'unknown',
|
||||
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
|
||||
content: stringifyValue(item.content),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -52,25 +167,11 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
|
|||
const message = (choice as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
|
||||
const content = typeof (message as Record<string, unknown>).content === 'string'
|
||||
? String((message as Record<string, unknown>).content)
|
||||
: JSON.stringify((message as Record<string, unknown>).content ?? '');
|
||||
const messageRecord = message as Record<string, unknown>;
|
||||
const content = stringifyValue(messageRecord.content);
|
||||
const reasoning = stringifyValue(messageRecord.reasoning_content ?? messageRecord.reasoning).trim();
|
||||
const toolCalls = normalizeToolCalls(messageRecord.tool_calls);
|
||||
const metadata = [
|
||||
(message as Record<string, unknown>).reasoning_content !== undefined
|
||||
? {
|
||||
key: 'Reasoning',
|
||||
value:
|
||||
typeof (message as Record<string, unknown>).reasoning_content === 'string'
|
||||
? String((message as Record<string, unknown>).reasoning_content)
|
||||
: JSON.stringify((message as Record<string, unknown>).reasoning_content),
|
||||
}
|
||||
: null,
|
||||
(message as Record<string, unknown>).tool_calls !== undefined
|
||||
? {
|
||||
key: 'Tool Calls',
|
||||
value: JSON.stringify((message as Record<string, unknown>).tool_calls),
|
||||
}
|
||||
: null,
|
||||
(choice as Record<string, unknown>).finish_reason !== undefined
|
||||
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
|
||||
: null,
|
||||
|
|
@ -85,6 +186,8 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
|
|||
return {
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
reasoning: reasoning || undefined,
|
||||
toolCalls,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
|
@ -92,9 +195,187 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
|
|||
return messages.filter((message): message is ParsedMessage => message !== null);
|
||||
}
|
||||
|
||||
function extractSseJsonPayloads(value: string): Record<string, unknown>[] {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const lines = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||||
let dataLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (dataLines.length === 0) return;
|
||||
const data = dataLines.join('\n');
|
||||
dataLines = [];
|
||||
|
||||
if (data.trim() === '[DONE]') return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (isRecord(parsed)) payloads.push(parsed);
|
||||
} catch {
|
||||
// Ignore non-JSON SSE data frames.
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return payloads;
|
||||
}
|
||||
|
||||
function mergeToolCall(target: StreamToolCallState, chunk: Record<string, unknown>): void {
|
||||
if (typeof chunk.id === 'string') target.id = chunk.id;
|
||||
if (typeof chunk.type === 'string') target.type = chunk.type;
|
||||
|
||||
const functionChunk = chunk.function;
|
||||
if (!isRecord(functionChunk)) return;
|
||||
|
||||
target.function = target.function ?? {};
|
||||
if (typeof functionChunk.name === 'string') {
|
||||
target.function.name = `${target.function.name ?? ''}${functionChunk.name}`;
|
||||
}
|
||||
if (typeof functionChunk.arguments === 'string') {
|
||||
target.function.arguments = `${target.function.arguments ?? ''}${functionChunk.arguments}`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
|
||||
if (typeof value !== 'string' || !value.includes('data:')) return null;
|
||||
|
||||
const payloads = extractSseJsonPayloads(value);
|
||||
if (payloads.length === 0) return null;
|
||||
|
||||
const choices = new Map<number, StreamChoiceState>();
|
||||
let model: string | undefined;
|
||||
let created: number | undefined;
|
||||
let usage: Record<string, unknown> | undefined;
|
||||
|
||||
const getChoice = (index: number) => {
|
||||
const existing = choices.get(index);
|
||||
if (existing) return existing;
|
||||
|
||||
const createdChoice: StreamChoiceState = {
|
||||
index,
|
||||
content: [],
|
||||
reasoning: [],
|
||||
toolCalls: new Map(),
|
||||
};
|
||||
choices.set(index, createdChoice);
|
||||
return createdChoice;
|
||||
};
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload.model === 'string' && !model) model = payload.model;
|
||||
if (typeof payload.created === 'number' && created === undefined) created = payload.created;
|
||||
if (isRecord(payload.usage)) usage = payload.usage;
|
||||
|
||||
if (!Array.isArray(payload.choices)) continue;
|
||||
|
||||
for (const rawChoice of payload.choices) {
|
||||
if (!isRecord(rawChoice)) continue;
|
||||
|
||||
const index = typeof rawChoice.index === 'number' ? rawChoice.index : 0;
|
||||
const choice = getChoice(index);
|
||||
const delta = rawChoice.delta;
|
||||
|
||||
if (isRecord(delta)) {
|
||||
if (typeof delta.role === 'string') choice.role = delta.role;
|
||||
if (typeof delta.content === 'string') choice.content.push(delta.content);
|
||||
const reasoning = typeof delta.reasoning_content === 'string'
|
||||
? delta.reasoning_content
|
||||
: typeof delta.reasoning === 'string'
|
||||
? delta.reasoning
|
||||
: undefined;
|
||||
if (reasoning) choice.reasoning.push(reasoning);
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const rawToolCall of delta.tool_calls) {
|
||||
if (!isRecord(rawToolCall)) continue;
|
||||
const toolIndex = typeof rawToolCall.index === 'number' ? rawToolCall.index : choice.toolCalls.size;
|
||||
const existing = choice.toolCalls.get(toolIndex) ?? { index: toolIndex };
|
||||
mergeToolCall(existing, rawToolCall);
|
||||
choice.toolCalls.set(toolIndex, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawChoice.finish_reason !== undefined && rawChoice.finish_reason !== null) {
|
||||
choice.finishReason = String(rawChoice.finish_reason);
|
||||
}
|
||||
if (rawChoice.stop_reason !== undefined && rawChoice.stop_reason !== null) {
|
||||
choice.stopReason = String(rawChoice.stop_reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messages = [...choices.values()]
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((choice) => {
|
||||
const toolCalls = [...choice.toolCalls.values()].sort((left, right) => left.index - right.index);
|
||||
const metadata = [
|
||||
choice.finishReason ? { key: 'Finish', value: choice.finishReason } : null,
|
||||
choice.stopReason ? { key: 'Stop Reason', value: choice.stopReason } : null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
|
||||
return {
|
||||
role: choice.role ?? 'assistant',
|
||||
content: choice.content.join(''),
|
||||
reasoning: choice.reasoning.join('') || undefined,
|
||||
toolCalls: normalizeToolCalls(toolCalls),
|
||||
metadata,
|
||||
};
|
||||
})
|
||||
.filter((message) => message.content || message.reasoning || message.toolCalls || (message.metadata?.length ?? 0) > 0);
|
||||
|
||||
return {
|
||||
messages,
|
||||
model,
|
||||
created,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantMessages(responseBody?: unknown): ParsedMessage[] {
|
||||
const payload = normalizePayload(responseBody);
|
||||
const compactStream = normalizeCompactStreamResponse(payload);
|
||||
if (compactStream) return compactStream.messages;
|
||||
|
||||
const stream = parseStreamResponse(responseBody);
|
||||
if (stream) return stream.messages;
|
||||
return normalizeAssistantMessages(payload);
|
||||
}
|
||||
|
||||
export function extractAssistantConversationPreview(responseBody?: unknown): string {
|
||||
const assistantMessage = getAssistantMessages(responseBody).find((message) => (
|
||||
message.content.trim() || message.reasoning?.trim() || message.toolCalls?.trim()
|
||||
));
|
||||
|
||||
if (!assistantMessage) return '-';
|
||||
|
||||
const source = assistantMessage.content.trim()
|
||||
? assistantMessage.content
|
||||
: assistantMessage.reasoning
|
||||
? `Thinking: ${assistantMessage.reasoning}`
|
||||
: `Tool Calls: ${assistantMessage.toolCalls ?? ''}`;
|
||||
const normalized = source
|
||||
.replace(/\r/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!normalized) return '-';
|
||||
return normalized.length > 50 ? `${normalized.slice(0, 50)}...` : normalized;
|
||||
}
|
||||
|
||||
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
|
||||
const requestMessages = normalizeMessages(normalizePayload(requestBody));
|
||||
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
|
||||
const responseMessages = getAssistantMessages(responseBody);
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
}
|
||||
|
||||
|
|
@ -121,23 +402,29 @@ function getRoleClass(role: string): string {
|
|||
export function ConversationTimeline(props: ConversationTimelineProps) {
|
||||
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
|
||||
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
|
||||
const parsedCompactStreamResponse = createMemo(() => normalizeCompactStreamResponse(parsedResponse()));
|
||||
const parsedStreamResponse = createMemo(() => parseStreamResponse(props.responseBody));
|
||||
|
||||
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
|
||||
const responseMessages = createMemo(() => normalizeAssistantMessages(parsedResponse()));
|
||||
const responseMessages = createMemo(() => parsedCompactStreamResponse()?.messages ?? parsedStreamResponse()?.messages ?? normalizeAssistantMessages(parsedResponse()));
|
||||
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const request = parsedRequest();
|
||||
const response = parsedResponse();
|
||||
const stream = parsedCompactStreamResponse() ?? parsedStreamResponse();
|
||||
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
|
||||
const responseUsage = usage ?? stream?.usage ?? null;
|
||||
|
||||
return [
|
||||
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
|
||||
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
|
||||
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
|
||||
usage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(usage.prompt_tokens) } : null,
|
||||
usage?.completion_tokens !== undefined ? { key: 'Completion', value: String(usage.completion_tokens) } : null,
|
||||
usage?.total_tokens !== undefined ? { key: 'Total', value: String(usage.total_tokens) } : null,
|
||||
typeof stream?.model === 'string' && stream.model !== request?.model ? { key: 'Response Model', value: stream.model } : null,
|
||||
typeof response?.created === 'number' ? { key: 'Res. Created', value: String(response.created) } : null,
|
||||
// typeof stream?.created === 'number' ? { key: 'Stream Created', value: String(stream.created) } : null,
|
||||
responseUsage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(responseUsage.prompt_tokens) } : null,
|
||||
responseUsage?.completion_tokens !== undefined ? { key: 'Completion', value: String(responseUsage.completion_tokens) } : null,
|
||||
responseUsage?.total_tokens !== undefined ? { key: 'Total', value: String(responseUsage.total_tokens) } : null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
});
|
||||
|
||||
|
|
@ -160,7 +447,26 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
|
|||
<span class="ui-conversation__turn-index">Turn {index() + 1}</span>
|
||||
</header>
|
||||
<div class="ui-conversation__bubble">
|
||||
<pre class="ui-conversation__content">{message.content}</pre>
|
||||
<Show when={message.reasoning}>
|
||||
<section class="ui-conversation__block ui-conversation__block--reasoning">
|
||||
<div class="ui-conversation__block-label">Thinking</div>
|
||||
<pre class="ui-conversation__content">{message.reasoning}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={message.content.trim().length > 0 || (!message.reasoning && !message.toolCalls)}>
|
||||
<section class="ui-conversation__block">
|
||||
<Show when={message.reasoning}>
|
||||
<div class="ui-conversation__block-label">Response</div>
|
||||
</Show>
|
||||
<pre class="ui-conversation__content">{message.content}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={message.toolCalls}>
|
||||
<section class="ui-conversation__block ui-conversation__block--tool-calls">
|
||||
<div class="ui-conversation__block-label">Tool Calls</div>
|
||||
<pre class="ui-conversation__content">{message.toolCalls}</pre>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={message.metadata && message.metadata.length > 0}>
|
||||
<div class="ui-conversation__meta">
|
||||
<MetaCluster items={message.metadata!} />
|
||||
|
|
|
|||
|
|
@ -86,3 +86,10 @@ select {
|
|||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.ui-divider--vertical {
|
||||
width: 1px;
|
||||
height: 1.5em;
|
||||
background: var(--color-border);
|
||||
align-self: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,6 +464,8 @@
|
|||
}
|
||||
|
||||
.ui-conversation__bubble {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
|
|
@ -498,8 +500,29 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-conversation__block {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-conversation__block--reasoning,
|
||||
.ui-conversation__block--tool-calls {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-inset);
|
||||
}
|
||||
|
||||
.ui-conversation__block-label {
|
||||
color: var(--color-text-soft);
|
||||
font-size: var(--text-1);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.ui-conversation__meta {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
|
@ -623,6 +646,18 @@
|
|||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dashboard__refresh-button {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.detail-logs__refresh-button {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.analytics__refresh-button {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.analytics__grid--wide {
|
||||
grid-template-columns: 1.5fr minmax(340px, 1fr);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-button--loading svg {
|
||||
animation: ui-button-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.ui-field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
|
|
@ -445,3 +449,15 @@
|
|||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-button--loading svg {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ui-button-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,5 +28,8 @@ CREATE TABLE IF NOT EXISTS backend_metrics (
|
|||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date);
|
||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_date ON backend_metrics(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ CREATE TABLE IF NOT EXISTS request_logs (
|
|||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date ON request_logs(local_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_detail_logged ON request_logs(detail_logged);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
email TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
detail_logging INTEGER NOT NULL DEFAULT 0,
|
||||
copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@
|
|||
|
||||
- 모델 추이의 모델 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
|
||||
- response length 계열 시각화는 `completion_tokens` 값이 있는 요청만 집계한다.
|
||||
- response length 계열 시각화는 긴 꼬리 분포를 읽기 쉽도록 로그 계열 스케일을 사용한다.
|
||||
- 상세 요청 단위의 latency/body 확인은 계속 `DetailLogs` 화면에서 담당한다.
|
||||
|
|
|
|||
17
docs/api.md
17
docs/api.md
|
|
@ -23,9 +23,12 @@
|
|||
`/v1/**`는 기존 사용자 API 키 인증을 유지하며 관리자 인증과 분리된다.
|
||||
|
||||
추가 동작:
|
||||
- `/v1/chat/completions` 는 요청 모델명을 먼저 전역 rewrite 규칙으로 해석한 뒤, 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 사용한다
|
||||
- `force=true` rewrite 는 항상 적용된다
|
||||
- `force=false` rewrite 는 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용된다
|
||||
- `/v1/chat/completions` 는 요청 모델명을 먼저 전역 rewrite 체인으로 해석한 뒤, 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 사용한다
|
||||
- 사용자 옵션 `copy_reasoning_to_reasoning_content` 가 켜져 있으면 chat completion 응답의 `reasoning` 필드를 `reasoning_content` 로 추가 복제한다. streaming/non-stream 모두 적용되며 기존 `reasoning_content` 는 덮어쓰지 않는다
|
||||
- `force=true` rewrite 는 항상 적용되고 target 모델의 다음 규칙까지 계속 평가한다
|
||||
- `force=false` rewrite 는 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용되고 target 모델의 다음 규칙까지 계속 평가한다
|
||||
- `/v1/models` 는 native backend 모델뿐 아니라 현재 사용자 권한에서 최종 후보가 있는 rewrite source alias도 함께 반환한다
|
||||
- `MODEL_LIST_INCLUDE_ROUTING_METADATA=1|true|yes|on` 이면 `/v1/models` 의 각 model object에 비표준 `kyush_router` metadata를 추가한다. 이 metadata는 `requested_model`, `routed_model`, `was_rewritten`, `rule_type`, `rewrite_path` 를 포함한다
|
||||
- 최종 후보가 없으면 모델 미지원 오류를 반환하고 `request_model`, `routed_model` 을 함께 내려준다
|
||||
|
||||
## Admin API
|
||||
|
|
@ -52,9 +55,9 @@
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/admin/users` | 전체 사용자 목록 |
|
||||
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록) |
|
||||
| POST | `/admin/users` | 사용자 생성 (`api_key` 생략 시 자동 발급, 지정 시 수동 등록, copy_reasoning_to_reasoning_content 선택 가능) |
|
||||
| GET | `/admin/users/:id` | 사용자 조회 |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging) |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, api_key, is_active, detail_logging, copy_reasoning_to_reasoning_content) |
|
||||
| DELETE | `/admin/users/:id` | 사용자 삭제 |
|
||||
| POST | `/admin/users/:id/regenerate-api-key` | API 키 재발급 |
|
||||
|
||||
|
|
@ -80,6 +83,8 @@
|
|||
| PUT | `/admin/model-rewrites/:id` | 전역 모델 rewrite 규칙 수정 |
|
||||
| DELETE | `/admin/model-rewrites/:id` | 전역 모델 rewrite 규칙 삭제 |
|
||||
|
||||
활성 rewrite 그래프에 cycle을 만드는 생성/수정 요청은 `409 { error, cycle }` 로 거부된다. 비활성 규칙끼리의 cycle은 저장할 수 있지만 활성화 시점에는 같은 검사를 통과해야 한다.
|
||||
|
||||
`GET /admin/backends/:id/models` 응답에는 아래가 함께 포함된다.
|
||||
- `backend`: 백엔드 기본 정보 + 캐시 요약
|
||||
- `cache`: 메모리 캐시 상태 (`ready`, `uninitialized`, `error`, `inactive`)
|
||||
|
|
@ -128,6 +133,8 @@
|
|||
|
||||
- `model-trends` 는 `response_model -> routed_model -> request_model -> unknown` 순서로 모델 키를 결정한다.
|
||||
- response length 계열 endpoint는 `completion_tokens` 가 있는 요청만 집계한다.
|
||||
- `response-length-histogram` 은 긴 꼬리 분포를 읽기 쉽도록 로그 간격 bin을 반환한다.
|
||||
- stream response body 저장 방식은 `DETAIL_STREAM_LOG_MODE=compact|raw|both|off` 로 제어한다. 기본값 `compact` 는 raw SSE를 저장하지 않고 누적된 thinking/content/tool call/usage JSON을 저장하며, 기존 raw SSE 로그는 관리자 UI에서 계속 파싱된다.
|
||||
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
|
||||
|
||||
### Dashboard Summary
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
|
|||
- 공통 필터는 기간(`7`, `30`, `90`일)과 backend 선택이다.
|
||||
- 상단 summary strip 뒤에 일별 volume, reliability, response time, model trends, response length 분포 패널이 배치된다.
|
||||
- 상세 raw request 확인은 계속 `DetailLogs` 화면이 담당한다.
|
||||
- `DetailLogs` 의 Conversation 탭은 non-stream completion JSON, 기존 raw SSE stream 문자열, 신규 compact stream JSON(`kyush.chat_stream.compact.v1`)을 모두 파싱한다.
|
||||
- 자세한 내용은 [docs/analytics.md](./analytics.md) 참고.
|
||||
|
||||
## Model Management UI
|
||||
|
|
@ -77,7 +78,13 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
|
|||
- `Backends` 화면은 백엔드별 모델 캐시 상태, 모델 수, 마지막 sync 상태를 표시한다
|
||||
- `Backends` 화면에서 활성 백엔드는 수동 refresh 와 캐시된 모델 목록 확인이 가능하다
|
||||
- 비활성 백엔드는 모델 조회를 시도하지 않으며 UI에서도 `Skipped` 상태로 표시된다
|
||||
- `Models` 화면은 전체 메모리 모델 카탈로그와 전역 모델 rewrite 규칙을 관리한다
|
||||
- `Models` 화면은 전체 메모리 모델 카탈로그와 전역 모델 rewrite 체인을 관리한다
|
||||
- rewrite 규칙은 2가지 모드를 가진다
|
||||
- `Force`: 원본 모델 사용 가능 여부와 관계없이 항상 target model 로 rewrite
|
||||
- `Fallback`: 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 rewrite
|
||||
- `Force`: 현재 모델 사용 가능 여부와 관계없이 항상 target model 로 이동하고 다음 규칙을 계속 평가
|
||||
- `Fallback`: 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 이동하고 다음 규칙을 계속 평가
|
||||
- 활성 rewrite cycle은 저장 시점에 거부되며, `/v1/models` 는 실제 요청 가능한 rewrite alias를 함께 반환한다
|
||||
|
||||
## User Reasoning Compatibility
|
||||
|
||||
- `Users` 화면은 API 키별 `Copy reasoning to reasoning_content` 옵션을 표시하고 편집한다
|
||||
- 이 옵션은 같은 백엔드를 공유하는 사용자라도 downstream 클라이언트 호환성에 맞춰 독립적으로 켜거나 끌 수 있다
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ DB는 `DB_DIR` 하위에 분리 저장된다.
|
|||
| email | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| copy_reasoning_to_reasoning_content | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
|
|
@ -215,7 +216,16 @@ Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
|
|||
| request_headers | TEXT | JSON 문자열 |
|
||||
| request_body | TEXT | JSON 또는 raw 문자열 |
|
||||
| response_headers | TEXT | JSON 문자열 |
|
||||
| response_body | TEXT | JSON 또는 raw 문자열 |
|
||||
| response_body | TEXT | JSON 또는 raw 문자열. stream 상세 로그는 기본적으로 `kyush.chat_stream.compact.v1` JSON으로 저장되며, `DETAIL_STREAM_LOG_MODE=raw` 인 경우 기존 raw SSE 문자열로 저장된다 |
|
||||
| created_at | TEXT | UTC ISO timestamp |
|
||||
|
||||
Indexes: `idx_request_logs_created_at`, `idx_request_logs_local_date`, `idx_request_logs_user`, `idx_request_logs_backend`, `idx_request_logs_endpoint`, `idx_request_logs_detail_logged`
|
||||
|
||||
### Stream response body formats
|
||||
|
||||
기존 월별 DB의 raw SSE 문자열은 계속 유효하다. 새 stream 로그는 `DETAIL_STREAM_LOG_MODE` 값에 따라 아래 형식 중 하나로 저장된다.
|
||||
|
||||
- `compact`(기본): `response_body` 가 `{"format":"kyush.chat_stream.compact.v1", ...}` JSON 문자열이다. 반복되는 chunk 공통 필드(`id`, `object`, `created`, `model`)는 한 번만 저장하고, `choices[].reasoning`, `choices[].content`, `choices[].tool_calls[].function.arguments` 는 누적 문자열로 저장한다.
|
||||
- `raw`: `response_body` 가 기존처럼 `data: ...\n\n` 형태의 raw SSE 문자열이다.
|
||||
- `both`: `response_body` 가 `{"format":"kyush.chat_stream.raw.v1","compact":...,"raw_sse":"..."}` JSON 문자열이다.
|
||||
- `off`: stream `response_body` 를 저장하지 않는다.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
1. 사용자 API 키 인증
|
||||
2. 사용자가 접근 가능한 backend id 목록 로드
|
||||
3. 접근 가능한 활성 백엔드 중 아직 메모리 카탈로그가 초기화되지 않은 백엔드만 `/v1/models` 로 lazy fetch
|
||||
4. 요청 `model` 에 대해 전역 `model_rewrites` 규칙 평가
|
||||
4. 요청 `model` 에 대해 전역 `model_rewrites` 체인을 끝까지 평가
|
||||
5. 최종 모델을 서빙하는 허용 가능한 활성 백엔드만 후보로 선택
|
||||
6. 후보 중 1개를 랜덤 선택 후 업스트림으로 포워딩
|
||||
|
||||
|
|
@ -17,7 +17,9 @@
|
|||
|
||||
1. 사용자 API 키 인증
|
||||
2. 접근 가능한 활성 백엔드의 메모리 카탈로그를 확인
|
||||
3. 모델 ID 합집합을 반환
|
||||
3. native backend 모델과 rewrite `source_model` alias를 같은 체인 해석기로 평가
|
||||
4. 최종 모델 후보가 있는 requestable 모델 ID 합집합을 반환
|
||||
5. `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 각 model object에 비표준 `kyush_router` routing metadata를 추가한다
|
||||
|
||||
## Caching Rules
|
||||
|
||||
|
|
@ -37,13 +39,24 @@
|
|||
|
||||
| Mode | Condition | Result |
|
||||
|------|-----------|--------|
|
||||
| `force=true` | 항상 | `source_model` 을 즉시 `target_model` 로 치환 |
|
||||
| `force=false` | 원본 모델 후보가 없을 때만 | `target_model` 을 fallback 으로 사용 |
|
||||
| `force=true` | 항상 | `source_model` 을 `target_model` 로 치환하고 다음 규칙을 계속 평가 |
|
||||
| `force=false` | 현재 모델 후보가 없을 때만 | `target_model` 을 fallback 으로 사용하고 다음 규칙을 계속 평가 |
|
||||
|
||||
해석 기준:
|
||||
- “원본 모델 후보가 있다”는 것은 사용자가 접근 가능하고 활성 상태이며, 메모리 카탈로그상 해당 모델을 서빙하는 백엔드가 하나 이상 있다는 뜻이다
|
||||
- 원본 모델 후보가 있으면 fallback 규칙은 무시된다
|
||||
- “현재 모델 후보가 있다”는 것은 사용자가 접근 가능하고 활성 상태이며, 메모리 카탈로그상 해당 모델을 서빙하는 백엔드가 하나 이상 있다는 뜻이다
|
||||
- 현재 모델 후보가 있으면 fallback 규칙은 무시되고 체인 평가가 멈춘다
|
||||
- force 규칙은 현재 모델 후보 존재 여부와 관계없이 target으로 이동한다
|
||||
- 최종 모델 후보가 없으면 라우터는 포워딩하지 않고 모델 미지원 오류를 반환한다
|
||||
- 활성 rewrite 그래프에 cycle이 생기는 관리자 생성/수정은 거부된다
|
||||
- 직접 DB 조작 등으로 runtime cycle이 발견되면 라우터는 설정 오류를 반환한다
|
||||
- 체인 평가는 요청별 allowed backend set과 candidate memo를 사용해 반복 DB 조회를 피한다
|
||||
- `/v1/models` 의 `kyush_router` metadata는 적용된 rewrite hop만 `rewrite_path` 에 담는다. 후보가 있어 fallback이 중단된 규칙은 path에 포함하지 않는다
|
||||
- `kyush_router` 는 public routing 설명용이며 backend id/name 같은 내부 라우팅 대상 정보는 포함하지 않는다
|
||||
|
||||
예시:
|
||||
`AutoModelTranslate -(Force)-> Qwen3.5 -(Force)-> Qwen/Qwen3.5-397B-A17B-FP8 -(Fallback)-> Gemma4 -(Force)-> cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit`
|
||||
|
||||
위 예시에서 `Qwen/Qwen3.5-397B-A17B-FP8` 후보가 있으면 fallback이 적용되지 않고 그 모델로 라우팅된다. 후보가 없으면 `Gemma4`로 이동한 뒤 force 규칙을 이어서 적용한다.
|
||||
|
||||
## Admin Surface
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ isolated-vm 기반 JavaScript 샌드박스에서 요청/응답을 조작하는
|
|||
|
||||
백엔드 응답 수신 후 실행. 현재 구현에서는 응답 컨텍스트를 검사하거나 로그/부가 처리를 수행하는 용도로 실행되며, 훅이 반환한 변경 내용이 최종 HTTP 응답에 다시 반영되지는 않는다.
|
||||
|
||||
참고: `reasoning` 을 `reasoning_content` 로 복제하는 OpenAI-compatible 호환 처리는 script hook이 아니라 사용자별 라우터 내장 옵션 `copy_reasoning_to_reasoning_content` 로 수행한다.
|
||||
|
||||
## Script Context
|
||||
|
||||
스크립트에서 접근 가능한 데이터:
|
||||
|
|
|
|||
|
|
@ -54,14 +54,28 @@ server/src/
|
|||
- `core.db` 에는 `admin_sessions`, `admin_api_tokens` 도 함께 저장된다
|
||||
- `core.db` 에는 `backend_models`, `model_rewrites` 도 저장된다
|
||||
- 시간 경계 계산은 `TZ` 기준이다
|
||||
- 상세 로그가 켜진 stream 응답은 `DETAIL_STREAM_LOG_MODE` 에 따라 저장된다
|
||||
- `compact`(기본): SSE chunk를 전달하면서 동시에 누적 파싱해 반복 필드를 제거한 `kyush.chat_stream.compact.v1` JSON을 `response_body`에 저장한다
|
||||
- `raw`: 기존 동작처럼 raw SSE 문자열 전체를 저장한다
|
||||
- `both`: compact JSON과 raw SSE를 함께 담은 `kyush.chat_stream.raw.v1` JSON을 저장한다
|
||||
- `off`: stream `response_body` 저장을 생략한다. request/response headers와 request body는 detail logging 설정에 따라 계속 저장된다
|
||||
|
||||
## Model Routing
|
||||
|
||||
- 요청 모델명은 먼저 전역 `model_rewrites` 규칙을 확인한다
|
||||
- `force=1` 규칙은 항상 `source_model -> target_model` 로 변환한다
|
||||
- `force=0` 규칙은 원본 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용한다
|
||||
- 요청 모델명은 먼저 전역 `model_rewrites` 체인을 확인한다
|
||||
- `force=1` 규칙은 항상 `source_model -> target_model` 로 변환하고 다음 규칙을 계속 확인한다
|
||||
- `force=0` 규칙은 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 fallback 으로 적용하고 다음 규칙을 계속 확인한다
|
||||
- 활성 rewrite cycle은 관리자 생성/수정 시 거부하고, runtime에서도 방어한다
|
||||
- 최종 모델을 서빙하는 허용 가능한 활성 백엔드가 없으면 `/v1/chat/completions` 는 모델 미지원 오류를 반환한다
|
||||
- `/v1/models` 는 허용 가능한 활성 백엔드들의 캐시된 모델 목록 합집합을 반환한다
|
||||
- `/v1/models` 는 허용 가능한 활성 백엔드들의 native 모델과 실제 요청 가능한 rewrite alias 합집합을 반환한다
|
||||
- `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 `/v1/models` 는 비표준 `kyush_router` metadata를 추가해 요청 모델, 최종 라우팅 모델, 적용된 rewrite path를 노출한다.
|
||||
|
||||
## Reasoning Compatibility
|
||||
|
||||
- 사용자별 `copy_reasoning_to_reasoning_content` 옵션이 켜져 있으면 `/v1/chat/completions` 응답에서 `reasoning` 을 `reasoning_content` 로 추가 복제한다
|
||||
- 같은 백엔드라도 API 키별로 옵션을 다르게 둘 수 있다
|
||||
- streaming 응답은 옵션이 켜진 경우에만 SSE JSON frame을 변환하고, 옵션이 꺼진 경우 기존처럼 원본 바이트를 전달한다
|
||||
- 이미 `reasoning_content` 가 있으면 덮어쓰지 않고 `reasoning` 원본도 유지한다
|
||||
|
||||
참고:
|
||||
- 세부 라우팅 규칙과 캐시 트리거는 [docs/model-routing.md](./model-routing.md) 참고
|
||||
|
|
@ -73,6 +87,7 @@ server/src/
|
|||
- `AnalyticsService` 는 `analytics.db` 의 일별 집계와 `request_logs_YYYY-MM.db` 의 범위 조회를 함께 사용해 시계열/분포 데이터를 만든다.
|
||||
- 모델 추이 키는 `response_model -> routed_model -> request_model -> unknown` 순서로 결정한다.
|
||||
- response length 계열 집계는 `completion_tokens` 가 있는 요청만 포함한다.
|
||||
- response length histogram은 긴 꼬리 분포를 위해 로그 간격 bin을 사용한다.
|
||||
- 자세한 화면/API 설명은 [docs/analytics.md](./analytics.md) 참고.
|
||||
|
||||
## Deployment Notes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kyush-llm-router",
|
||||
"version": "1.0",
|
||||
"name": "kyush-llm-router-express",
|
||||
"version": "1.0.10-express",
|
||||
"description": "LLM routing server with multi-user API key management",
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel dev",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ packages:
|
|||
- server
|
||||
- client
|
||||
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
isolated-vm: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@ import path from 'path';
|
|||
import fs from 'fs';
|
||||
import { ensureDir, getAnalyticsDbPath } from './db-paths';
|
||||
|
||||
function hasIndex(database: Database.Database, tableName: string, indexName: string): boolean {
|
||||
const result = database.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?`).get(tableName, indexName) as { name: string } | undefined;
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
function ensureAnalyticsIndexes(db: Database.Database): void {
|
||||
const indexes: Array<{ name: string; table: string; sql: string }> = [
|
||||
{ name: 'idx_usage_stats_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_backend_date ON usage_stats(backend_id, date)' },
|
||||
{ name: 'idx_usage_stats_user_backend_date', table: 'usage_stats', sql: 'CREATE INDEX IF NOT EXISTS idx_usage_stats_user_backend_date ON usage_stats(user_id, backend_id, date)' },
|
||||
{ name: 'idx_backend_metrics_backend_date', table: 'backend_metrics', sql: 'CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend_date ON backend_metrics(backend_id, date)' },
|
||||
];
|
||||
|
||||
for (const { name, table, sql } of indexes) {
|
||||
if (!hasIndex(db, table, name)) {
|
||||
db.exec(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getAnalyticsDb(): Database.Database {
|
||||
|
|
@ -16,6 +35,7 @@ export function getAnalyticsDb(): Database.Database {
|
|||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
ensureAnalyticsIndexes(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ function runCoreMigrations(database: Database.Database): void {
|
|||
if (hasColumn(database, 'model_rewrites', 'force') === false) {
|
||||
database.exec('ALTER TABLE model_rewrites ADD COLUMN force BOOLEAN DEFAULT 0');
|
||||
}
|
||||
if (hasColumn(database, 'users', 'copy_reasoning_to_reasoning_content') === false) {
|
||||
database.exec('ALTER TABLE users ADD COLUMN copy_reasoning_to_reasoning_content INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
|
|
|
|||
4
server/src/config/model-list-metadata.ts
Normal file
4
server/src/config/model-list-metadata.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function shouldIncludeModelListRoutingMetadata(): boolean {
|
||||
const value = process.env.MODEL_LIST_INCLUDE_ROUTING_METADATA?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
|
||||
}
|
||||
|
|
@ -20,6 +20,22 @@ function initRequestLogsSchema(db: Database.Database): void {
|
|||
}
|
||||
}
|
||||
|
||||
function ensureRequestLogsIndexes(db: Database.Database): void {
|
||||
const existingIndexes = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'request_logs'").all() as Array<{ name: string }>;
|
||||
const indexNames = new Set(existingIndexes.map((idx) => idx.name));
|
||||
|
||||
const indexes = [
|
||||
['idx_request_logs_local_date_backend', 'CREATE INDEX IF NOT EXISTS idx_request_logs_local_date_backend ON request_logs(local_date, backend_id)'],
|
||||
['idx_request_logs_completion_tokens', 'CREATE INDEX IF NOT EXISTS idx_request_logs_completion_tokens ON request_logs(completion_tokens)'],
|
||||
];
|
||||
|
||||
for (const [name, sql] of indexes) {
|
||||
if (!indexNames.has(name)) {
|
||||
db.exec(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
|
|
@ -31,6 +47,7 @@ export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Databas
|
|||
|
||||
const db = new Database(dbPath);
|
||||
initRequestLogsSchema(db);
|
||||
ensureRequestLogsIndexes(db);
|
||||
connections.set(monthKey, db);
|
||||
return db;
|
||||
}
|
||||
|
|
|
|||
11
server/src/config/stream-logging.ts
Normal file
11
server/src/config/stream-logging.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type DetailStreamLogMode = 'compact' | 'raw' | 'both' | 'off';
|
||||
|
||||
export function getDetailStreamLogMode(): DetailStreamLogMode {
|
||||
const value = process.env.DETAIL_STREAM_LOG_MODE?.trim().toLowerCase();
|
||||
|
||||
if (value === 'compact' || value === 'raw' || value === 'both' || value === 'off') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'compact';
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth';
|
|||
import { logger } from './utils/logger';
|
||||
import { getUtcTimestamp } from './utils/time';
|
||||
import { ModelCatalogService } from './services/ModelCatalogService';
|
||||
import { createJsonBodyParser, JSON_BODY_LIMIT, requestBodyErrorHandler } from './utils/requestBody';
|
||||
|
||||
const envPathCandidates = [
|
||||
path.resolve(__dirname, '..', '..', '.env'),
|
||||
|
|
@ -43,9 +44,8 @@ export function createServer(): Application {
|
|||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json({
|
||||
limit: '30mb',
|
||||
}));
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export class UserModel {
|
|||
static asUser(row: any): User {
|
||||
row.is_active = !!row.is_active;
|
||||
row.detail_logging = !!row.detail_logging;
|
||||
row.copy_reasoning_to_reasoning_content = !!row.copy_reasoning_to_reasoning_content;
|
||||
return row as User;
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +32,11 @@ export class UserModel {
|
|||
const apiKey = data.api_key ?? generateApiKey();
|
||||
const timestamp = getUtcTimestamp();
|
||||
const detailLogging = data.detail_logging ?? false;
|
||||
const copyReasoning = data.copy_reasoning_to_reasoning_content ?? false;
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO users (api_key, name, email, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO users (api_key, name, email, detail_logging, copy_reasoning_to_reasoning_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, timestamp, timestamp);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, copyReasoning ? 1 : 0, timestamp, timestamp);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
|
|
@ -43,6 +45,7 @@ export class UserModel {
|
|||
email: data.email,
|
||||
is_active: true,
|
||||
detail_logging: detailLogging,
|
||||
copy_reasoning_to_reasoning_content: copyReasoning,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
|
|
@ -72,6 +75,10 @@ export class UserModel {
|
|||
updates.push('detail_logging = ?');
|
||||
values.push(data.detail_logging ? 1 : 0);
|
||||
}
|
||||
if (data.copy_reasoning_to_reasoning_content !== undefined) {
|
||||
updates.push('copy_reasoning_to_reasoning_content = ?');
|
||||
values.push(data.copy_reasoning_to_reasoning_content ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return this.findById(id);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,19 @@ const router: Router = Router();
|
|||
|
||||
router.use('/scripts', scriptRoutes);
|
||||
|
||||
function sendRewriteCycleError(res: Response, rules: ReturnType<typeof ModelRewriteModel.findAll>): boolean {
|
||||
const cycle = ModelCatalogService.detectRewriteCycle(rules);
|
||||
if (!cycle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
res.status(409).json({
|
||||
error: 'Model rewrite cycle detected',
|
||||
cycle,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
router.get('/dashboard/summary', (req: Request, res: Response) => {
|
||||
const days = req.query.days ? Number(req.query.days) : 30;
|
||||
res.json(AnalyticsService.getDashboardSummary(days));
|
||||
|
|
@ -34,7 +47,7 @@ router.get('/users', (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
router.post('/users', (req: Request, res: Response) => {
|
||||
const { name, email, api_key, detail_logging } = req.body as CreateUserData;
|
||||
const { name, email, api_key, detail_logging, copy_reasoning_to_reasoning_content } = req.body as CreateUserData;
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
|
|
@ -47,6 +60,7 @@ router.post('/users', (req: Request, res: Response) => {
|
|||
email: email?.trim() || undefined,
|
||||
api_key: api_key?.trim() || undefined,
|
||||
detail_logging,
|
||||
copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
|
|
@ -80,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, email, api_key, is_active, detail_logging } = req.body as UpdateUserData;
|
||||
const { name, email, api_key, is_active, detail_logging, copy_reasoning_to_reasoning_content } = req.body as UpdateUserData;
|
||||
|
||||
if (typeof name === 'string' && !name.trim()) {
|
||||
res.status(400).json({ error: 'Name cannot be empty' });
|
||||
|
|
@ -94,6 +108,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||
api_key: typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
|
||||
is_active,
|
||||
detail_logging,
|
||||
copy_reasoning_to_reasoning_content,
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
|
|
@ -303,10 +318,30 @@ router.post('/model-rewrites', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const sourceModel = source_model.trim();
|
||||
const targetModel = target_model.trim();
|
||||
const timestamp = getUtcTimestamp();
|
||||
const candidateRules = [
|
||||
...ModelRewriteModel.findAll(),
|
||||
{
|
||||
id: 0,
|
||||
source_model: sourceModel,
|
||||
target_model: targetModel,
|
||||
is_active: is_active === false ? false : true,
|
||||
force: !!force,
|
||||
note,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
},
|
||||
];
|
||||
if (sendRewriteCycleError(res, candidateRules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rule = ModelRewriteModel.create({
|
||||
source_model: source_model.trim(),
|
||||
target_model: target_model.trim(),
|
||||
source_model: sourceModel,
|
||||
target_model: targetModel,
|
||||
is_active,
|
||||
force,
|
||||
note,
|
||||
|
|
@ -330,8 +365,40 @@ router.put('/model-rewrites/:id', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const data = req.body as UpdateModelRewriteData;
|
||||
if (typeof data.source_model === 'string' && !data.source_model.trim()) {
|
||||
res.status(400).json({ error: 'source_model cannot be empty' });
|
||||
return;
|
||||
}
|
||||
if (typeof data.target_model === 'string' && !data.target_model.trim()) {
|
||||
res.status(400).json({ error: 'target_model cannot be empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateRules = ModelRewriteModel.findAll().map((rule) => {
|
||||
if (rule.id !== id) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
source_model: typeof data.source_model === 'string' ? data.source_model.trim() : rule.source_model,
|
||||
target_model: typeof data.target_model === 'string' ? data.target_model.trim() : rule.target_model,
|
||||
is_active: data.is_active !== undefined ? data.is_active : rule.is_active,
|
||||
force: data.force !== undefined ? data.force : rule.force,
|
||||
note: data.note !== undefined ? data.note : rule.note,
|
||||
};
|
||||
});
|
||||
if (sendRewriteCycleError(res, candidateRules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = ModelRewriteModel.update(id, req.body as UpdateModelRewriteData);
|
||||
const updated = ModelRewriteModel.update(id, {
|
||||
...data,
|
||||
source_model: typeof data.source_model === 'string' ? data.source_model.trim() : undefined,
|
||||
target_model: typeof data.target_model === 'string' ? data.target_model.trim() : undefined,
|
||||
});
|
||||
ModelCatalogService.loadRewriteMap();
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,85 +4,117 @@ import { AnalyticsService } from '../services/AnalyticsService';
|
|||
const router: Router = Router();
|
||||
|
||||
router.get('/usage', (req: Request, res: Response) => {
|
||||
const { userId, backendId, days } = req.query;
|
||||
const result = AnalyticsService.getUsageStats(
|
||||
userId ? Number(userId) : undefined,
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { userId, backendId, days } = req.query;
|
||||
const result = AnalyticsService.getUsageStats(
|
||||
userId ? Number(userId) : undefined,
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch usage stats', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/requests', (req: Request, res: Response) => {
|
||||
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
|
||||
const result = AnalyticsService.getRequestLogs({
|
||||
month: typeof month === 'string' ? month : undefined,
|
||||
date: typeof date === 'string' ? date : undefined,
|
||||
limit: limit ? Number(limit) : 100,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
q: typeof q === 'string' ? q : undefined,
|
||||
userId: userId ? Number(userId) : undefined,
|
||||
backendId: backendId ? Number(backendId) : undefined,
|
||||
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
|
||||
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
|
||||
});
|
||||
res.json(result);
|
||||
try {
|
||||
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
|
||||
const result = AnalyticsService.getRequestLogs({
|
||||
month: typeof month === 'string' ? month : undefined,
|
||||
date: typeof date === 'string' ? date : undefined,
|
||||
limit: limit ? Number(limit) : 100,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
q: typeof q === 'string' ? q : undefined,
|
||||
userId: userId ? Number(userId) : undefined,
|
||||
backendId: backendId ? Number(backendId) : undefined,
|
||||
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
|
||||
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch request logs', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/metrics', (req: Request, res: Response) => {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getBackendMetrics(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getBackendMetrics(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch backend metrics', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/daily-totals', (req: Request, res: Response) => {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getDailyTotals(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getDailyTotals(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch daily totals', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backend-quality', (req: Request, res: Response) => {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getBackendQuality(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getBackendQuality(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch backend quality', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/model-trends', (req: Request, res: Response) => {
|
||||
const { backendId, days, limit } = req.query;
|
||||
const result = AnalyticsService.getModelTrends(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30,
|
||||
limit ? Number(limit) : 8
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days, limit } = req.query;
|
||||
const result = AnalyticsService.getModelTrends(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30,
|
||||
limit ? Number(limit) : 8
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch model trends', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/response-length-histogram', (req: Request, res: Response) => {
|
||||
const { backendId, days, bins } = req.query;
|
||||
const result = AnalyticsService.getResponseLengthHistogram(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30,
|
||||
bins ? Number(bins) : 20
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days, bins } = req.query;
|
||||
const result = AnalyticsService.getResponseLengthHistogram(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30,
|
||||
bins ? Number(bins) : 20
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch response length histogram', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/response-length-box-plot', (req: Request, res: Response) => {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getResponseLengthBoxPlot(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
try {
|
||||
const { backendId, days } = req.query;
|
||||
const result = AnalyticsService.getResponseLengthBoxPlot(
|
||||
backendId ? Number(backendId) : undefined,
|
||||
days ? Number(days) : 30
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch response length box plot', details: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate, AuthenticatedRequest } from './auth';
|
||||
import { BackendModel } from '../models/Backend';
|
||||
import { RouterService } from '../services/RouterService';
|
||||
import { AnalyticsService } from '../services/AnalyticsService';
|
||||
import { ScriptEngine } from '../services/ScriptEngine';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ModelCatalogService } from '../services/ModelCatalogService';
|
||||
import { ModelCatalogService, ModelRewriteCycleError } from '../services/ModelCatalogService';
|
||||
import { getDetailStreamLogMode } from '../config/stream-logging';
|
||||
import { shouldIncludeModelListRoutingMetadata } from '../config/model-list-metadata';
|
||||
import { ChatStreamLogAccumulator } from '../utils/streamLog';
|
||||
import { ReasoningCompatSseTransformer, copyReasoningToReasoningContentInChatCompletion } from '../utils/reasoningCompat';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
|
|
@ -34,17 +37,14 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
const requestedModel = typeof req.body?.model === 'string' ? req.body.model : '';
|
||||
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
|
||||
const resolution = ModelCatalogService.resolveRequestedModel(requestedModel, allowedBackendIds);
|
||||
const activeAllowedBackendIds = BackendModel.findActive()
|
||||
.map((item) => item.id)
|
||||
.filter((backendId) => allowedBackendIds.includes(backendId));
|
||||
const activeAllowedBackendIds = ModelCatalogService.getActiveAllowedBackendIds(allowedBackendIds);
|
||||
if (activeAllowedBackendIds.length === 0) {
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: requestedModel,
|
||||
routed_model: resolution.routedModel,
|
||||
routed_model: requestedModel,
|
||||
status_code: 403,
|
||||
error_message: 'No active backends available',
|
||||
detail_logged: user.detail_logging,
|
||||
|
|
@ -54,7 +54,34 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
res.status(403).json({ error: 'No active backends available' });
|
||||
return;
|
||||
}
|
||||
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(resolution.routedModel, allowedBackendIds);
|
||||
|
||||
let resolution: ReturnType<typeof ModelCatalogService.resolveRequestedModel>;
|
||||
try {
|
||||
resolution = ModelCatalogService.resolveRequestedModel(requestedModel, activeAllowedBackendIds);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof ModelRewriteCycleError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Model rewrite resolution failed';
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
backend_id: 0,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: requestedModel,
|
||||
routed_model: requestedModel,
|
||||
status_code: 500,
|
||||
error_message: errorMsg,
|
||||
detail_logged: user.detail_logging,
|
||||
request_headers: user.detail_logging ? normalizeHeaders(req.headers) : undefined,
|
||||
request_body: user.detail_logging ? req.body : undefined,
|
||||
});
|
||||
logger.error(`Model rewrite resolution failed for user ${user.id}: ${errorMsg}`);
|
||||
res.status(500).json({ error: 'Model rewrite configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateBackendIds = ModelCatalogService.getCandidateBackendIds(resolution.routedModel, activeAllowedBackendIds);
|
||||
const backend = RouterService.selectBackend(candidateBackendIds);
|
||||
if (!backend) {
|
||||
AnalyticsService.logRequest({
|
||||
|
|
@ -187,37 +214,49 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
const reader = backendResponse.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const streamTransformer = user.copy_reasoning_to_reasoning_content ? new ReasoningCompatSseTransformer() : null;
|
||||
req.on('close', () => reader.cancel());
|
||||
|
||||
let responseModel: string | undefined;
|
||||
let usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | undefined;
|
||||
const collectedChunks: string[] = [];
|
||||
const detailStreamLogMode = getDetailStreamLogMode();
|
||||
const streamLog = new ChatStreamLogAccumulator(detailLoggingEnabled && (detailStreamLogMode === 'raw' || detailStreamLogMode === 'both'));
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(value);
|
||||
|
||||
// Parse SSE chunks for model and usage metadata
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
if (detailLoggingEnabled) collectedChunks.push(text);
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(6));
|
||||
if (parsed.model && !responseModel) responseModel = parsed.model;
|
||||
if (parsed.usage) usage = parsed.usage;
|
||||
} catch { /* non-JSON data line, skip */ }
|
||||
if (streamTransformer) {
|
||||
const transformedText = streamTransformer.append(text);
|
||||
if (transformedText) {
|
||||
res.write(encoder.encode(transformedText));
|
||||
streamLog.append(transformedText);
|
||||
}
|
||||
} else {
|
||||
res.write(value);
|
||||
streamLog.append(text);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingText = decoder.decode();
|
||||
if (streamTransformer) {
|
||||
const transformedText = streamTransformer.append(remainingText) + streamTransformer.flush();
|
||||
if (transformedText) {
|
||||
res.write(encoder.encode(transformedText));
|
||||
streamLog.append(transformedText, false);
|
||||
}
|
||||
} else if (remainingText) {
|
||||
streamLog.append(remainingText, false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Stream interrupted for user ${user.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
|
||||
streamLog.flush();
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
AnalyticsService.logRequest({
|
||||
user_id: user.id,
|
||||
|
|
@ -225,17 +264,17 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
endpoint: '/v1/chat/completions',
|
||||
request_model: model,
|
||||
routed_model: resolution.routedModel,
|
||||
response_model: responseModel,
|
||||
prompt_tokens: usage?.prompt_tokens,
|
||||
completion_tokens: usage?.completion_tokens,
|
||||
total_tokens: usage?.total_tokens,
|
||||
response_model: streamLog.getResponseModel(),
|
||||
prompt_tokens: streamLog.getUsage()?.prompt_tokens,
|
||||
completion_tokens: streamLog.getUsage()?.completion_tokens,
|
||||
total_tokens: streamLog.getUsage()?.total_tokens,
|
||||
status_code: backendResponse.status,
|
||||
response_time_ms: responseTime,
|
||||
detail_logged: detailLoggingEnabled,
|
||||
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
|
||||
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
|
||||
response_headers: detailLoggingEnabled ? backendResponseHeaders : undefined,
|
||||
response_body: detailLoggingEnabled ? collectedChunks.join('') : undefined,
|
||||
response_body: detailLoggingEnabled ? streamLog.toLogBody(detailStreamLogMode) : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
|
|
@ -255,11 +294,14 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const responseData = user.copy_reasoning_to_reasoning_content
|
||||
? copyReasoningToReasoningContentInChatCompletion(response.data, false)
|
||||
: response.data;
|
||||
|
||||
const responseContext = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.data,
|
||||
body: responseData,
|
||||
isStream: false,
|
||||
};
|
||||
|
||||
|
|
@ -276,23 +318,23 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
endpoint: '/v1/chat/completions',
|
||||
request_model: model,
|
||||
routed_model: resolution.routedModel,
|
||||
response_model: response.data && typeof response.data === 'object' && 'model' in response.data ? String(response.data.model) : undefined,
|
||||
prompt_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (response.data as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
|
||||
completion_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { completion_tokens?: number } }).usage === 'object' ? (response.data as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
|
||||
total_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { total_tokens?: number } }).usage === 'object' ? (response.data as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
|
||||
response_model: responseData && typeof responseData === 'object' && 'model' in responseData ? String(responseData.model) : undefined,
|
||||
prompt_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (responseData as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
|
||||
completion_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { completion_tokens?: number } }).usage === 'object' ? (responseData as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
|
||||
total_tokens: responseData && typeof responseData === 'object' && 'usage' in responseData && typeof (responseData as { usage?: { total_tokens?: number } }).usage === 'object' ? (responseData as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
|
||||
status_code: response.status,
|
||||
response_time_ms: responseTime,
|
||||
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
|
||||
error_message: response.status >= 400 ? JSON.stringify(responseData) : undefined,
|
||||
detail_logged: detailLoggingEnabled,
|
||||
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
|
||||
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
|
||||
response_headers: detailLoggingEnabled ? response.headers : undefined,
|
||||
response_body: detailLoggingEnabled ? response.data : undefined,
|
||||
response_body: detailLoggingEnabled ? responseData : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
const errorDetails = response.data as any;
|
||||
const errorDetails = responseData as any;
|
||||
const errorInfo = errorDetails.error || 'Unknown error';
|
||||
const causeInfo = errorDetails.cause ? ` (Cause: ${errorDetails.cause})` : '';
|
||||
const backendInfo = errorDetails.backend ? ` [Backend: ${errorDetails.backend}]` : '';
|
||||
|
|
@ -300,7 +342,7 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
void ModelCatalogService.refreshBackendAfterFailure(backend.id);
|
||||
}
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
res.status(response.status).json(responseData);
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
|
|
@ -338,17 +380,51 @@ router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
|
|||
}
|
||||
|
||||
await ModelCatalogService.ensureInitializedForBackends(allowedBackendIds);
|
||||
const activeAllowedBackendIds = BackendModel.findActive()
|
||||
.map((item) => item.id)
|
||||
.filter((backendId) => allowedBackendIds.includes(backendId));
|
||||
const activeAllowedBackendIds = ModelCatalogService.getActiveAllowedBackendIds(allowedBackendIds);
|
||||
if (activeAllowedBackendIds.length === 0) {
|
||||
res.status(403).json({ error: 'No active backends available' });
|
||||
return;
|
||||
}
|
||||
const models = ModelCatalogService.getModelsForAllowedBackends(activeAllowedBackendIds).map((entry) => ({
|
||||
id: entry.model_id,
|
||||
object: 'model',
|
||||
}));
|
||||
let models: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
kyush_router?: {
|
||||
requested_model: string;
|
||||
routed_model: string;
|
||||
was_rewritten: boolean;
|
||||
rule_type: string;
|
||||
rewrite_path: Array<{ source_model: string; target_model: string; mode: string }>;
|
||||
};
|
||||
}>;
|
||||
try {
|
||||
const includeRoutingMetadata = shouldIncludeModelListRoutingMetadata();
|
||||
models = ModelCatalogService.getRequestableModelsForAllowedBackends(activeAllowedBackendIds).map((entry) => {
|
||||
const model = {
|
||||
id: entry.model_id,
|
||||
object: 'model',
|
||||
};
|
||||
|
||||
if (!includeRoutingMetadata) {
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
...model,
|
||||
kyush_router: {
|
||||
requested_model: entry.routing.requestedModel,
|
||||
routed_model: entry.routing.routedModel,
|
||||
was_rewritten: entry.routing.wasRewritten,
|
||||
rule_type: entry.routing.ruleType,
|
||||
rewrite_path: entry.routing.rewritePath,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Model rewrite resolution failed';
|
||||
logger.error(`Model list resolution failed: ${errorMsg}`);
|
||||
res.status(500).json({ error: 'Model rewrite configuration error' });
|
||||
return;
|
||||
}
|
||||
res.json({ object: 'list', data: models });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,15 +21,6 @@ type DailyTotalsRow = {
|
|||
total_tokens: number;
|
||||
};
|
||||
|
||||
type RequestLogRangeRow = {
|
||||
local_date: string;
|
||||
backend_id: number;
|
||||
request_model: string | null;
|
||||
routed_model: string | null;
|
||||
response_model: string | null;
|
||||
completion_tokens: number | null;
|
||||
};
|
||||
|
||||
function getDateRange(days: number): { startDate: string; endDate: string } {
|
||||
const normalizedDays = Math.max(1, days);
|
||||
const endDate = getLocalDateKey();
|
||||
|
|
@ -37,17 +28,17 @@ function getDateRange(days: number): { startDate: string; endDate: string } {
|
|||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
function buildRequestLogRangeWhere(filter: RequestLogFilter): { whereClause: string; params: unknown[] } {
|
||||
function buildWhereClause(startDate: string, endDate: string, backendId: number | undefined): { whereClause: string; params: unknown[] } {
|
||||
const clauses = ['local_date >= ?', 'local_date <= ?'];
|
||||
const params: unknown[] = [filter.startDate, filter.endDate];
|
||||
const params: unknown[] = [startDate, endDate];
|
||||
|
||||
if (filter.backendId) {
|
||||
if (backendId) {
|
||||
clauses.push('backend_id = ?');
|
||||
params.push(filter.backendId);
|
||||
params.push(backendId);
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: `WHERE ${clauses.join(' AND ')}`,
|
||||
whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
|
@ -63,10 +54,10 @@ function groupByDate(rows: DailyTotalsRow[]): DailyTotalsRow[] {
|
|||
for (const row of rows) {
|
||||
const existing = grouped.get(row.date);
|
||||
if (existing) {
|
||||
existing.total_requests += row.total_requests;
|
||||
existing.total_tokens += row.total_tokens;
|
||||
existing.total_requests += row.total_requests;
|
||||
existing.total_tokens += row.total_tokens;
|
||||
} else {
|
||||
grouped.set(row.date, { ...row });
|
||||
grouped.set(row.date, { ...row });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,49 +122,22 @@ export class AnalyticsService {
|
|||
const db = getAnalyticsDb();
|
||||
const today = getLocalDateKey();
|
||||
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
|
||||
const tokens = logData.total_tokens || 0;
|
||||
const responseTime = logData.response_time_ms || 0;
|
||||
const errorIncrement = isSuccess ? 0 : 1;
|
||||
const initialSuccessRate = isSuccess ? 1.0 : 0.0;
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT * FROM backend_metrics WHERE backend_id = ? AND date = ?'
|
||||
).get(backendId, today) as {
|
||||
total_requests: number;
|
||||
total_tokens: number;
|
||||
avg_response_time_ms: number;
|
||||
error_count: number;
|
||||
} | undefined;
|
||||
|
||||
if (existing) {
|
||||
const newTotalRequests = existing.total_requests + 1;
|
||||
const newTotalTokens = existing.total_tokens + (logData.total_tokens || 0);
|
||||
const newErrorCount = existing.error_count + (isSuccess ? 0 : 1);
|
||||
const newAvgResponseTime = logData.response_time_ms
|
||||
? (existing.avg_response_time_ms * existing.total_requests + logData.response_time_ms) / newTotalRequests
|
||||
: existing.avg_response_time_ms;
|
||||
const newSuccessRate = (newTotalRequests - newErrorCount) / newTotalRequests;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE backend_metrics SET
|
||||
total_requests = ?,
|
||||
total_tokens = ?,
|
||||
avg_response_time_ms = ?,
|
||||
error_count = ?,
|
||||
success_rate = ?
|
||||
WHERE backend_id = ? AND date = ?
|
||||
`).run(newTotalRequests, newTotalTokens, newAvgResponseTime, newErrorCount, newSuccessRate, backendId, today);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO backend_metrics (
|
||||
backend_id, date, total_requests, total_tokens,
|
||||
avg_response_time_ms, error_count, success_rate
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?)
|
||||
`).run(
|
||||
backendId,
|
||||
today,
|
||||
logData.total_tokens || 0,
|
||||
logData.response_time_ms || 0,
|
||||
isSuccess ? 0 : 1,
|
||||
isSuccess ? 1.0 : 0.0
|
||||
);
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO backend_metrics (backend_id, date, total_requests, total_tokens, avg_response_time_ms, error_count, success_rate)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?)
|
||||
ON CONFLICT(backend_id, date)
|
||||
DO UPDATE SET
|
||||
total_requests = total_requests + 1,
|
||||
total_tokens = total_tokens + excluded.total_tokens,
|
||||
avg_response_time_ms = (avg_response_time_ms * total_requests + excluded.avg_response_time_ms) / (total_requests + 1),
|
||||
error_count = error_count + excluded.error_count,
|
||||
success_rate = (total_requests + 1 - (error_count + excluded.error_count)) / (total_requests + 1)
|
||||
`).run(backendId, today, tokens, responseTime, errorIncrement, initialSuccessRate);
|
||||
}
|
||||
|
||||
static getRequestLogs(query: RequestLogQuery = {}): RequestLogPage {
|
||||
|
|
@ -269,50 +233,74 @@ export class AnalyticsService {
|
|||
return db.prepare(query).all(...params);
|
||||
}
|
||||
|
||||
private static collectRequestLogRangeRows(filter: RequestLogFilter): RequestLogRangeRow[] {
|
||||
const { whereClause, params } = buildRequestLogRangeWhere(filter);
|
||||
const rows: RequestLogRangeRow[] = [];
|
||||
|
||||
for (const month of getRequestLogMonthsForRange(filter.startDate, filter.endDate)) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const monthRows = db.prepare(`
|
||||
SELECT local_date, backend_id, request_model, routed_model, response_model, completion_tokens
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
`).all(...params) as RequestLogRangeRow[];
|
||||
rows.push(...monthRows);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// SQL-level aggregation: first find top models, then get per-date counts
|
||||
static getModelTrends(backendId?: number, days: number = 30, limit: number = 8): unknown[] {
|
||||
const { startDate, endDate } = getDateRange(days);
|
||||
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
|
||||
const countsByModel = new Map<string, number>();
|
||||
const countsByDateAndModel = new Map<string, number>();
|
||||
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||
|
||||
for (const row of rows) {
|
||||
const model = row.response_model || row.routed_model || row.request_model || 'unknown';
|
||||
countsByModel.set(model, (countsByModel.get(model) ?? 0) + 1);
|
||||
const key = `${row.local_date}::${model}`;
|
||||
countsByDateAndModel.set(key, (countsByDateAndModel.get(key) ?? 0) + 1);
|
||||
const modelCounts = new Map<string, number>();
|
||||
|
||||
for (const month of months) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||
const rows = db.prepare(`
|
||||
SELECT COALESCE(response_model, COALESCE(routed_model, COALESCE(request_model, 'unknown'))) as model,
|
||||
COUNT(*) as cnt
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
GROUP BY model
|
||||
`).all(...params) as Array<{ model: string; cnt: number }>;
|
||||
|
||||
for (const row of rows) {
|
||||
modelCounts.set(row.model, (modelCounts.get(row.model) ?? 0) + row.cnt);
|
||||
}
|
||||
}
|
||||
|
||||
const topModels = Array.from(countsByModel.entries())
|
||||
const topModels = Array.from(modelCounts.entries())
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.slice(0, Math.max(1, limit))
|
||||
.map(([model]) => model);
|
||||
|
||||
const result: Array<{ date: string; model: string; request_count: number }> = [];
|
||||
const seenDates = new Set(rows.map((row) => row.local_date));
|
||||
if (topModels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const date of Array.from(seenDates).sort((left, right) => left.localeCompare(right))) {
|
||||
const topModelSet = new Set(topModels);
|
||||
const dateCounts = new Map<string, Map<string, number>>();
|
||||
|
||||
for (const month of months) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||
const rows = db.prepare(`
|
||||
SELECT local_date,
|
||||
COALESCE(response_model, COALESCE(routed_model, COALESCE(request_model, 'unknown'))) as model,
|
||||
COUNT(*) as cnt
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
GROUP BY local_date, model
|
||||
`).all(...params) as Array<{ local_date: string; model: string; cnt: number }>;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!topModelSet.has(row.model)) continue;
|
||||
let dateMap = dateCounts.get(row.local_date);
|
||||
if (!dateMap) {
|
||||
dateMap = new Map();
|
||||
dateCounts.set(row.local_date, dateMap);
|
||||
}
|
||||
dateMap.set(row.model, row.cnt);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Array<{ date: string; model: string; request_count: number }> = [];
|
||||
const sortedDates = Array.from(dateCounts.keys()).sort((left, right) => left.localeCompare(right));
|
||||
|
||||
for (const date of sortedDates) {
|
||||
const dateMap = dateCounts.get(date)!;
|
||||
for (const model of topModels) {
|
||||
result.push({
|
||||
date,
|
||||
model,
|
||||
request_count: countsByDateAndModel.get(`${date}::${model}`) ?? 0,
|
||||
request_count: dateMap.get(model) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -320,65 +308,158 @@ export class AnalyticsService {
|
|||
return result;
|
||||
}
|
||||
|
||||
// SQL-level histogram: use CASE-based binning with log-transformed values
|
||||
static getResponseLengthHistogram(backendId?: number, days: number = 30, bins: number = 20): unknown[] {
|
||||
const { startDate, endDate } = getDateRange(days);
|
||||
const values = this.collectRequestLogRangeRows({ backendId, startDate, endDate })
|
||||
.map((row) => row.completion_tokens)
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0);
|
||||
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||
const safeBinCount = Math.max(1, bins);
|
||||
|
||||
if (values.length === 0) {
|
||||
// First pass: find min/max across all months (aggregated, not row-level)
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
let totalCount = 0;
|
||||
|
||||
for (const month of months) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||
const row = db.prepare(`
|
||||
SELECT MIN(completion_tokens) as min_val, MAX(completion_tokens) as max_val,
|
||||
COUNT(*) as cnt
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
AND completion_tokens IS NOT NULL
|
||||
AND completion_tokens >= 0
|
||||
`).get(...params) as { min_val: number | null; max_val: number | null; cnt: number } | undefined;
|
||||
|
||||
if (row && row.cnt > 0) {
|
||||
if (typeof row.min_val === 'number') globalMin = Math.min(globalMin, row.min_val);
|
||||
if (typeof row.max_val === 'number') globalMax = Math.max(globalMax, row.max_val);
|
||||
totalCount += row.cnt;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0 || globalMin === Infinity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safeBinCount = Math.max(1, bins);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
if (min === max) {
|
||||
return [{ bin_start: min, bin_end: max, count: values.length }];
|
||||
if (globalMin === globalMax) {
|
||||
return [{ bin_start: globalMin, bin_end: globalMax, count: totalCount }];
|
||||
}
|
||||
|
||||
const transformedMin = Math.log1p(globalMin);
|
||||
const transformedMax = Math.log1p(globalMax);
|
||||
const width = (transformedMax - transformedMin) / safeBinCount;
|
||||
|
||||
// Build bin boundaries for SQL CASE expression
|
||||
const binBoundaries: number[] = [];
|
||||
for (let i = 0; i < safeBinCount - 1; i++) {
|
||||
binBoundaries.push(Math.expm1(transformedMin + width * (i + 1)));
|
||||
}
|
||||
|
||||
// Build SQL CASE expression for bin assignment
|
||||
const caseParts: string[] = [];
|
||||
for (let i = 0; i < safeBinCount - 1; i++) {
|
||||
caseParts.push(`WHEN completion_tokens < ${binBoundaries[i]} THEN ${i}`);
|
||||
}
|
||||
caseParts.push(`ELSE ${safeBinCount - 1}`);
|
||||
const caseExpr = `CASE ${caseParts.join(' ')} END`;
|
||||
|
||||
// Second pass: count per bin using SQL aggregation
|
||||
const binCounts = new Array(safeBinCount).fill(0);
|
||||
|
||||
for (const month of months) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||
const rows = db.prepare(`
|
||||
SELECT ${caseExpr} as bin, COUNT(*) as cnt
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
AND completion_tokens IS NOT NULL
|
||||
AND completion_tokens >= 0
|
||||
GROUP BY bin
|
||||
`).all(...params) as Array<{ bin: number; cnt: number }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const binIndex = Math.min(safeBinCount - 1, Math.max(0, row.bin));
|
||||
binCounts[binIndex] += row.cnt;
|
||||
}
|
||||
}
|
||||
|
||||
const width = (max - min) / safeBinCount;
|
||||
const histogram = Array.from({ length: safeBinCount }, (_, index) => ({
|
||||
bin_start: min + width * index,
|
||||
bin_end: index === safeBinCount - 1 ? max : min + width * (index + 1),
|
||||
count: 0,
|
||||
bin_start: index === 0 ? globalMin : Math.expm1(transformedMin + width * index),
|
||||
bin_end: index === safeBinCount - 1 ? globalMax : Math.expm1(transformedMin + width * (index + 1)),
|
||||
count: binCounts[index],
|
||||
}));
|
||||
|
||||
for (const value of values) {
|
||||
const index = Math.min(safeBinCount - 1, Math.floor((value - min) / width));
|
||||
histogram[index].count += 1;
|
||||
}
|
||||
|
||||
return histogram;
|
||||
}
|
||||
|
||||
// SQL-level box plot: fetch per-date aggregates, compute quantiles from sampled data
|
||||
static getResponseLengthBoxPlot(backendId?: number, days: number = 30): unknown[] {
|
||||
const { startDate, endDate } = getDateRange(days);
|
||||
const rows = this.collectRequestLogRangeRows({ backendId, startDate, endDate });
|
||||
const valuesByDate = new Map<string, number[]>();
|
||||
const months = getRequestLogMonthsForRange(startDate, endDate);
|
||||
|
||||
for (const row of rows) {
|
||||
if (typeof row.completion_tokens !== 'number' || !Number.isFinite(row.completion_tokens) || row.completion_tokens < 0) {
|
||||
continue;
|
||||
const dailyStats = new Map<string, { min: number; max: number; count: number; values: number[] }>();
|
||||
|
||||
for (const month of months) {
|
||||
const db = getRequestLogsDb(month);
|
||||
const { whereClause, params } = buildWhereClause(startDate, endDate, backendId);
|
||||
|
||||
// Get per-date min/max/count via SQL aggregation
|
||||
const summaryRows = db.prepare(`
|
||||
SELECT local_date, MIN(completion_tokens) as min_val, MAX(completion_tokens) as max_val, COUNT(*) as cnt
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
AND completion_tokens IS NOT NULL
|
||||
AND completion_tokens >= 0
|
||||
GROUP BY local_date
|
||||
`).all(...params) as Array<{ local_date: string; min_val: number; max_val: number; cnt: number }>;
|
||||
|
||||
for (const row of summaryRows) {
|
||||
const entry = dailyStats.get(row.local_date);
|
||||
if (entry) {
|
||||
entry.count += row.cnt;
|
||||
entry.min = Math.min(entry.min, row.min_val);
|
||||
entry.max = Math.max(entry.max, row.max_val);
|
||||
} else {
|
||||
dailyStats.set(row.local_date, {
|
||||
min: row.min_val,
|
||||
max: row.max_val,
|
||||
count: row.cnt,
|
||||
values: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const values = valuesByDate.get(row.local_date) ?? [];
|
||||
values.push(row.completion_tokens);
|
||||
valuesByDate.set(row.local_date, values);
|
||||
// For quantiles, fetch values per date (only completion_tokens column, limited)
|
||||
const dateRows = db.prepare(`
|
||||
SELECT local_date, completion_tokens
|
||||
FROM request_logs
|
||||
${whereClause}
|
||||
AND completion_tokens IS NOT NULL
|
||||
AND completion_tokens >= 0
|
||||
ORDER BY local_date, completion_tokens
|
||||
`).all(...params) as Array<{ local_date: string; completion_tokens: number }>;
|
||||
|
||||
for (const row of dateRows) {
|
||||
const entry = dailyStats.get(row.local_date);
|
||||
if (entry) {
|
||||
entry.values.push(row.completion_tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(valuesByDate.entries())
|
||||
return Array.from(dailyStats.entries())
|
||||
.sort((left, right) => left[0].localeCompare(right[0]))
|
||||
.map(([date, values]) => {
|
||||
const sortedValues = [...values].sort((left, right) => left - right);
|
||||
.map(([date, stats]) => {
|
||||
const sortedValues = stats.values.sort((left, right) => left - right);
|
||||
return {
|
||||
date,
|
||||
min: sortedValues[0],
|
||||
min: sortedValues.length > 0 ? sortedValues[0] : stats.min,
|
||||
q1: calculateQuantile(sortedValues, 0.25),
|
||||
median: calculateQuantile(sortedValues, 0.5),
|
||||
q3: calculateQuantile(sortedValues, 0.75),
|
||||
max: sortedValues[sortedValues.length - 1],
|
||||
max: sortedValues.length > 0 ? sortedValues[sortedValues.length - 1] : stats.max,
|
||||
count: sortedValues.length,
|
||||
};
|
||||
});
|
||||
|
|
@ -433,6 +514,11 @@ export class AnalyticsService {
|
|||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
// Parallel execution for series data (better-sqlite3 is synchronous, but this makes it explicit)
|
||||
const dailyTotals = this.getDailyTotals(undefined, normalizedDays);
|
||||
const backendQuality = this.getBackendQuality(undefined, normalizedDays);
|
||||
const modelTrends = this.getModelTrends(undefined, normalizedDays, 6);
|
||||
|
||||
return {
|
||||
window_days: normalizedDays,
|
||||
generated_at: now,
|
||||
|
|
@ -470,9 +556,9 @@ export class AnalyticsService {
|
|||
users_without_permissions: users.filter((user) => !permissionsByUserId.has(user.id)).length,
|
||||
},
|
||||
series: {
|
||||
daily_totals: this.getDailyTotals(undefined, normalizedDays),
|
||||
backend_quality: this.getBackendQuality(undefined, normalizedDays) as DashboardSummaryResponse['series']['backend_quality'],
|
||||
model_trends: this.getModelTrends(undefined, normalizedDays, 6) as DashboardSummaryResponse['series']['model_trends'],
|
||||
daily_totals: dailyTotals,
|
||||
backend_quality: backendQuality as DashboardSummaryResponse['series']['backend_quality'],
|
||||
model_trends: modelTrends as DashboardSummaryResponse['series']['model_trends'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,20 +31,49 @@ interface FetchModelsResponse {
|
|||
rawModels: Array<{ model_id: string; raw_json?: string }>;
|
||||
}
|
||||
|
||||
interface RewriteResolution {
|
||||
export interface RewritePathHop {
|
||||
source_model: string;
|
||||
target_model: string;
|
||||
mode: 'force' | 'fallback';
|
||||
}
|
||||
|
||||
export interface RewriteResolution {
|
||||
requestedModel: string;
|
||||
routedModel: string;
|
||||
wasRewritten: boolean;
|
||||
ruleType: 'none' | 'force' | 'fallback';
|
||||
ruleType: 'none' | 'force' | 'fallback' | 'chain';
|
||||
rewritePath: RewritePathHop[];
|
||||
}
|
||||
|
||||
interface RewriteConfig {
|
||||
id: number;
|
||||
sourceModel: string;
|
||||
targetModel: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
interface ResolutionContext {
|
||||
allowedActiveBackendIds: number[];
|
||||
allowedActiveBackendIdSet: Set<number>;
|
||||
candidateMemo: Map<string, number[]>;
|
||||
}
|
||||
|
||||
export interface RequestableModelCatalogEntry extends BackendModelCatalogEntry {
|
||||
routing: RewriteResolution;
|
||||
}
|
||||
|
||||
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
|
||||
|
||||
export class ModelRewriteCycleError extends Error {
|
||||
cycle: string[];
|
||||
|
||||
constructor(cycle: string[]) {
|
||||
super(`Model rewrite cycle detected: ${cycle.join(' -> ')}`);
|
||||
this.name = 'ModelRewriteCycleError';
|
||||
this.cycle = cycle;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelCatalogService {
|
||||
private static backendModelsByBackendId = new Map<number, BackendCacheEntry>();
|
||||
private static backendIdsByModel = new Map<string, Set<number>>();
|
||||
|
|
@ -188,14 +217,60 @@ export class ModelCatalogService {
|
|||
this.modelRewriteMap.clear();
|
||||
for (const rule of ModelRewriteModel.findAll()) {
|
||||
if (rule.is_active) {
|
||||
this.modelRewriteMap.set(rule.source_model, {
|
||||
targetModel: rule.target_model,
|
||||
const sourceModel = this.normalizeModelId(rule.source_model);
|
||||
const targetModel = this.normalizeModelId(rule.target_model);
|
||||
this.modelRewriteMap.set(sourceModel, {
|
||||
id: rule.id,
|
||||
sourceModel,
|
||||
targetModel,
|
||||
force: rule.force,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static createResolutionContext(allowedBackendIds: number[]): ResolutionContext {
|
||||
const allowed = new Set(allowedBackendIds);
|
||||
const allowedActiveBackendIds = BackendModel.findActive()
|
||||
.map((backend) => backend.id)
|
||||
.filter((backendId) => allowed.has(backendId));
|
||||
|
||||
return {
|
||||
allowedActiveBackendIds,
|
||||
allowedActiveBackendIdSet: new Set(allowedActiveBackendIds),
|
||||
candidateMemo: new Map<string, number[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
static getActiveAllowedBackendIds(allowedBackendIds: number[]): number[] {
|
||||
return this.createResolutionContext(allowedBackendIds).allowedActiveBackendIds;
|
||||
}
|
||||
|
||||
private static getCandidateBackendIdsWithContext(modelId: string, context: ResolutionContext): number[] {
|
||||
const normalized = this.normalizeModelId(modelId);
|
||||
const memoized = context.candidateMemo.get(normalized);
|
||||
if (memoized) return memoized;
|
||||
|
||||
const backendIds = this.backendIdsByModel.get(normalized);
|
||||
const candidates = backendIds
|
||||
? Array.from(backendIds).filter((backendId) => context.allowedActiveBackendIdSet.has(backendId))
|
||||
: [];
|
||||
const sorted = candidates.sort((a, b) => a - b);
|
||||
|
||||
context.candidateMemo.set(normalized, sorted);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static getRuleTypeFromAppliedRules(appliedRules: RewriteConfig[]): RewriteResolution['ruleType'] {
|
||||
if (appliedRules.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
if (appliedRules.length === 1) {
|
||||
return appliedRules[0].force ? 'force' : 'fallback';
|
||||
}
|
||||
return 'chain';
|
||||
}
|
||||
|
||||
static syncActiveBackendCacheState(): void {
|
||||
const backends = BackendModel.findAll();
|
||||
const backendIds = new Set(backends.map((backend) => backend.id));
|
||||
|
|
@ -216,44 +291,139 @@ export class ModelCatalogService {
|
|||
this.rebuildModelIndex();
|
||||
}
|
||||
|
||||
static resolveRequestedModel(modelId: string, allowedBackendIds: number[]): RewriteResolution {
|
||||
private static resolveRequestedModelWithContext(modelId: string, context: ResolutionContext): RewriteResolution {
|
||||
const requestedModel = this.normalizeModelId(modelId);
|
||||
const rewrite = this.modelRewriteMap.get(requestedModel);
|
||||
if (!rewrite) {
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel: requestedModel,
|
||||
wasRewritten: false,
|
||||
ruleType: 'none',
|
||||
};
|
||||
const visitedModels = new Map<string, number>();
|
||||
const path: string[] = [];
|
||||
const appliedRules: RewriteConfig[] = [];
|
||||
let currentModel = requestedModel;
|
||||
const maxSteps = this.modelRewriteMap.size + 1;
|
||||
|
||||
for (let step = 0; step <= maxSteps; step += 1) {
|
||||
const firstSeenAt = visitedModels.get(currentModel);
|
||||
if (firstSeenAt !== undefined) {
|
||||
throw new ModelRewriteCycleError([...path.slice(firstSeenAt), currentModel]);
|
||||
}
|
||||
visitedModels.set(currentModel, path.length);
|
||||
path.push(currentModel);
|
||||
|
||||
const rewrite = this.modelRewriteMap.get(currentModel);
|
||||
if (!rewrite) {
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel: currentModel,
|
||||
wasRewritten: currentModel !== requestedModel,
|
||||
ruleType: this.getRuleTypeFromAppliedRules(appliedRules),
|
||||
rewritePath: appliedRules.map((rule) => ({
|
||||
source_model: rule.sourceModel,
|
||||
target_model: rule.targetModel,
|
||||
mode: rule.force ? 'force' : 'fallback',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (!rewrite.force) {
|
||||
const originalCandidates = this.getCandidateBackendIdsWithContext(currentModel, context);
|
||||
if (originalCandidates.length > 0) {
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel: currentModel,
|
||||
wasRewritten: currentModel !== requestedModel,
|
||||
ruleType: this.getRuleTypeFromAppliedRules(appliedRules),
|
||||
rewritePath: appliedRules.map((rule) => ({
|
||||
source_model: rule.sourceModel,
|
||||
target_model: rule.targetModel,
|
||||
mode: rule.force ? 'force' : 'fallback',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
appliedRules.push(rewrite);
|
||||
currentModel = this.normalizeModelId(rewrite.targetModel);
|
||||
}
|
||||
|
||||
if (rewrite.force) {
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel: rewrite.targetModel,
|
||||
wasRewritten: rewrite.targetModel !== requestedModel,
|
||||
ruleType: 'force',
|
||||
};
|
||||
throw new ModelRewriteCycleError([...path, currentModel]);
|
||||
}
|
||||
|
||||
static resolveRequestedModel(modelId: string, allowedBackendIds: number[]): RewriteResolution {
|
||||
return this.resolveRequestedModelWithContext(modelId, this.createResolutionContext(allowedBackendIds));
|
||||
}
|
||||
|
||||
static detectRewriteCycle(rules: ModelRewriteRule[]): string[] | null {
|
||||
const activeRules = new Map<string, string>();
|
||||
for (const rule of rules) {
|
||||
if (rule.is_active) {
|
||||
activeRules.set(this.normalizeModelId(rule.source_model), this.normalizeModelId(rule.target_model));
|
||||
}
|
||||
}
|
||||
|
||||
const originalCandidates = this.getCandidateBackendIds(requestedModel, allowedBackendIds);
|
||||
if (originalCandidates.length > 0) {
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel: requestedModel,
|
||||
wasRewritten: false,
|
||||
ruleType: 'none',
|
||||
};
|
||||
}
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Map<string, number>();
|
||||
const path: string[] = [];
|
||||
|
||||
const routedModel = rewrite.targetModel;
|
||||
return {
|
||||
requestedModel,
|
||||
routedModel,
|
||||
wasRewritten: routedModel !== requestedModel,
|
||||
ruleType: 'fallback',
|
||||
const visit = (modelId: string): string[] | null => {
|
||||
const firstSeenAt = visiting.get(modelId);
|
||||
if (firstSeenAt !== undefined) {
|
||||
return [...path.slice(firstSeenAt), modelId];
|
||||
}
|
||||
if (visited.has(modelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
visiting.set(modelId, path.length);
|
||||
path.push(modelId);
|
||||
|
||||
const targetModel = activeRules.get(modelId);
|
||||
if (targetModel) {
|
||||
const cycle = visit(targetModel);
|
||||
if (cycle) {
|
||||
return cycle;
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
visiting.delete(modelId);
|
||||
visited.add(modelId);
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const sourceModel of activeRules.keys()) {
|
||||
const cycle = visit(sourceModel);
|
||||
if (cycle) {
|
||||
return cycle;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static getRequestableModelsForAllowedBackends(allowedBackendIds: number[]): RequestableModelCatalogEntry[] {
|
||||
const context = this.createResolutionContext(allowedBackendIds);
|
||||
const requestableModelIds = new Set<string>();
|
||||
const candidateModelIds = new Set<string>([
|
||||
...this.backendIdsByModel.keys(),
|
||||
...this.modelRewriteMap.keys(),
|
||||
]);
|
||||
|
||||
for (const modelId of candidateModelIds) {
|
||||
const resolution = this.resolveRequestedModelWithContext(modelId, context);
|
||||
const routedBackendIds = this.getCandidateBackendIdsWithContext(resolution.routedModel, context);
|
||||
if (routedBackendIds.length > 0) {
|
||||
requestableModelIds.add(this.normalizeModelId(modelId));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(requestableModelIds)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((modelId) => {
|
||||
const resolution = this.resolveRequestedModelWithContext(modelId, context);
|
||||
return {
|
||||
model_id: modelId,
|
||||
backend_ids: this.getCandidateBackendIdsWithContext(resolution.routedModel, context),
|
||||
routing: resolution,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static getBackendCacheStatus(backendId: number): BackendModelCacheStatus {
|
||||
|
|
@ -378,13 +548,7 @@ export class ModelCatalogService {
|
|||
}
|
||||
|
||||
static getCandidateBackendIds(modelId: string, allowedBackendIds: number[]): number[] {
|
||||
const normalized = this.normalizeModelId(modelId);
|
||||
const backendIds = this.backendIdsByModel.get(normalized);
|
||||
if (!backendIds) return [];
|
||||
|
||||
const allowed = new Set(allowedBackendIds);
|
||||
const active = new Set(BackendModel.findActive().map((backend) => backend.id));
|
||||
return Array.from(backendIds).filter((backendId) => allowed.has(backendId) && active.has(backendId));
|
||||
return this.getCandidateBackendIdsWithContext(modelId, this.createResolutionContext(allowedBackendIds));
|
||||
}
|
||||
|
||||
static getModelsForAllowedBackends(allowedBackendIds: number[]): BackendModelCatalogEntry[] {
|
||||
|
|
|
|||
91
server/src/utils/reasoningCompat.ts
Normal file
91
server/src/utils/reasoningCompat.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function copyReasoningField(target: Record<string, unknown>): boolean {
|
||||
if (typeof target.reasoning !== 'string' || target.reasoning_content !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target.reasoning_content = target.reasoning;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function copyReasoningToReasoningContentInChatCompletion(payload: unknown, stream: boolean): unknown {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.choices)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
for (const choice of payload.choices) {
|
||||
if (!isRecord(choice)) continue;
|
||||
|
||||
const target = stream ? choice.delta : choice.message;
|
||||
if (isRecord(target)) {
|
||||
copyReasoningField(target);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export class ReasoningCompatSseTransformer {
|
||||
private buffer = '';
|
||||
|
||||
append(text: string): string {
|
||||
this.buffer = `${this.buffer}${text}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const transformedEvents: string[] = [];
|
||||
|
||||
while (true) {
|
||||
const delimiterIndex = this.buffer.indexOf('\n\n');
|
||||
if (delimiterIndex < 0) break;
|
||||
|
||||
const eventText = this.buffer.slice(0, delimiterIndex);
|
||||
this.buffer = this.buffer.slice(delimiterIndex + 2);
|
||||
transformedEvents.push(this.transformEvent(eventText));
|
||||
}
|
||||
|
||||
return transformedEvents.join('');
|
||||
}
|
||||
|
||||
flush(): string {
|
||||
if (!this.buffer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const remaining = this.buffer;
|
||||
this.buffer = '';
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private transformEvent(eventText: string): string {
|
||||
const lines = eventText.split('\n');
|
||||
const dataLines = lines
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^ /, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (data.trim() === '[DONE]') {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const before = JSON.stringify(parsed);
|
||||
copyReasoningToReasoningContentInChatCompletion(parsed, true);
|
||||
const after = JSON.stringify(parsed);
|
||||
|
||||
if (before === after) {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
|
||||
const nonDataLines = lines.filter((line) => !line.startsWith('data:'));
|
||||
return `${[...nonDataLines, `data: ${after}`].join('\n')}\n\n`;
|
||||
} catch {
|
||||
return `${eventText}\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
server/src/utils/requestBody.ts
Normal file
82
server/src/utils/requestBody.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import express, { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { logger } from './logger';
|
||||
|
||||
export const JSON_BODY_LIMIT = '30mb';
|
||||
|
||||
interface BodyParserError extends Error {
|
||||
type?: string;
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
limit?: number;
|
||||
length?: number;
|
||||
expected?: number;
|
||||
received?: number;
|
||||
}
|
||||
|
||||
function parseContentLength(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function firstNumber(...values: Array<number | undefined>): number | undefined {
|
||||
return values.find((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||
}
|
||||
|
||||
export function formatByteSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined) return 'unknown';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]} (${bytes} B)`;
|
||||
}
|
||||
|
||||
export function createJsonBodyParser(limit: string = JSON_BODY_LIMIT): RequestHandler {
|
||||
return express.json({ limit });
|
||||
}
|
||||
|
||||
export const requestBodyErrorHandler: ErrorRequestHandler = (err: BodyParserError, req, res, next) => {
|
||||
const status = err.status ?? err.statusCode;
|
||||
const isPayloadTooLarge = err.type === 'entity.too.large' || status === 413;
|
||||
const isBodyParserClientError = typeof err.type === 'string' && status !== undefined && status >= 400 && status < 500;
|
||||
|
||||
if (isPayloadTooLarge) {
|
||||
const contentLengthBytes = parseContentLength(req.get('content-length'));
|
||||
const payloadBytes = firstNumber(err.length, err.received, err.expected, contentLengthBytes);
|
||||
const limitBytes = firstNumber(err.limit);
|
||||
|
||||
logger.warn(
|
||||
`Request body too large: ${req.method} ${req.originalUrl || req.url} ` +
|
||||
`payload=${formatByteSize(payloadBytes)} limit=${formatByteSize(limitBytes)} ` +
|
||||
`content-length=${formatByteSize(contentLengthBytes)}`,
|
||||
{
|
||||
content_length_bytes: contentLengthBytes,
|
||||
payload_size_bytes: payloadBytes,
|
||||
parser_limit_bytes: limitBytes,
|
||||
parser_error_type: err.type,
|
||||
}
|
||||
);
|
||||
|
||||
res.status(413).json({
|
||||
error: 'Request body too large',
|
||||
payload_size_bytes: payloadBytes,
|
||||
limit_bytes: limitBytes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBodyParserClientError) {
|
||||
logger.warn(`Invalid request body: ${req.method} ${req.originalUrl || req.url} (${err.type})`);
|
||||
res.status(status).json({ error: err.message || 'Invalid request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
301
server/src/utils/streamLog.ts
Normal file
301
server/src/utils/streamLog.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { DetailStreamLogMode } from '../config/stream-logging';
|
||||
|
||||
export const COMPACT_CHAT_STREAM_FORMAT = 'kyush.chat_stream.compact.v1';
|
||||
export const RAW_CHAT_STREAM_FORMAT = 'kyush.chat_stream.raw.v1';
|
||||
|
||||
type UsageSnapshot = {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
|
||||
type CompactToolCall = {
|
||||
index: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CompactChoice = {
|
||||
index: number;
|
||||
role?: string;
|
||||
reasoning?: string;
|
||||
content?: string;
|
||||
tool_calls?: CompactToolCall[];
|
||||
finish_reason?: string;
|
||||
stop_reason?: string;
|
||||
matched_stop?: string;
|
||||
};
|
||||
|
||||
type CompactChatStreamLog = {
|
||||
format: typeof COMPACT_CHAT_STREAM_FORMAT;
|
||||
id?: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
model?: string;
|
||||
choices: CompactChoice[];
|
||||
usage?: UsageSnapshot;
|
||||
stream: {
|
||||
network_chunk_count: number;
|
||||
data_frame_count: number;
|
||||
done: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RawChatStreamLog = {
|
||||
format: typeof RAW_CHAT_STREAM_FORMAT;
|
||||
compact: CompactChatStreamLog;
|
||||
raw_sse: string;
|
||||
};
|
||||
|
||||
type ChoiceState = {
|
||||
index: number;
|
||||
role?: string;
|
||||
reasoning: string[];
|
||||
content: string[];
|
||||
toolCalls: Map<number, CompactToolCall>;
|
||||
finishReason?: string;
|
||||
stopReason?: string;
|
||||
matchedStop?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeToolCall(target: CompactToolCall, chunk: Record<string, unknown>): void {
|
||||
if (typeof chunk.id === 'string') target.id = chunk.id;
|
||||
if (typeof chunk.type === 'string') target.type = chunk.type;
|
||||
|
||||
const functionChunk = chunk.function;
|
||||
if (!isRecord(functionChunk)) return;
|
||||
|
||||
target.function = target.function ?? {};
|
||||
if (typeof functionChunk.name === 'string') {
|
||||
target.function.name = `${target.function.name ?? ''}${functionChunk.name}`;
|
||||
}
|
||||
if (typeof functionChunk.arguments === 'string') {
|
||||
target.function.arguments = `${target.function.arguments ?? ''}${functionChunk.arguments}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(value: unknown): UsageSnapshot | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
return {
|
||||
prompt_tokens: typeof value.prompt_tokens === 'number' ? value.prompt_tokens : undefined,
|
||||
completion_tokens: typeof value.completion_tokens === 'number' ? value.completion_tokens : undefined,
|
||||
total_tokens: typeof value.total_tokens === 'number' ? value.total_tokens : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class ChatStreamLogAccumulator {
|
||||
private id: string | undefined;
|
||||
private object: string | undefined;
|
||||
private created: number | undefined;
|
||||
private model: string | undefined;
|
||||
private usage: UsageSnapshot | undefined;
|
||||
private networkChunkCount = 0;
|
||||
private dataFrameCount = 0;
|
||||
private done = false;
|
||||
private buffer = '';
|
||||
private rawChunks: string[] = [];
|
||||
private choices = new Map<number, ChoiceState>();
|
||||
|
||||
constructor(private readonly collectRaw: boolean) {}
|
||||
|
||||
append(text: string, countNetworkChunk = true): void {
|
||||
if (countNetworkChunk) {
|
||||
this.networkChunkCount += 1;
|
||||
}
|
||||
|
||||
if (this.collectRaw) {
|
||||
this.rawChunks.push(text);
|
||||
}
|
||||
|
||||
this.buffer = `${this.buffer}${text}`.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
while (true) {
|
||||
const delimiterIndex = this.buffer.indexOf('\n\n');
|
||||
if (delimiterIndex < 0) break;
|
||||
|
||||
const eventText = this.buffer.slice(0, delimiterIndex);
|
||||
this.buffer = this.buffer.slice(delimiterIndex + 2);
|
||||
this.processEvent(eventText);
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
const remaining = this.buffer.trim();
|
||||
if (!remaining) {
|
||||
this.buffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.processEvent(this.buffer);
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
getResponseModel(): string | undefined {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
getUsage(): UsageSnapshot | undefined {
|
||||
return this.usage;
|
||||
}
|
||||
|
||||
toLogBody(mode: DetailStreamLogMode): string | CompactChatStreamLog | RawChatStreamLog | undefined {
|
||||
this.flush();
|
||||
|
||||
if (mode === 'off') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (mode === 'raw') {
|
||||
return this.rawChunks.join('');
|
||||
}
|
||||
|
||||
const compact = this.toCompactLog();
|
||||
if (mode === 'both') {
|
||||
return {
|
||||
format: RAW_CHAT_STREAM_FORMAT,
|
||||
compact,
|
||||
raw_sse: this.rawChunks.join(''),
|
||||
};
|
||||
}
|
||||
|
||||
return compact;
|
||||
}
|
||||
|
||||
private getChoice(index: number): ChoiceState {
|
||||
const existing = this.choices.get(index);
|
||||
if (existing) return existing;
|
||||
|
||||
const created: ChoiceState = {
|
||||
index,
|
||||
reasoning: [],
|
||||
content: [],
|
||||
toolCalls: new Map(),
|
||||
};
|
||||
this.choices.set(index, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private processEvent(eventText: string): void {
|
||||
const dataLines = eventText
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^ /, ''));
|
||||
|
||||
if (dataLines.length === 0) return;
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (data.trim() === '[DONE]') {
|
||||
this.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataFrameCount += 1;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (isRecord(parsed)) {
|
||||
this.processPayload(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON data frames are forwarded to clients, but not represented in compact logs.
|
||||
}
|
||||
}
|
||||
|
||||
private processPayload(payload: Record<string, unknown>): void {
|
||||
if (typeof payload.id === 'string' && !this.id) this.id = payload.id;
|
||||
if (typeof payload.object === 'string' && !this.object) this.object = payload.object;
|
||||
if (typeof payload.created === 'number' && this.created === undefined) this.created = payload.created;
|
||||
if (typeof payload.model === 'string' && !this.model) this.model = payload.model;
|
||||
|
||||
const usage = getUsage(payload.usage);
|
||||
if (usage) this.usage = usage;
|
||||
|
||||
if (!Array.isArray(payload.choices)) return;
|
||||
|
||||
for (const rawChoice of payload.choices) {
|
||||
if (!isRecord(rawChoice)) continue;
|
||||
|
||||
const index = typeof rawChoice.index === 'number' ? rawChoice.index : 0;
|
||||
const choice = this.getChoice(index);
|
||||
const delta = rawChoice.delta;
|
||||
|
||||
if (isRecord(delta)) {
|
||||
if (typeof delta.role === 'string') choice.role = delta.role;
|
||||
const reasoning = typeof delta.reasoning_content === 'string'
|
||||
? delta.reasoning_content
|
||||
: typeof delta.reasoning === 'string'
|
||||
? delta.reasoning
|
||||
: undefined;
|
||||
if (reasoning) choice.reasoning.push(reasoning);
|
||||
if (typeof delta.content === 'string') choice.content.push(delta.content);
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const rawToolCall of delta.tool_calls) {
|
||||
if (!isRecord(rawToolCall)) continue;
|
||||
const toolIndex = typeof rawToolCall.index === 'number' ? rawToolCall.index : choice.toolCalls.size;
|
||||
const toolCall = choice.toolCalls.get(toolIndex) ?? { index: toolIndex };
|
||||
mergeToolCall(toolCall, rawToolCall);
|
||||
choice.toolCalls.set(toolIndex, toolCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawChoice.finish_reason !== undefined && rawChoice.finish_reason !== null) {
|
||||
choice.finishReason = String(rawChoice.finish_reason);
|
||||
}
|
||||
if (rawChoice.stop_reason !== undefined && rawChoice.stop_reason !== null) {
|
||||
choice.stopReason = String(rawChoice.stop_reason);
|
||||
}
|
||||
if (rawChoice.matched_stop !== undefined && rawChoice.matched_stop !== null) {
|
||||
choice.matchedStop = String(rawChoice.matched_stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toCompactLog(): CompactChatStreamLog {
|
||||
const choices = [...this.choices.values()]
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((choice) => {
|
||||
const compactChoice: CompactChoice = {
|
||||
index: choice.index,
|
||||
};
|
||||
const reasoning = choice.reasoning.join('');
|
||||
const content = choice.content.join('');
|
||||
const toolCalls = [...choice.toolCalls.values()].sort((left, right) => left.index - right.index);
|
||||
|
||||
if (choice.role) compactChoice.role = choice.role;
|
||||
if (reasoning) compactChoice.reasoning = reasoning;
|
||||
if (content) compactChoice.content = content;
|
||||
if (toolCalls.length > 0) compactChoice.tool_calls = toolCalls;
|
||||
if (choice.finishReason) compactChoice.finish_reason = choice.finishReason;
|
||||
if (choice.stopReason) compactChoice.stop_reason = choice.stopReason;
|
||||
if (choice.matchedStop) compactChoice.matched_stop = choice.matchedStop;
|
||||
|
||||
return compactChoice;
|
||||
});
|
||||
|
||||
return {
|
||||
format: COMPACT_CHAT_STREAM_FORMAT,
|
||||
id: this.id,
|
||||
object: this.object,
|
||||
created: this.created,
|
||||
model: this.model,
|
||||
choices,
|
||||
usage: this.usage,
|
||||
stream: {
|
||||
network_chunk_count: this.networkChunkCount,
|
||||
data_frame_count: this.dataFrameCount,
|
||||
done: this.done,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,17 @@ describe('Admin API - User Management', () => {
|
|||
expect(response.body.email).toBe(userData.email);
|
||||
expect(response.body).toHaveProperty('api_key');
|
||||
expect(response.body.api_key).toMatch(/^sk-/);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(false);
|
||||
});
|
||||
|
||||
it('should create a user with reasoning compatibility enabled', async () => {
|
||||
const response = await admin.post('/admin/users').send({
|
||||
name: 'Reasoning Compat User',
|
||||
copy_reasoning_to_reasoning_content: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a user with a manually supplied api key', async () => {
|
||||
|
|
@ -111,6 +122,15 @@ describe('Admin API - User Management', () => {
|
|||
expect(response.body.api_key).toBe('legacy-updated-key-001');
|
||||
});
|
||||
|
||||
it('should update user reasoning compatibility setting', async () => {
|
||||
const response = await admin
|
||||
.put(`/admin/users/${userId}`)
|
||||
.send({ copy_reasoning_to_reasoning_content: true });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.copy_reasoning_to_reasoning_content).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
const response = await admin.put('/admin/users/99999').send({ name: 'Test' });
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { initDb } from '../../src/config/database';
|
|||
import { RequestLogService } from '../../src/services/RequestLogService';
|
||||
import { AnalyticsService } from '../../src/services/AnalyticsService';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
import { getLocalDateKey, getUtcTimestamp } from '../../src/utils/time';
|
||||
|
||||
describe('Auth & Proxy API', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
|
|
@ -164,6 +165,10 @@ describe('Auth & Proxy API', () => {
|
|||
});
|
||||
|
||||
it('should expose chart-friendly analytics endpoints', async () => {
|
||||
const primaryDate = getLocalDateKey();
|
||||
const secondarySourceDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const secondaryDate = getLocalDateKey(secondarySourceDate);
|
||||
|
||||
AnalyticsService.logRequest({
|
||||
user_id: 7001,
|
||||
backend_id: backendId,
|
||||
|
|
@ -175,8 +180,8 @@ describe('Auth & Proxy API', () => {
|
|||
total_tokens: 240,
|
||||
response_time_ms: 380,
|
||||
status_code: 200,
|
||||
local_date: '2026-03-10',
|
||||
created_at: '2026-03-10T03:00:00.000Z',
|
||||
local_date: primaryDate,
|
||||
created_at: getUtcTimestamp(),
|
||||
});
|
||||
|
||||
AnalyticsService.logRequest({
|
||||
|
|
@ -191,8 +196,8 @@ describe('Auth & Proxy API', () => {
|
|||
response_time_ms: 510,
|
||||
status_code: 500,
|
||||
error_message: 'synthetic-error',
|
||||
local_date: '2026-03-11',
|
||||
created_at: '2026-03-11T03:00:00.000Z',
|
||||
local_date: secondaryDate,
|
||||
created_at: getUtcTimestamp(secondarySourceDate),
|
||||
});
|
||||
|
||||
const [dailyTotals, backendQuality, modelTrends, histogram, boxPlot] = await Promise.all([
|
||||
|
|
@ -221,7 +226,7 @@ describe('Auth & Proxy API', () => {
|
|||
|
||||
expect(boxPlot.status).toBe(200);
|
||||
expect(Array.isArray(boxPlot.body)).toBe(true);
|
||||
expect(boxPlot.body.some((row: any) => row.date === '2026-03-10' && row.median === 120)).toBe(true);
|
||||
expect(boxPlot.body.some((row: any) => row.date === primaryDate && row.median === 120)).toBe(true);
|
||||
});
|
||||
|
||||
it('should expose dashboard summary data for the ops cockpit', async () => {
|
||||
|
|
|
|||
|
|
@ -280,6 +280,8 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.MODEL_LIST_INCLUDE_ROUTING_METADATA;
|
||||
|
||||
if (mockServer) {
|
||||
await new Promise<void>(resolve => mockServer.close(resolve));
|
||||
mockServer = undefined;
|
||||
|
|
@ -374,6 +376,57 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
expect(receivedAuthorization).toBe('Bearer upstream-secret-key');
|
||||
expect(receivedAuthorization).not.toBe(`Bearer ${userApiKey}`);
|
||||
});
|
||||
|
||||
it('should preserve multimodal image messages and chat template kwargs when proxying', async () => {
|
||||
let receivedBody: any;
|
||||
const { server, port } = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
if (req.path === '/v1/chat/completions') {
|
||||
receivedBody = req.body;
|
||||
}
|
||||
},
|
||||
modelsResponse: [{ id: 'vision-test-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Vision Payload User 6-6' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Vision Payload Backend 6-6',
|
||||
base_url: `http://localhost:${mockPort}`,
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
const imageDataUrl = `data:image/jpeg;base64,${'a'.repeat(128 * 1024)}`;
|
||||
const payload = {
|
||||
model: 'vision-test-model',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '이 이미지는 무엇인가요?' },
|
||||
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
||||
],
|
||||
}],
|
||||
chat_template_kwargs: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send(payload);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(receivedBody).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 7: Models endpoint routing', () => {
|
||||
|
|
@ -568,5 +621,271 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
expect(receivedModel).toBe('fallback-model');
|
||||
expect(response.body.model).toBe('fallback-model');
|
||||
});
|
||||
|
||||
it('should follow force rewrite chains before upstream forwarding', async () => {
|
||||
let receivedModel: string | undefined;
|
||||
const { server, port } = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
if (req.path === '/v1/chat/completions') {
|
||||
receivedModel = req.body.model;
|
||||
}
|
||||
},
|
||||
chatResponse: {
|
||||
id: 'force-chain-success',
|
||||
model: 'chain-final-c',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'chain' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
modelsResponse: [{ id: 'chain-final-c', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Force Chain User 8-10' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Force Chain Backend 8-10',
|
||||
base_url: `http://localhost:${port}`,
|
||||
});
|
||||
|
||||
await admin.post('/admin/permissions').send({ user_id: userId, backend_id: backendResponse.body.id });
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'chain-start-a', target_model: 'chain-mid-b', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'chain-mid-b', target_model: 'chain-final-c', force: true })).status).toBe(201);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send({ model: 'chain-start-a', messages: [] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(receivedModel).toBe('chain-final-c');
|
||||
});
|
||||
|
||||
it('should continue a mixed chain only when the current fallback model is unavailable', async () => {
|
||||
let unavailableReceivedModel: string | undefined;
|
||||
const unavailableBackend = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
if (req.path === '/v1/chat/completions') {
|
||||
unavailableReceivedModel = req.body.model;
|
||||
}
|
||||
},
|
||||
chatResponse: {
|
||||
id: 'mixed-chain-unavailable',
|
||||
model: 'mixed-final-e',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'fallback-chain' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
modelsResponse: [{ id: 'mixed-final-e', object: 'model' }],
|
||||
});
|
||||
mockServer = unavailableBackend.server;
|
||||
mockPort = unavailableBackend.port;
|
||||
|
||||
const unavailableUser = await admin.post('/admin/users').send({ name: 'Mixed Chain Missing User 8-11' });
|
||||
const unavailableBackendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Mixed Chain Missing Backend 8-11',
|
||||
base_url: `http://localhost:${unavailableBackend.port}`,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: unavailableUser.body.id, backend_id: unavailableBackendResponse.body.id });
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-missing-a', target_model: 'mixed-missing-b', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-missing-b', target_model: 'mixed-missing-c', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-missing-c', target_model: 'mixed-missing-d', force: false })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-missing-d', target_model: 'mixed-final-e', force: true })).status).toBe(201);
|
||||
|
||||
const unavailableResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${unavailableUser.body.api_key}`)
|
||||
.send({ model: 'mixed-missing-a', messages: [] });
|
||||
|
||||
expect(unavailableResponse.status).toBe(200);
|
||||
expect(unavailableReceivedModel).toBe('mixed-final-e');
|
||||
|
||||
let availableReceivedModel: string | undefined;
|
||||
const availableBackend = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
if (req.path === '/v1/chat/completions') {
|
||||
availableReceivedModel = req.body.model;
|
||||
}
|
||||
},
|
||||
chatResponse: {
|
||||
id: 'mixed-chain-available',
|
||||
model: 'mixed-available-c',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'available-chain' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
modelsResponse: [{ id: 'mixed-available-c', object: 'model' }, { id: 'mixed-available-e', object: 'model' }],
|
||||
});
|
||||
|
||||
try {
|
||||
const availableUser = await admin.post('/admin/users').send({ name: 'Mixed Chain Available User 8-12' });
|
||||
const availableBackendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Mixed Chain Available Backend 8-12',
|
||||
base_url: `http://localhost:${availableBackend.port}`,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: availableUser.body.id, backend_id: availableBackendResponse.body.id });
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-available-a', target_model: 'mixed-available-b', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-available-b', target_model: 'mixed-available-c', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-available-c', target_model: 'mixed-available-d', force: false })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'mixed-available-d', target_model: 'mixed-available-e', force: true })).status).toBe(201);
|
||||
|
||||
const availableResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${availableUser.body.api_key}`)
|
||||
.send({ model: 'mixed-available-a', messages: [] });
|
||||
|
||||
expect(availableResponse.status).toBe(200);
|
||||
expect(availableReceivedModel).toBe('mixed-available-c');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => availableBackend.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('should expose only requestable native models and rewrite aliases from /v1/models', async () => {
|
||||
const allowedBackend = createMockBackend({
|
||||
modelsResponse: [
|
||||
{ id: 'models-visible-final', object: 'model' },
|
||||
{ id: 'models-native-forced-away', object: 'model' },
|
||||
],
|
||||
});
|
||||
const deniedBackend = createMockBackend({
|
||||
modelsResponse: [{ id: 'models-denied-final', object: 'model' }],
|
||||
});
|
||||
mockServer = allowedBackend.server;
|
||||
mockPort = allowedBackend.port;
|
||||
|
||||
try {
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Requestable Models User 8-13' });
|
||||
const allowedBackendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Requestable Models Allowed Backend 8-13',
|
||||
base_url: `http://localhost:${allowedBackend.port}`,
|
||||
});
|
||||
await admin.post('/admin/backends').send({
|
||||
name: 'Requestable Models Denied Backend 8-13',
|
||||
base_url: `http://localhost:${deniedBackend.port}`,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: userResponse.body.id, backend_id: allowedBackendResponse.body.id });
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'models-visible-alias', target_model: 'models-visible-final', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'models-missing-alias', target_model: 'models-missing-final', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'models-denied-alias', target_model: 'models-denied-final', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'models-native-forced-away', target_model: 'models-missing-final', force: true })).status).toBe(201);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/v1/models')
|
||||
.set('Authorization', `Bearer ${userResponse.body.api_key}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const ids = response.body.data.map((item: any) => item.id);
|
||||
expect(ids).toContain('models-visible-final');
|
||||
expect(ids).toContain('models-visible-alias');
|
||||
expect(ids).not.toContain('models-missing-alias');
|
||||
expect(ids).not.toContain('models-denied-alias');
|
||||
expect(ids).not.toContain('models-native-forced-away');
|
||||
expect(response.body.data.every((item: any) => item.kyush_router === undefined)).toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => deniedBackend.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('should include kyush_router metadata for /v1/models when enabled', async () => {
|
||||
process.env.MODEL_LIST_INCLUDE_ROUTING_METADATA = 'true';
|
||||
|
||||
const { server, port } = createMockBackend({
|
||||
modelsResponse: [
|
||||
{ id: 'metadata-native', object: 'model' },
|
||||
{ id: 'metadata-final', object: 'model' },
|
||||
{ id: 'metadata-skip-current', object: 'model' },
|
||||
],
|
||||
});
|
||||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Model Metadata User 8-14' });
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Model Metadata Backend 8-14',
|
||||
base_url: `http://localhost:${port}`,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: userResponse.body.id, backend_id: backendResponse.body.id });
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'metadata-alias-a', target_model: 'metadata-alias-b', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'metadata-alias-b', target_model: 'metadata-missing-c', force: true })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'metadata-missing-c', target_model: 'metadata-final', force: false })).status).toBe(201);
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'metadata-skip-current', target_model: 'metadata-skip-target', force: false })).status).toBe(201);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/v1/models')
|
||||
.set('Authorization', `Bearer ${userResponse.body.api_key}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
expect(response.body.data.every((item: any) => item.kyush_router && !('backend_ids' in item.kyush_router))).toBe(true);
|
||||
|
||||
const native = response.body.data.find((item: any) => item.id === 'metadata-native');
|
||||
expect(native.kyush_router).toEqual({
|
||||
requested_model: 'metadata-native',
|
||||
routed_model: 'metadata-native',
|
||||
was_rewritten: false,
|
||||
rule_type: 'none',
|
||||
rewrite_path: [],
|
||||
});
|
||||
|
||||
const alias = response.body.data.find((item: any) => item.id === 'metadata-alias-a');
|
||||
expect(alias.kyush_router).toEqual({
|
||||
requested_model: 'metadata-alias-a',
|
||||
routed_model: 'metadata-final',
|
||||
was_rewritten: true,
|
||||
rule_type: 'chain',
|
||||
rewrite_path: [
|
||||
{ source_model: 'metadata-alias-a', target_model: 'metadata-alias-b', mode: 'force' },
|
||||
{ source_model: 'metadata-alias-b', target_model: 'metadata-missing-c', mode: 'force' },
|
||||
{ source_model: 'metadata-missing-c', target_model: 'metadata-final', mode: 'fallback' },
|
||||
],
|
||||
});
|
||||
|
||||
const skippedFallback = response.body.data.find((item: any) => item.id === 'metadata-skip-current');
|
||||
expect(skippedFallback.kyush_router).toEqual({
|
||||
requested_model: 'metadata-skip-current',
|
||||
routed_model: 'metadata-skip-current',
|
||||
was_rewritten: false,
|
||||
rule_type: 'none',
|
||||
rewrite_path: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject active rewrite cycles while allowing inactive cycles until activation', async () => {
|
||||
const selfLoop = await admin.post('/admin/model-rewrites').send({
|
||||
source_model: 'cycle-self-a',
|
||||
target_model: 'cycle-self-a',
|
||||
force: true,
|
||||
});
|
||||
expect(selfLoop.status).toBe(409);
|
||||
expect(selfLoop.body.error).toBe('Model rewrite cycle detected');
|
||||
|
||||
expect((await admin.post('/admin/model-rewrites').send({ source_model: 'cycle-active-a', target_model: 'cycle-active-b', force: true })).status).toBe(201);
|
||||
const activeCycle = await admin.post('/admin/model-rewrites').send({
|
||||
source_model: 'cycle-active-b',
|
||||
target_model: 'cycle-active-a',
|
||||
force: true,
|
||||
});
|
||||
expect(activeCycle.status).toBe(409);
|
||||
|
||||
const inactiveA = await admin.post('/admin/model-rewrites').send({
|
||||
source_model: 'cycle-inactive-a',
|
||||
target_model: 'cycle-inactive-b',
|
||||
is_active: false,
|
||||
force: true,
|
||||
});
|
||||
const inactiveB = await admin.post('/admin/model-rewrites').send({
|
||||
source_model: 'cycle-inactive-b',
|
||||
target_model: 'cycle-inactive-a',
|
||||
is_active: false,
|
||||
force: true,
|
||||
});
|
||||
expect(inactiveA.status).toBe(201);
|
||||
expect(inactiveB.status).toBe(201);
|
||||
|
||||
const activation = await admin.put(`/admin/model-rewrites/${inactiveA.body.id}`).send({ is_active: true });
|
||||
expect(activation.status).toBe(200);
|
||||
const secondActivation = await admin.put(`/admin/model-rewrites/${inactiveB.body.id}`).send({ is_active: true });
|
||||
expect(secondActivation.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import request from 'supertest';
|
|||
import { createTestApp } from '../utils/testApp';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
import { createMockBackend } from '../utils/mockBackend';
|
||||
|
||||
describe('Script API Endpoints', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
|
|
@ -10,6 +11,8 @@ describe('Script API Endpoints', () => {
|
|||
let userId: number;
|
||||
let backendId: number;
|
||||
let scriptId: number;
|
||||
let backendServer: ReturnType<typeof createMockBackend>['server'];
|
||||
let receivedChatBody: any;
|
||||
|
||||
beforeAll(() => {
|
||||
initDb();
|
||||
|
|
@ -22,12 +25,22 @@ describe('Script API Endpoints', () => {
|
|||
|
||||
// Setup: Create user and backend for testing
|
||||
beforeAll(async () => {
|
||||
const mockBackend = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
if (req.path === '/v1/chat/completions') {
|
||||
receivedChatBody = req.body;
|
||||
}
|
||||
},
|
||||
modelsResponse: [{ id: 'test-model', object: 'model' }],
|
||||
});
|
||||
backendServer = mockBackend.server;
|
||||
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Script Test User' });
|
||||
userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Script Test Backend',
|
||||
base_url: 'http://localhost:8006/v1'
|
||||
base_url: `http://localhost:${mockBackend.port}/v1`
|
||||
});
|
||||
backendId = backendResponse.body.id;
|
||||
});
|
||||
|
|
@ -36,6 +49,7 @@ describe('Script API Endpoints', () => {
|
|||
// Cleanup: Delete created resources
|
||||
await admin.delete(`/admin/users/${userId}`);
|
||||
await admin.delete(`/admin/backends/${backendId}`);
|
||||
await new Promise<void>((resolve) => backendServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('GET /admin/scripts', () => {
|
||||
|
|
@ -369,9 +383,11 @@ export const onResponse = (context) => {
|
|||
name: 'Integration Test Script',
|
||||
script_code: `
|
||||
export const onRequest = (context) => {
|
||||
const body = JSON.parse(context.request.body);
|
||||
const body = typeof context.request.body === 'string'
|
||||
? JSON.parse(context.request.body)
|
||||
: context.request.body;
|
||||
body.messages.push({ role: 'system', content: 'Modified by middleware' });
|
||||
context.request.body = JSON.stringify(body);
|
||||
context.request.body = body;
|
||||
return context;
|
||||
};
|
||||
|
||||
|
|
@ -402,6 +418,8 @@ export const onResponse = (context) => {
|
|||
it('should execute script when making request to backend', async () => {
|
||||
// This test verifies that scripts are executed during actual API calls
|
||||
// The script should modify the request before forwarding to backend
|
||||
receivedChatBody = undefined;
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
|
|
@ -410,9 +428,8 @@ export const onResponse = (context) => {
|
|||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
});
|
||||
|
||||
// Request will fail (502) because backend is not actually running,
|
||||
// but we can verify the script was executed by checking logs
|
||||
expect(response.status).toBe(502); // Backend unreachable
|
||||
expect(response.status).toBe(200);
|
||||
expect(receivedChatBody?.messages).toContainEqual({ role: 'system', content: 'Modified by middleware' });
|
||||
|
||||
// Check that request was logged with script execution
|
||||
const analyticsResponse = await admin.get('/admin/analytics/requests?limit=10');
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ describe('Streaming Response Proxying', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.DETAIL_STREAM_LOG_MODE;
|
||||
|
||||
if (mockServer) {
|
||||
await new Promise<void>(resolve => mockServer.close(resolve));
|
||||
mockServer = undefined;
|
||||
|
|
@ -73,7 +75,7 @@ describe('Streaming Response Proxying', () => {
|
|||
}
|
||||
});
|
||||
|
||||
async function setupUserAndBackend(mockPort: number) {
|
||||
async function setupUserAndBackend(mockPort: number, options: { detailLogging?: boolean; copyReasoning?: boolean } = {}) {
|
||||
// Deactivate all existing backends to ensure only our mock backend is selected
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
for (const backend of allBackendsResponse.body) {
|
||||
|
|
@ -82,7 +84,11 @@ describe('Streaming Response Proxying', () => {
|
|||
}
|
||||
}
|
||||
|
||||
const userResponse = await admin.post('/admin/users').send({ name: `Stream Test User ${Date.now()}` });
|
||||
const userResponse = await admin.post('/admin/users').send({
|
||||
name: `Stream Test User ${Date.now()}`,
|
||||
detail_logging: options.detailLogging,
|
||||
copy_reasoning_to_reasoning_content: options.copyReasoning,
|
||||
});
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
|
|
@ -97,6 +103,15 @@ describe('Streaming Response Proxying', () => {
|
|||
return { userApiKey, userId, backendId };
|
||||
}
|
||||
|
||||
async function createUserForBackend(backendId: number, options: { copyReasoning?: boolean } = {}) {
|
||||
const userResponse = await admin.post('/admin/users').send({
|
||||
name: `Stream Compat User ${Date.now()} ${Math.random()}`,
|
||||
copy_reasoning_to_reasoning_content: options.copyReasoning,
|
||||
});
|
||||
await admin.post('/admin/permissions').send({ user_id: userResponse.body.id, backend_id: backendId });
|
||||
return { userApiKey: userResponse.body.api_key as string, userId: userResponse.body.id as number };
|
||||
}
|
||||
|
||||
it('should return Content-Type text/event-stream for stream requests', async () => {
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: sampleStreamChunks,
|
||||
|
|
@ -237,4 +252,244 @@ describe('Streaming Response Proxying', () => {
|
|||
expect(receivedBody).toBeDefined();
|
||||
expect(receivedBody.stream).toBe(true);
|
||||
});
|
||||
|
||||
it('should store compact stream logs by default when detail logging is enabled', async () => {
|
||||
process.env.DETAIL_STREAM_LOG_MODE = 'compact';
|
||||
const compactStreamChunks = [
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', reasoning: 'Think' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { reasoning: ' first. ' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
id: 'tool-1',
|
||||
type: 'function',
|
||||
index: 0,
|
||||
function: { name: 'search_web', arguments: '' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '{"query":"' } }] }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: 'apple"}' } }] }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { content: ' world' }, finish_reason: 'stop', stop_reason: 106 }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-compact-1',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1776916142,
|
||||
model: 'mock-model',
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 7, completion_tokens: 5, total_tokens: 12 },
|
||||
}),
|
||||
];
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: compactStreamChunks,
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { userApiKey, userId } = await setupUserAndBackend(port, { detailLogging: true });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toMatch(/text\/event-stream/);
|
||||
|
||||
const logsResponse = await admin.get(`/admin/analytics/requests?limit=1&userId=${userId}&detailLogged=1`);
|
||||
expect(logsResponse.body.rows).toHaveLength(1);
|
||||
|
||||
const responseBody = logsResponse.body.rows[0].response_body;
|
||||
expect(responseBody).not.toContain('data: ');
|
||||
|
||||
const parsed = JSON.parse(responseBody);
|
||||
expect(parsed.format).toBe('kyush.chat_stream.compact.v1');
|
||||
expect(parsed.id).toBe('chatcmpl-compact-1');
|
||||
expect(parsed.model).toBe('mock-model');
|
||||
expect(parsed.choices[0].reasoning).toBe('Think first. ');
|
||||
expect(parsed.choices[0].content).toBe('Hello world');
|
||||
expect(parsed.choices[0].tool_calls[0].function.arguments).toBe('{"query":"apple"}');
|
||||
expect(parsed.choices[0].finish_reason).toBe('stop');
|
||||
expect(parsed.choices[0].stop_reason).toBe('106');
|
||||
expect(parsed.usage.total_tokens).toBe(12);
|
||||
expect(parsed.stream.done).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep raw SSE logs when DETAIL_STREAM_LOG_MODE=raw', async () => {
|
||||
process.env.DETAIL_STREAM_LOG_MODE = 'raw';
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: sampleStreamChunks,
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { userApiKey, userId } = await setupUserAndBackend(port, { detailLogging: true });
|
||||
|
||||
await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const logsResponse = await admin.get(`/admin/analytics/requests?limit=1&userId=${userId}&detailLogged=1`);
|
||||
expect(logsResponse.body.rows).toHaveLength(1);
|
||||
expect(logsResponse.body.rows[0].response_body).toContain('data: ');
|
||||
expect(logsResponse.body.rows[0].response_body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('should copy streaming reasoning to reasoning_content only for users with compatibility enabled', async () => {
|
||||
const reasoningStreamChunks = [
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { role: 'assistant', reasoning: 'Think once.' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { reasoning: ' Keep original.', reasoning_content: 'Existing wins.' }, finish_reason: null }],
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-reasoning-1',
|
||||
object: 'chat.completion.chunk',
|
||||
model: 'mock-model',
|
||||
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: 'stop' }],
|
||||
}),
|
||||
];
|
||||
const { server, port } = createMockBackend({
|
||||
streamChunks: reasoningStreamChunks,
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { backendId, userApiKey: defaultUserApiKey } = await setupUserAndBackend(port);
|
||||
const { userApiKey: compatUserApiKey } = await createUserForBackend(backendId, { copyReasoning: true });
|
||||
|
||||
const defaultResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${defaultUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const compatResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${compatUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const defaultFirstChunk = JSON.parse(defaultResponse.text.split('\n').find((line: string) => line.startsWith('data: {'))!.replace('data: ', ''));
|
||||
expect(defaultFirstChunk.choices[0].delta.reasoning).toBe('Think once.');
|
||||
expect(defaultFirstChunk.choices[0].delta.reasoning_content).toBeUndefined();
|
||||
|
||||
const compatDataLines = compatResponse.text.split('\n').filter((line: string) => line.startsWith('data: {'));
|
||||
const compatFirstChunk = JSON.parse(compatDataLines[0].replace('data: ', ''));
|
||||
expect(compatFirstChunk.choices[0].delta.reasoning).toBe('Think once.');
|
||||
expect(compatFirstChunk.choices[0].delta.reasoning_content).toBe('Think once.');
|
||||
|
||||
const compatSecondChunk = JSON.parse(compatDataLines[1].replace('data: ', ''));
|
||||
expect(compatSecondChunk.choices[0].delta.reasoning).toBe(' Keep original.');
|
||||
expect(compatSecondChunk.choices[0].delta.reasoning_content).toBe('Existing wins.');
|
||||
expect(compatResponse.text).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('should copy non-stream reasoning to reasoning_content only for users with compatibility enabled', async () => {
|
||||
const { server, port } = createMockBackend({
|
||||
chatResponse: {
|
||||
id: 'non-stream-reasoning-1',
|
||||
model: 'mock-model',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello', reasoning: 'Think non-stream.' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
modelsResponse: [{ id: 'mock-model', object: 'model' }],
|
||||
});
|
||||
mockServer = server;
|
||||
|
||||
const { backendId, userApiKey: defaultUserApiKey } = await setupUserAndBackend(port);
|
||||
const { userApiKey: compatUserApiKey } = await createUserForBackend(backendId, { copyReasoning: true });
|
||||
|
||||
const defaultResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${defaultUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
const compatResponse = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${compatUserApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
expect(defaultResponse.body.choices[0].message.reasoning).toBe('Think non-stream.');
|
||||
expect(defaultResponse.body.choices[0].message.reasoning_content).toBeUndefined();
|
||||
expect(compatResponse.body.choices[0].message.reasoning).toBe('Think non-stream.');
|
||||
expect(compatResponse.body.choices[0].message.reasoning_content).toBe('Think non-stream.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
37
server/tests/unit/request-body.test.ts
Normal file
37
server/tests/unit/request-body.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
import { logger } from '../../src/utils/logger';
|
||||
|
||||
describe('request body parser errors', () => {
|
||||
it('should log payload size details when JSON body exceeds the configured limit', async () => {
|
||||
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const app = express();
|
||||
|
||||
app.use(createJsonBodyParser('1kb'));
|
||||
app.use(requestBodyErrorHandler);
|
||||
app.post('/v1/chat/completions', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.send({ model: 'vision-test-model', data: 'x'.repeat(2048) });
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(response.body.error).toBe('Request body too large');
|
||||
expect(response.body.payload_size_bytes).toBeGreaterThan(1024);
|
||||
expect(response.body.limit_bytes).toBe(1024);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('Request body too large');
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('payload=');
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('limit=1.00 KB (1024 B)');
|
||||
expect(warnSpy.mock.calls[0][1]).toMatchObject({
|
||||
payload_size_bytes: expect.any(Number),
|
||||
parser_limit_bytes: 1024,
|
||||
parser_error_type: 'entity.too.large',
|
||||
});
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
|
||||
export interface MockBackendOptions {
|
||||
port?: number;
|
||||
|
|
@ -8,7 +9,7 @@ export interface MockBackendOptions {
|
|||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: string; content: string };
|
||||
message: { role: string; content: string; reasoning?: string; reasoning_content?: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
|
|
@ -33,7 +34,8 @@ export function createMockBackend(options: MockBackendOptions = {}) {
|
|||
} = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.post('/v1/chat/completions', (req, res) => {
|
||||
onRequest?.(req);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { initRequestLogsDb } from '../../src/config/request-logs-db';
|
|||
import { getUtcTimestamp } from '../../src/utils/time';
|
||||
import { requireAdminAccess, requireSessionCsrf } from '../../src/utils/adminAuth';
|
||||
import { ModelCatalogService } from '../../src/services/ModelCatalogService';
|
||||
import { createJsonBodyParser, requestBodyErrorHandler } from '../../src/utils/requestBody';
|
||||
|
||||
export function createTestApp() {
|
||||
// Initialize both databases
|
||||
|
|
@ -22,7 +23,8 @@ export function createTestApp() {
|
|||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(requestBodyErrorHandler);
|
||||
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface User {
|
|||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
copy_reasoning_to_reasoning_content: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -102,6 +103,7 @@ export interface CreateUserData {
|
|||
email?: string;
|
||||
api_key?: string;
|
||||
detail_logging?: boolean;
|
||||
copy_reasoning_to_reasoning_content?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBackendData {
|
||||
|
|
@ -122,6 +124,7 @@ export interface UpdateUserData {
|
|||
api_key?: string;
|
||||
is_active?: boolean;
|
||||
detail_logging?: boolean;
|
||||
copy_reasoning_to_reasoning_content?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBackendData {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue