Compare commits

...

21 commits

Author SHA1 Message Date
28049bce2c chore(package): bump version to 1.0.10-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m57s
2026-05-26 16:48:23 +09:00
e8276cde3f fix: analytics overload problem with group and indexing 2026-05-26 16:47:48 +09:00
cb55e2d24a chore(package): bump version to 1.0.9-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m11s
2026-05-13 21:55:05 +09:00
0f64a4cd85 feat(users): add 'copy_reasoning_to_reasoning_content' option for user creation and updates 2026-05-13 21:54:44 +09:00
227e5b12da chore(package): bump version to 1.0.8-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 1m20s
2026-05-12 18:24:46 +09:00
4cae96500e feat(chart): update symlog tick generation to allow custom intervals 2026-05-12 18:24:01 +09:00
1f1514b5da feat(dashboard): add traffic volume scale selector for linear and logarithmic views 2026-05-12 18:20:41 +09:00
fd37fd276a feat(chart): filter out non-positive ticks from symlog scale in TimeSeriesChart 2026-05-12 18:20:33 +09:00
43664819d4 feat(analytics): add logarithmic scale option for daily volume chart 2026-05-12 18:20:25 +09:00
96c9b963b4 chore(package): bump version to 1.0.7-express
All checks were successful
Publish Container Images / publish-images (push) Successful in 2m10s
2026-05-12 16:20:01 +09:00
472e289198 feat(routes): implement memoization for resource states in Backends, Models, Scripts, and Users components 2026-05-12 16:17:32 +09:00
f6a032f81c feat(analytics): add auto-refresh functionality with customizable interval and refresh button 2026-05-12 16:14:51 +09:00
3bcac29fa1 feat(detail-logs): enhance search functionality with debounce and improve refresh button accessibility 2026-05-12 16:13:12 +09:00
5b8b91d942 feat(dashboard): implement auto-refresh feature with customizable interval and loading state for refresh button 2026-05-12 16:09:58 +09:00
c3b743ccbd feat(dashboard): add auto-refresh functionality and refresh interval selection 2026-05-12 15:56:10 +09:00
dee98a88b4 feat(workspace): add allowBuilds section for better dependency management 2026-05-12 15:55:06 +09:00
7d42d208b5 fix(workflow): add progress output to Docker build for better visibility 2026-04-27 18:19:57 +09:00
308ed58467 fix(conversation): update response creation key for clarity and remove stream creation key 2026-04-23 18:41:38 +09:00
fd67e481ec fix(conversation): tool call normalization for message handling 2026-04-23 18:31:30 +09:00
6b0e37cff7 release(package): update version to 1.0.6-express in package.json
All checks were successful
Publish Container Images / publish-images (push) Successful in 56s
2026-04-23 18:13:08 +09:00
3fcc017c0c feat(routing): add MODEL_LIST_INCLUDE_ROUTING_METADATA for enhanced model metadata in responses 2026-04-23 18:12:51 +09:00
45 changed files with 1201 additions and 332 deletions

View file

@ -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=

View file

@ -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.

View file

@ -78,6 +78,7 @@ 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

View file

@ -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) }),

View file

