refactor: shared zod, tanstack-query auth, env module, primitives, lucide icons

@kyush/shared workspace package
- Add shared workspace with package.json + tsconfig + index/schemas/types
- Schemas cover the major input shapes (CreateUser, UpdateBackend,
  CreateScriptInput as a discriminated union, AdminLoginInput, ScriptTestInput,
  v1 chat completion request/response, etc.)
- Loose chat completion schemas for the conversation timeline
- Both server and client now depend on the workspace package; client/src/types
  is now a thin re-export of @kyush/shared

Server route validation
- All admin/auth + admin + scripts mutating routes now use @hono/zod-validator
  with the shared input schemas, eliminating dozens of manual `if (!field)` checks
- server/src/schemas/{common,v1}.ts re-export the shared schemas with .openapi()
  metadata so the OpenAPI doc still describes everything

Single-source process.env
- New server/src/config/env.ts is the only file that touches process.env
- admin-auth, db-paths, time, ModelCatalogService, index, main all read from env
- Each value is parsed/normalised exactly once with sensible fallbacks

Production-ready auth (TanStack Query)
- @tanstack/solid-query is the new data layer for the session
- AuthProvider wraps QueryClientProvider, exposes session/loading/login/logout
  via a useAuth() context that mirrors the previous shape
- 401 responses collapse the cached session via setQueryData; CSRF is mirrored
  into the api client through a tracked createEffect

API client (ky + es-toolkit + shared types)
- client/src/api/client.ts now imports CreateUserInput, CreateScriptInput, etc.
  from @kyush/shared
- compactSearchParams() uses es-toolkit's omitBy to drop undefined query params
- buildUrl() retained for OIDC redirects through window.location.href

Lazy imports + default exports
- Every lazy-loaded route file (Dashboard, Users, Backends, Analytics,
  DetailLogs, Models, Scripts) now has a default export
- App.tsx + Scripts.tsx use the cleaner `lazy(() => import('./...'))` pattern
  with no `.then((m) => ({ default: m.X }))` indirection
- ScriptEditor moved to client/src/components/script-editor.tsx (kebab-case)
  with default export; LoginGate similarly moved to login-gate.tsx

UI primitive cleanup
- KCheckbox/KSelect/KDialog/KDropdownMenu/KPopover/KSwitch/KTabs/KTextField/
  KToast/KTooltip → CheckboxPrimitive/SelectPrimitive/DialogPrimitive/...
- Select.tsx and Checkbox.tsx now use lucide-solid icons (Check, ChevronDown)
  instead of '✓' / '▾' literals
- Select drops its createMemo over a tiny option list — inline derivation is
  cheaper than the memo bookkeeping

ConversationTimeline refactor (the prior hacky parser)
- Drives entirely off the new LooseChatCompletionRequestSchema /
  LooseChatCompletionResponseSchema
- All the inlined `(message as Record<string, unknown>).reasoning_content`
  type-narrowing chains are gone; helpers extract messages cleanly
- createMemo wrappers replaced by inline reactive derivations

ts-reset + Tailwind v4 + es-toolkit + Node 24 cleanups
- Add @total-typescript/ts-reset to client and server (reset.d.ts in both)
- @tailwindcss/vite plugin wired into client/vite.config.ts; styles.css now
  imports tailwindcss and bridges existing tokens via @theme inline
- es-toolkit added to client + server
- Replace `path.dirname(fileURLToPath(import.meta.url))` with the native
  `import.meta.dirname` (Node 20.11+) across server config files + tests

Type-guard / no-`as` cleanup pass
- isPragmaColumnRow type guard replaces `as Array<{ name: string }>` casts in
  database.ts and request-logs-db.ts
- Lazy DB singletons rewrite: `let db: Database | undefined` + `db ??= openDb()`
  removes the `undefined as unknown as Database` reset hack
- Scripts.tsx splits the save payload through buildCreatePayload /
  buildUpdatePayload helpers that use a switch on script_type so the
  discriminated union picks the correct variant — no payload casts

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JellyBrick 2026-04-08 01:52:10 +09:00
commit 435ffdce42
No known key found for this signature in database
GPG key ID: B0F85809E2E3759D
53 changed files with 2343 additions and 1686 deletions

View file