@ -1,4 +1,5 @@
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';
@ -13,6 +14,7 @@ import {
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,7 +154,7 @@ 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);
@ -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>
@ -285,14 +348,14 @@ export const Analytics: Component = () => {
<div class="ui-section-grid analytics__grid--spread-wide">
<Panel title="Response Length Distribution" description="Log-scaled completion_tokens histogram across the selected window.">
<HistogramChart
data={histogram() ?? []}
data={currentHistogram() ?? []}
xTickUnit="tok"
yTickUnit="req"
/>
</Panel>
<Panel title="Daily Response Length Spread" description="Log-scaled daily completion_tokens spread; outliers are hidden.">
<BoxPlotChart data={boxPlot() ?? []} />
<BoxPlotChart data={currentBoxPlot() ?? []} />
</Panel>
</div>
</div>

View file

@ -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

View file

@ -1,5 +1,5 @@
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';
@ -14,6 +14,7 @@ import {
Panel,
Select,
SummaryStrip,
Switch,
TimeSeriesChart,
} from '../ui';
@ -33,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>();
@ -47,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,
@ -56,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;
@ -74,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);
@ -83,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}`,
@ -93,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);
@ -102,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,
@ -111,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 [
@ -123,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) },
@ -134,7 +148,7 @@ export const Dashboard: Component = () => {
});
const scriptItems = createMemo(() => {
const payload = summary();
const payload = currentSummary();
if (!payload) return [];
return [
@ -145,7 +159,7 @@ export const Dashboard: Component = () => {
});
const accessItems = createMemo(() => {
const payload = summary();
const payload = currentSummary();
if (!payload) return [];
return [
@ -177,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()} />
@ -194,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
@ -218,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>
@ -277,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>

View file

@ -1,4 +1,4 @@
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';
@ -15,6 +15,7 @@ interface FilterState {
}
const PAGE_SIZE_OPTIONS = [25, 50, 100];
const SEARCH_DEBOUNCE_MS = 350;
const emptyFilters = (): FilterState => ({
month: '',
@ -37,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);
@ -62,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()));
@ -139,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
@ -169,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"
@ -228,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={{
@ -243,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>

View file

@ -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(', '),
}))
@ -151,9 +154,9 @@ export const Models: Component = () => {
<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>
@ -229,11 +232,11 @@ export const Models: Component = () => {
>
<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)} />

View file

@ -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">

View file

@ -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>

View file

@ -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;
};

View file

@ -214,6 +214,7 @@ interface TimeSeriesChartProps {
formatLeftValue?: (value: number) => string;
formatRightValue?: (value: number) => string;
tooltipTitle?: string;
yScaleType?: 'linear' | 'log';
}
interface ChartLegendProps {
@ -287,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();
@ -326,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;
})
: []
);

View file

@ -70,6 +70,24 @@ function prettyJson(value: unknown): string {
}
}
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;
@ -113,7 +131,7 @@ function normalizeCompactStreamResponse(payload: Record<string, unknown> | null)
role: typeof choice.role === 'string' ? choice.role : 'assistant',
content: stringifyValue(choice.content),
reasoning: stringifyValue(choice.reasoning).trim() || undefined,
toolCalls: choice.tool_calls !== undefined ? prettyJson(choice.tool_calls) : undefined,
toolCalls: normalizeToolCalls(choice.tool_calls),
metadata,
};
})
@ -152,7 +170,7 @@ function normalizeAssistantMessages(payload: Record<string, unknown> | null): Pa
const messageRecord = message as Record<string, unknown>;
const content = stringifyValue(messageRecord.content);
const reasoning = stringifyValue(messageRecord.reasoning_content ?? messageRecord.reasoning).trim();
const toolCalls = messageRecord.tool_calls !== undefined ? prettyJson(messageRecord.tool_calls) : undefined;
const toolCalls = normalizeToolCalls(messageRecord.tool_calls);
const metadata = [
(choice as Record<string, unknown>).finish_reason !== undefined
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
@ -270,8 +288,12 @@ function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
if (isRecord(delta)) {
if (typeof delta.role === 'string') choice.role = delta.role;
if (typeof delta.content === 'string') choice.content.push(delta.content);
if (typeof delta.reasoning === 'string') choice.reasoning.push(delta.reasoning);
if (typeof delta.reasoning_content === 'string') choice.reasoning.push(delta.reasoning_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) {
@ -306,7 +328,7 @@ function parseStreamResponse(value: unknown): ParsedStreamResponse | null {
role: choice.role ?? 'assistant',
content: choice.content.join(''),
reasoning: choice.reasoning.join('') || undefined,
toolCalls: toolCalls.length > 0 ? prettyJson(toolCalls) : undefined,
toolCalls: normalizeToolCalls(toolCalls),
metadata,
};
})
@ -398,8 +420,8 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
typeof stream?.model === 'string' && stream.model !== request?.model ? { key: 'Response Model', value: stream.model } : null,
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
typeof stream?.created === 'number' ? { key: 'Created', value: String(stream.created) } : 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,

View file

@ -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;
}

View file

@ -646,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);
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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
);

View file

@ -24,9 +24,11 @@
추가 동작:
- `/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
@ -53,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 키 재발급 |

View file

@ -83,3 +83,8 @@ SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `
- `Force`: 현재 모델 사용 가능 여부와 관계없이 항상 target model 로 이동하고 다음 규칙을 계속 평가
- `Fallback`: 현재 모델을 서빙하는 허용 가능한 활성 백엔드가 없을 때만 target model 로 이동하고 다음 규칙을 계속 평가
- 활성 rewrite cycle은 저장 시점에 거부되며, `/v1/models` 는 실제 요청 가능한 rewrite alias를 함께 반환한다
## User Reasoning Compatibility
- `Users` 화면은 API 키별 `Copy reasoning to reasoning_content` 옵션을 표시하고 편집한다
- 이 옵션은 같은 백엔드를 공유하는 사용자라도 downstream 클라이언트 호환성에 맞춰 독립적으로 켜거나 끌 수 있다

View file

@ -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 |

View file

@ -19,6 +19,7 @@
2. 접근 가능한 활성 백엔드의 메모리 카탈로그를 확인
3. native backend 모델과 rewrite `source_model` alias를 같은 체인 해석기로 평가
4. 최종 모델 후보가 있는 requestable 모델 ID 합집합을 반환
5. `MODEL_LIST_INCLUDE_ROUTING_METADATA` 가 켜져 있으면 각 model object에 비표준 `kyush_router` routing metadata를 추가한다
## Caching Rules
@ -49,6 +50,8 @@
- 활성 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`

View file

@ -20,6 +20,8 @@ isolated-vm 기반 JavaScript 샌드박스에서 요청/응답을 조작하는
백엔드 응답 수신 후 실행. 현재 구현에서는 응답 컨텍스트를 검사하거나 로그/부가 처리를 수행하는 용도로 실행되며, 훅이 반환한 변경 내용이 최종 HTTP 응답에 다시 반영되지는 않는다.
참고: `reasoning``reasoning_content` 로 복제하는 OpenAI-compatible 호환 처리는 script hook이 아니라 사용자별 라우터 내장 옵션 `copy_reasoning_to_reasoning_content` 로 수행한다.
## Script Context
스크립트에서 접근 가능한 데이터:

View file