@ -16,24 +16,31 @@
"license": "MIT",
"dependencies": {
"@kobalte/core": "^0.13.11",
"@kyush/shared": "workspace:*",
"@solidjs/router": "^0.15.4",
"@tanstack/solid-query": "^5.62.7",
"d3": "^7.9.0",
"es-toolkit": "^1.32.0",
"ky": "^1.7.5",
"lucide-solid": "^1.1.0",
"solid-js": "^1.9.11",
"solid-monaco": "^0.3.0"
"solid-monaco": "^0.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-links": "^10.3.3",
"@storybook/addon-vitest": "^10.3.3",
"@tailwindcss/vite": "^4.0.0",
"@total-typescript/ts-reset": "^0.6.1",
"@types/node": "^25.3.3",
"@vitest/browser": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1",
"playwright": "^1.58.2",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",

View file

@ -1,45 +1,25 @@
import { Router, Route } from '@solidjs/router';
import { Route, Router } from '@solidjs/router';
import { lazy, Show, Suspense } from 'solid-js';
import { AuthProvider, useAuth } from './auth';
import { LoginGate } from './components/LoginGate';
import LoginGate from './components/login-gate';
import { Panel } from './ui';
const Dashboard = lazy(() =>
import('./routes/Dashboard').then((module) => ({
default: module.Dashboard,
})),
);
const Users = lazy(() =>
import('./routes/Users').then((module) => ({ default: module.Users })),
);
const Backends = lazy(() =>
import('./routes/Backends').then((module) => ({ default: module.Backends })),
);
const Analytics = lazy(() =>
import('./routes/Analytics').then((module) => ({
default: module.Analytics,
})),
);
const DetailLogs = lazy(() =>
import('./routes/DetailLogs').then((module) => ({
default: module.DetailLogs,
})),
);
const Models = lazy(() =>
import('./routes/Models').then((module) => ({ default: module.Models })),
);
const Scripts = lazy(() =>
import('./routes/Scripts').then((module) => ({ default: module.Scripts })),
);
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Users = lazy(() => import('./routes/Users'));
const Backends = lazy(() => import('./routes/Backends'));
const Analytics = lazy(() => import('./routes/Analytics'));
const DetailLogs = lazy(() => import('./routes/DetailLogs'));
const Models = lazy(() => import('./routes/Models'));
const Scripts = lazy(() => import('./routes/Scripts'));
function RouteLoadingFallback() {
function FullScreenPanel(props: { title: string; description: string }) {
return (
<div class="auth-screen">
<Panel
class="auth-screen__panel"
description="Preparing the selected dashboard view."
title="Loading Admin Page"
description={props.description}
title={props.title}
/>
</div>
);
@ -51,18 +31,22 @@ function AuthenticatedApp() {
return (
<Show
fallback={
<div class="auth-screen">
<Panel
class="auth-screen__panel"
description="Restoring the current administrator session."
title="Loading Admin Session"
/>
</div>
<FullScreenPanel
description="Restoring the current administrator session."
title="Loading Admin Session"
/>
}
when={!auth.loading()}
>
<Show fallback={<LoginGate />} when={auth.session()?.authenticated}>
<Suspense fallback={<RouteLoadingFallback />}>
<Suspense
fallback={
<FullScreenPanel
description="Preparing the selected dashboard view."
title="Loading Admin Page"
/>
}
>
<Router base="/dashboard">
<Route component={Dashboard} path="/" />
<Route component={Users} path="/users" />

View file

@ -1,3 +1,4 @@
import { omitBy } from 'es-toolkit';
import ky, {
HTTPError,
type KyInstance,
@ -6,27 +7,34 @@ import ky, {
} from 'ky';
import type {
User,
AdminApiTokenSummary,
AdminSessionResponse,
AnalyticsBackendQualityPoint,
AnalyticsBoxPlotPoint,
AnalyticsDailyTotalsPoint,
AnalyticsHistogramBin,
AnalyticsModelTrendPoint,
Backend,
BackendMetrics,
BackendModelsResponse,
CreateBackendInput,
CreateModelRewriteInput,
CreatePermissionInput,
CreateScriptInput,
CreateUserInput,
DashboardSummaryResponse,
ModelCacheOverview,
ModelRewriteRule,
Permission,
RequestLogPage,
UpdateBackendInput,
UpdateModelRewriteInput,
UpdateScriptInput,
UpdateUserInput,
UsageStats,
BackendMetrics,
AnalyticsDailyTotalsPoint,
AnalyticsBackendQualityPoint,
AnalyticsModelTrendPoint,
AnalyticsHistogramBin,
AnalyticsBoxPlotPoint,
DashboardSummaryResponse,
User,
UserScript,
CreateScriptData,
UpdateScriptData,
AdminApiTokenSummary,
AdminSessionResponse,
} from '../types';
} from '@kyush/shared';
/**
* Base URL prepended by ky to every request.
@ -68,12 +76,8 @@ const httpClient: KyInstance = ky.extend({
hooks: {
beforeRequest: [
(request) => {
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) {
return;
}
if (!csrfToken) {
return;
}
if (!UNSAFE_METHODS.has(request.method.toUpperCase())) return;
if (!csrfToken) return;
const url = new URL(request.url);
if (url.pathname.startsWith('/admin')) {
request.headers.set('X-CSRF-Token', csrfToken);
@ -84,15 +88,31 @@ const httpClient: KyInstance = ky.extend({
});
type SearchParamsInit = Exclude<KyOptions['searchParams'], undefined>;
type Primitive = string | number | boolean | undefined | null;
/**
* Drop `undefined`/`null` keys so callers can pass `{ userId: maybeUndefined }`
* without polluting the query string with empty values. Returns `undefined`
* when nothing is left so ky skips the search parameter step entirely.
*/
function compactSearchParams(
params: Record<string, Primitive>,
): SearchParamsInit | undefined {
const cleaned = omitBy(
params,
(value) => value === undefined || value === null,
);
const entries = Object.entries(cleaned);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([k, v]) => [k, String(v)]));
}
async function toApiError(error: HTTPError): Promise<ApiError> {
const { response, request } = error;
let message = `HTTP ${response.status}`;
try {
const payload = (await response.clone().json()) as { error?: string };
if (payload.error) {
message = payload.error;
}
if (payload.error) message = payload.error;
} catch {
// body wasn't JSON; keep the default message
}
@ -113,14 +133,10 @@ async function toApiError(error: HTTPError): Promise<ApiError> {
async function unwrap<T>(promise: ResponsePromise): Promise<T> {
try {
const response = await promise;
if (response.status === 204) {
return {} as T;
}
if (response.status === 204) return {} as T;
return (await response.json()) as T;
} catch (error) {
if (error instanceof HTTPError) {
throw await toApiError(error);
}
if (error instanceof HTTPError) throw await toApiError(error);
throw error;
}
}
@ -170,81 +186,85 @@ function buildUrl(path: string, searchParams?: Record<string, string>): string {
return url.toString();
}
interface AnalyticsRequestParams {
limit?: number;
offset?: number;
month?: string;
date?: string;
q?: string;
userId?: number;
backendId?: number;
endpoint?: string;
detailLogged?: boolean;
}
interface ModelTrendsParams {
backendId?: number;
days?: number;
limit?: number;
}
interface HistogramParams {
backendId?: number;
days?: number;
bins?: number;
}
export const api = {
auth: {
getSession: (): Promise<AdminSessionResponse> =>
getJson<AdminSessionResponse>('admin/auth/session'),
login: (
username: string,
password: string,
): Promise<AdminSessionResponse> =>
getSession: () => getJson<AdminSessionResponse>('admin/auth/session'),
login: (username: string, password: string) =>
postJson<AdminSessionResponse>('admin/auth/login', {
username,
password,
}),
logout: (): Promise<void> => postJson<void>('admin/auth/logout'),
logout: () => postJson<void>('admin/auth/logout'),
beginOidc: (next: string = window.location.pathname) => {
window.location.href = buildUrl('admin/auth/oidc/start', { next });
},
getTokens: (): Promise<AdminApiTokenSummary[]> =>
getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
createToken: (
name: string,
expiresInDays?: number,
): Promise<{ token: string; record: AdminApiTokenSummary }> =>
postJson('admin/auth/tokens', { name, expiresInDays }),
deleteToken: (id: number): Promise<void> =>
deleteJson<void>(`admin/auth/tokens/${id}`),
getTokens: () => getJson<AdminApiTokenSummary[]>('admin/auth/tokens'),
createToken: (name: string, expiresInDays?: number) =>
postJson<{ token: string; record: AdminApiTokenSummary }>(
'admin/auth/tokens',
{ name, expiresInDays },
),
deleteToken: (id: number) => deleteJson<void>(`admin/auth/tokens/${id}`),
},
users: {
getAll: (): Promise<User[]> => getJson<User[]>('admin/users'),
getById: (id: number): Promise<User> => getJson<User>(`admin/users/${id}`),
create: (data: {
name: string;
email?: string;
api_key?: string;
detail_logging?: boolean;
}): Promise<User> => postJson<User>('admin/users', data),
update: (id: number, data: Partial<User>): Promise<User> =>
getAll: () => getJson<User[]>('admin/users'),
getById: (id: number) => getJson<User>(`admin/users/${id}`),
create: (data: CreateUserInput) => postJson<User>('admin/users', data),
update: (id: number, data: UpdateUserInput) =>
putJson<User>(`admin/users/${id}`, data),
delete: (id: number): Promise<void> =>
deleteJson<void>(`admin/users/${id}`),
regenerateApiKey: (id: number): Promise<User> =>
delete: (id: number) => deleteJson<void>(`admin/users/${id}`),
regenerateApiKey: (id: number) =>
postJson<User>(`admin/users/${id}/regenerate-api-key`),
},
backends: {
getAll: (): Promise<Backend[]> => getJson<Backend[]>('admin/backends'),
getById: (id: number): Promise<Backend> =>
getJson<Backend>(`admin/backends/${id}`),
getModels: (id: number): Promise<BackendModelsResponse> =>
getAll: () => getJson<Backend[]>('admin/backends'),
getById: (id: number) => getJson<Backend>(`admin/backends/${id}`),
getModels: (id: number) =>
getJson<BackendModelsResponse>(`admin/backends/${id}/models`),
refreshModels: (id: number): Promise<BackendModelsResponse> =>
refreshModels: (id: number) =>
postJson<BackendModelsResponse>(`admin/backends/${id}/models/refresh`),
create: (data: {
name: string;
base_url: string;
api_key?: string;
detail_logging?: boolean;
}): Promise<Backend> => postJson<Backend>('admin/backends', data),
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
create: (data: CreateBackendInput) =>
postJson<Backend>('admin/backends', data),
update: (id: number, data: UpdateBackendInput) =>
putJson<Backend>(`admin/backends/${id}`, data),
delete: (id: number): Promise<void> =>
deleteJson<void>(`admin/backends/${id}`),
delete: (id: number) => deleteJson<void>(`admin/backends/${id}`),
},
permissions: {
getAll: (): Promise<Permission[]> =>
getJson<Permission[]>('admin/permissions'),
getByUser: (userId: number): Promise<Permission[]> =>
getAll: () => getJson<Permission[]>('admin/permissions'),
getByUser: (userId: number) =>
getJson<Permission[]>(`admin/permissions/user/${userId}`),
getByBackend: (backendId: number): Promise<Permission[]> =>
getByBackend: (backendId: number) =>
getJson<Permission[]>(`admin/permissions/backend/${backendId}`),
create: (data: {
user_id: number;
backend_id: number;
}): Promise<Permission> => postJson<Permission>('admin/permissions', data),
delete: (userId: number, backendId: number): Promise<void> =>
create: (data: CreatePermissionInput) =>
postJson<Permission>('admin/permissions', data),
delete: (userId: number, backendId: number) =>
deleteJson<void>('admin/permissions', {
user_id: String(userId),
backend_id: String(backendId),
@ -252,49 +272,35 @@ export const api = {
},
modelRewrites: {
getAll: (): Promise<ModelRewriteRule[]> =>
getJson<ModelRewriteRule[]>('admin/model-rewrites'),
create: (data: {
source_model: string;
target_model: string;
is_active?: boolean;
force?: boolean;
note?: string;
}): Promise<ModelRewriteRule> =>
getAll: () => getJson<ModelRewriteRule[]>('admin/model-rewrites'),
create: (data: CreateModelRewriteInput) =>
postJson<ModelRewriteRule>('admin/model-rewrites', data),
update: (
id: number,
data: Partial<ModelRewriteRule>,
): Promise<ModelRewriteRule> =>
update: (id: number, data: UpdateModelRewriteInput) =>
putJson<ModelRewriteRule>(`admin/model-rewrites/${id}`, data),
delete: (id: number): Promise<void> =>
deleteJson<void>(`admin/model-rewrites/${id}`),
delete: (id: number) => deleteJson<void>(`admin/model-rewrites/${id}`),
},
modelCache: {
getOverview: (): Promise<ModelCacheOverview> =>
getJson<ModelCacheOverview>('admin/models/cache'),
getOverview: () => getJson<ModelCacheOverview>('admin/models/cache'),
},
scripts: {
getAll: (): Promise<UserScript[]> => getJson<UserScript[]>('admin/scripts'),
getById: (id: number): Promise<UserScript> =>
getJson<UserScript>(`admin/scripts/${id}`),
create: (data: CreateScriptData): Promise<UserScript> =>
getAll: () => getJson<UserScript[]>('admin/scripts'),
getById: (id: number) => getJson<UserScript>(`admin/scripts/${id}`),
create: (data: CreateScriptInput) =>
postJson<UserScript>('admin/scripts', data),
update: (id: number, data: UpdateScriptData): Promise<UserScript> =>
update: (id: number, data: UpdateScriptInput) =>
putJson<UserScript>(`admin/scripts/${id}`, data),
delete: (id: number): Promise<void> =>
deleteJson<void>(`admin/scripts/${id}`),
activate: (id: number): Promise<UserScript> =>
delete: (id: number) => deleteJson<void>(`admin/scripts/${id}`),
activate: (id: number) =>
postJson<UserScript>(`admin/scripts/${id}/activate`),
deactivate: (id: number): Promise<UserScript> =>
deactivate: (id: number) =>
postJson<UserScript>(`admin/scripts/${id}/deactivate`),
test: (
id: number,
context: {
user?: User;
backend?: Backend;
user?: { id: number; name: string; email?: string };
backend?: { id: number; name: string; base_url: string };
request: {
method: string;
path: string;
@ -303,120 +309,81 @@ export const api = {
isStream: boolean;
};
},
): Promise<{ success: boolean; error?: string; executionTime?: number }> =>
postJson(`admin/scripts/${id}/test`, context),
) =>
postJson<{ success: boolean; error?: string; executionTime?: number }>(
`admin/scripts/${id}/test`,
context,
),
},
dashboard: {
getSummary: (days: number = 30): Promise<DashboardSummaryResponse> =>
getSummary: (days: number = 30) =>
getJson<DashboardSummaryResponse>('admin/dashboard/summary', { days }),
},
analytics: {
getUsage: (
userId?: number,
backendId?: number,
days: number = 30,
): Promise<UsageStats[]> => {
const searchParams: Record<string, number> = { days };
if (userId) searchParams.userId = userId;
if (backendId) searchParams.backendId = backendId;
return getJson<UsageStats[]>('admin/analytics/usage', searchParams);
},
getRequests: (
params: {
limit?: number;
offset?: number;
month?: string;
date?: string;
q?: string;
userId?: number;
backendId?: number;
endpoint?: string;
detailLogged?: boolean;
} = {},
): Promise<RequestLogPage> => {
const searchParams: Record<string, string | number> = {
limit: params.limit ?? 100,
offset: params.offset ?? 0,
};
if (params.month) searchParams.month = params.month;
if (params.date) searchParams.date = params.date;
if (params.q) searchParams.q = params.q;
if (params.userId) searchParams.userId = params.userId;
if (params.backendId) searchParams.backendId = params.backendId;
if (params.endpoint) searchParams.endpoint = params.endpoint;
if (params.detailLogged !== undefined) {
searchParams.detailLogged = params.detailLogged ? '1' : '0';
}
return getJson<RequestLogPage>('admin/analytics/requests', searchParams);
},
getMetrics: (
backendId?: number,
days: number = 30,
): Promise<BackendMetrics[]> => {
const searchParams: Record<string, number> = { days };
if (backendId) searchParams.backendId = backendId;
return getJson<BackendMetrics[]>('admin/analytics/metrics', searchParams);
},
getDailyTotals: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsDailyTotalsPoint[]> => {
const searchParams: Record<string, number> = { days };
if (backendId) searchParams.backendId = backendId;
return getJson<AnalyticsDailyTotalsPoint[]>(
getUsage: (userId?: number, backendId?: number, days: number = 30) =>
getJson<UsageStats[]>(
'admin/analytics/usage',
compactSearchParams({ userId, backendId, days }),
),
getRequests: (params: AnalyticsRequestParams = {}) =>
getJson<RequestLogPage>(
'admin/analytics/requests',
compactSearchParams({
limit: params.limit ?? 100,
offset: params.offset ?? 0,
month: params.month,
date: params.date,
q: params.q,
userId: params.userId,
backendId: params.backendId,
endpoint: params.endpoint,
detailLogged:
params.detailLogged === undefined
? undefined
: params.detailLogged
? '1'
: '0',
}),
),
getMetrics: (backendId?: number, days: number = 30) =>
getJson<BackendMetrics[]>(
'admin/analytics/metrics',
compactSearchParams({ backendId, days }),
),
getDailyTotals: (backendId?: number, days: number = 30) =>
getJson<AnalyticsDailyTotalsPoint[]>(
'admin/analytics/daily-totals',
searchParams,
);
},
getBackendQuality: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsBackendQualityPoint[]> => {
const searchParams: Record<string, number> = { days };
if (backendId) searchParams.backendId = backendId;
return getJson<AnalyticsBackendQualityPoint[]>(
compactSearchParams({ backendId, days }),
),
getBackendQuality: (backendId?: number, days: number = 30) =>
getJson<AnalyticsBackendQualityPoint[]>(
'admin/analytics/backend-quality',
searchParams,
);
},
getModelTrends: (
params: { backendId?: number; days?: number; limit?: number } = {},
): Promise<AnalyticsModelTrendPoint[]> => {
const searchParams: Record<string, number> = {
days: params.days ?? 30,
limit: params.limit ?? 8,
};
if (params.backendId) searchParams.backendId = params.backendId;
return getJson<AnalyticsModelTrendPoint[]>(
compactSearchParams({ backendId, days }),
),
getModelTrends: (params: ModelTrendsParams = {}) =>
getJson<AnalyticsModelTrendPoint[]>(
'admin/analytics/model-trends',
searchParams,
);
},
getResponseLengthHistogram: (
params: { backendId?: number; days?: number; bins?: number } = {},
): Promise<AnalyticsHistogramBin[]> => {
const searchParams: Record<string, number> = {
days: params.days ?? 30,
bins: params.bins ?? 20,
};
if (params.backendId) searchParams.backendId = params.backendId;
return getJson<AnalyticsHistogramBin[]>(
compactSearchParams({
backendId: params.backendId,
days: params.days ?? 30,
limit: params.limit ?? 8,
}),
),
getResponseLengthHistogram: (params: HistogramParams = {}) =>
getJson<AnalyticsHistogramBin[]>(
'admin/analytics/response-length-histogram',
searchParams,
);
},
getResponseLengthBoxPlot: (
backendId?: number,
days: number = 30,
): Promise<AnalyticsBoxPlotPoint[]> => {
const searchParams: Record<string, number> = { days };
if (backendId) searchParams.backendId = backendId;
return getJson<AnalyticsBoxPlotPoint[]>(
compactSearchParams({
backendId: params.backendId,
days: params.days ?? 30,
bins: params.bins ?? 20,
}),
),
getResponseLengthBoxPlot: (backendId?: number, days: number = 30) =>
getJson<AnalyticsBoxPlotPoint[]>(
'admin/analytics/response-length-box-plot',
searchParams,
);
},
compactSearchParams({ backendId, days }),
),
},
};

View file

@ -1,20 +1,82 @@
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/solid-query';
import {
createContext,
createSignal,
onMount,
createEffect,
onCleanup,
useContext,
type Accessor,
type JSX,
type ParentComponent,
} from 'solid-js';
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
import {
ApiError,
api,
setAdminCsrfToken,
setUnauthorizedHandler,
} from './api/client';
import type { AdminSessionResponse } from './types';
import type { AdminSessionResponse } from '@kyush/shared';
/*
* QueryClient
* */
function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
// Treat 401 specially via a global onError pipeline; never retry auth
// failures, since the unauthorizedHandler will reset the session.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 401) return false;
if (
error instanceof ApiError &&
error.status >= 400 &&
error.status < 500
) {
return false;
}
return failureCount < 2;
},
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
throwOnError: false,
},
mutations: {
retry: false,
},
},
});
}
/*
* Query keys
* */
export const authKeys = {
session: ['auth', 'session'] as const,
} as const;
const UNAUTHENTICATED_FALLBACK: AdminSessionResponse = {
authenticated: false,
authMode: 'both',
csrfToken: null,
principal: null,
};
/*
* Auth context (thin wrapper around the session query + mutations)
* */
interface AuthContextValue {
session: Accessor<AdminSessionResponse | null>;
loading: Accessor<boolean>;
session: () => AdminSessionResponse | null;
loading: () => boolean;
refreshSession: () => Promise<AdminSessionResponse>;
login: (username: string, password: string) => Promise<AdminSessionResponse>;
logout: () => Promise<void>;
@ -22,74 +84,112 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue>();
function unauthenticatedState(
previous: AdminSessionResponse | null,
): AdminSessionResponse {
return {
authenticated: false,
authMode: previous?.authMode ?? 'both',
csrfToken: null,
principal: null,
};
function useSessionQuery() {
return useQuery(() => ({
queryKey: authKeys.session,
queryFn: () => api.auth.getSession(),
// The session is already authoritative for the dashboard's lifecycle,
// so cache it forever and let mutations invalidate it explicitly.
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
refetchOnMount: true,
refetchOnWindowFocus: false,
retry: false,
}));
}
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (
props,
) => {
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
const [loading, setLoading] = createSignal(true);
function AuthContextProvider(props: {
children: import('solid-js').JSX.Element;
}) {
const queryClient = useQueryClient();
const sessionQuery = useSessionQuery();
const refreshSession = async () => {
const nextSession = await api.auth.getSession();
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
setLoading(false);
return nextSession;
};
// Mirror the CSRF token into the api client whenever the session updates.
createEffect(() => {
const data = sessionQuery.data;
setAdminCsrfToken(data?.csrfToken ?? null);
});
const login = async (username: string, password: string) => {
const nextSession = await api.auth.login(username, password);
setSession(nextSession);
setAdminCsrfToken(nextSession.csrfToken);
return nextSession;
};
const logout = async () => {
await api.auth.logout();
setSession((previous) => unauthenticatedState(previous));
setAdminCsrfToken(null);
};
onMount(() => {
// Wire the api client's 401 handler — when an unauthorized response surfaces,
// immediately collapse the cached session to the unauthenticated fallback.
createEffect(() => {
setUnauthorizedHandler(() => {
setSession((previous) => unauthenticatedState(previous));
queryClient.setQueryData<AdminSessionResponse>(
authKeys.session,
(previous) => ({
...UNAUTHENTICATED_FALLBACK,
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
}),
);
setAdminCsrfToken(null);
});
void refreshSession().catch(() => {
setSession({
authenticated: false,
authMode: 'both',
csrfToken: null,
principal: null,
});
setLoading(false);
});
onCleanup(() => setUnauthorizedHandler(null));
});
const loginMutation = useMutation(() => ({
mutationFn: ({
username,
password,
}: {
username: string;
password: string;
}) => api.auth.login(username, password),
onSuccess: (next) => {
queryClient.setQueryData(authKeys.session, next);
},
}));
const logoutMutation = useMutation(() => ({
mutationFn: () => api.auth.logout(),
onSuccess: () => {
queryClient.setQueryData<AdminSessionResponse>(
authKeys.session,
(previous) => ({
...UNAUTHENTICATED_FALLBACK,
authMode: previous?.authMode ?? UNAUTHENTICATED_FALLBACK.authMode,
}),
);
},
}));
const value: AuthContextValue = {
session: () => sessionQuery.data ?? null,
// Only treat the very first fetch as "loading" — once we have any data
// (or an error), the gate should resolve to login or to the dashboard.
loading: () =>
sessionQuery.isPending && sessionQuery.fetchStatus !== 'idle',
refreshSession: async () => {
const next = await queryClient.fetchQuery({
queryKey: authKeys.session,
queryFn: () => api.auth.getSession(),
staleTime: 0,
});
return next;
},
login: (username, password) =>
loginMutation.mutateAsync({ username, password }),
logout: () => logoutMutation.mutateAsync(),
};
return (
<AuthContext.Provider
value={{ session, loading, refreshSession, login, logout }}
>
{props.children}
</AuthContext.Provider>
<AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>
);
}
export const AuthProvider: ParentComponent = (props) => {
const queryClient = makeQueryClient();
return (
<QueryClientProvider client={queryClient}>
<AuthContextProvider>{props.children}</AuthContextProvider>
</QueryClientProvider>
);
};
export function useAuth() {
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('Auth context is not available');
throw new Error('useAuth must be used inside <AuthProvider>');
}
return context;
}

View file

@ -1,17 +1,21 @@
import { createSignal, Show, type Component } from 'solid-js';
import { Alert, Button, Panel, TextField } from '../ui';
import { ApiError, api } from '../api/client';
import { useAuth } from '../auth';
import { api, ApiError } from '../api/client';
import { Alert, Button, Panel, TextField } from '../ui';
export const LoginGate: Component = () => {
const LoginGate: Component = () => {
const auth = useAuth();
const [username, setUsername] = createSignal('');
const [password, setPassword] = createSignal('');
const [submitting, setSubmitting] = createSignal(false);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
const handleSubmit = async (event: Event) => {
const authMode = () => auth.session()?.authMode ?? 'both';
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
const handleSubmit = async (event: SubmitEvent) => {
event.preventDefault();
setSubmitting(true);
setErrorMessage(null);
@ -27,10 +31,6 @@ export const LoginGate: Component = () => {
}
};
const authMode = () => auth.session()?.authMode ?? 'both';
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
return (
<div class="auth-screen">
<Panel
@ -84,3 +84,5 @@ export const LoginGate: Component = () => {
</div>
);
};
export default LoginGate;

View file

@ -1,10 +1,12 @@
import { Dynamic } from 'solid-js/web';
import { createSignal, lazy, onCleanup, onMount, Suspense } from 'solid-js';
import { Dynamic } from 'solid-js/web';
const THEME_STORAGE_KEY = 'kyush-theme';
const MonacoEditor = lazy(() =>
import('solid-monaco').then((module) => ({ default: module.MonacoEditor })),
);
const MonacoEditor = lazy(async () => {
const module = await import('solid-monaco');
return { default: module.MonacoEditor };
});
interface ScriptEditorProps {
value: string;
@ -13,8 +15,7 @@ interface ScriptEditorProps {
path?: string;
}
export function ScriptEditor(props: ScriptEditorProps) {
const defaultCode = `// User-defined middleware script
const DEFAULT_CODE = `// User-defined middleware script
// Available functions: onRequest, onResponse
/**
@ -28,13 +29,12 @@ export async function onRequest(ctx) {
// Example: Edit body
// if (typeof ctx.request.body === 'object') {
// if (typeof ctx.request.body['chat_template_kwargs'] !== 'object') {
// ctx.request.body['chat_template_kwargs'] = {};
// }
// ctx.request.body['chat_template_kwargs'] ??= {};
// }
// Example: Log request
// console.log('Request:', ctx.request.method, ctx.request.path);
return ctx;
}
@ -46,20 +46,23 @@ export async function onRequest(ctx) {
export async function onResponse(ctx) {
// Example: Log response
// console.log('Response status:', ctx.response?.status);
// Example: Handle streaming responses
// if (ctx.response?.isStream && ctx.onChunk) {
// const originalOnChunk = ctx.onChunk;
// ctx.onChunk = (chunk) => {
// console.log('Stream chunk:', chunk);
// originalOnChunk(chunk);
// };
// }
return ctx;
}
`;
function readThemePreference(): 'vs' | 'vs-dark' {
const root = document.documentElement;
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
const explicit = root.dataset.theme;
const preferred = stored === 'light' || stored === 'dark' ? stored : explicit;
const isDark = preferred
? preferred === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
return isDark ? 'vs-dark' : 'vs';
}
function ScriptEditor(props: ScriptEditorProps) {
const [editorTheme, setEditorTheme] = createSignal<'vs' | 'vs-dark'>(
'vs-dark',
);
@ -68,25 +71,13 @@ export async function onResponse(ctx) {
const root = document.documentElement;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const syncTheme = () => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const explicitTheme = root.dataset.theme;
const preferredTheme =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: explicitTheme;
const isDark = preferredTheme
? preferredTheme === 'dark'
: mediaQuery.matches;
setEditorTheme(isDark ? 'vs-dark' : 'vs');
};
const syncTheme = () => setEditorTheme(readThemePreference());
const observer = new MutationObserver(syncTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['data-theme'],
});
mediaQuery.addEventListener('change', syncTheme);
syncTheme();
@ -113,7 +104,7 @@ export async function onResponse(ctx) {
<Dynamic
component={MonacoEditor}
language="typescript"
onChange={(value: string) => props.onChange(value)}
onChange={props.onChange}
options={{
minimap: { enabled: false },
fontSize: 14,
@ -125,9 +116,11 @@ export async function onResponse(ctx) {
}}
path={props.path}
theme={editorTheme()}
value={props.value || defaultCode}
value={props.value || DEFAULT_CODE}
/>
</Suspense>
</div>
);
}
export default ScriptEditor;

5
client/src/reset.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// Activate ts-reset's improved built-in types globally for the client.
// See https://www.totaltypescript.com/ts-reset for the rules this enables —
// it sharpens types like `JSON.parse`, `Array.prototype.filter`, `fetch`,
// `Object.entries`, etc., so we don't need to widen them with manual casts.
import '@total-typescript/ts-reset';

View file

@ -45,7 +45,7 @@ type AnalyticsChartRow = { date: string } & Record<
>;
const formatInteger = new Intl.NumberFormat('en-US');
export const Analytics: Component = () => {
const Analytics: Component = () => {
const [days, setDays] = createSignal('30');
const [backendFilter, setBackendFilter] = createSignal('all');
const [hiddenDailySeries, setHiddenDailySeries] = createSignal<Set<string>>(
@ -419,3 +419,5 @@ export const Analytics: Component = () => {
</Layout>
);
};
export default Analytics;

View file

@ -46,7 +46,7 @@ const emptyForm = (): BackendFormState => ({
detail_logging: false,
});
export const Backends: Component = () => {
const Backends: Component = () => {
const [backends, { refetch }] = createResource(() => api.backends.getAll());
const [dialogOpen, setDialogOpen] = createSignal(false);
const [confirmOpen, setConfirmOpen] = createSignal(false);
@ -527,3 +527,5 @@ export const Backends: Component = () => {
</Layout>
);
};
export default Backends;

View file

@ -45,7 +45,7 @@ type DashboardChartRow = { date: string } & Record<
string | number | null
>;
export const Dashboard: Component = () => {
const Dashboard: Component = () => {
const [days, setDays] = createSignal('30');
const [hiddenTrafficSeries, setHiddenTrafficSeries] = createSignal<
Set<string>
@ -494,3 +494,5 @@ export const Dashboard: Component = () => {
</Layout>
);
};
export default Dashboard;

View file

@ -85,7 +85,7 @@ function prettyPrint(value?: string): string {
}
}
export const DetailLogs: Component = () => {
const DetailLogs: Component = () => {
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
const [page, setPage] = createSignal(1);
const [pageSize, setPageSize] = createSignal(25);
@ -527,3 +527,5 @@ export const DetailLogs: Component = () => {
</Layout>
);
};
export default DetailLogs;

View file

@ -46,7 +46,7 @@ const emptyForm = (): RewriteFormState => ({
note: '',
});
export const Models: Component = () => {
const Models: Component = () => {
const [overview, { refetch: refetchOverview }] = createResource(() =>
api.modelCache.getOverview(),
);
@ -528,3 +528,5 @@ export const Models: Component = () => {
</Layout>
);
};
export default Models;

View file

@ -39,14 +39,15 @@ import {
TextField,
} from '../ui';
import type { ScriptType, UserScript } from '../types';
import type {
CreateScriptInput,
ScriptType,
UpdateScriptInput,
UserScript,
} from '../types';
type NoticeTone = 'success' | 'warning' | 'danger' | 'info';
const ScriptEditor = lazy(() =>
import('../components/ScriptEditor').then((module) => ({
default: module.ScriptEditor,
})),
);
const ScriptEditor = lazy(() => import('../components/script-editor'));
interface ScriptFormState {
id?: number;
@ -119,7 +120,7 @@ const scriptTypeLabels: Record<ScriptType, string> = {
'per-user': 'Per User',
};
export const Scripts: Component = () => {
const Scripts: Component = () => {
const [scripts, { refetch: refetchScripts }] = createResource(() =>
api.scripts.getAll(),
);
@ -247,6 +248,51 @@ export const Scripts: Component = () => {
return null;
};
const buildCreatePayload = (current: ScriptFormState): CreateScriptInput => {
const base = {
name: current.name.trim(),
script_code: current.script_code,
is_active: current.is_active,
};
// Use type narrowing on script_type so the discriminated union picks the
// right variant — no `as` casting required.
switch (current.script_type) {
case 'per-user-backend':
return {
...base,
script_type: 'per-user-backend',
target_user_id: Number(current.target_user_id),
target_backend_id: Number(current.target_backend_id),
};
case 'per-backend':
return {
...base,
script_type: 'per-backend',
target_backend_id: Number(current.target_backend_id),
};
case 'per-user':
return {
...base,
script_type: 'per-user',
target_user_id: Number(current.target_user_id),
};
}
};
const buildUpdatePayload = (current: ScriptFormState): UpdateScriptInput => ({
name: current.name.trim(),
script_type: current.script_type,
target_user_id: current.target_user_id
? Number(current.target_user_id)
: null,
target_backend_id: current.target_backend_id
? Number(current.target_backend_id)
: null,
script_code: current.script_code,
is_active: current.is_active,
});
const saveScript = async () => {
const error = validateForm();
if (error) {
@ -255,27 +301,18 @@ export const Scripts: Component = () => {
}
const current = form();
const payload = {
name: current.name.trim(),
script_type: current.script_type,
target_user_id: current.target_user_id
? Number(current.target_user_id)
: null,
target_backend_id: current.target_backend_id
? Number(current.target_backend_id)
: null,
script_code: current.script_code,
is_active: current.is_active,
};
setSubmitting(true);
try {
if (current.id) {
const updated = await api.scripts.update(current.id, payload);
const updated = await api.scripts.update(
current.id,
buildUpdatePayload(current),
);
setNotice({ tone: 'success', message: 'Script updated.' });
syncForm(updated);
} else {
const created = await api.scripts.create(payload);
const created = await api.scripts.create(buildCreatePayload(current));
setNotice({ tone: 'success', message: 'Script created.' });
syncForm(created);
}
@ -668,7 +705,7 @@ export const Scripts: Component = () => {
}
>
<ScriptEditor
onChange={(value) =>
onChange={(value: string) =>
setForm((current) => ({ ...current, script_code: value }))
}
path={
@ -750,3 +787,5 @@ export const Scripts: Component = () => {
</Layout>
);
};
export default Scripts;

View file

@ -60,7 +60,7 @@ const emptyForm = (): UserFormState => ({
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
export const Users: Component = () => {
const Users: Component = () => {
const [users, { refetch: refetchUsers }] = createResource(() =>
api.users.getAll(),
);
@ -897,3 +897,5 @@ export const Users: Component = () => {
</Layout>
);
};
export default Users;

View file

@ -1,293 +1,4 @@
export type User = {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
detail_logging: boolean;
created_at: string;
updated_at: string;
};
export type Backend = {
id: number;
name: string;
base_url: string;
api_key?: string;
is_active: boolean;
detail_logging: boolean;
created_at: string;
updated_at: string;
cached_model_count?: number;
last_model_sync_at?: string;
model_cache_initialized?: boolean;
model_cache_state?: 'ready' | 'uninitialized' | 'error' | 'inactive';
};
export type BackendModelSnapshot = {
id: number;
backend_id: number;
model_id: string;
raw_json?: string;
fetched_at: string;
created_at: string;
updated_at: string;
};
export type BackendModelCacheStatus = {
backend_id: number;
initialized: boolean;
state: 'ready' | 'uninitialized' | 'error' | 'inactive';
model_count: number;
last_synced_at?: string;
last_attempted_at?: string;
last_error?: string;
};
export type BackendModelsResponse = {
backend: Backend;
cache: BackendModelCacheStatus;
snapshots: BackendModelSnapshot[];
models: string[];
};
export type BackendModelCatalogEntry = {
model_id: string;
backend_ids: number[];
};
export type ModelCacheOverview = {
backends: BackendModelCacheStatus[];
models: BackendModelCatalogEntry[];
};
export type ModelRewriteRule = {
id: number;
source_model: string;
target_model: string;
is_active: boolean;
force: boolean;
note?: string;
created_at: string;
updated_at: string;
};
export type Permission = {
id: number;
user_id: number;
backend_id: number;
created_at: string;
};
export type RequestLog = {
id: number;
user_id: number;
backend_id: number;
endpoint: string;
request_model?: string;
routed_model?: string;
response_model?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
status_code: number;
response_time_ms?: number;
error_message?: string;
detail_logged: boolean;
local_date: string;
request_headers?: string;
request_body?: string;
response_headers?: string;
response_body?: string;
created_at: string;
};
export type RequestLogPage = {
rows: RequestLog[];
total: number;
limit: number;
offset: number;
};
export type UsageStats = {
id: number;
user_id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
};
export type BackendMetrics = {
id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsDailyTotalsPoint = {
date: string;
total_requests: number;
total_tokens: number;
};
export type AnalyticsBackendQualityPoint = {
date: string;
backend_id: number;
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
success_rate: number;
};
export type AnalyticsModelTrendPoint = {
date: string;
model: string;
request_count: number;
};
export type AnalyticsHistogramBin = {
bin_start: number;
bin_end: number;
count: number;
};
export type AnalyticsBoxPlotPoint = {
date: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
count: number;
};
export type DashboardHealthStatus = {
status: 'ok';
timestamp: string;
};
export type DashboardOverviewSummary = {
total_users: number;
active_users: number;
total_backends: number;
active_backends: number;
total_permissions: number;
total_scripts: number;
active_scripts: number;
};
export type DashboardHealthSummary = {
cache_state_counts: Record<
Backend['model_cache_state'] extends infer T ? Extract<T, string> : never,
number
>;
stale_backends: Array<{
id: number;
name: string;
state: NonNullable<Backend['model_cache_state']>;
last_synced_at?: string;
}>;
public_health: DashboardHealthStatus;
admin_health: DashboardHealthStatus;
};
export type DashboardLoggingSummary = {
users_with_detail_logging: number;
backends_with_detail_logging: number;
};
export type DashboardScriptSummary = {
active_by_type: Record<ScriptType, number>;
total_by_type: Record<ScriptType, number>;
};
export type DashboardAccessSummary = {
permission_assignments: number;
users_without_permissions: number;
};
export type DashboardSummaryResponse = {
window_days: number;
generated_at: string;
overview: DashboardOverviewSummary;
health: DashboardHealthSummary;
logging: DashboardLoggingSummary;
scripts: DashboardScriptSummary;
access: DashboardAccessSummary;
series: {
daily_totals: AnalyticsDailyTotalsPoint[];
backend_quality: AnalyticsBackendQualityPoint[];
model_trends: AnalyticsModelTrendPoint[];
};
};
export type ScriptType = 'per-user-backend' | 'per-backend' | 'per-user';
export type UserScript = {
id: number;
name: string;
script_type: ScriptType;
target_user_id: number | null;
target_backend_id: number | null;
script_code: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type CreateScriptData = {
name: string;
script_type: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code: string;
is_active?: boolean;
};
export type UpdateScriptData = {
name?: string;
script_type?: ScriptType;
target_user_id?: number | null;
target_backend_id?: number | null;
script_code?: string;
is_active?: boolean;
};
export type AdminAuthMode = 'env' | 'oidc' | 'both';
export type AdminPrincipal = {
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
displayName: string;
};
export type AdminSessionResponse = {
authenticated: boolean;
authMode: AdminAuthMode;
csrfToken: string | null;
principal: AdminPrincipal | null;
};
export type AdminApiTokenSummary = {
id: number;
name: string;
provider: 'env' | 'oidc';
subject: string;
username?: string;
email?: string;
display_name: string;
token_prefix: string;
expires_at: string;
last_used_at?: string;
revoked_at?: string;
created_at: string;
updated_at: string;
};
// Re-export every shared domain type so existing client code can keep
// importing from `../types`. The shared package is the single source of
// truth for the schemas and types that travel across the wire.
export * from '@kyush/shared';

View file

@ -1,14 +1,27 @@
import { For, Show, createMemo } from 'solid-js';
import {
LooseChatCompletionRequestSchema,
LooseChatCompletionResponseSchema,
} from '@kyush/shared';
import { For, Show, type Component } from 'solid-js';
import { MetaCluster } from './MetaCluster';
import { StatusBadge, type StatusTone } from './StatusBadge';
/*
* Types
* */
type KnownChatRole = 'system' | 'user' | 'assistant';
interface MetaItem {
key: string;
value: string;
}
interface ParsedMessage {
role: string;
content: string;
metadata?: Array<{ key: string; value: string }>;
metadata?: MetaItem[];
}
interface ConversationTimelineProps {
@ -17,184 +30,191 @@ interface ConversationTimelineProps {
emptyMessage?: string;
}
function normalizePayload(value: unknown): Record<string, unknown> | null {
if (!value) return null;
/*
* Parsing helpers
* */
function parseJsonLike(value: unknown): unknown {
if (value == null) return null;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: null;
return JSON.parse(value);
} catch {
return null;
}
}
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
return value;
}
function normalizeMessages(
payload: Record<string, unknown> | null,
function stringifyContent(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function parseRequest(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionRequestSchema.safeParse(raw);
return result.success ? result.data : null;
}
function parseResponse(body: unknown) {
const raw = parseJsonLike(body);
if (raw == null) return null;
const result = LooseChatCompletionResponseSchema.safeParse(raw);
return result.success ? result.data : null;
}
/**
* Lift the user/system/assistant messages out of an OpenAI-shaped request body.
* Returns an empty array when nothing parsable is present.
*/
function extractRequestMessages(
request: ReturnType<typeof parseRequest>,
): ParsedMessage[] {
const messages = payload?.messages;
if (!Array.isArray(messages)) return [];
return messages
.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
.map((item) => ({
role: typeof item.role === 'string' ? item.role : 'unknown',
content:
typeof item.content === 'string'
? item.content
: JSON.stringify(item.content ?? ''),
}));
if (!request?.messages) return [];
return request.messages.map((message) => ({
role: typeof message.role === 'string' ? message.role : 'unknown',
content: stringifyContent(message.content),
}));
}
function normalizeAssistantMessages(
payload: Record<string, unknown> | null,
/**
* Lift the assistant turns out of an OpenAI-shaped response body, including the
* extra metadata fields (reasoning, tool calls, finish_reason, etc.) that show
* up in chat completion choices.
*/
function extractResponseMessages(
response: ReturnType<typeof parseResponse>,
): ParsedMessage[] {
const choices = payload?.choices;
if (!Array.isArray(choices)) return [];
if (!response?.choices) return [];
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
if (!choice || typeof choice !== 'object') return null;
const message = (choice as Record<string, unknown>).message;
if (!message || typeof message !== 'object') return null;
return response.choices
.map((choice): ParsedMessage | null => {
const message = choice.message;
if (!message) return null;
const content =
typeof (message as Record<string, unknown>).content === 'string'
? String((message as Record<string, unknown>).content)
: JSON.stringify((message as Record<string, unknown>).content ?? '');
const metadata = [
(message as Record<string, unknown>).reasoning_content !== undefined
? {
key: 'Reasoning',
value:
typeof (message as Record<string, unknown>).reasoning_content ===
'string'
? String((message as Record<string, unknown>).reasoning_content)
: JSON.stringify(
(message as Record<string, unknown>).reasoning_content,
),
}
: null,
(message as Record<string, unknown>).tool_calls !== undefined
? {
key: 'Tool Calls',
value: JSON.stringify(
(message as Record<string, unknown>).tool_calls,
),
}
: null,
(choice as Record<string, unknown>).finish_reason !== undefined
? {
key: 'Finish',
value: String((choice as Record<string, unknown>).finish_reason),
}
: null,
(choice as Record<string, unknown>).matched_stop !== undefined
? {
key: 'Matched Stop',
value: String((choice as Record<string, unknown>).matched_stop),
}
: null,
(choice as Record<string, unknown>).logprobs !== undefined
? {
key: 'Logprobs',
value: JSON.stringify((choice as Record<string, unknown>).logprobs),
}
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
const metadata: MetaItem[] = [];
if (message.reasoning_content !== undefined) {
metadata.push({
key: 'Reasoning',
value: stringifyContent(message.reasoning_content),
});
}
if (message.tool_calls !== undefined) {
metadata.push({
key: 'Tool Calls',
value: stringifyContent(message.tool_calls),
});
}
if (choice.finish_reason !== undefined) {
metadata.push({
key: 'Finish',
value: String(choice.finish_reason),
});
}
if (choice.matched_stop !== undefined) {
metadata.push({
key: 'Matched Stop',
value: String(choice.matched_stop),
});
}
if (choice.logprobs !== undefined) {
metadata.push({
key: 'Logprobs',
value: stringifyContent(choice.logprobs),
});
}
return {
role: 'assistant' as const,
content,
metadata,
};
});
return messages.filter(
(message): message is ParsedMessage => message !== null,
);
return {
role: 'assistant',
content: stringifyContent(message.content),
metadata: metadata.length > 0 ? metadata : undefined,
};
})
.filter((message): message is ParsedMessage => message !== null);
}
export function hasRenderableConversation(
requestBody?: unknown,
responseBody?: unknown,
): boolean {
const requestMessages = normalizeMessages(normalizePayload(requestBody));
const responseMessages = normalizeAssistantMessages(
normalizePayload(responseBody),
);
return requestMessages.length > 0 || responseMessages.length > 0;
function buildSummaryItems(
request: ReturnType<typeof parseRequest>,
response: ReturnType<typeof parseResponse>,
): MetaItem[] {
const items: MetaItem[] = [];
if (typeof request?.model === 'string') {
items.push({ key: 'Model', value: request.model });
}
if (request?.temperature !== undefined) {
items.push({ key: 'Temp', value: String(request.temperature) });
}
if (typeof response?.created === 'number') {
items.push({ key: 'Created', value: String(response.created) });
}
const usage = response?.usage;
if (usage) {
if (usage.prompt_tokens !== undefined) {
items.push({ key: 'Prompt', value: String(usage.prompt_tokens) });
}
if (usage.completion_tokens !== undefined) {
items.push({ key: 'Completion', value: String(usage.completion_tokens) });
}
if (usage.total_tokens !== undefined) {
items.push({ key: 'Total', value: String(usage.total_tokens) });
}
}
return items;
}
const roleTone: Record<KnownChatRole, StatusTone> = {
/*
* Public helpers + component
* */
const ROLE_TONES: Record<KnownChatRole, StatusTone> = {
system: 'info',
user: 'warning',
assistant: 'success',
};
function getRoleTone(role: string): StatusTone {
if (role === 'system' || role === 'user' || role === 'assistant') {
return roleTone[role];
}
return 'neutral';
function isKnownRole(role: string): role is KnownChatRole {
return role === 'system' || role === 'user' || role === 'assistant';
}
function getRoleClass(role: string): string {
if (role === 'system' || role === 'user' || role === 'assistant') {
return `ui-conversation__turn--${role}`;
}
return 'ui-conversation__turn--unknown';
const getRoleTone = (role: string): StatusTone =>
isKnownRole(role) ? ROLE_TONES[role] : 'neutral';
const getRoleClass = (role: string): string =>
isKnownRole(role)
? `ui-conversation__turn--${role}`
: 'ui-conversation__turn--unknown';
export function hasRenderableConversation(
requestBody?: unknown,
responseBody?: unknown,
): boolean {
const requestMessages = extractRequestMessages(parseRequest(requestBody));
const responseMessages = extractResponseMessages(parseResponse(responseBody));
return requestMessages.length > 0 || responseMessages.length > 0;
}
export function ConversationTimeline(props: ConversationTimelineProps) {
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
const responseMessages = createMemo(() =>
normalizeAssistantMessages(parsedResponse()),
);
const messages = createMemo(() => [
...requestMessages(),
...responseMessages(),
]);
const summaryItems = createMemo(() => {
const request = parsedRequest();
const response = parsedResponse();
const usage =
response?.usage && typeof response.usage === 'object'
? (response.usage as Record<string, unknown>)
: null;
return [
typeof request?.model === 'string'
? { key: 'Model', value: request.model }
: null,
request?.temperature !== undefined
? { key: 'Temp', value: String(request.temperature) }
: null,
typeof response?.created === 'number'
? { key: 'Created', value: String(response.created) }
: null,
usage?.prompt_tokens !== undefined
? { key: 'Prompt', value: String(usage.prompt_tokens) }
: null,
usage?.completion_tokens !== undefined
? { key: 'Completion', value: String(usage.completion_tokens) }
: null,
usage?.total_tokens !== undefined
? { key: 'Total', value: String(usage.total_tokens) }
: null,
].filter((item): item is { key: string; value: string } => Boolean(item));
});
export const ConversationTimeline: Component<ConversationTimelineProps> = (
props,
) => {
// Inline derivations — Solid's reactive prop reads make extra createMemo
// wrappers unnecessary for cheap shape transforms like these.
const request = () => parseRequest(props.requestBody);
const response = () => parseResponse(props.responseBody);
const messages = () => [
...extractRequestMessages(request()),
...extractResponseMessages(response()),
];
const summaryItems = () => buildSummaryItems(request(), response());
return (
<div class="ui-conversation">
@ -240,4 +260,4 @@ export function ConversationTimeline(props: ConversationTimelineProps) {
</Show>
</div>
);
}
};

View file

@ -1,4 +1,6 @@
import * as KCheckbox from '@kobalte/core/checkbox';
import * as CheckboxPrimitive from '@kobalte/core/checkbox';
import Check from 'lucide-solid/icons/check';
import { Show, type Component } from 'solid-js';
import { cn } from '../lib/cn';
@ -12,29 +14,29 @@ interface CheckboxProps {
onChange?: (checked: boolean) => void;
}
export function Checkbox(props: CheckboxProps) {
export const Checkbox: Component<CheckboxProps> = (props) => {
return (
<KCheckbox.Root
<CheckboxPrimitive.Root
checked={props.checked}
class={cn('ui-checkbox', props.class)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
>
<KCheckbox.Input />
<KCheckbox.Control class="ui-checkbox__control">
<KCheckbox.Indicator class="ui-checkbox__indicator">
</KCheckbox.Indicator>
</KCheckbox.Control>
<CheckboxPrimitive.Input />
<CheckboxPrimitive.Control class="ui-checkbox__control">
<CheckboxPrimitive.Indicator class="ui-checkbox__indicator">
<Check aria-hidden="true" size={14} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
<span>
<KCheckbox.Label>{props.label}</KCheckbox.Label>
{props.description && (
<KCheckbox.Description class="ui-field__description">
<CheckboxPrimitive.Label>{props.label}</CheckboxPrimitive.Label>
<Show when={props.description}>
<CheckboxPrimitive.Description class="ui-field__description">
{props.description}
</KCheckbox.Description>
)}
</CheckboxPrimitive.Description>
</Show>
</span>
</KCheckbox.Root>
</CheckboxPrimitive.Root>
);
}
};

View file

@ -1,4 +1,4 @@
import * as KDialog from '@kobalte/core/dialog';
import * as DialogPrimitive from '@kobalte/core/dialog';
import { cn } from '../lib/cn';
@ -8,54 +8,54 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Dialog = {
Root: (props: WrapperProps) => (
<KDialog.Root {...(props as KDialog.DialogRootProps)}>
<DialogPrimitive.Root {...(props as DialogPrimitive.DialogRootProps)}>
{props.children}
</KDialog.Root>
</DialogPrimitive.Root>
),
Trigger: (props: WrapperProps) => (
<KDialog.Trigger
{...(props as KDialog.DialogTriggerProps)}
<DialogPrimitive.Trigger
{...(props as DialogPrimitive.DialogTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDialog.Trigger>
</DialogPrimitive.Trigger>
),
Portal: (props: WrapperProps) => (
<KDialog.Portal>{props.children}</KDialog.Portal>
<DialogPrimitive.Portal>{props.children}</DialogPrimitive.Portal>
),
Overlay: (props: WrapperProps) => (
<KDialog.Overlay
{...(props as KDialog.DialogOverlayProps)}
<DialogPrimitive.Overlay
{...(props as DialogPrimitive.DialogOverlayProps)}
class={cn('ui-dialog__overlay', props.class)}
/>
),
Content: (props: WrapperProps) => (
<KDialog.Content
{...(props as KDialog.DialogContentProps)}
<DialogPrimitive.Content
{...(props as DialogPrimitive.DialogContentProps)}
class={cn('ui-dialog__content', props.class)}
>
{props.children}
</KDialog.Content>
</DialogPrimitive.Content>
),
Title: (props: WrapperProps) => (
<KDialog.Title {...(props as KDialog.DialogTitleProps)}>
<DialogPrimitive.Title {...(props as DialogPrimitive.DialogTitleProps)}>
{props.children}
</KDialog.Title>
</DialogPrimitive.Title>
),
Description: (props: WrapperProps) => (
<KDialog.Description
{...(props as KDialog.DialogDescriptionProps)}
<DialogPrimitive.Description
{...(props as DialogPrimitive.DialogDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KDialog.Description>
</DialogPrimitive.Description>
),
CloseButton: (props: WrapperProps) => (
<KDialog.CloseButton
{...(props as KDialog.DialogCloseButtonProps)}
<DialogPrimitive.CloseButton
{...(props as DialogPrimitive.DialogCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDialog.CloseButton>
</DialogPrimitive.CloseButton>
),
};

View file

@ -1,4 +1,4 @@
import * as KDropdownMenu from '@kobalte/core/dropdown-menu';
import * as DropdownMenuPrimitive from '@kobalte/core/dropdown-menu';
import { cn } from '../lib/cn';
@ -8,40 +8,44 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const DropdownMenu = {
Root: (props: WrapperProps) => (
<KDropdownMenu.Root {...(props as KDropdownMenu.DropdownMenuRootProps)}>
<DropdownMenuPrimitive.Root
{...(props as DropdownMenuPrimitive.DropdownMenuRootProps)}
>
{props.children}
</KDropdownMenu.Root>
</DropdownMenuPrimitive.Root>
),
Trigger: (props: WrapperProps) => (
<KDropdownMenu.Trigger
{...(props as KDropdownMenu.DropdownMenuTriggerProps)}
<DropdownMenuPrimitive.Trigger
{...(props as DropdownMenuPrimitive.DropdownMenuTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KDropdownMenu.Trigger>
</DropdownMenuPrimitive.Trigger>
),
Portal: (props: WrapperProps) => (
<KDropdownMenu.Portal>{props.children}</KDropdownMenu.Portal>
<DropdownMenuPrimitive.Portal>
{props.children}
</DropdownMenuPrimitive.Portal>
),
Content: (props: WrapperProps) => (
<KDropdownMenu.Content
{...(props as KDropdownMenu.DropdownMenuContentProps)}
<DropdownMenuPrimitive.Content
{...(props as DropdownMenuPrimitive.DropdownMenuContentProps)}
class={cn('ui-dropdown__content', props.class)}
>
{props.children}
</KDropdownMenu.Content>
</DropdownMenuPrimitive.Content>
),
Item: (props: WrapperProps) => (
<KDropdownMenu.Item
{...(props as KDropdownMenu.DropdownMenuItemProps)}
<DropdownMenuPrimitive.Item
{...(props as DropdownMenuPrimitive.DropdownMenuItemProps)}
class={cn('ui-dropdown__item', props.class)}
>
{props.children}
</KDropdownMenu.Item>
</DropdownMenuPrimitive.Item>
),
Separator: (props: WrapperProps) => (
<KDropdownMenu.Separator
{...(props as KDropdownMenu.DropdownMenuSeparatorProps)}
<DropdownMenuPrimitive.Separator
{...(props as DropdownMenuPrimitive.DropdownMenuSeparatorProps)}
class={cn('ui-dropdown__separator', props.class)}
/>
),

View file

@ -1,4 +1,4 @@
import * as KPopover from '@kobalte/core/popover';
import * as PopoverPrimitive from '@kobalte/core/popover';
import { cn } from '../lib/cn';
@ -8,48 +8,48 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Popover = {
Root: (props: WrapperProps) => (
<KPopover.Root {...(props as KPopover.PopoverRootProps)}>
<PopoverPrimitive.Root {...(props as PopoverPrimitive.PopoverRootProps)}>
{props.children}
</KPopover.Root>
</PopoverPrimitive.Root>
),
Trigger: (props: WrapperProps) => (
<KPopover.Trigger
{...(props as KPopover.PopoverTriggerProps)}
<PopoverPrimitive.Trigger
{...(props as PopoverPrimitive.PopoverTriggerProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KPopover.Trigger>
</PopoverPrimitive.Trigger>
),
Portal: (props: WrapperProps) => (
<KPopover.Portal>{props.children}</KPopover.Portal>
<PopoverPrimitive.Portal>{props.children}</PopoverPrimitive.Portal>
),
Content: (props: WrapperProps) => (
<KPopover.Content
{...(props as KPopover.PopoverContentProps)}
<PopoverPrimitive.Content
{...(props as PopoverPrimitive.PopoverContentProps)}
class={cn('ui-popover__content', props.class)}
>
{props.children}
</KPopover.Content>
</PopoverPrimitive.Content>
),
Title: (props: WrapperProps) => (
<KPopover.Title {...(props as KPopover.PopoverTitleProps)}>
<PopoverPrimitive.Title {...(props as PopoverPrimitive.PopoverTitleProps)}>
{props.children}
</KPopover.Title>
</PopoverPrimitive.Title>
),
Description: (props: WrapperProps) => (
<KPopover.Description
{...(props as KPopover.PopoverDescriptionProps)}
<PopoverPrimitive.Description
{...(props as PopoverPrimitive.PopoverDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KPopover.Description>
</PopoverPrimitive.Description>
),
CloseButton: (props: WrapperProps) => (
<KPopover.CloseButton
{...(props as KPopover.PopoverCloseButtonProps)}
<PopoverPrimitive.CloseButton
{...(props as PopoverPrimitive.PopoverCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KPopover.CloseButton>
</PopoverPrimitive.CloseButton>
),
};

View file

@ -1,5 +1,7 @@
import * as KSelect from '@kobalte/core/select';
import { Show, createMemo } from 'solid-js';
import * as SelectPrimitive from '@kobalte/core/select';
import Check from 'lucide-solid/icons/check';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import { Show, type Component } from 'solid-js';
import { cn } from '../lib/cn';
@ -18,19 +20,25 @@ interface SelectProps {
onChange?: (value: string) => void;
}
export function Select(props: SelectProps) {
const selected = createMemo(() =>
props.options.find((option) => option.value === props.value),
);
export const Select: Component<SelectProps> = (props) => {
// Note: derive `selected` inline (no createMemo) — Solid's reactive primitives
// already cache prop reads, and `find` over a typically tiny option list is
// cheaper than the memo bookkeeping.
const selected = () =>
props.options.find((option) => option.value === props.value);
return (
<KSelect.Root<SelectOption>
<SelectPrimitive.Root<SelectOption>
class={cn('ui-select', props.class)}
itemComponent={(itemProps) => (
<KSelect.Item class="ui-select__item" item={itemProps.item}>
<KSelect.ItemLabel>{itemProps.item.rawValue.label}</KSelect.ItemLabel>
<KSelect.ItemIndicator></KSelect.ItemIndicator>
</KSelect.Item>
<SelectPrimitive.Item class="ui-select__item" item={itemProps.item}>
<SelectPrimitive.ItemLabel>
{itemProps.item.rawValue.label}
</SelectPrimitive.ItemLabel>
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
<Check aria-hidden="true" size={14} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)}
onChange={(option) => props.onChange?.(option?.value ?? '')}
optionTextValue="label"
@ -40,21 +48,25 @@ export function Select(props: SelectProps) {
value={selected()}
>
<Show when={props.label}>
<KSelect.Label class="ui-field__label">{props.label}</KSelect.Label>
<SelectPrimitive.Label class="ui-field__label">
{props.label}
</SelectPrimitive.Label>
</Show>
<KSelect.Trigger class="ui-select__trigger">
<KSelect.Value<SelectOption> class="ui-select__value">
<SelectPrimitive.Trigger class="ui-select__trigger">
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
{(state) =>
state.selectedOption()?.label ?? props.placeholder ?? 'Select'
}
</KSelect.Value>
<KSelect.Icon></KSelect.Icon>
</KSelect.Trigger>
<KSelect.Portal>
<KSelect.Content class="ui-select__content">
<KSelect.Listbox />
</KSelect.Content>
</KSelect.Portal>
</KSelect.Root>
</SelectPrimitive.Value>
<SelectPrimitive.Icon class="ui-select__icon">
<ChevronDown aria-hidden="true" size={14} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content class="ui-select__content">
<SelectPrimitive.Listbox />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
}
};

View file

@ -1,4 +1,4 @@
import * as KSwitch from '@kobalte/core/switch';
import * as SwitchPrimitive from '@kobalte/core/switch';
import { cn } from '../lib/cn';
@ -14,25 +14,25 @@ interface SwitchProps {
export function Switch(props: SwitchProps) {
return (
<KSwitch.Root
<SwitchPrimitive.Root
checked={props.checked}
class={cn('ui-switch', props.class)}
defaultChecked={props.defaultChecked}
disabled={props.disabled}
onChange={props.onChange}
>
<KSwitch.Input />
<KSwitch.Control class="ui-switch__control">
<KSwitch.Thumb class="ui-switch__thumb" />
</KSwitch.Control>
<SwitchPrimitive.Input />
<SwitchPrimitive.Control class="ui-switch__control">
<SwitchPrimitive.Thumb class="ui-switch__thumb" />
</SwitchPrimitive.Control>
<span>
<KSwitch.Label>{props.label}</KSwitch.Label>
<SwitchPrimitive.Label>{props.label}</SwitchPrimitive.Label>
{props.description && (
<KSwitch.Description class="ui-field__description">
<SwitchPrimitive.Description class="ui-field__description">
{props.description}
</KSwitch.Description>
</SwitchPrimitive.Description>
)}
</span>
</KSwitch.Root>
</SwitchPrimitive.Root>
);
}

View file

@ -1,4 +1,4 @@
import * as KTabs from '@kobalte/core/tabs';
import * as TabsPrimitive from '@kobalte/core/tabs';
import { cn } from '../lib/cn';
@ -8,35 +8,35 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Tabs = {
Root: (props: WrapperProps) => (
<KTabs.Root
{...(props as unknown as KTabs.TabsRootProps)}
<TabsPrimitive.Root
{...(props as unknown as TabsPrimitive.TabsRootProps)}
class={cn('ui-tabs', props.class)}
>
{props.children}
</KTabs.Root>
</TabsPrimitive.Root>
),
List: (props: WrapperProps) => (
<KTabs.List
{...(props as unknown as KTabs.TabsListProps)}
<TabsPrimitive.List
{...(props as unknown as TabsPrimitive.TabsListProps)}
class={cn('ui-tabs__list', props.class)}
>
{props.children}
</KTabs.List>
</TabsPrimitive.List>
),
Trigger: (props: WrapperProps) => (
<KTabs.Trigger
{...(props as unknown as KTabs.TabsTriggerProps)}
<TabsPrimitive.Trigger
{...(props as unknown as TabsPrimitive.TabsTriggerProps)}
class={cn('ui-tabs__trigger', props.class)}
>
{props.children}
</KTabs.Trigger>
</TabsPrimitive.Trigger>
),
Content: (props: WrapperProps) => (
<KTabs.Content
{...(props as unknown as KTabs.TabsContentProps)}
<TabsPrimitive.Content
{...(props as unknown as TabsPrimitive.TabsContentProps)}
class={cn('ui-tabs__content', props.class)}
>
{props.children}
</KTabs.Content>
</TabsPrimitive.Content>
),
};

View file

@ -1,4 +1,4 @@
import * as KTextField from '@kobalte/core/text-field';
import * as TextFieldPrimitive from '@kobalte/core/text-field';
import { cn } from '../lib/cn';
@ -21,15 +21,17 @@ interface TextFieldProps extends ParentProps {
export function TextField(props: TextFieldProps) {
return (
<KTextField.Root
<TextFieldPrimitive.Root
class={cn('ui-field', props.class)}
validationState={props.errorMessage ? 'invalid' : 'valid'}
>
<KTextField.Label class="ui-field__label">{props.label}</KTextField.Label>
<TextFieldPrimitive.Label class="ui-field__label">
{props.label}
</TextFieldPrimitive.Label>
<div class="ui-field__control-row">
<div class="ui-field__control-fill">
{props.multiline ? (
<KTextField.TextArea
<TextFieldPrimitive.TextArea
class="ui-textarea"
onInput={
props.onInput as JSX.EventHandlerUnion<
@ -41,7 +43,7 @@ export function TextField(props: TextFieldProps) {
value={props.value}
/>
) : (
<KTextField.Input
<TextFieldPrimitive.Input
class="ui-input"
onInput={
props.onInput as JSX.EventHandlerUnion<
@ -58,15 +60,15 @@ export function TextField(props: TextFieldProps) {
{props.children}
</div>
{props.description && (
<KTextField.Description class="ui-field__description">
<TextFieldPrimitive.Description class="ui-field__description">
{props.description}
</KTextField.Description>
</TextFieldPrimitive.Description>
)}
{props.errorMessage && (
<KTextField.ErrorMessage class="ui-field__error">
<TextFieldPrimitive.ErrorMessage class="ui-field__error">
{props.errorMessage}
</KTextField.ErrorMessage>
</TextFieldPrimitive.ErrorMessage>
)}
</KTextField.Root>
</TextFieldPrimitive.Root>
);
}

View file

@ -1,4 +1,4 @@
import * as KToast from '@kobalte/core/toast';
import * as ToastPrimitive from '@kobalte/core/toast';
import { cn } from '../lib/cn';
@ -8,49 +8,49 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Toast = {
Region: (props: WrapperProps) => (
<KToast.Region
{...(props as KToast.ToastRegionProps)}
<ToastPrimitive.Region
{...(props as ToastPrimitive.ToastRegionProps)}
class={cn('ui-toast-region', props.class)}
>
{props.children}
</KToast.Region>
</ToastPrimitive.Region>
),
List: (props: WrapperProps) => (
<KToast.List
{...(props as KToast.ToastListProps)}
<ToastPrimitive.List
{...(props as ToastPrimitive.ToastListProps)}
class={cn('ui-toast-list', props.class)}
>
{props.children}
</KToast.List>
</ToastPrimitive.List>
),
Root: (props: WrapperProps) => (
<KToast.Root
{...(props as unknown as KToast.ToastRootProps)}
<ToastPrimitive.Root
{...(props as unknown as ToastPrimitive.ToastRootProps)}
class={cn('ui-toast', props.class)}
>
{props.children}
</KToast.Root>
</ToastPrimitive.Root>
),
Title: (props: WrapperProps) => (
<KToast.Title {...(props as KToast.ToastTitleProps)}>
<ToastPrimitive.Title {...(props as ToastPrimitive.ToastTitleProps)}>
{props.children}
</KToast.Title>
</ToastPrimitive.Title>
),
Description: (props: WrapperProps) => (
<KToast.Description
{...(props as KToast.ToastDescriptionProps)}
<ToastPrimitive.Description
{...(props as ToastPrimitive.ToastDescriptionProps)}
class={cn('ui-subtitle', props.class)}
>
{props.children}
</KToast.Description>
</ToastPrimitive.Description>
),
CloseButton: (props: WrapperProps) => (
<KToast.CloseButton
{...(props as KToast.ToastCloseButtonProps)}
<ToastPrimitive.CloseButton
{...(props as ToastPrimitive.ToastCloseButtonProps)}
class={cn('ui-button', props.class)}
>
{props.children}
</KToast.CloseButton>
</ToastPrimitive.CloseButton>
),
toaster: KToast.toaster,
toaster: ToastPrimitive.toaster,
};

View file

@ -1,4 +1,4 @@
import * as KTooltip from '@kobalte/core/tooltip';
import * as TooltipPrimitive from '@kobalte/core/tooltip';
import { cn } from '../lib/cn';
@ -8,30 +8,35 @@ type WrapperProps = ParentProps<{ class?: string; [key: string]: unknown }>;
export const Tooltip = {
Root: (props: WrapperProps) => (
<KTooltip.Root openDelay={150} {...(props as KTooltip.TooltipRootProps)}>
<TooltipPrimitive.Root
openDelay={150}
{...(props as TooltipPrimitive.TooltipRootProps)}
>
{props.children}
</KTooltip.Root>
</TooltipPrimitive.Root>
),
Trigger: (props: WrapperProps) => (
<KTooltip.Trigger
{...(props as KTooltip.TooltipTriggerProps)}
<TooltipPrimitive.Trigger
{...(props as TooltipPrimitive.TooltipTriggerProps)}
class={props.class}
>
{props.children}
</KTooltip.Trigger>
</TooltipPrimitive.Trigger>
),
Portal: (props: WrapperProps) => (
<KTooltip.Portal>{props.children}</KTooltip.Portal>
<TooltipPrimitive.Portal>{props.children}</TooltipPrimitive.Portal>
),
Content: (props: WrapperProps) => (
<KTooltip.Content
{...(props as KTooltip.TooltipContentProps)}
<TooltipPrimitive.Content
{...(props as TooltipPrimitive.TooltipContentProps)}
class={cn('ui-tooltip__content', props.class)}
>
{props.children}
</KTooltip.Content>
</TooltipPrimitive.Content>
),
Arrow: (props: WrapperProps) => (
<KTooltip.Arrow {...(props as KTooltip.TooltipArrowProps)} />
<TooltipPrimitive.Arrow
{...(props as TooltipPrimitive.TooltipArrowProps)}
/>
),
};

View file

@ -1,6 +1,32 @@
@import 'tailwindcss';
@import './styles/tokens.css';
@import './styles/base.css';
@import './styles/primitives.css';
@import './styles/patterns.css';
@import './styles/layout.css';
@import './styles/pages.css';
/*
* Tailwind v4 theme bridging expose existing CSS custom properties as theme tokens
* so utility classes (e.g. `text-(--color-text)`, `bg-(--color-surface)`) and the
* `theme()` function resolve to the same design tokens that the legacy stylesheets use.
*/
@theme inline {
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-text: var(--color-text);
--color-text-muted: var(--color-text-muted);
--color-accent: var(--color-accent);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
--color-danger: var(--color-danger);
--color-border: var(--color-border);
--radius-1: var(--radius-1);
--radius-2: var(--radius-2);
--radius-3: var(--radius-3);
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
--font-mono: var(--font-mono, ui-monospace, monospace);
}

View file

@ -1,3 +1,4 @@
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
@ -5,6 +6,7 @@ export default defineConfig({
base: '/dashboard/',
plugins: [
solidPlugin(),
tailwindcss(),
{
name: 'dashboard-trailing-slash-redirect',
configureServer(server) {

644
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
packages:
- shared
- server
- client

View file

@ -21,7 +21,10 @@
"@hono/node-server": "^1.13.7",
"@hono/swagger-ui": "^0.5.0",
"@hono/zod-openapi": "^0.18.0",
"@hono/zod-validator": "^0.4.2",
"@kyush/shared": "workspace:*",
"better-sqlite3": "^12.6.2",
"es-toolkit": "^1.32.0",
"dotenv": "^17.3.1",
"hono": "^4.6.14",
"isolated-vm": "^6.0.2",
@ -29,6 +32,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.3.3",
"chalk": "^5.6.0",

View file

@ -1,90 +1,68 @@
import { createHash } from 'node:crypto';
import { env } from './env.js';
import type { AdminAuthMode } from '../../../shared/types.js';
function normalizeAuthMode(value?: string): AdminAuthMode {
if (value === 'env' || value === 'oidc' || value === 'both') {
return value;
}
return 'both';
}
function parseList(value?: string): string[] {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
export function getAdminAuthMode(): AdminAuthMode {
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
return env.ADMIN_AUTH_MODE;
}
export function isEnvAdminEnabled(): boolean {
const mode = getAdminAuthMode();
const mode = env.ADMIN_AUTH_MODE;
return mode === 'env' || mode === 'both';
}
export function isOidcEnabled(): boolean {
const mode = getAdminAuthMode();
const mode = env.ADMIN_AUTH_MODE;
return mode === 'oidc' || mode === 'both';
}
export function getAdminUsername(): string | null {
return process.env.ADMIN_USERNAME?.trim() || null;
return env.ADMIN_USERNAME;
}
export function getAdminPasswordHash(): string | null {
return process.env.ADMIN_PASSWORD_HASH?.trim() || null;
return env.ADMIN_PASSWORD_HASH;
}
export function getAdminSessionSecret(): string {
return (
process.env.ADMIN_SESSION_SECRET?.trim() ||
'development-admin-session-secret'
);
return env.ADMIN_SESSION_SECRET;
}
export function getAdminSessionTtlHours(): number {
const parsed = Number(process.env.ADMIN_SESSION_TTL_HOURS ?? 12);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
return env.ADMIN_SESSION_TTL_HOURS;
}
export function getAdminApiTokenTtlDays(): number {
const parsed = Number(process.env.ADMIN_API_TOKEN_TTL_DAYS ?? 30);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
return env.ADMIN_API_TOKEN_TTL_DAYS;
}
export function getCookieSecure(): boolean {
return (
process.env.NODE_ENV === 'production' &&
process.env.ADMIN_COOKIE_SECURE !== 'false'
);
return env.ADMIN_COOKIE_SECURE;
}
export function getAllowedOidcEmails(): string[] {
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
entry.toLowerCase(),
);
return env.OIDC_ALLOWED_EMAILS;
}
export function getTrustedProxyIps(): string[] {
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
return env.ADMIN_TRUSTED_PROXY_IPS;
}
export function getOidcConfig() {
return {
issuerUrl: process.env.OIDC_ISSUER_URL?.trim() || '',
clientId: process.env.OIDC_CLIENT_ID?.trim() || '',
clientSecret: process.env.OIDC_CLIENT_SECRET?.trim() || '',
redirectUri: process.env.OIDC_REDIRECT_URI?.trim() || '',
scopes: process.env.OIDC_SCOPES?.trim() || 'openid profile email',
issuerUrl: env.OIDC_ISSUER_URL,
clientId: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
redirectUri: env.OIDC_REDIRECT_URI,
scopes: env.OIDC_SCOPES,
};
}
export function hashOpaqueToken(token: string): string {
return createHash('sha256')
.update(getAdminSessionSecret())
.update(env.ADMIN_SESSION_SECRET)
.update(':')
.update(token)
.digest('hex');

View file

@ -1,18 +1,17 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import Database from 'better-sqlite3';
import { ensureDir, getAnalyticsDbPath } from './db-paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = import.meta.dirname;
let db: Database.Database;
let db: Database.Database | undefined;
function loadSchema(database: Database.Database): void {
const schemaPath = path.join(
__dirname,
moduleDir,
'..',
'..',
'..',
@ -23,36 +22,27 @@ function loadSchema(database: Database.Database): void {
database.exec(schema);
}
export function getAnalyticsDb(): Database.Database {
if (!db) {
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
function openDb(): Database.Database {
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
const handle = new Database(analyticsDbPath);
handle.pragma('foreign_keys = ON');
loadSchema(handle);
return handle;
}
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
loadSchema(db);
}
export function getAnalyticsDb(): Database.Database {
db ??= openDb();
return db;
}
export function initAnalyticsDb(): Database.Database {
if (db) {
db.close();
}
const analyticsDbPath = getAnalyticsDbPath();
ensureDir(path.dirname(analyticsDbPath));
db = new Database(analyticsDbPath);
db.pragma('foreign_keys = ON');
loadSchema(db);
db?.close();
db = openDb();
return db;
}
export function closeAnalyticsDb(): void {
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
db?.close();
db = undefined;
}

View file

@ -1,24 +1,35 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import { ensureDir, getCoreDbPath } from './db-paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Node 20.11+ exposes import.meta.dirname directly — no fileURLToPath needed.
const moduleDir = import.meta.dirname;
let db: Database.Database;
// Lazy singleton — `getDb()` instantiates on first access, `closeDb()` resets
// it back to `undefined` so the next `getDb()` reopens a fresh handle.
let db: Database.Database | undefined;
function isPragmaColumnRow(value: unknown): value is { name: string } {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
);
}
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
return columns.some(
(column) => isPragmaColumnRow(column) && column.name === columnName,
);
}
function runCoreMigrations(database: Database.Database): void {
@ -31,7 +42,7 @@ function runCoreMigrations(database: Database.Database): void {
function loadSchema(database: Database.Database): void {
const schemaPath = path.join(
__dirname,
moduleDir,
'..',
'..',
'..',
@ -42,40 +53,29 @@ function loadSchema(database: Database.Database): void {
database.exec(schema);
}
function openDb(): Database.Database {
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
const handle = new Database(coreDbPath);
handle.pragma('foreign_keys = ON');
loadSchema(handle);
runCoreMigrations(handle);
return handle;
}
export function getDb(): Database.Database {
if (!db) {
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
loadSchema(db);
runCoreMigrations(db);
}
db ??= openDb();
return db;
}
export function initDb(): Database.Database {
if (db) {
db.close();
}
const coreDbPath = getCoreDbPath();
ensureDir(path.dirname(coreDbPath));
db = new Database(coreDbPath);
db.pragma('foreign_keys = ON');
loadSchema(db);
runCoreMigrations(db);
db?.close();
db = openDb();
return db;
}
export function closeDb(): void {
if (db) {
db.close();
db = undefined as unknown as Database.Database;
}
db?.close();
db = undefined;
}

View file

@ -1,10 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
import { env } from './env.js';
export function getDbRootDir(): string {
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
return env.DB_DIR;
}
export function ensureDir(dirPath: string): void {

165
server/src/config/env.ts Normal file
View file

@ -0,0 +1,165 @@
/**
* Single source of truth for every environment variable the server reads.
*
* - All `process.env` access is centralised here.
* - Each value is parsed/validated/normalised once at module load and exposed
* via `env`, an immutable object.
* - Importers should grab a typed value (`env.SERVER_PORT`) instead of touching
* `process.env` directly. This makes mistakes loud (typos surface as TS
* errors) and concentrates default values in one place.
*
* Tests can mutate the underlying `process.env` before importing this module
* (see tests/setup.ts) values that may legitimately change at runtime are
* exposed as functions instead of frozen primitives.
*/
import path from 'node:path';
import type { AdminAuthMode } from '../../../shared/types.js';
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
const DEFAULT_TIME_ZONE = 'UTC';
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
const DEFAULT_CORS_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:3002',
'http://127.0.0.1:3002',
];
function trimmed(value: string | undefined): string | undefined {
const result = value?.trim();
return result && result.length > 0 ? result : undefined;
}
function parseList(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parsePositiveNumber(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeNumber(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function normalizeAuthMode(value: string | undefined): AdminAuthMode {
return value === 'env' || value === 'oidc' || value === 'both'
? value
: 'both';
}
/*
* Eagerly-resolved values (read once at boot)
* */
export const env = {
// Server runtime
get SERVER_PORT(): number {
return parsePositiveNumber(process.env.SERVER_PORT, 3000);
},
get NODE_ENV(): string | undefined {
return process.env.NODE_ENV;
},
get IS_PRODUCTION(): boolean {
return process.env.NODE_ENV === 'production';
},
// Storage paths
get DB_DIR(): string {
return (
trimmed(process.env.DB_DIR) ??
trimmed(process.env.DB_PATH) ??
DEFAULT_DB_DIR
);
},
// Time zone (used for daily/monthly bucket math)
get TIME_ZONE(): string {
return trimmed(process.env.TZ) ?? DEFAULT_TIME_ZONE;
},
// CORS
get CORS_ORIGINS(): string[] {
const raw = trimmed(process.env.CORS_ORIGINS);
return raw
? raw.split(',').map((origin) => origin.trim())
: DEFAULT_CORS_ORIGINS;
},
// Admin auth mode + ENV credentials
get ADMIN_AUTH_MODE(): AdminAuthMode {
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
},
get ADMIN_USERNAME(): string | null {
return trimmed(process.env.ADMIN_USERNAME) ?? null;
},
get ADMIN_PASSWORD_HASH(): string | null {
return trimmed(process.env.ADMIN_PASSWORD_HASH) ?? null;
},
get ADMIN_SESSION_SECRET(): string {
return (
trimmed(process.env.ADMIN_SESSION_SECRET) ??
'development-admin-session-secret'
);
},
get ADMIN_SESSION_TTL_HOURS(): number {
return parsePositiveNumber(process.env.ADMIN_SESSION_TTL_HOURS, 12);
},
get ADMIN_API_TOKEN_TTL_DAYS(): number {
return parsePositiveNumber(process.env.ADMIN_API_TOKEN_TTL_DAYS, 30);
},
get ADMIN_COOKIE_SECURE(): boolean {
return (
process.env.NODE_ENV === 'production' &&
process.env.ADMIN_COOKIE_SECURE !== 'false'
);
},
get ADMIN_TRUSTED_PROXY_IPS(): string[] {
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
},
// OIDC
get OIDC_ISSUER_URL(): string {
return trimmed(process.env.OIDC_ISSUER_URL) ?? '';
},
get OIDC_CLIENT_ID(): string {
return trimmed(process.env.OIDC_CLIENT_ID) ?? '';
},
get OIDC_CLIENT_SECRET(): string {
return trimmed(process.env.OIDC_CLIENT_SECRET) ?? '';
},
get OIDC_REDIRECT_URI(): string {
return trimmed(process.env.OIDC_REDIRECT_URI) ?? '';
},
get OIDC_SCOPES(): string {
return trimmed(process.env.OIDC_SCOPES) ?? 'openid profile email';
},
get OIDC_ALLOWED_EMAILS(): string[] {
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) =>
entry.toLowerCase(),
);
},
// Catalog refresh
get MODEL_CATALOG_REFRESH_MIN_MS(): number {
return parseNonNegativeNumber(
process.env.MODEL_CATALOG_REFRESH_MIN_MS,
DEFAULT_REFRESH_MIN_MS,
);
},
};

View file

@ -1,6 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
@ -12,24 +11,33 @@ import {
import { getLocalMonthKey } from '../utils/time.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = import.meta.dirname;
const connections = new Map<string, Database.Database>();
function isPragmaColumnRow(value: unknown): value is { name: string } {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
);
}
function hasColumn(
database: Database.Database,
tableName: string,
columnName: string,
): boolean {
const columns = database
.prepare(`PRAGMA table_info(${tableName})`)
.all() as Array<{ name: string }>;
return columns.some((column) => column.name === columnName);
const columns = database.prepare(`PRAGMA table_info(${tableName})`).all();
return columns.some(
(column) => isPragmaColumnRow(column) && column.name === columnName,
);
}
function initRequestLogsSchema(db: Database.Database): void {
const schemaPath = path.join(
__dirname,
moduleDir,
'..',
'..',
'..',
@ -38,7 +46,7 @@ function initRequestLogsSchema(db: Database.Database): void {
);
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
if (hasColumn(db, 'request_logs', 'routed_model') === false) {
if (!hasColumn(db, 'request_logs', 'routed_model')) {
db.exec('ALTER TABLE request_logs ADD COLUMN routed_model TEXT');
}
}
@ -47,9 +55,7 @@ export function getRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
return existing;
}
if (existing) return existing;
const dbPath = getRequestLogsDbPath(monthKey);
ensureDir(path.dirname(dbPath));
@ -63,23 +69,21 @@ export function getRequestLogsDb(
export function initRequestLogsDb(
monthKey: string = getLocalMonthKey(),
): Database.Database {
const existing = connections.get(monthKey);
if (existing) {
existing.close();
connections.delete(monthKey);
}
connections.get(monthKey)?.close();
connections.delete(monthKey);
return getRequestLogsDb(monthKey);
}
const REQUEST_LOG_FILENAME_PATTERN = /^request_logs_(\d{4}-\d{2})\.db$/;
export function listRequestLogMonths(): string[] {
const requestLogsDir = getRequestLogsDir();
ensureDir(requestLogsDir);
return fs
.readdirSync(requestLogsDir)
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
.filter((value): value is string => Boolean(value))
.map((entry) => REQUEST_LOG_FILENAME_PATTERN.exec(entry)?.[1])
.filter((value): value is string => value !== undefined)
.sort((a, b) => b.localeCompare(a));
}

View file

@ -1,6 +1,5 @@
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { OpenAPIHono } from '@hono/zod-openapi';
import { swaggerUI } from '@hono/swagger-ui';
@ -9,6 +8,7 @@ import { bodyLimit } from 'hono/body-limit';
import { serveStatic } from '@hono/node-server/serve-static';
import dotenv from 'dotenv';
import { env } from './config/env.js';
import adminRoutes from './routes/admin.js';
import adminAuthRoutes from './routes/admin-auth.js';
import apiRoutes from './routes/api.js';
@ -19,11 +19,11 @@ import { ModelCatalogService } from './services/ModelCatalogService.js';
import type { AppEnv } from './types/hono.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = import.meta.dirname;
const envPathCandidates = [
path.resolve(__dirname, '..', '..', '.env'),
path.resolve(__dirname, '..', '..', '..', '..', '.env'),
path.resolve(moduleDir, '..', '..', '.env'),
path.resolve(moduleDir, '..', '..', '..', '..', '.env'),
path.resolve(process.cwd(), '.env'),
path.resolve(process.cwd(), '..', '.env'),
];
@ -43,26 +43,17 @@ export function createApp(): OpenAPIHono<AppEnv> {
const app = new OpenAPIHono<AppEnv>();
const adminDistCandidates = [
path.resolve(__dirname, '..', '..', '..', 'client', 'dist'),
path.resolve(__dirname, '..', '..', '..', '..', 'client', 'dist'),
path.resolve(moduleDir, '..', '..', '..', 'client', 'dist'),
path.resolve(moduleDir, '..', '..', '..', '..', 'client', 'dist'),
];
const adminDistPath = adminDistCandidates.find((candidate) =>
fs.existsSync(candidate),
);
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
: [
'http://localhost:3000',
'http://127.0.0.1:3000',
'http://localhost:3002',
'http://127.0.0.1:3002',
];
app.use(
'*',
cors({
origin: corsOrigins,
origin: env.CORS_ORIGINS,
credentials: true,
}),
);

View file

@ -1,20 +1,19 @@
import { serve } from '@hono/node-server';
import { env } from './config/env.js';
import app from './index.js';
import { logger } from './utils/logger.js';
const PORT = Number(process.env.SERVER_PORT) || 3000;
serve(
{
fetch: app.fetch,
port: PORT,
port: env.SERVER_PORT,
},
() => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin API: http://localhost:${PORT}/admin`);
logger.info(`Admin UI: http://localhost:${PORT}/dashboard`);
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
logger.info(`API Docs: http://localhost:${PORT}/admin/docs`);
logger.info(`Server running on port ${env.SERVER_PORT}`);
logger.info(`Admin API: http://localhost:${env.SERVER_PORT}/admin`);
logger.info(`Admin UI: http://localhost:${env.SERVER_PORT}/dashboard`);
logger.info(`OpenAI API: http://localhost:${env.SERVER_PORT}/v1`);
logger.info(`API Docs: http://localhost:${env.SERVER_PORT}/admin/docs`);
},
);

3
server/src/reset.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
// Activate ts-reset's improved built-in types globally for the server.
// See https://www.totaltypescript.com/ts-reset
import '@total-typescript/ts-reset';

View file

@ -1,5 +1,11 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import {
AdminLoginInputSchema,
CreateAdminTokenInputSchema,
} from '@kyush/shared';
import { AdminApiTokenModel } from '../models/AdminApiToken.js';
import { AdminSessionModel } from '../models/AdminSession.js';
import {
@ -97,13 +103,12 @@ router.get('/session', (c) => {
return c.json(buildSessionResponse(adminAuth));
});
router.post('/login', async (c) => {
router.post('/login', zValidator('json', AdminLoginInputSchema), (c) => {
if (!isEnvAdminEnabled()) {
return c.json({ error: 'ENV admin login is disabled' }, 404);
}
const body = await c.req.json();
const { username, password } = body;
const { username, password } = c.req.valid('json');
const configuredUsername = getAdminUsername();
if (!configuredUsername || !username || !password) {
@ -311,32 +316,29 @@ router.get('/tokens', requireAdminAccess, (c) => {
return c.json(AdminApiTokenModel.listBySubject(adminAuth.principal.subject));
});
router.post('/tokens', requireAdminAccess, requireSessionCsrf, async (c) => {
const body = await c.req.json();
const { name, expiresInDays } = body;
const trimmedName = name?.trim();
if (!trimmedName) {
return c.json({ error: 'Token name is required' }, 400);
}
router.post(
'/tokens',
requireAdminAccess,
requireSessionCsrf,
zValidator('json', CreateAdminTokenInputSchema),
(c) => {
const { name: trimmedName, expiresInDays } = c.req.valid('json');
const ttlDays = expiresInDays ?? getAdminApiTokenTtlDays();
const token = generateOpaqueToken('adm_tok');
const adminAuth = c.get('adminAuth')!;
const record = AdminApiTokenModel.create({
tokenHash: hashAdminToken(token),
tokenPrefix: tokenPrefix(token),
name: trimmedName,
principal: adminAuth.principal,
expiresAt: new Date(
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
).toISOString(),
});
const ttlDays =
Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
? Number(expiresInDays)
: getAdminApiTokenTtlDays();
const token = generateOpaqueToken('adm_tok');
const adminAuth = c.get('adminAuth')!;
const record = AdminApiTokenModel.create({
tokenHash: hashAdminToken(token),
tokenPrefix: tokenPrefix(token),
name: trimmedName,
principal: adminAuth.principal,
expiresAt: new Date(
Date.now() + ttlDays * 24 * 60 * 60 * 1000,
).toISOString(),
});
return c.json({ token, record }, 201);
});
return c.json({ token, record }, 201);
},
);
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (c) => {
const tokenId = Number(c.req.param('id'));

View file

@ -1,5 +1,16 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import {
CreateBackendInputSchema,
CreateModelRewriteInputSchema,
CreatePermissionInputSchema,
CreateUserInputSchema,
UpdateBackendInputSchema,
UpdateModelRewriteInputSchema,
UpdateUserInputSchema,
} from '@kyush/shared';
import scriptRoutes from './scripts.js';
import { UserModel } from '../models/User.js';
@ -11,16 +22,6 @@ import { getUtcTimestamp } from '../utils/time.js';
import { ModelCatalogService } from '../services/ModelCatalogService.js';
import { AnalyticsService } from '../services/AnalyticsService.js';
import type {
CreateBackendData,
CreateModelRewriteData,
CreatePermissionData,
CreateUserData,
UpdateBackendData,
UpdateModelRewriteData,
UpdateUserData,
} from '../../../shared/types.js';
import type { AppEnv } from '../types/hono.js';
const router = new Hono<AppEnv>();
@ -38,21 +39,11 @@ router.get('/users', (c) => {
return c.json(UserModel.findAll());
});
router.post('/users', async (c) => {
const body = await c.req.json();
const { name, email, api_key, detail_logging } = body;
if (!name?.trim()) {
return c.json({ error: 'Name is required' }, 400);
}
router.post('/users', zValidator('json', CreateUserInputSchema), (c) => {
const data = c.req.valid('json');
try {
const user = UserModel.create({
name: name.trim(),
email: email?.trim() || undefined,
api_key: api_key?.trim() || undefined,
detail_logging,
});
const user = UserModel.create(data);
return c.json(user, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
@ -71,7 +62,7 @@ router.get('/users/:id', (c) => {
return c.json(user);
});
router.put('/users/:id', async (c) => {
router.put('/users/:id', zValidator('json', UpdateUserInputSchema), (c) => {
const id = Number(c.req.param('id'));
const user = UserModel.findById(id);
@ -79,22 +70,8 @@ router.put('/users/:id', async (c) => {
return c.json({ error: 'User not found' }, 404);
}
const body = await c.req.json();
const { name, email, api_key, is_active, detail_logging } = body;
if (typeof name === 'string' && !name.trim()) {
return c.json({ error: 'Name cannot be empty' }, 400);
}
try {
const updatedUser = UserModel.update(id, {
name: typeof name === 'string' ? name.trim() : undefined,
email: typeof email === 'string' ? email.trim() || undefined : undefined,
api_key:
typeof api_key === 'string' ? api_key.trim() || undefined : undefined,
is_active,
detail_logging,
});
const updatedUser = UserModel.update(id, c.req.valid('json'));
return c.json(updatedUser);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
@ -134,20 +111,8 @@ router.get('/backends', (c) => {
return c.json(ModelCatalogService.getBackendsWithSummary());
});
router.post('/backends', async (c) => {
const body = await c.req.json();
const { name, base_url, api_key, detail_logging } = body;
if (!name || !base_url) {
return c.json({ error: 'Name and base_url are required' }, 400);
}
const backend = BackendModel.create({
name,
base_url,
api_key,
detail_logging,
});
router.post('/backends', zValidator('json', CreateBackendInputSchema), (c) => {
const backend = BackendModel.create(c.req.valid('json'));
return c.json(backend, 201);
});
@ -162,29 +127,25 @@ router.get('/backends/:id', (c) => {
return c.json(backend);
});
router.put('/backends/:id', async (c) => {
const id = Number(c.req.param('id'));
const backend = BackendModel.findById(id);
if (!backend) {
return c.json({ error: 'Backend not found' }, 404);
}
router.put(
'/backends/:id',
zValidator('json', UpdateBackendInputSchema),
async (c) => {
const id = Number(c.req.param('id'));
const backend = BackendModel.findById(id);
if (!backend) {
return c.json({ error: 'Backend not found' }, 404);
}
const body = await c.req.json();
const { name, base_url, api_key, is_active, detail_logging } = body;
const updatedBackend = BackendModel.update(id, {
name,
base_url,
api_key,
is_active,
detail_logging,
});
await ModelCatalogService.handleBackendUpdated(id);
return c.json(
ModelCatalogService.getBackendsWithSummary().find(
(item) => item.id === id,
) || updatedBackend,
);
});
const updatedBackend = BackendModel.update(id, c.req.valid('json'));
await ModelCatalogService.handleBackendUpdated(id);
return c.json(
ModelCatalogService.getBackendsWithSummary().find(
(item) => item.id === id,
) || updatedBackend,
);
},
);
router.delete('/backends/:id', async (c) => {
const id = Number(c.req.param('id'));
@ -255,24 +216,21 @@ router.get('/permissions/backend/:backendId', (c) => {
return c.json(PermissionModel.findByBackendId(backendId));
});
router.post('/permissions', async (c) => {
const body = await c.req.json();
const { user_id, backend_id } = body;
if (!user_id || !backend_id) {
return c.json({ error: 'user_id and backend_id are required' }, 400);
}
try {
const permission = PermissionModel.create({ user_id, backend_id });
return c.json(permission, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
return c.json({ error: error.message }, 409);
router.post(
'/permissions',
zValidator('json', CreatePermissionInputSchema),
(c) => {
try {
const permission = PermissionModel.create(c.req.valid('json'));
return c.json(permission, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
return c.json({ error: error.message }, 409);
}
return c.json({ error: 'Failed to create permission' }, 500);
}
return c.json({ error: 'Failed to create permission' }, 500);
}
});
},
);
router.delete('/permissions', (c) => {
const user_id = c.req.query('user_id');
@ -293,57 +251,51 @@ router.get('/model-rewrites', (c) => {
return c.json(ModelRewriteModel.findAll());
});
router.post('/model-rewrites', async (c) => {
const body = await c.req.json();
const { source_model, target_model, is_active, force, note } = body;
if (!source_model?.trim() || !target_model?.trim()) {
return c.json({ error: 'source_model and target_model are required' }, 400);
}
try {
const rule = ModelRewriteModel.create({
source_model: source_model.trim(),
target_model: target_model.trim(),
is_active,
force,
note,
});
ModelCatalogService.loadRewriteMap();
return c.json(rule, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
router.post(
'/model-rewrites',
zValidator('json', CreateModelRewriteInputSchema),
(c) => {
try {
const rule = ModelRewriteModel.create(c.req.valid('json'));
ModelCatalogService.loadRewriteMap();
return c.json(rule, 201);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
}
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
}
return c.json({ error: 'Failed to create model rewrite rule' }, 500);
}
});
},
);
router.put('/model-rewrites/:id', async (c) => {
const id = Number(c.req.param('id'));
const existing = ModelRewriteModel.findById(id);
if (!existing) {
return c.json({ error: 'Model rewrite rule not found' }, 404);
}
try {
const body = await c.req.json();
const updated = ModelRewriteModel.update(id, body);
ModelCatalogService.loadRewriteMap();
return c.json(updated);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
router.put(
'/model-rewrites/:id',
zValidator('json', UpdateModelRewriteInputSchema),
(c) => {
const id = Number(c.req.param('id'));
const existing = ModelRewriteModel.findById(id);
if (!existing) {
return c.json({ error: 'Model rewrite rule not found' }, 404);
}
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
}
});
try {
const updated = ModelRewriteModel.update(id, c.req.valid('json'));
ModelCatalogService.loadRewriteMap();
return c.json(updated);
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE')) {
return c.json(
{ error: 'Rewrite rule already exists for this source_model' },
409,
);
}
return c.json({ error: 'Failed to update model rewrite rule' }, 500);
}
},
);
router.delete('/model-rewrites/:id', (c) => {
const id = Number(c.req.param('id'));

View file

@ -1,93 +1,45 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import {
CreateScriptInputSchema,
ScriptTestInputSchema,
UpdateScriptInputSchema,
} from '@kyush/shared';
import { ScriptModel } from '../models/Script.js';
import { CompiledScript } from '../services/ScriptExecutor.js';
import type {
CreateScriptData,
UpdateScriptData,
ScriptContextData,
} from '../../../shared/types.js';
import type { ScriptContextData } from '../../../shared/types.js';
import type { AppEnv } from '../types/hono.js';
const router = new Hono<AppEnv>();
// ============ Script Management ============
router.get('/', (c) => {
return c.json(ScriptModel.findAll());
});
router.get('/active', (c) => {
return c.json(ScriptModel.findActive());
});
router.get('/', (c) => c.json(ScriptModel.findAll()));
router.get('/active', (c) => c.json(ScriptModel.findActive()));
router.get('/type/:type', (c) => {
const scriptType = String(c.req.param('type'));
const scriptType = c.req.param('type');
return c.json(ScriptModel.findByScriptType(scriptType));
});
router.get('/:id', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
return c.json({ error: 'Script not found' }, 404);
}
if (!script) return c.json({ error: 'Script not found' }, 404);
return c.json(script);
});
router.post('/', async (c) => {
const body = await c.req.json();
const {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
} = body;
if (!name || !script_type || !script_code) {
return c.json(
{ error: 'name, script_type, and script_code are required' },
400,
);
}
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
}
router.post('/', zValidator('json', CreateScriptInputSchema), (c) => {
const data = c.req.valid('json');
try {
const script = ScriptModel.create({
name,
script_type,
target_user_id: target_user_id ?? null,
target_backend_id: target_backend_id ?? null,
script_code,
is_active: is_active ?? true,
name: data.name,
script_type: data.script_type,
target_user_id: data.target_user_id ?? null,
target_backend_id: data.target_backend_id ?? null,
script_code: data.script_code,
is_active: data.is_active ?? true,
});
return c.json(script, 201);
} catch (error) {
@ -99,68 +51,46 @@ router.post('/', async (c) => {
}
});
router.put('/:id', async (c) => {
router.put('/:id', zValidator('json', UpdateScriptInputSchema), (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!script) {
return c.json({ error: 'Script not found' }, 404);
}
const data = c.req.valid('json');
const body = await c.req.json();
const {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
} = body;
if (script_type) {
if (script_type === 'per-user-backend') {
if (!target_user_id || !target_backend_id) {
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (script_type === 'per-backend') {
if (!target_backend_id) {
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
}
} else if (script_type === 'per-user') {
if (!target_user_id) {
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
// When script_type changes, the target fields must satisfy the discriminated
// shape. The shared CreateScriptInputSchema enforces this on creation; the
// update path mirrors the same checks because UpdateScriptInputSchema accepts
// any combination by design.
if (data.script_type === 'per-user-backend') {
if (!data.target_user_id || !data.target_backend_id) {
return c.json(
{
error:
'target_user_id and target_backend_id are required for per-user-backend scripts',
},
400,
);
}
} else if (data.script_type === 'per-backend' && !data.target_backend_id) {
return c.json(
{ error: 'target_backend_id is required for per-backend scripts' },
400,
);
} else if (data.script_type === 'per-user' && !data.target_user_id) {
return c.json(
{ error: 'target_user_id is required for per-user scripts' },
400,
);
}
const updatedScript = ScriptModel.update(id, {
name,
script_type,
target_user_id,
target_backend_id,
script_code,
is_active,
});
const updatedScript = ScriptModel.update(id, data);
return c.json(updatedScript);
});
router.delete('/:id', (c) => {
const id = Number(c.req.param('id'));
const success = ScriptModel.delete(id);
if (!success) {
if (!ScriptModel.delete(id)) {
return c.json({ error: 'Script not found' }, 404);
}
return c.body(null, 204);
@ -169,83 +99,64 @@ router.delete('/:id', (c) => {
router.post('/:id/activate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
return c.json({ error: 'Script not found' }, 404);
}
const success = ScriptModel.activate(id);
if (!success) {
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!ScriptModel.activate(id)) {
return c.json({ error: 'Failed to activate script' }, 500);
}
return c.json({ ...script, is_active: true });
});
router.post('/:id/deactivate', (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
return c.json({ error: 'Script not found' }, 404);
}
const success = ScriptModel.deactivate(id);
if (!success) {
if (!script) return c.json({ error: 'Script not found' }, 404);
if (!ScriptModel.deactivate(id)) {
return c.json({ error: 'Failed to deactivate script' }, 500);
}
return c.json({ ...script, is_active: false });
});
// ============ Script Testing ============
router.post(
'/:id/test',
zValidator('json', ScriptTestInputSchema),
async (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) return c.json({ error: 'Script not found' }, 404);
router.post('/:id/test', async (c) => {
const id = Number(c.req.param('id'));
const script = ScriptModel.findById(id);
if (!script) {
return c.json({ error: 'Script not found' }, 404);
}
const body = c.req.valid('json');
const testContext: ScriptContextData = {
user: body.user ?? null,
backend: body.backend ?? null,
request: body.request,
};
const body = await c.req.json();
let compiled: CompiledScript | null = null;
try {
const startTime = Date.now();
compiled = await CompiledScript.compile(script.script_code);
if (!body.request) {
return c.json({ error: 'request is required' }, 400);
}
if (compiled.hasOnRequest) await compiled.callOnRequest(testContext);
if (compiled.hasOnResponse) await compiled.callOnResponse(testContext);
const testContext: ScriptContextData = {
user: body.user ?? null,
backend: body.backend ?? null,
request: body.request,
};
let compiled: CompiledScript | null = null;
try {
const startTime = Date.now();
compiled = await CompiledScript.compile(script.script_code);
if (compiled.hasOnRequest) {
await compiled.callOnRequest(testContext);
return c.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
400,
);
} finally {
compiled?.dispose();
}
if (compiled.hasOnResponse) {
await compiled.callOnResponse(testContext);
}
return c.json({
success: true,
executionTime: Date.now() - startTime,
hasOnRequest: compiled.hasOnRequest,
hasOnResponse: compiled.hasOnResponse,
});
} catch (error) {
return c.json(
{
success: false,
error: error instanceof Error ? error.message : String(error),
},
400,
);
} finally {
compiled?.dispose();
}
});
},
);
export default router;

View file

@ -1,12 +1,11 @@
import { z } from '@hono/zod-openapi';
import { ErrorResponseSchema } from '@kyush/shared';
export const ErrorResponse = z
.object({
error: z.string().openapi({ example: 'Something went wrong' }),
cause: z.string().optional(),
backend: z.string().optional(),
path: z.string().optional(),
})
.openapi('ErrorResponse');
export const ErrorResponse = ErrorResponseSchema.openapi('ErrorResponse', {
example: { error: 'Something went wrong' },
});
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
export type { ErrorResponse as ErrorResponseType } from '@kyush/shared';
// Keep `z` available for any future schema definitions in this file.
export { z };

View file

@ -1,68 +1,32 @@
// Importing `z` from `@hono/zod-openapi` patches zod's prototype with `.openapi()`,
// which lets us decorate the shared schemas with OpenAPI metadata without
// duplicating their shape on the server.
import { z } from '@hono/zod-openapi';
import {
ChatCompletionRequestSchema,
ChatCompletionResponseSchema,
ChatMessageSchema,
ModelEntrySchema,
ModelListResponseSchema,
ModelNotAvailableResponseSchema,
} from '@kyush/shared';
export const ChatMessage = z
.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
})
.openapi('ChatMessage');
// Re-export the shared schemas with OpenAPI annotations attached. The
// underlying zod instances are shared with the client, so any schema change
// applies to both sides simultaneously.
export const ChatMessage = ChatMessageSchema.openapi('ChatMessage');
export const ChatCompletionRequest = ChatCompletionRequestSchema.openapi(
'ChatCompletionRequest',
{ example: { model: 'gpt-4o-mini', messages: [] } },
);
export const ChatCompletionResponse = ChatCompletionResponseSchema.openapi(
'ChatCompletionResponse',
);
export const ModelEntry = ModelEntrySchema.openapi('ModelEntry');
export const ModelListResponse =
ModelListResponseSchema.openapi('ModelListResponse');
export const ModelNotAvailableResponse =
ModelNotAvailableResponseSchema.openapi('ModelNotAvailableResponse');
export const ChatCompletionRequest = z
.object({
model: z.string().openapi({ example: 'gpt-4o-mini' }),
messages: z.array(ChatMessage),
temperature: z.number().min(0).max(2).optional(),
max_tokens: z.number().int().positive().optional(),
top_p: z.number().min(0).max(1).optional(),
frequency_penalty: z.number().optional(),
presence_penalty: z.number().optional(),
stream: z.boolean().optional(),
})
.passthrough()
.openapi('ChatCompletionRequest');
export const ChatCompletionChoice = z.object({
index: z.number().int(),
message: ChatMessage,
finish_reason: z.string(),
});
export const ChatCompletionUsage = z.object({
prompt_tokens: z.number().int(),
completion_tokens: z.number().int(),
total_tokens: z.number().int(),
});
export const ChatCompletionResponse = z
.object({
id: z.string(),
object: z.string(),
created: z.number().int(),
model: z.string(),
choices: z.array(ChatCompletionChoice),
usage: ChatCompletionUsage,
})
.passthrough()
.openapi('ChatCompletionResponse');
export const ModelEntry = z
.object({
id: z.string(),
object: z.literal('model'),
})
.openapi('ModelEntry');
export const ModelListResponse = z
.object({
object: z.literal('list'),
data: z.array(ModelEntry),
})
.openapi('ModelListResponse');
export const ModelNotAvailableResponse = z
.object({
error: z.string(),
request_model: z.string(),
routed_model: z.string(),
})
.openapi('ModelNotAvailableResponse');
// Re-export `z` so other server code can keep importing from a single place.
export { z };

View file

@ -1,3 +1,4 @@
import { env } from '../config/env.js';
import { BackendModel } from '../models/Backend.js';
import { BackendModelSnapshotModel } from '../models/BackendModelSnapshot.js';
import { ModelRewriteModel } from '../models/ModelRewrite.js';
@ -44,8 +45,6 @@ interface RewriteConfig {
force: boolean;
}
const DEFAULT_REFRESH_MIN_MS = 5 * 60 * 1000;
export class ModelCatalogService {
private static backendModelsByBackendId = new Map<
number,
@ -60,12 +59,7 @@ export class ModelCatalogService {
private static initialized = false;
private static getRefreshMinMs(): number {
const raw = process.env.MODEL_CATALOG_REFRESH_MIN_MS;
if (!raw) return DEFAULT_REFRESH_MIN_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0
? parsed
: DEFAULT_REFRESH_MIN_MS;
return env.MODEL_CATALOG_REFRESH_MIN_MS;
}
private static normalizeModelId(modelId: string): string {

View file

@ -1,4 +1,4 @@
const DEFAULT_TIME_ZONE = 'UTC';
import { env } from '../config/env.js';
function getFormatter(
timeZone: string,
@ -32,7 +32,7 @@ function getParts(date: Date, timeZone: string): Record<string, string> {
}
export function getConfiguredTimeZone(): string {
return process.env.TZ || DEFAULT_TIME_ZONE;
return env.TIME_ZONE;
}
export function getUtcTimestamp(date: Date = new Date()): string {

View file

@ -1,16 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { beforeAll, afterAll } from 'vitest';
import { afterAll, beforeAll } from 'vitest';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = import.meta.dirname;
const workerId =
process.env.VITEST_POOL_ID ||
process.env.VITEST_WORKER_ID ||
String(process.pid);
const TEST_DB_DIR = path.join(__dirname, '..', 'data', `test-db-${workerId}`);
const TEST_DB_DIR = path.join(moduleDir, '..', 'data', `test-db-${workerId}`);
process.env.DB_DIR = TEST_DB_DIR;
process.env.TZ = 'Asia/Seoul';

View file

@ -1,9 +1,8 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const moduleDir = import.meta.dirname;
export default defineConfig({
test: {
@ -14,7 +13,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@': path.resolve(moduleDir, './src'),
},
// Allow `.js` import specifiers in `.ts` files (NodeNext convention)
extensionAlias: {

2
shared/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './types.js';
export * from './schemas.js';

16
shared/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "@kyush/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./index.ts",
"types": "./index.ts",
"exports": {
".": "./index.ts",
"./schemas": "./schemas.ts",
"./types": "./types.ts"
},
"dependencies": {
"zod": "^3.23.8"
}
}

346
shared/schemas.ts Normal file
View file

@ -0,0 +1,346 @@
import { z } from 'zod';
/*
* Common
* */
export const ErrorResponseSchema = z.object({
error: z.string(),
cause: z.string().optional(),
backend: z.string().optional(),
path: z.string().optional(),
});
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
const trimmedString = (label: string, max = 256) =>
z
.string()
.trim()
.min(1, `${label} is required`)
.max(max, `${label} is too long`);
const optionalTrimmedString = (max = 256) =>
z
.string()
.trim()
.max(max)
.optional()
.transform((value) => (value && value.length > 0 ? value : undefined));
/*
* Users
* */
export const CreateUserInputSchema = z.object({
name: trimmedString('Name'),
email: optionalTrimmedString(),
api_key: optionalTrimmedString(),
detail_logging: z.boolean().optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
export const UpdateUserInputSchema = z.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(256).optional(),
email: optionalTrimmedString(),
api_key: optionalTrimmedString(),
is_active: z.boolean().optional(),
detail_logging: z.boolean().optional(),
});
export type UpdateUserInput = z.infer<typeof UpdateUserInputSchema>;
/*
* Backends
* */
export const CreateBackendInputSchema = z.object({
name: trimmedString('Name'),
base_url: trimmedString('Base URL', 2048),
api_key: optionalTrimmedString(),
detail_logging: z.boolean().optional(),
});
export type CreateBackendInput = z.infer<typeof CreateBackendInputSchema>;
export const UpdateBackendInputSchema = z.object({
name: z.string().trim().min(1).max(256).optional(),
base_url: z.string().trim().min(1).max(2048).optional(),
api_key: optionalTrimmedString(),
is_active: z.boolean().optional(),
detail_logging: z.boolean().optional(),
});
export type UpdateBackendInput = z.infer<typeof UpdateBackendInputSchema>;
/*
* Permissions
* */
export const CreatePermissionInputSchema = z.object({
user_id: z.number().int().positive(),
backend_id: z.number().int().positive(),
});
export type CreatePermissionInput = z.infer<typeof CreatePermissionInputSchema>;
/*
* Model rewrites
* */
export const CreateModelRewriteInputSchema = z.object({
source_model: trimmedString('Source model'),
target_model: trimmedString('Target model'),
is_active: z.boolean().optional(),
force: z.boolean().optional(),
note: optionalTrimmedString(1024),
});
export type CreateModelRewriteInput = z.infer<
typeof CreateModelRewriteInputSchema
>;
export const UpdateModelRewriteInputSchema = z.object({
source_model: z.string().trim().min(1).max(256).optional(),
target_model: z.string().trim().min(1).max(256).optional(),
is_active: z.boolean().optional(),
force: z.boolean().optional(),
note: optionalTrimmedString(1024),
});
export type UpdateModelRewriteInput = z.infer<
typeof UpdateModelRewriteInputSchema
>;
/*
* Scripts
* */
export const ScriptTypeSchema = z.enum([
'per-user-backend',
'per-backend',
'per-user',
]);
const baseScriptFields = z.object({
name: trimmedString('Name'),
script_code: trimmedString('Script code', 1_000_000),
is_active: z.boolean().optional(),
});
export const CreateScriptInputSchema = z.discriminatedUnion('script_type', [
baseScriptFields.extend({
script_type: z.literal('per-user-backend'),
target_user_id: z.number().int().positive(),
target_backend_id: z.number().int().positive(),
}),
baseScriptFields.extend({
script_type: z.literal('per-backend'),
target_user_id: z.null().optional(),
target_backend_id: z.number().int().positive(),
}),
baseScriptFields.extend({
script_type: z.literal('per-user'),
target_user_id: z.number().int().positive(),
target_backend_id: z.null().optional(),
}),
]);
export type CreateScriptInput = z.infer<typeof CreateScriptInputSchema>;
export const UpdateScriptInputSchema = z.object({
name: z.string().trim().min(1).max(256).optional(),
script_type: ScriptTypeSchema.optional(),
target_user_id: z.number().int().positive().nullable().optional(),
target_backend_id: z.number().int().positive().nullable().optional(),
script_code: z.string().min(1).max(1_000_000).optional(),
is_active: z.boolean().optional(),
});
export type UpdateScriptInput = z.infer<typeof UpdateScriptInputSchema>;
export const ScriptContextRequestSchema = z
.object({
method: z.string(),
path: z.string(),
headers: z.record(z.string()),
// `unknown` is optional in the wire schema, but the script runtime always
// receives a `body` field (it might just be undefined). The transform
// re-adds the field so the result satisfies the strict
// `ScriptContextData` shape downstream consumers expect.
body: z.unknown(),
isStream: z.boolean(),
})
.transform((value) => ({
method: value.method,
path: value.path,
headers: value.headers,
body: value.body,
isStream: value.isStream,
}));
export type ScriptContextRequest = z.infer<typeof ScriptContextRequestSchema>;
export const ScriptContextResponseSchema = z.object({
status: z.number().int(),
headers: z.record(z.string()),
body: z.unknown(),
isStream: z.boolean(),
});
export type ScriptContextResponse = z.infer<typeof ScriptContextResponseSchema>;
export const ScriptTestInputSchema = z.object({
user: z
.object({
id: z.number().int(),
name: z.string(),
email: z.string().optional(),
})
.nullable()
.optional(),
backend: z
.object({
id: z.number().int(),
name: z.string(),
base_url: z.string(),
})
.nullable()
.optional(),
request: ScriptContextRequestSchema,
});
export type ScriptTestInput = z.infer<typeof ScriptTestInputSchema>;
/*
* Admin auth
* */
export const AdminLoginInputSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type AdminLoginInput = z.infer<typeof AdminLoginInputSchema>;
export const CreateAdminTokenInputSchema = z.object({
name: trimmedString('Token name'),
expiresInDays: z.number().int().positive().optional(),
});
export type CreateAdminTokenInput = z.infer<typeof CreateAdminTokenInputSchema>;
/*
* OpenAI v1
* */
export const ChatRoleSchema = z.enum(['system', 'user', 'assistant']);
export type ChatRole = z.infer<typeof ChatRoleSchema>;
export const ChatMessageSchema = z.object({
role: ChatRoleSchema,
content: z.string(),
});
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export const ChatCompletionRequestSchema = z
.object({
model: z.string(),
messages: z.array(ChatMessageSchema),
temperature: z.number().min(0).max(2).optional(),
max_tokens: z.number().int().positive().optional(),
top_p: z.number().min(0).max(1).optional(),
frequency_penalty: z.number().optional(),
presence_penalty: z.number().optional(),
stream: z.boolean().optional(),
})
.passthrough();
export type ChatCompletionRequest = z.infer<typeof ChatCompletionRequestSchema>;
export const ChatCompletionUsageSchema = z.object({
prompt_tokens: z.number().int(),
completion_tokens: z.number().int(),
total_tokens: z.number().int(),
});
export type ChatCompletionUsage = z.infer<typeof ChatCompletionUsageSchema>;
export const ChatCompletionChoiceSchema = z.object({
index: z.number().int(),
message: ChatMessageSchema,
finish_reason: z.string(),
});
export type ChatCompletionChoice = z.infer<typeof ChatCompletionChoiceSchema>;
export const ChatCompletionResponseSchema = z
.object({
id: z.string(),
object: z.string(),
created: z.number().int(),
model: z.string(),
choices: z.array(ChatCompletionChoiceSchema),
usage: ChatCompletionUsageSchema,
})
.passthrough();
export type ChatCompletionResponse = z.infer<
typeof ChatCompletionResponseSchema
>;
export const ModelEntrySchema = z.object({
id: z.string(),
object: z.literal('model'),
});
export type ModelEntry = z.infer<typeof ModelEntrySchema>;
export const ModelListResponseSchema = z.object({
object: z.literal('list'),
data: z.array(ModelEntrySchema),
});
export type ModelListResponse = z.infer<typeof ModelListResponseSchema>;
export const ModelNotAvailableResponseSchema = z.object({
error: z.string(),
request_model: z.string(),
routed_model: z.string(),
});
export type ModelNotAvailableResponse = z.infer<
typeof ModelNotAvailableResponseSchema
>;
/*
* Loose response message parser (used by ConversationTimeline et al.)
* Tolerant: accepts unknown extras and unparseable bodies are skipped at the
* call site.
* */
export const LooseChatMessageSchema = z
.object({
role: z.string().optional(),
content: z.unknown().optional(),
})
.passthrough();
export const LooseChatCompletionRequestSchema = z
.object({
model: z.string().optional(),
temperature: z.number().optional(),
messages: z.array(LooseChatMessageSchema).optional(),
})
.passthrough();
export const LooseChatCompletionChoiceSchema = z
.object({
message: z
.object({
role: z.string().optional(),
content: z.unknown().optional(),
reasoning_content: z.unknown().optional(),
tool_calls: z.unknown().optional(),
})
.passthrough()
.optional(),
finish_reason: z.unknown().optional(),
matched_stop: z.unknown().optional(),
logprobs: z.unknown().optional(),
})
.passthrough();
export const LooseChatCompletionResponseSchema = z
.object({
created: z.number().optional(),
choices: z.array(LooseChatCompletionChoiceSchema).optional(),
usage: z
.object({
prompt_tokens: z.number().optional(),
completion_tokens: z.number().optional(),
total_tokens: z.number().optional(),
})
.passthrough()
.optional(),
})
.passthrough();

17
shared/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}