@ -68,6 +68,14 @@ server/src/
- 활성 rewrite cycle은 관리자 생성/수정 시 거부하고, runtime에서도 방어한다
- 최종 모델을 서빙하는 허용 가능한 활성 백엔드가 없으면 `/v1/chat/completions` 는 모델 미지원 오류를 반환한다
- `/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) 참고

View file

@ -1,6 +1,6 @@
{
"name": "kyush-llm-router-express",
"version": "1.0.5-express",
"version": "1.0.10-express",
"description": "LLM routing server with multi-user API key management",
"scripts": {
"dev": "pnpm --parallel dev",

View file

@ -2,6 +2,11 @@ packages:
- server
- client
allowBuilds:
better-sqlite3: true
esbuild: true
isolated-vm: true
onlyBuiltDependencies:
- better-sqlite3
- esbuild

View file

@ -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;
}

View file

@ -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 {

View 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';
}

View file

@ -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;
}

View file

@ -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);

View file

@ -47,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' });
@ -60,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);
@ -93,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' });
@ -107,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);

View file

@ -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;

View file

@ -6,7 +6,9 @@ import { ScriptEngine } from '../services/ScriptEngine';
import { logger } from '../utils/logger';
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();
@ -212,6 +214,8 @@ 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());
const detailStreamLogMode = getDetailStreamLogMode();
@ -221,14 +225,28 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
const text = decoder.decode(value, { stream: true });
streamLog.append(text);
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 (remainingText) {
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) {
@ -276,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,
};
@ -297,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}]` : '';
@ -321,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;
@ -364,12 +385,40 @@ router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
res.status(403).json({ error: 'No active backends available' });
return;
}
let models: Array<{ id: string; object: string }>;
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 {
models = ModelCatalogService.getRequestableModelsForAllowedBackends(activeAllowedBackendIds).map((entry) => ({
id: entry.model_id,
object: 'model',
}));
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}`);

View file

@ -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,67 +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(min);
const transformedMax = Math.log1p(max);
const transformedMin = Math.log1p(globalMin);
const transformedMax = Math.log1p(globalMax);
const width = (transformedMax - transformedMin) / safeBinCount;
const histogram = Array.from({ length: safeBinCount }, (_, index) => ({
bin_start: Math.expm1(transformedMin + width * index),
bin_end: index === safeBinCount - 1 ? max : Math.expm1(transformedMin + width * (index + 1)),
count: 0,
}));
for (const value of values) {
const index = Math.min(safeBinCount - 1, Math.floor((Math.log1p(value) - transformedMin) / width));
histogram[index].count += 1;
// 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 histogram = Array.from({ length: safeBinCount }, (_, index) => ({
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],
}));
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,
};
});
@ -435,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,
@ -472,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'],
},
};
}

View file

@ -31,11 +31,18 @@ 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' | 'chain';
rewritePath: RewritePathHop[];
}
interface RewriteConfig {
@ -51,6 +58,10 @@ interface ResolutionContext {
candidateMemo: Map<string, number[]>;
}
export interface RequestableModelCatalogEntry extends BackendModelCatalogEntry {
routing: RewriteResolution;
}
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
export class ModelRewriteCycleError extends Error {
@ -303,6 +314,11 @@ export class ModelCatalogService {
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',
})),
};
}
@ -314,6 +330,11 @@ export class ModelCatalogService {
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',
})),
};
}
}
@ -377,7 +398,7 @@ export class ModelCatalogService {
return null;
}
static getRequestableModelsForAllowedBackends(allowedBackendIds: number[]): BackendModelCatalogEntry[] {
static getRequestableModelsForAllowedBackends(allowedBackendIds: number[]): RequestableModelCatalogEntry[] {
const context = this.createResolutionContext(allowedBackendIds);
const requestableModelIds = new Set<string>();
const candidateModelIds = new Set<string>([
@ -400,6 +421,7 @@ export class ModelCatalogService {
return {
model_id: modelId,
backend_ids: this.getCandidateBackendIdsWithContext(resolution.routedModel, context),
routing: resolution,
};
});
}

View 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`;
}
}
}

View file

@ -230,8 +230,12 @@ export class ChatStreamLogAccumulator {
if (isRecord(delta)) {
if (typeof delta.role === 'string') choice.role = delta.role;
if (typeof delta.reasoning === 'string') choice.reasoning.push(delta.reasoning);
if (typeof delta.reasoning_content === 'string') choice.reasoning.push(delta.reasoning_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 (typeof delta.content === 'string') choice.content.push(delta.content);
if (Array.isArray(delta.tool_calls)) {

View file

@ -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' });

View file

@ -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;
@ -778,11 +780,76 @@ describe('OpenAI Compatible Backend Integration', () => {
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',

View file

@ -75,7 +75,7 @@ describe('Streaming Response Proxying', () => {
}
});
async function setupUserAndBackend(mockPort: number, options: { detailLogging?: boolean } = {}) {
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) {
@ -87,6 +87,7 @@ describe('Streaming Response Proxying', () => {
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;
@ -102,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,
@ -378,4 +388,108 @@ describe('Streaming Response Proxying', () => {
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.');
});
});

View file

@ -9,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 };

View file

@ -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 {