This commit is contained in:
Kyush 2026-03-05 23:46:54 +09:00
commit 1cd7941472
46 changed files with 6539 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# OS specific files
.DS_Store
Thumbs.db
# Project dependencies
node_modules/
# Distribution files
dist/
# User defined
memo.md

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
# only = production
prefer-offline=true

477
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,477 @@
# Kyush LLM Router Architecture Document
## Overview
Kyush LLM Router is a proxy server that manages multiple users and routes their requests to various OpenAI-compatible backend APIs. It provides API key-based authentication, user permission management, and usage monitoring through an admin web dashboard.
## System Architecture
```
┌───────────────────────────────────────────────────────────────────────────┐
│ Client Layer │
├─────────────────────┬───────────────────────────────┬─────────────────────┤
│ LLM Clients │ Admin Dashboard │ Vite Dev Server │
│ (OpenAI SDK etc) │ (Solid.js + Vite) │ (Port 5173) │
└──────────┬──────────┴──────────────┬────────────────┴──────────┬──────────┘
│ │ │
│ OpenAI-Compatible API │ REST API │
│ (Port 3000) │ (Port 3001) │
▼ ▼ │
┌───────────────────────────────────────────────────────────────────────────┐
│ Router Server (Node.js/Express) │
│ (Port 3000) │
├───────────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Authentication Middleware │ │
│ │ - API Key Validation │ │
│ │ - Rate Limiting │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Request Router │ │
│ │ - Backend Selection │ │
│ │ - Load Balancing (optional) │ │
│ │ - Request/Response Transformation │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Admin API Endpoints (proxied to Vite dev server) │ │
│ │ - User Management │ │
│ │ - Backend Management │ │
│ │ - Permission Management │ │
│ │ - Usage Analytics │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└────────────────────────────┬──────────────────────────────────────────────┘
┌─────────────────┴─────────────────┐
▼ ▼
┌───────────────────────┐ ┌─────────────────────────┐
│ Core Database │ │ Analytics Database │
│ (SQLite - core.db) │ │ (SQLite - analytics.db)│
├───────────────────────┤ ├─────────────────────────┤
│ - users │ │ - request_logs │
│ - backends │ │ - usage_stats │
│ - permissions │ │ - backend_metrics │
└───────────────────────┘ └─────────────────────────┘
│ │
└─────────────────┬─────────────────┘
┌───────────────────────────────────────────────────────────────────────────┐
│ Backend Layer │
├───────────────────────────────────────────────────────────────────────────┤
│ Multiple OpenAI-Compatible APIs (vLLM, SGLang, etc.) │
└───────────────────────────────────────────────────────────────────────────┘
```
## Project Structure
```
kyush-llm-router/
├── server/
│ ├── src/
│ │ ├── index.ts # Express app entry point
│ │ ├── config/
│ │ │ ├── database.ts # Core SQLite connection
│ │ │ └── analytics-db.ts # Analytics SQLite connection
│ │ ├── routes/
│ │ │ ├── api.ts # OpenAI-compatible API proxy
│ │ │ ├── admin.ts # Admin management API
│ │ │ └── auth.ts # Authentication middleware
│ │ ├── models/
│ │ │ ├── User.ts # User model
│ │ │ ├── Backend.ts # Backend model
│ │ │ └── Permission.ts # Permission model
│ │ ├── analytics/
│ │ │ ├── RequestLog.ts # Request log model (analytics DB)
│ │ │ ├── UsageStats.ts # Usage stats model (analytics DB)
│ │ │ └── BackendMetrics.ts # Backend metrics model
│ │ ├── services/
│ │ │ ├── AuthService.ts # API key validation
│ │ │ ├── RouterService.ts # Backend routing logic
│ │ │ └── AnalyticsService.ts # Usage tracking
│ │ └── utils/
│ │ └── logger.ts # Logging utility
│ ├── package.json
│ └── tsconfig.json
├── client/
│ ├── src/
│ │ ├── index.tsx # Solid.js entry point
│ │ ├── App.tsx # Main application component
│ │ ├── routes/
│ │ │ ├── Dashboard.tsx # Main dashboard
│ │ │ ├── Users.tsx # User management
│ │ │ ├── Backends.tsx # Backend management
│ │ │ ├── Permissions.tsx # Permission management
│ │ │ └── Analytics.tsx # Usage monitoring
│ │ ├── components/
│ │ │ ├── Layout.tsx # Admin layout
│ │ │ ├── UserTable.tsx # User list table
│ │ │ ├── BackendTable.tsx # Backend list table
│ │ │ └── StatsChart.tsx # Usage chart component
│ │ ├── api/
│ │ │ └── client.ts # API client for admin endpoints
│ │ └── types/
│ │ └── index.ts # TypeScript type definitions
│ ├── package.json
│ ├── vite.config.ts
│ └── tsconfig.json
├── shared/
│ └── types.ts # Shared type definitions
├── database/
│ ├── schema.sql # Core database schema
│ └── analytics-schema.sql # Analytics database schema
├── scripts/
│ └── dev.js # Dev server launcher (runs both)
├── docker-compose.yml # Docker Compose setup
├── package.json # Root package.json (scripts)
└── README.md
```
## Core Components
### 1. Server (Node.js/Express)
#### Authentication Middleware (`src/routes/auth.ts`)
- Validates API keys from incoming requests
- Extracts user identity from `Authorization: Bearer <api_key>` header
- Returns 401 if authentication fails
- Attaches user info to request object for downstream handlers
#### API Proxy Route (`src/routes/api.ts`)
```typescript
// OpenAI-compatible endpoints
POST /v1/chat/completions
POST /v1/completions
GET /v1/models
```
- Forwards requests to selected backend
- Handles request/response transformation
- Logs all requests for analytics
#### Admin API Route (`src/routes/admin.ts`)
```typescript
// User Management
POST /admin/users # Create user
GET /admin/users # List users
PUT /admin/users/:id # Update user
DELETE /admin/users/:id # Delete user
// Backend Management
POST /admin/backends # Add backend
GET /admin/backends # List backends
PUT /admin/backends/:id # Update backend
DELETE /admin/backends/:id # Delete backend
// Permission Management
POST /admin/permissions # Grant permission
DELETE /admin/permissions # Revoke permission
GET /admin/permissions # List permissions
// Analytics
GET /admin/analytics/usage # Usage statistics
GET /admin/analytics/requests # Request logs
```
#### Router Service (`src/services/RouterService.ts`)
- Selects appropriate backend based on:
- User's permissions
- Backend availability
- Load balancing strategy (round-robin, least-loaded)
- Handles backend failures with retry logic
### 2. Client (Solid.js + Vite)
#### Pages
- **Dashboard**: Overview of system status, recent activity
- **Users**: CRUD operations for users, API key generation
- **Backends**: Manage backend API configurations
- **Permissions**: Assign/revoke backend access per user
- **Analytics**: View usage statistics and request logs
#### Key Features
- Real-time updates via polling or WebSocket (optional)
- Form validation for user/backend input
- Error handling with user-friendly messages
- Responsive design for various screen sizes
### 3. Databases (SQLite)
#### Core Database Schema (core.db)
```sql
-- Users table
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Backends table
CREATE TABLE backends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Permissions table (many-to-many: users ↔ backends)
CREATE TABLE permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (backend_id) REFERENCES backends(id),
UNIQUE(user_id, backend_id)
);
-- Indexes for performance
CREATE INDEX idx_users_api_key ON users(api_key);
CREATE INDEX idx_permissions_user ON permissions(user_id);
CREATE INDEX idx_permissions_backend ON permissions(backend_id);
```
#### Analytics Database Schema (analytics.db)
```sql
-- Request logs table
CREATE TABLE request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_model TEXT,
response_model TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
status_code INTEGER,
response_time_ms INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (backend_id) REFERENCES backends(id)
);
-- Usage stats table (aggregated daily)
CREATE TABLE usage_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
date DATE NOT NULL,
total_requests INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (backend_id) REFERENCES backends(id),
UNIQUE(user_id, backend_id, date)
);
-- Backend metrics table (aggregated metrics per backend)
CREATE TABLE backend_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
backend_id INTEGER NOT NULL,
date DATE NOT NULL,
total_requests INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
avg_response_time_ms REAL DEFAULT 0,
error_count INTEGER DEFAULT 0,
success_rate REAL DEFAULT 1.0,
FOREIGN KEY (backend_id) REFERENCES backends(id),
UNIQUE(backend_id, date)
);
-- Indexes for performance
CREATE INDEX idx_request_logs_user ON request_logs(user_id);
CREATE INDEX idx_request_logs_backend ON request_logs(backend_id);
CREATE INDEX idx_request_logs_date ON request_logs(created_at);
CREATE INDEX idx_usage_stats_user ON usage_stats(user_id);
CREATE INDEX idx_usage_stats_date ON usage_stats(date);
CREATE INDEX idx_backend_metrics_backend ON backend_metrics(backend_id);
CREATE INDEX idx_backend_metrics_date ON backend_metrics(date);
```
## API Design
### OpenAI-Compatible API Proxy
The router exposes the same API interface as OpenAI, making it easy for clients to integrate.
#### Example Request
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Authorization: Bearer <user_api_key>" \
-H "Content-Type: application/json" \
-d '{
"model": "llama-3",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
#### Example Response
```json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "llama-3",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 15,
"total_tokens": 25
}
}
```
### Admin API
#### Create User
```bash
POST /admin/users
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com"
}
Response: {
"id": 1,
"name": "John Doe",
"api_key": "sk-xxx...",
"created_at": "2024-01-01T00:00:00Z"
}
```
#### Add Backend
```bash
POST /admin/backends
Content-Type: application/json
{
"name": "vLLM Server 1",
"base_url": "http://localhost:8000/v1",
"api_key": "backend-key-xxx"
}
```
## Security Considerations
1. **API Key Storage**: API keys are stored hashed in the database
2. **Transport Security**: Use HTTPS in production
3. **Rate Limiting**: Implement per-user rate limits
4. **Input Validation**: Validate all admin API inputs
5. **CORS**: Configure CORS for admin dashboard only
## Deployment
### Development
Run both server and client with a single command:
```bash
npm run dev
```
This starts:
- Express server on port 3000
- Vite dev server on port 3001 (admin API routes proxied from Express)
### Docker Compose
```yaml
version: '3.8'
services:
router:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SERVER_PORT=3000
- CLIENT_PORT=3001
- CORE_DB_PATH=/data/core.db
- ANALYTICS_DB_PATH=/data/analytics.db
volumes:
- router-data:/data
restart: unless-stopped
volumes:
router-data:
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SERVER_PORT` | Express server port | `3000` |
| `CLIENT_PORT` | Vite dev server port | `3001` |
| `CORE_DB_PATH` | Core database path | `./data/core.db` |
| `ANALYTICS_DB_PATH` | Analytics database path | `./data/analytics.db` |
| `ADMIN_PASSWORD` | Admin dashboard password | (required) |
## Development Roadmap
### Phase 1: Core Infrastructure
- [ ] Set up Express server with TypeScript
- [ ] Implement SQLite database schema
- [ ] Create user and backend models
- [ ] Implement API key authentication
### Phase 2: API Proxy
- [ ] Implement OpenAI-compatible API endpoints
- [ ] Add request forwarding to backends
- [ ] Implement basic routing logic
- [ ] Add request logging
### Phase 3: Admin API
- [ ] Implement CRUD endpoints for users
- [ ] Implement CRUD endpoints for backends
- [ ] Implement permission management
- [ ] Add usage analytics endpoints
### Phase 4: Admin Dashboard
- [ ] Set up Solid.js + Vite project
- [ ] Create user management UI
- [ ] Create backend management UI
- [ ] Create permission management UI
- [ ] Create analytics dashboard
### Phase 5: Advanced Features
- [ ] Add rate limiting
- [ ] Implement load balancing
- [ ] Add WebSocket for real-time updates
- [ ] Implement backend health checks
## Technology Stack Summary
| Component | Technology |
|-----------|------------|
| Backend | Node.js 18+, Express.js, TypeScript |
| Core Database | SQLite (better-sqlite3) |
| Analytics Database | SQLite (better-sqlite3) |
| Frontend | Solid.js, Vite, TypeScript |
| HTTP Client | Axios/Fetch |
| Charts | Chart.js or Solid-chart |
| Styling | Tailwind CSS or CSS Modules |
| Validation | Zod |
| Testing | Vitest (frontend), Jest (backend) |
| Dev Server | Concurrently for running both servers |

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
FROM node:18-alpine
WORKDIR /app
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy package files
COPY package.json package-lock.json* ./
COPY server/package.json ./server/
COPY client/package.json ./client/
# Install dependencies
RUN npm ci
# Copy source code
COPY server/ ./server/
COPY client/ ./client/
COPY scripts/ ./scripts/
COPY database/ ./database/
# Build client
RUN npm run build --workspace=client
# Build server
RUN npm run build --workspace=server
# Create data directory
RUN mkdir -p /data
EXPOSE 3000
CMD ["npm", "start"]

121
README.md Normal file
View file

@ -0,0 +1,121 @@
# Kyush LLM Router
Multi-user LLM routing proxy with API key management and usage monitoring.
## Features
- **Multi-user support**: Manage multiple users with individual API keys
- **Backend routing**: Route requests to multiple OpenAI-compatible backends (vLLM, SGLang, etc.)
- **Permission management**: Control which users can access which backends
- **Usage monitoring**: Track request logs, token usage, and backend metrics
- **Admin dashboard**: Web UI for managing users, backends, and permissions
## Architecture
```
┌─────────────────┐ ┌──────────────────────────────────────┐
│ LLM Clients │────▶│ Router Server (Port 3000) │
│ (OpenAI SDK) │ │ - Authentication │
└─────────────────┘ │ - Request Routing │
│ - Admin API │
└─────────────────┬────────────────────┘
┌─────────────────┴────────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ core.db │ │ analytics.db │
│ - users │ │ - request_logs │
│ - backends │ │ - usage_stats │
│ - permissions │ │ - metrics │
└─────────────────┘ └─────────────────┘
```
## Quick Start
### Development
1. Install dependencies:
```bash
npm install
```
2. Start development servers:
```bash
npm run dev
```
This starts:
- Express API server on http://localhost:3000
- Vite admin dashboard on http://localhost:3001
### Production with Docker
1. Build and run:
```bash
docker-compose up -d
```
2. Set admin password via environment variable:
```bash
ADMIN_PASSWORD=your_secure_password docker-compose up -d
```
## Project Structure
```
kyush-llm-router/
├── server/ # Express backend
├── client/ # Solid.js admin dashboard
├── database/ # SQL schema files
├── scripts/ # Development scripts
└── docs/ # Documentation
```
## API Endpoints
### OpenAI-Compatible API (Port 3000)
```
POST /v1/chat/completions
POST /v1/completions
GET /v1/models
```
### Admin API (Port 3001)
```
# User Management
POST /admin/users
GET /admin/users
PUT /admin/users/:id
DELETE /admin/users/:id
# Backend Management
POST /admin/backends
GET /admin/backends
PUT /admin/backends/:id
DELETE /admin/backends/:id
# Permission Management
POST /admin/permissions
DELETE /admin/permissions
GET /admin/permissions
# Analytics
GET /admin/analytics/usage
GET /admin/analytics/requests
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SERVER_PORT` | Express server port | `3000` |
| `CLIENT_PORT` | Vite dev server port | `3001` |
| `CORE_DB_PATH` | Core database path | `./data/core.db` |
| `ANALYTICS_DB_PATH` | Analytics database path | `./data/analytics.db` |
| `ADMIN_PASSWORD` | Admin dashboard password | Required |
## License
MIT

12
client/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM Router Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

25
client/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "client",
"version": "1.0.0",
"description": "LLM Router Admin Dashboard",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"solid-js": "^1.9.11",
"@solidjs/router": "^0.15.4"
},
"devDependencies": {
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10",
"typescript": "^5.9.3",
"@types/node": "^25.3.3"
}
}

1219
client/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

18
client/src/App.tsx Normal file
View file

@ -0,0 +1,18 @@
import { Router, Route } from '@solidjs/router';
import { Dashboard } from './routes/Dashboard';
import { Users } from './routes/Users';
import { Backends } from './routes/Backends';
import { Permissions } from './routes/Permissions';
import { Analytics } from './routes/Analytics';
export default function App() {
return (
<Router>
<Route path="/" component={Dashboard} />
<Route path="/users" component={Users} />
<Route path="/backends" component={Backends} />
<Route path="/permissions" component={Permissions} />
<Route path="/analytics" component={Analytics} />
</Router>
);
}

80
client/src/api/client.ts Normal file
View file

@ -0,0 +1,80 @@
import type { User, Backend, Permission, RequestLog, UsageStats, BackendMetrics } from '../types';
const API_BASE = '/api';
async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
export const api = {
users: {
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
create: (data: { name: string; email?: string }): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<User>): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/users/${id}`, { method: 'DELETE' }),
regenerateApiKey: (id: number): Promise<User> =>
fetchJson<User>(`${API_BASE}/admin/users/${id}/regenerate-api-key`, { method: 'POST' }),
},
backends: {
getAll: (): Promise<Backend[]> => fetchJson<Backend[]>(`${API_BASE}/admin/backends`),
getById: (id: number): Promise<Backend> => fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`),
create: (data: { name: string; base_url: string; api_key?: string }): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/backends/${id}`, { method: 'DELETE' }),
},
permissions: {
getAll: (): Promise<Permission[]> => fetchJson<Permission[]>(`${API_BASE}/admin/permissions`),
getByUser: (userId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/user/${userId}`),
getByBackend: (backendId: number): Promise<Permission[]> =>
fetchJson<Permission[]>(`${API_BASE}/admin/permissions/backend/${backendId}`),
create: (data: { user_id: number; backend_id: number }): Promise<Permission> =>
fetchJson<Permission>(`${API_BASE}/admin/permissions`, { method: 'POST', body: JSON.stringify(data) }),
delete: (userId: number, backendId: number): Promise<void> =>
fetchJson<void>(`${API_BASE}/admin/permissions?user_id=${userId}&backend_id=${backendId}`, { method: 'DELETE' }),
},
analytics: {
getUsage: (userId?: number, backendId?: number, days: number = 30): Promise<UsageStats[]> => {
const params = new URLSearchParams();
if (userId) params.append('userId', String(userId));
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<UsageStats[]>(`${API_BASE}/admin/analytics/usage?${params}`);
},
getRequests: (limit: number = 100, offset: number = 0): Promise<RequestLog[]> =>
fetchJson<RequestLog[]>(`${API_BASE}/admin/analytics/requests?limit=${limit}&offset=${offset}`),
getMetrics: (backendId?: number, days: number = 30): Promise<BackendMetrics[]> => {
const params = new URLSearchParams();
if (backendId) params.append('backendId', String(backendId));
params.append('days', String(days));
return fetchJson<BackendMetrics[]>(`${API_BASE}/admin/analytics/metrics?${params}`);
},
},
};

View file

@ -0,0 +1,55 @@
import { Component, JSX, ParentComponent } from 'solid-js';
import { useLocation } from '@solidjs/router';
interface LayoutProps {
children: JSX.Element;
}
const navItems = [
{ path: '/', label: 'Dashboard', icon: '📊' },
{ path: '/users', label: 'Users', icon: '👥' },
{ path: '/backends', label: 'Backends', icon: '🔧' },
{ path: '/permissions', label: 'Permissions', icon: '🔐' },
{ path: '/analytics', label: 'Analytics', icon: '📈' },
];
export const Layout: ParentComponent<LayoutProps> = (props) => {
const location = useLocation();
return (
<div style={{ display: 'flex', 'min-height': '100vh', 'font-family': 'system-ui, sans-serif' }}>
<aside style={{
width: '250px',
background: '#1e293b',
color: 'white',
padding: '20px',
}}>
<h1 style={{ margin: '0 0 30px 0', 'font-size': '1.5rem' }}>LLM Router</h1>
<nav style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
{navItems.map(item => (
<a
href={item.path}
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
padding: '12px 15px',
background: location.pathname === item.path ? '#3b82f6' : 'transparent',
color: 'white',
'text-decoration': 'none',
'border-radius': '8px',
transition: 'background 0.2s',
}}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</a>
))}
</nav>
</aside>
<main style={{ flex: 1, padding: '30px', background: '#f1f5f9' }}>
{props.children}
</main>
</div>
);
};

8
client/src/index.tsx Normal file
View file

@ -0,0 +1,8 @@
import { render } from 'solid-js/web';
import App from './App';
const root = document.getElementById('root');
if (root) {
render(() => <App />, root);
}

View file

@ -0,0 +1,108 @@
import { Component, createResource, For } from 'solid-js';
import { api } from '../api/client';
import type { RequestLog, UsageStats, BackendMetrics } from '../types';
import { Layout } from '../components/Layout';
export const Analytics: Component = () => {
const [requests] = createResource(() => api.analytics.getRequests(50));
const [usage] = createResource(() => api.analytics.getUsage(undefined, undefined, 7));
const [metrics] = createResource(() => api.analytics.getMetrics(undefined, 7));
return (
<Layout>
<div style={{ padding: '30px' }}>
<h2 style={{ margin: '0 0 20px 0' }}>Analytics</h2>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(300px, 1fr))', gap: '20px', 'margin-bottom': '30px' }}>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 15px 0', 'font-size': '0.9rem', color: '#64748b' }}>Recent Requests</h3>
<div style={{ 'max-height': '300px', overflow: 'auto' }}>
{requests.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'font-size': '0.85rem' }}>
<thead>
<tr style={{ 'border-bottom': '2px solid #e2e8f0' }}>
<th style={{ 'text-align': 'left', padding: '8px' }}>User</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Tokens</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Status</th>
</tr>
</thead>
<tbody>
<For each={requests()}>{(req) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '8px' }}>{req.user_id}</td>
<td style={{ padding: '8px' }}>{req.total_tokens || 0}</td>
<td style={{ padding: '8px', color: req.status_code >= 400 ? '#ef4444' : '#22c55e' }}>{req.status_code}</td>
</tr>
)}</For>
</tbody>
</table>
)}
</div>
</div>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 15px 0', 'font-size': '0.9rem', color: '#64748b' }}>Usage Stats (7 days)</h3>
<div style={{ 'max-height': '300px', overflow: 'auto' }}>
{usage.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'font-size': '0.85rem' }}>
<thead>
<tr style={{ 'border-bottom': '2px solid #e2e8f0' }}>
<th style={{ 'text-align': 'left', padding: '8px' }}>Date</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Requests</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Tokens</th>
</tr>
</thead>
<tbody>
<For each={usage()}>{(stat) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '8px' }}>{stat.date}</td>
<td style={{ padding: '8px' }}>{stat.total_requests}</td>
<td style={{ padding: '8px' }}>{stat.total_tokens}</td>
</tr>
)}</For>
</tbody>
</table>
)}
</div>
</div>
</div>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 15px 0', 'font-size': '0.9rem', color: '#64748b' }}>Backend Metrics (7 days)</h3>
<div style={{ 'max-height': '300px', overflow: 'auto' }}>
{metrics.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'font-size': '0.85rem' }}>
<thead>
<tr style={{ 'border-bottom': '2px solid #e2e8f0' }}>
<th style={{ 'text-align': 'left', padding: '8px' }}>Date</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Backend</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Requests</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Avg Response (ms)</th>
<th style={{ 'text-align': 'left', padding: '8px' }}>Success Rate</th>
</tr>
</thead>
<tbody>
<For each={metrics()}>{(metric) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '8px' }}>{metric.date}</td>
<td style={{ padding: '8px' }}>{metric.backend_id}</td>
<td style={{ padding: '8px' }}>{metric.total_requests}</td>
<td style={{ padding: '8px' }}>{metric.avg_response_time_ms?.toFixed(1) || 0}</td>
<td style={{ padding: '8px' }}>{(metric.success_rate * 100).toFixed(1)}%</td>
</tr>
)}</For>
</tbody>
</table>
)}
</div>
</div>
</div>
</Layout>
);
};

View file

@ -0,0 +1,135 @@
import { Component, createResource, For, createSignal } from 'solid-js';
import { api } from '../api/client';
import type { Backend } from '../types';
import { Layout } from '../components/Layout';
export const Backends: Component = () => {
const [backends, { refetch }] = createResource(() => api.backends.getAll());
const [showModal, setShowModal] = createSignal(false);
const [formData, setFormData] = createSignal({ name: '', base_url: '', api_key: '' });
const handleSubmit = async (e: Event) => {
e.preventDefault();
const { name, base_url, api_key } = formData();
if (!name.trim() || !base_url.trim()) return;
await api.backends.create({ name: name.trim(), base_url: base_url.trim(), api_key: api_key.trim() || undefined });
setFormData({ name: '', base_url: '', api_key: '' });
setShowModal(false);
refetch();
};
const handleDelete = async (backendId: number) => {
if (!confirm('Are you sure you want to delete this backend?')) return;
await api.backends.delete(backendId);
refetch();
};
return (
<Layout>
<div style={{ padding: '30px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '20px' }}>
<h2 style={{ margin: 0 }}>Backends</h2>
<button
onClick={() => setShowModal(true)}
style={{ padding: '10px 20px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '6px', cursor: 'pointer' }}
>
Add Backend
</button>
</div>
{backends.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'border-collapse': 'collapse', background: 'white', 'border-radius': '8px', overflow: 'hidden', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<thead style={{ background: '#f8fafc' }}>
<tr>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>ID</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Name</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Base URL</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Status</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Actions</th>
</tr>
</thead>
<tbody>
<For each={backends()}>{(backend) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '12px' }}>{backend.id}</td>
<td style={{ padding: '12px' }}>{backend.name}</td>
<td style={{ padding: '12px', 'font-family': 'monospace', 'font-size': '0.85rem' }}>{backend.base_url}</td>
<td style={{ padding: '12px', color: backend.is_active ? '#22c55e' : '#ef4444' }}>
{backend.is_active ? 'Active' : 'Inactive'}
</td>
<td style={{ padding: '12px' }}>
<button
onClick={() => handleDelete(backend.id)}
style={{ padding: '4px 8px', background: '#ef4444', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Delete
</button>
</td>
</tr>
)}</For>
</tbody>
</table>
)}
{showModal() && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 1000 }}>
<div style={{ background: 'white', padding: '30px', 'border-radius': '8px', width: '500px' }}>
<h3 style={{ margin: '0 0 20px 0' }}>Add New Backend</h3>
<form onSubmit={handleSubmit}>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Name *</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px', 'box-sizing': 'border-box' }}
required
/>
</div>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Base URL *</label>
<input
type="text"
value={formData().base_url}
onInput={(e) => setFormData({ ...formData(), base_url: e.target.value })}
placeholder="http://localhost:8000/v1"
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px', 'box-sizing': 'border-box' }}
required
/>
</div>
<div style={{ 'margin-bottom': '20px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>API Key (optional)</label>
<input
type="text"
value={formData().api_key}
onInput={(e) => setFormData({ ...formData(), api_key: e.target.value })}
placeholder="Backend API key if required"
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px', 'box-sizing': 'border-box' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button
type="button"
onClick={() => setShowModal(false)}
style={{ padding: '8px 16px', background: '#e2e8f0', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Cancel
</button>
<button
type="submit"
style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Create
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
};

View file

@ -0,0 +1,70 @@
import { Component, createResource, For } from 'solid-js';
import { api } from '../api/client';
import type { User, Backend, RequestLog } from '../types';
import { Layout } from '../components/Layout';
export const Dashboard: Component = () => {
const [data, { refetch }] = createResource(async () => ({
users: await api.users.getAll(),
backends: await api.backends.getAll(),
recentRequests: await api.analytics.getRequests(10),
}));
return (
<Layout>
<div style={{ padding: '30px' }}>
<h2 style={{ margin: '0 0 20px 0' }}>Dashboard</h2>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))', gap: '20px', 'margin-bottom': '30px' }}>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 10px 0', 'font-size': '0.9rem', color: '#64748b' }}>Total Users</h3>
<p style={{ margin: 0, 'font-size': '2rem', 'font-weight': 'bold' }}>{data()?.users.length || 0}</p>
</div>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 10px 0', 'font-size': '0.9rem', color: '#64748b' }}>Active Backends</h3>
<p style={{ margin: 0, 'font-size': '2rem', 'font-weight': 'bold' }}>{data()?.backends.filter(b => b.is_active).length || 0}</p>
</div>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 10px 0', 'font-size': '0.9rem', color: '#64748b' }}>Recent Requests</h3>
<p style={{ margin: 0, 'font-size': '2rem', 'font-weight': 'bold' }}>{data()?.recentRequests.length || 0}</p>
</div>
</div>
<div style={{ background: 'white', padding: '20px', 'border-radius': '8px', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '15px' }}>
<h3 style={{ margin: 0 }}>Recent Requests</h3>
<button onClick={() => refetch()} style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '6px', cursor: 'pointer' }}>
Refresh
</button>
</div>
{data()?.recentRequests.length === 0 ? (
<p style={{ color: '#64748b' }}>No requests yet</p>
) : (
<table style={{ width: '100%', 'border-collapse': 'collapse' }}>
<thead>
<tr style={{ 'border-bottom': '2px solid #e2e8f0' }}>
<th style={{ 'text-align': 'left', padding: '10px', color: '#64748b' }}>User ID</th>
<th style={{ 'text-align': 'left', padding: '10px', color: '#64748b' }}>Backend</th>
<th style={{ 'text-align': 'left', padding: '10px', color: '#64748b' }}>Model</th>
<th style={{ 'text-align': 'left', padding: '10px', color: '#64748b' }}>Status</th>
<th style={{ 'text-align': 'left', padding: '10px', color: '#64748b' }}>Time</th>
</tr>
</thead>
<tbody>
<For each={data()?.recentRequests}>{(request) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '10px' }}>{request.user_id}</td>
<td style={{ padding: '10px' }}>{request.backend_id}</td>
<td style={{ padding: '10px' }}>{request.request_model || '-'}</td>
<td style={{ padding: '10px', color: request.status_code >= 400 ? '#ef4444' : '#22c55e' }}>{request.status_code}</td>
<td style={{ padding: '10px' }}>{new Date(request.created_at).toLocaleString()}</td>
</tr>
)}</For>
</tbody>
</table>
)}
</div>
</div>
</Layout>
);
};

View file

@ -0,0 +1,135 @@
import { Component, createResource, For, createSignal } from 'solid-js';
import { api } from '../api/client';
import type { User, Backend, Permission } from '../types';
import { Layout } from '../components/Layout';
export const Permissions: Component = () => {
const [users, { refetch: refetchUsers }] = createResource(() => api.users.getAll());
const [backends, { refetch: refetchBackends }] = createResource(() => api.backends.getAll());
const [permissions, { refetch: refetchPermissions }] = createResource(() => api.permissions.getAll());
const [showModal, setShowModal] = createSignal(false);
const [formData, setFormData] = createSignal({ user_id: '', backend_id: '' });
const handleSubmit = async (e: Event) => {
e.preventDefault();
const { user_id, backend_id } = formData();
if (!user_id || !backend_id) return;
await api.permissions.create({ user_id: Number(user_id), backend_id: Number(backend_id) });
setFormData({ user_id: '', backend_id: '' });
setShowModal(false);
refetchPermissions();
};
const handleDelete = async (userId: number, backendId: number) => {
if (!confirm('Are you sure you want to revoke this permission?')) return;
await api.permissions.delete(userId, backendId);
refetchPermissions();
};
return (
<Layout>
<div style={{ padding: '30px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '20px' }}>
<h2 style={{ margin: 0 }}>Permissions</h2>
<button
onClick={() => setShowModal(true)}
style={{ padding: '10px 20px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '6px', cursor: 'pointer' }}
>
Add Permission
</button>
</div>
{permissions.loading || users.loading || backends.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'border-collapse': 'collapse', background: 'white', 'border-radius': '8px', overflow: 'hidden', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<thead style={{ background: '#f8fafc' }}>
<tr>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>User</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Backend</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Created At</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Actions</th>
</tr>
</thead>
<tbody>
<For each={permissions()}>{(permission) => {
const user = users()?.find(u => u.id === permission.user_id);
const backend = backends()?.find(b => b.id === permission.backend_id);
return (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '12px' }}>{user?.name || `User #${permission.user_id}`}</td>
<td style={{ padding: '12px' }}>{backend?.name || `Backend #${permission.backend_id}`}</td>
<td style={{ padding: '12px' }}>{new Date(permission.created_at).toLocaleString()}</td>
<td style={{ padding: '12px' }}>
<button
onClick={() => handleDelete(permission.user_id, permission.backend_id)}
style={{ padding: '4px 8px', background: '#ef4444', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Revoke
</button>
</td>
</tr>
);
}}</For>
</tbody>
</table>
)}
{showModal() && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 1000 }}>
<div style={{ background: 'white', padding: '30px', 'border-radius': '8px', width: '400px' }}>
<h3 style={{ margin: '0 0 20px 0' }}>Add Permission</h3>
<form onSubmit={handleSubmit}>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>User *</label>
<select
value={formData().user_id}
onChange={(e) => setFormData({ ...formData(), user_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a user</option>
<For each={users()}>{(user) => (
<option value={user.id}>{user.name}</option>
)}</For>
</select>
</div>
<div style={{ 'margin-bottom': '20px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Backend *</label>
<select
value={formData().backend_id}
onChange={(e) => setFormData({ ...formData(), backend_id: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px' }}
required
>
<option value="">Select a backend</option>
<For each={backends()}>{(backend) => (
<option value={backend.id}>{backend.name}</option>
)}</For>
</select>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button
type="button"
onClick={() => setShowModal(false)}
style={{ padding: '8px 16px', background: '#e2e8f0', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Cancel
</button>
<button
type="submit"
style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Grant
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
};

136
client/src/routes/Users.tsx Normal file
View file

@ -0,0 +1,136 @@
import { Component, createResource, For, createSignal } from 'solid-js';
import { api } from '../api/client';
import type { User } from '../types';
import { Layout } from '../components/Layout';
export const Users: Component = () => {
const [users, { refetch }] = createResource(() => api.users.getAll());
const [showModal, setShowModal] = createSignal(false);
const [formData, setFormData] = createSignal({ name: '', email: '' });
const handleSubmit = async (e: Event) => {
e.preventDefault();
const { name, email } = formData();
if (!name.trim()) return;
await api.users.create({ name: name.trim(), email: email.trim() || undefined });
setFormData({ name: '', email: '' });
setShowModal(false);
refetch();
};
const handleRegenerateApiKey = async (userId: number) => {
await api.users.regenerateApiKey(userId);
refetch();
};
const handleDelete = async (userId: number) => {
if (!confirm('Are you sure you want to delete this user?')) return;
await api.users.delete(userId);
refetch();
};
return (
<Layout>
<div style={{ padding: '30px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', 'margin-bottom': '20px' }}>
<h2 style={{ margin: 0 }}>Users</h2>
<button
onClick={() => setShowModal(true)}
style={{ padding: '10px 20px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '6px', cursor: 'pointer' }}
>
Add User
</button>
</div>
{users.loading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', 'border-collapse': 'collapse', background: 'white', 'border-radius': '8px', overflow: 'hidden', 'box-shadow': '0 1px 3px rgba(0,0,0,0.1)' }}>
<thead style={{ background: '#f8fafc' }}>
<tr>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>ID</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Name</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Email</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>API Key</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Status</th>
<th style={{ 'text-align': 'left', padding: '12px', 'border-bottom': '2px solid #e2e8f0' }}>Actions</th>
</tr>
</thead>
<tbody>
<For each={users()}>{(user) => (
<tr style={{ 'border-bottom': '1px solid #e2e8f0' }}>
<td style={{ padding: '12px' }}>{user.id}</td>
<td style={{ padding: '12px' }}>{user.name}</td>
<td style={{ padding: '12px' }}>{user.email || '-'}</td>
<td style={{ padding: '12px', 'font-family': 'monospace', 'font-size': '0.85rem' }}>{user.api_key.substring(0, 15)}...</td>
<td style={{ padding: '12px', color: user.is_active ? '#22c55e' : '#ef4444' }}>
{user.is_active ? 'Active' : 'Inactive'}
</td>
<td style={{ padding: '12px', display: 'flex', gap: '8px' }}>
<button
onClick={() => handleRegenerateApiKey(user.id)}
style={{ padding: '4px 8px', background: '#64748b', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Regenerate Key
</button>
<button
onClick={() => handleDelete(user.id)}
style={{ padding: '4px 8px', background: '#ef4444', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer', 'font-size': '0.8rem' }}
>
Delete
</button>
</td>
</tr>
)}</For>
</tbody>
</table>
)}
{showModal() && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 1000 }}>
<div style={{ background: 'white', padding: '30px', 'border-radius': '8px', width: '400px' }}>
<h3 style={{ margin: '0 0 20px 0' }}>Add New User</h3>
<form onSubmit={handleSubmit}>
<div style={{ 'margin-bottom': '15px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Name *</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px', 'box-sizing': 'border-box' }}
required
/>
</div>
<div style={{ 'margin-bottom': '20px' }}>
<label style={{ display: 'block', 'margin-bottom': '5px', 'font-weight': 'bold' }}>Email</label>
<input
type="email"
value={formData().email}
onInput={(e) => setFormData({ ...formData(), email: e.target.value })}
style={{ width: '100%', padding: '8px', border: '1px solid #cbd5e1', 'border-radius': '4px', 'box-sizing': 'border-box' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button
type="button"
onClick={() => setShowModal(false)}
style={{ padding: '8px 16px', background: '#e2e8f0', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Cancel
</button>
<button
type="submit"
style={{ padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', 'border-radius': '4px', cursor: 'pointer' }}
>
Create
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
};

62
client/src/types/index.ts Normal file
View file

@ -0,0 +1,62 @@
export type User = {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type Backend = {
id: number;
name: string;
base_url: string;
api_key?: string;
is_active: boolean;
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;
response_model?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
status_code: number;
response_time_ms?: number;
error_message?: string;
created_at: string;
};
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;
};

23
client/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

19
client/vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
build: {
target: 'esnext',
},
});

BIN
data/analytics.db Normal file

Binary file not shown.

BIN
data/core.db Normal file

Binary file not shown.

View file

@ -0,0 +1,58 @@
-- Analytics Database Schema
-- Stores request logs, usage stats, and backend metrics
-- Request logs table
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
endpoint TEXT NOT NULL,
request_model TEXT,
response_model TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
status_code INTEGER,
response_time_ms INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (backend_id) REFERENCES backends(id)
);
-- Usage stats table (aggregated daily per user-backend pair)
CREATE TABLE IF NOT EXISTS usage_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
date DATE NOT NULL,
total_requests INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (backend_id) REFERENCES backends(id),
UNIQUE(user_id, backend_id, date)
);
-- Backend metrics table (aggregated daily per backend)
CREATE TABLE IF NOT EXISTS backend_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
backend_id INTEGER NOT NULL,
date DATE NOT NULL,
total_requests INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
avg_response_time_ms REAL DEFAULT 0,
error_count INTEGER DEFAULT 0,
success_rate REAL DEFAULT 1.0,
FOREIGN KEY (backend_id) REFERENCES backends(id),
UNIQUE(backend_id, date)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
CREATE INDEX IF NOT EXISTS idx_request_logs_date ON request_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_request_logs_user_backend ON request_logs(user_id, backend_id);
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
CREATE INDEX IF NOT EXISTS idx_backend_metrics_date ON backend_metrics(date);

40
database/schema.sql Normal file
View file

@ -0,0 +1,40 @@
-- Core Database Schema
-- Stores users, backends, and permissions
-- Users table
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Backends table
CREATE TABLE IF NOT EXISTS backends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT,
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Permissions table (many-to-many: users ↔ backends)
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
backend_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (backend_id) REFERENCES backends(id) ON DELETE CASCADE,
UNIQUE(user_id, backend_id)
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key);
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_permissions_backend ON permissions(backend_id);

25
docker-compose.yml Normal file
View file

@ -0,0 +1,25 @@
version: '3.8'
services:
router:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- SERVER_PORT=3000
- CLIENT_PORT=3001
- CORE_DB_PATH=/data/core.db
- ANALYTICS_DB_PATH=/data/analytics.db
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
volumes:
- router-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
router-data:

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "kyush-llm-router",
"version": "1.0.0",
"description": "LLM routing server with multi-user API key management",
"scripts": {
"dev": "concurrently \"pnpm exec tsx watch server/src/index.ts\" \"pnpm -r --filter client run dev\"",
"build": "pnpm -r build",
"start": "pnpm -r start"
},
"keywords": [
"llm",
"router",
"openai",
"proxy"
],
"author": "",
"license": "MIT",
"workspaces": [
"server",
"client"
],
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"concurrently": "^9.2.1",
"tsx": "^4.21.0"
}
}

2407
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

7
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,7 @@
packages:
- 'server'
- 'client'
onlyBuiltDependencies:
- 'better-sqlite3'
- 'esbuild'

92
scripts/dev.js Normal file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(color, message) {
console.log(`${color}${message}${colors.reset}`);
}
function runCommand(name, cwd, args) {
return new Promise((resolve, reject) => {
const proc = spawn(args[0], args.slice(1), {
cwd,
stdio: 'inherit',
shell: true,
});
proc.on('error', (err) => {
log(colors.red, `[${name}] Failed to start: ${err.message}`);
reject(err);
});
proc.on('close', (code) => {
if (code !== 0) {
log(colors.red, `[${name}] Process exited with code ${code}`);
reject(new Error(`${name} exited with code ${code}`));
} else {
resolve();
}
});
});
}
async function main() {
log(colors.cyan, 'Starting Kyush LLM Router development environment...');
log(colors.cyan, '===============================================');
const serverDir = path.join(__dirname, '..', 'server');
const clientDir = path.join(__dirname, '..', 'client');
const serverProcess = spawn('npm', ['run', 'dev'], {
cwd: serverDir,
stdio: 'inherit',
shell: true,
});
const clientProcess = spawn('npm', ['run', 'dev'], {
cwd: clientDir,
stdio: 'inherit',
shell: true,
});
log(colors.green, '===============================================');
log(colors.green, 'Development servers started:');
log(colors.green, ` - Express API Server: http://localhost:3000`);
log(colors.green, ` - Vite Admin Dashboard: http://localhost:3001`);
log(colors.green, '===============================================');
log(colors.yellow, 'Press Ctrl+C to stop all servers');
process.on('SIGINT', () => {
log(colors.yellow, '\nShutting down...');
serverProcess.kill('SIGINT');
clientProcess.kill('SIGINT');
process.exit(0);
});
process.on('SIGTERM', () => {
log(colors.yellow, '\nShutting down...');
serverProcess.kill('SIGTERM');
clientProcess.kill('SIGTERM');
process.exit(0);
});
await Promise.all([
new Promise((_, reject) => serverProcess.on('error', reject)),
new Promise((_, reject) => clientProcess.on('error', reject)),
]);
}
main().catch((err) => {
log(colors.red, `Fatal error: ${err.message}`);
process.exit(1);
});

31
server/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "server",
"version": "1.0.0",
"description": "LLM Router Server",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.6.2",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"node-fetch": "^3.3.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.3.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const ANALYTICS_DB_PATH = process.env.ANALYTICS_DB_PATH || path.join(process.cwd(), 'data', 'analytics.db');
// Ensure data directory exists
const dataDir = path.dirname(ANALYTICS_DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(ANALYTICS_DB_PATH);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Initialize schema
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
export default db;

View file

@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const CORE_DB_PATH = process.env.CORE_DB_PATH || path.join(process.cwd(), 'data', 'core.db');
// Ensure data directory exists
const dataDir = path.dirname(CORE_DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(CORE_DB_PATH);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Initialize schema
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
db.exec(schema);
export default db;

38
server/src/index.ts Normal file
View file

@ -0,0 +1,38 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import adminRoutes from './routes/admin';
import apiRoutes from './routes/api';
import analyticsRoutes from './routes/analytics';
import { logger } from './utils/logger';
dotenv.config();
const app = express();
const PORT = process.env.SERVER_PORT || 3000;
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3001'],
credentials: true,
}));
app.use(express.json());
app.use('/admin', adminRoutes);
app.use('/v1', apiRoutes);
app.use('/admin/analytics', analyticsRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin API: http://localhost:${PORT}/admin`);
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
});

View file

@ -0,0 +1,75 @@
import db from '../config/database';
import { Backend, CreateBackendData, UpdateBackendData } from '../../../shared/types';
export class BackendModel {
static findAll(): Backend[] {
return db.prepare('SELECT * FROM backends ORDER BY created_at DESC').all() as Backend[];
}
static findById(id: number): Backend | undefined {
return db.prepare('SELECT * FROM backends WHERE id = ?').get(id) as Backend | undefined;
}
static findActive(): Backend[] {
return db.prepare('SELECT * FROM backends WHERE is_active = 1 ORDER BY name').all() as Backend[];
}
static create(data: CreateBackendData): Backend {
const stmt = db.prepare(
'INSERT INTO backends (name, base_url, api_key) VALUES (?, ?, ?)'
);
const result = stmt.run(data.name, data.base_url, data.api_key || null);
return {
id: result.lastInsertRowid as number,
name: data.name,
base_url: data.base_url,
api_key: data.api_key,
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
static update(id: number, data: UpdateBackendData): Backend | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) {
updates.push('name = ?');
values.push(data.name);
}
if (data.base_url !== undefined) {
updates.push('base_url = ?');
values.push(data.base_url);
}
if (data.api_key !== undefined) {
updates.push('api_key = ?');
values.push(data.api_key);
}
if (data.is_active !== undefined) {
updates.push('is_active = ?');
values.push(data.is_active);
}
if (updates.length === 0) {
return this.findById(id);
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
db.prepare(`UPDATE backends SET ${updates.join(', ')} WHERE id = ?`).run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = db.prepare('DELETE FROM backends WHERE id = ?').run(id);
return result.changes > 0;
}
static deactivate(id: number): boolean {
const result = db.prepare('UPDATE backends SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
return result.changes > 0;
}
}

View file

@ -0,0 +1,61 @@
import db from '../config/database';
import { Permission, CreatePermissionData } from '../../../shared/types';
export class PermissionModel {
static findAll(): Permission[] {
return db.prepare('SELECT * FROM permissions ORDER BY created_at DESC').all() as Permission[];
}
static findByUserId(userId: number): Permission[] {
return db.prepare('SELECT * FROM permissions WHERE user_id = ? ORDER BY backend_id').all(userId) as Permission[];
}
static findByBackendId(backendId: number): Permission[] {
return db.prepare('SELECT * FROM permissions WHERE backend_id = ? ORDER BY user_id').all(backendId) as Permission[];
}
static findUserBackendPermissions(userId: number, backendId: number): Permission | undefined {
return db.prepare('SELECT * FROM permissions WHERE user_id = ? AND backend_id = ?').get(userId, backendId) as Permission | undefined;
}
static create(data: CreatePermissionData): Permission {
try {
const stmt = db.prepare(
'INSERT INTO permissions (user_id, backend_id) VALUES (?, ?)'
);
const result = stmt.run(data.user_id, data.backend_id);
return {
id: result.lastInsertRowid as number,
user_id: data.user_id,
backend_id: data.backend_id,
created_at: new Date().toISOString(),
};
} catch (error) {
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
throw new Error('Permission already exists for this user and backend');
}
throw error;
}
}
static delete(user_id: number, backend_id: number): boolean {
const result = db.prepare('DELETE FROM permissions WHERE user_id = ? AND backend_id = ?').run(user_id, backend_id);
return result.changes > 0;
}
static deleteByUserId(userId: number): number {
const result = db.prepare('DELETE FROM permissions WHERE user_id = ?').run(userId);
return result.changes;
}
static deleteByBackendId(backendId: number): number {
const result = db.prepare('DELETE FROM permissions WHERE backend_id = ?').run(backendId);
return result.changes;
}
static getUserBackendIds(userId: number): number[] {
const rows = db.prepare('SELECT backend_id FROM permissions WHERE user_id = ?').all(userId) as { backend_id: number }[];
return rows.map(row => row.backend_id);
}
}

80
server/src/models/User.ts Normal file
View file

@ -0,0 +1,80 @@
import db from '../config/database';
import { User, CreateUserData, UpdateUserData } from '../../../shared/types';
export class UserModel {
static findAll(): User[] {
return db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as User[];
}
static findById(id: number): User | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
static findByApiKey(apiKey: string): User | undefined {
return db.prepare('SELECT * FROM users WHERE api_key = ? AND is_active = 1').get(apiKey) as User | undefined;
}
static create(data: CreateUserData): User {
const stmt = db.prepare(
'INSERT INTO users (api_key, name, email) VALUES (?, ?, ?)'
);
const result = stmt.run(data.name, data.email || null);
return {
id: result.lastInsertRowid as number,
api_key: data.name,
name: data.name,
email: data.email,
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
static update(id: number, data: UpdateUserData): User | undefined {
const updates: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) {
updates.push('name = ?');
values.push(data.name);
}
if (data.email !== undefined) {
updates.push('email = ?');
values.push(data.email);
}
if (data.is_active !== undefined) {
updates.push('is_active = ?');
values.push(data.is_active);
}
if (updates.length === 0) {
return this.findById(id);
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
return this.findById(id);
}
static delete(id: number): boolean {
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id);
return result.changes > 0;
}
static deactivate(id: number): boolean {
const result = db.prepare('UPDATE users SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
return result.changes > 0;
}
static regenerateApiKey(id: number): string | null {
const user = this.findById(id);
if (!user) return null;
const newApiKey = `sk-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
db.prepare('UPDATE users SET api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newApiKey, id);
return newApiKey;
}
}

216
server/src/routes/admin.ts Normal file
View file

@ -0,0 +1,216 @@
import { Router, Request, Response } from 'express';
import { UserModel } from '../models/User';
import { BackendModel } from '../models/Backend';
import { PermissionModel } from '../models/Permission';
import { generateApiKey } from '../utils/apiKey';
import { CreateUserData, CreateBackendData, CreatePermissionData, UpdateUserData, UpdateBackendData } from '../../../shared/types';
const router = Router();
// ============ User Management ============
router.get('/users', (req: Request, res: Response) => {
const users = UserModel.findAll();
res.json(users);
});
router.post('/users', (req: Request, res: Response) => {
const { name, email } = req.body as CreateUserData;
if (!name) {
res.status(400).json({ error: 'Name is required' });
return;
}
const user = UserModel.create({
name,
email,
});
const updatedUser = UserModel.regenerateApiKey(user.id);
if (updatedUser) {
user.api_key = updatedUser;
}
res.status(201).json(user);
});
router.get('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
});
router.put('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const { name, email, is_active } = req.body as UpdateUserData;
const updatedUser = UserModel.update(id, { name, email, is_active });
res.json(updatedUser);
});
router.delete('/users/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const success = UserModel.delete(id);
if (!success) {
res.status(404).json({ error: 'User not found' });
return;
}
res.status(204).send();
});
router.post('/users/:id/regenerate-api-key', (req: Request, res: Response) => {
const id = Number(req.params.id);
const user = UserModel.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
const newApiKey = UserModel.regenerateApiKey(id);
if (!newApiKey) {
res.status(500).json({ error: 'Failed to regenerate API key' });
return;
}
res.json({ ...user, api_key: newApiKey });
});
// ============ Backend Management ============
router.get('/backends', (req: Request, res: Response) => {
const backends = BackendModel.findAll();
res.json(backends);
});
router.post('/backends', (req: Request, res: Response) => {
const { name, base_url, api_key } = req.body as CreateBackendData;
if (!name || !base_url) {
res.status(400).json({ error: 'Name and base_url are required' });
return;
}
const backend = BackendModel.create({ name, base_url, api_key });
res.status(201).json(backend);
});
router.get('/backends/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const backend = BackendModel.findById(id);
if (!backend) {
res.status(404).json({ error: 'Backend not found' });
return;
}
res.json(backend);
});
router.put('/backends/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const backend = BackendModel.findById(id);
if (!backend) {
res.status(404).json({ error: 'Backend not found' });
return;
}
const { name, base_url, api_key, is_active } = req.body as UpdateBackendData;
const updatedBackend = BackendModel.update(id, { name, base_url, api_key, is_active });
res.json(updatedBackend);
});
router.delete('/backends/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const success = BackendModel.delete(id);
if (!success) {
res.status(404).json({ error: 'Backend not found' });
return;
}
res.status(204).send();
});
// ============ Permission Management ============
router.get('/permissions', (req: Request, res: Response) => {
const permissions = PermissionModel.findAll();
res.json(permissions);
});
router.get('/permissions/user/:userId', (req: Request, res: Response) => {
const userId = Number(req.params.userId);
const permissions = PermissionModel.findByUserId(userId);
res.json(permissions);
});
router.get('/permissions/backend/:backendId', (req: Request, res: Response) => {
const backendId = Number(req.params.backendId);
const permissions = PermissionModel.findByBackendId(backendId);
res.json(permissions);
});
router.post('/permissions', (req: Request, res: Response) => {
const { user_id, backend_id } = req.body as CreatePermissionData;
if (!user_id || !backend_id) {
res.status(400).json({ error: 'user_id and backend_id are required' });
return;
}
try {
const permission = PermissionModel.create({ user_id, backend_id });
res.status(201).json(permission);
} catch (error) {
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Failed to create permission' });
}
});
router.delete('/permissions', (req: Request, res: Response) => {
const { user_id, backend_id } = req.query as { user_id?: string; backend_id?: string };
if (!user_id || !backend_id) {
res.status(400).json({ error: 'user_id and backend_id are required' });
return;
}
const success = PermissionModel.delete(Number(user_id), Number(backend_id));
if (!success) {
res.status(404).json({ error: 'Permission not found' });
return;
}
res.status(204).send();
});
// ============ Health Check ============
router.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
export default router;

View file

@ -0,0 +1,34 @@
import { Router, Request, Response } from 'express';
import { AnalyticsService } from '../services/AnalyticsService';
const router = Router();
router.get('/usage', (req: Request, res: Response) => {
const { userId, backendId, days } = req.query;
const result = AnalyticsService.getUsageStats(
userId ? Number(userId) : undefined,
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
);
res.json(result);
});
router.get('/requests', (req: Request, res: Response) => {
const { limit, offset } = req.query;
const result = AnalyticsService.getRequestLogs(
limit ? Number(limit) : 100,
offset ? Number(offset) : 0
);
res.json(result);
});
router.get('/metrics', (req: Request, res: Response) => {
const { backendId, days } = req.query;
const result = AnalyticsService.getBackendMetrics(
backendId ? Number(backendId) : undefined,
days ? Number(days) : 30
);
res.json(result);
});
export default router;

106
server/src/routes/api.ts Normal file
View file

@ -0,0 +1,106 @@
import { Router, Request, Response } from 'express';
import { authenticate, AuthenticatedRequest } from './auth';
import { RouterService } from '../services/RouterService';
import { AnalyticsService } from '../services/AnalyticsService';
import { logger } from '../utils/logger';
const router = Router();
router.use(authenticate);
router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response) => {
const startTime = Date.now();
const user = req.user!;
const allowedBackendIds = req.allowedBackendIds!;
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
}
const backend = RouterService.selectBackend(allowedBackendIds);
if (!backend) {
res.status(403).json({ error: 'No active backends available' });
return;
}
try {
const { model, messages, ...rest } = req.body;
const response = await RouterService.forwardRequest(
backend,
'/v1/chat/completions',
'POST',
{ 'Content-Type': 'application/json' },
req.body
);
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: model,
response_model: response.data && typeof response.data === 'object' && 'model' in response.data ? String(response.data.model) : undefined,
prompt_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { prompt_tokens?: number } }).usage === 'object' ? (response.data as { usage: { prompt_tokens: number } }).usage?.prompt_tokens : undefined,
completion_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { completion_tokens?: number } }).usage === 'object' ? (response.data as { usage: { completion_tokens: number } }).usage?.completion_tokens : undefined,
total_tokens: response.data && typeof response.data === 'object' && 'usage' in response.data && typeof (response.data as { usage?: { total_tokens?: number } }).usage === 'object' ? (response.data as { usage: { total_tokens: number } }).usage?.total_tokens : undefined,
status_code: response.status,
response_time_ms: responseTime,
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
});
if (response.status >= 400) {
logger.error(`Backend error for user ${user.id}: ${JSON.stringify(response.data)}`);
}
res.status(response.status).json(response.data);
} catch (error) {
const responseTime = Date.now() - startTime;
AnalyticsService.logRequest({
user_id: user.id,
backend_id: backend.id,
endpoint: '/v1/chat/completions',
request_model: req.body.model,
status_code: 502,
response_time_ms: responseTime,
error_message: error instanceof Error ? error.message : 'Unknown error',
});
logger.error(`Request failed for user ${user.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
res.status(502).json({ error: 'Backend request failed' });
}
});
router.get('/models', async (req: AuthenticatedRequest, res: Response) => {
const allowedBackendIds = req.allowedBackendIds!;
if (allowedBackendIds.length === 0) {
res.status(403).json({ error: 'No backends available for your account' });
return;
}
const backend = RouterService.selectBackend(allowedBackendIds);
if (!backend) {
res.status(403).json({ error: 'No active backends available' });
return;
}
try {
const response = await RouterService.forwardRequest(
backend,
'/v1/models',
'GET',
{}
);
res.status(response.status).json(response.data);
} catch (error) {
logger.error(`Models request failed for user ${req.user!.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
res.status(502).json({ error: 'Failed to fetch models from backend' });
}
});
export default router;

48
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import { UserModel } from '../models/User';
import { PermissionModel } from '../models/Permission';
import { User } from '../../../shared/types';
export interface AuthenticatedRequest extends Request {
user?: User;
allowedBackendIds?: number[];
}
export function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const apiKey = authHeader.substring(7);
const user = UserModel.findByApiKey(apiKey);
if (!user) {
res.status(401).json({ error: 'Invalid API key' });
return;
}
req.user = user;
req.allowedBackendIds = PermissionModel.getUserBackendIds(user.id);
next();
}
export function requireBackendPermission(backendId?: number) {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const targetBackendId = backendId || Number(req.params.backendId);
if (!req.allowedBackendIds?.includes(targetBackendId)) {
res.status(403).json({ error: 'Access denied to this backend' });
return;
}
next();
};
}

View file

@ -0,0 +1,144 @@
import analyticsDb from '../config/analytics-db';
import { RequestLog } from '../../../shared/types';
export class AnalyticsService {
static logRequest(logData: Omit<RequestLog, 'id' | 'created_at'>): void {
const stmt = analyticsDb.prepare(`
INSERT INTO request_logs (
user_id, backend_id, endpoint, request_model, response_model,
prompt_tokens, completion_tokens, total_tokens,
status_code, response_time_ms, error_message
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
logData.user_id,
logData.backend_id,
logData.endpoint,
logData.request_model || null,
logData.response_model || null,
logData.prompt_tokens || null,
logData.completion_tokens || null,
logData.total_tokens || null,
logData.status_code,
logData.response_time_ms || null,
logData.error_message || null
);
this.updateUsageStats(logData.user_id, logData.backend_id, logData.total_tokens || 0);
this.updateBackendMetrics(logData.backend_id, logData);
}
private static updateUsageStats(userId: number, backendId: number, tokens: number): void {
const today = new Date().toISOString().split('T')[0];
const upsertStmt = analyticsDb.prepare(`
INSERT INTO usage_stats (user_id, backend_id, date, total_requests, total_tokens)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT(user_id, backend_id, date)
DO UPDATE SET
total_requests = total_requests + 1,
total_tokens = total_tokens + ?
`);
upsertStmt.run(userId, backendId, today, tokens, tokens);
}
private static updateBackendMetrics(backendId: number, logData: Omit<RequestLog, 'id' | 'created_at'>): void {
const today = new Date().toISOString().split('T')[0];
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
const existing = analyticsDb.prepare(
'SELECT * FROM backend_metrics WHERE backend_id = ? AND date = ?'
).get(backendId, today) as {
total_requests: number;
total_tokens: number;
avg_response_time_ms: number;
error_count: number;
} | undefined;
if (existing) {
const newTotalRequests = existing.total_requests + 1;
const newTotalTokens = existing.total_tokens + (logData.total_tokens || 0);
const newErrorCount = existing.error_count + (isSuccess ? 0 : 1);
const newAvgResponseTime = logData.response_time_ms
? (existing.avg_response_time_ms * existing.total_requests + logData.response_time_ms) / newTotalRequests
: existing.avg_response_time_ms;
const newSuccessRate = (newTotalRequests - newErrorCount) / newTotalRequests;
analyticsDb.prepare(`
UPDATE backend_metrics SET
total_requests = ?,
total_tokens = ?,
avg_response_time_ms = ?,
error_count = ?,
success_rate = ?
WHERE backend_id = ? AND date = ?
`).run(newTotalRequests, newTotalTokens, newAvgResponseTime, newErrorCount, newSuccessRate, backendId, today);
} else {
analyticsDb.prepare(`
INSERT INTO backend_metrics (
backend_id, date, total_requests, total_tokens,
avg_response_time_ms, error_count, success_rate
) VALUES (?, ?, 1, ?, ?, ?, ?)
`).run(
backendId,
today,
logData.total_tokens || 0,
logData.response_time_ms || 0,
isSuccess ? 0 : 1,
isSuccess ? 1.0 : 0.0
);
}
}
static getRequestLogs(limit: number = 100, offset: number = 0): RequestLog[] {
return analyticsDb.prepare(`
SELECT * FROM request_logs ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(limit, offset) as RequestLog[];
}
static getUsageStats(userId?: number, backendId?: number, days: number = 30): unknown[] {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
let query = `
SELECT * FROM usage_stats
WHERE date >= ? AND date <= ?
`;
const params: unknown[] = [startDate, endDate];
if (userId) {
query += ' AND user_id = ?';
params.push(userId);
}
if (backendId) {
query += ' AND backend_id = ?';
params.push(backendId);
}
query += ' ORDER BY date DESC, user_id, backend_id';
return analyticsDb.prepare(query).all(...params);
}
static getBackendMetrics(backendId?: number, days: number = 30): unknown[] {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
let query = `
SELECT * FROM backend_metrics
WHERE date >= ? AND date <= ?
`;
const params: unknown[] = [startDate, endDate];
if (backendId) {
query += ' AND backend_id = ?';
params.push(backendId);
}
query += ' ORDER BY date DESC';
return analyticsDb.prepare(query).all(...params);
}
}

View file

@ -0,0 +1,59 @@
import { Backend } from '../../../shared/types';
import { BackendModel } from '../models/Backend';
export class RouterService {
static selectBackend(allowedBackendIds: number[]): Backend | null {
if (allowedBackendIds.length === 0) {
return null;
}
const backends = BackendModel.findAll()
.filter(b => b.is_active && allowedBackendIds.includes(b.id));
if (backends.length === 0) {
return null;
}
const roundRobinIndex = Math.floor(Math.random() * backends.length);
return backends[roundRobinIndex];
}
static async forwardRequest(
backend: Backend,
path: string,
method: string,
headers: Record<string, string>,
body?: unknown
): Promise<{ status: number; data: unknown }> {
const backendUrl = backend.base_url.replace(/\/$/, '') + path;
const fetchHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (backend.api_key) {
fetchHeaders['Authorization'] = `Bearer ${backend.api_key}`;
}
try {
const response = await fetch(backendUrl, {
method,
headers: fetchHeaders,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
return {
status: response.status,
data,
};
} catch (error) {
return {
status: 502,
data: { error: 'Failed to forward request to backend' },
};
}
}
}

View file

@ -0,0 +1,9 @@
export function generateApiKey(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 15);
return `sk-${timestamp}-${random}`;
}
export function isValidApiKey(key: string): boolean {
return key.startsWith('sk-') && key.length > 10;
}

View file

@ -0,0 +1,33 @@
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
export function log(level: 'info' | 'warn' | 'error' | 'debug', message: string, meta?: unknown): void {
const timestamp = new Date().toISOString();
const levelColor = {
info: colors.green,
warn: colors.yellow,
error: colors.red,
debug: colors.gray,
}[level];
const prefix = `[${timestamp}] ${levelColor}[${level.toUpperCase()}]${colors.reset}`;
console.log(prefix, message);
if (meta) {
console.log(meta);
}
}
export const logger = {
info: (message: string, meta?: unknown) => log('info', message, meta),
warn: (message: string, meta?: unknown) => log('warn', message, meta),
error: (message: string, meta?: unknown) => log('error', message, meta),
debug: (message: string, meta?: unknown) => log('debug', message, meta),
};

20
server/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

131
shared/types.ts Normal file
View file

@ -0,0 +1,131 @@
export interface User {
id: number;
api_key: string;
name: string;
email?: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface Backend {
id: number;
name: string;
base_url: string;
api_key?: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface Permission {
id: number;
user_id: number;
backend_id: number;
created_at: string;
}
export interface CreateUserData {
name: string;
email?: string;
}
export interface CreateBackendData {
name: string;
base_url: string;
api_key?: string;
}
export interface CreatePermissionData {
user_id: number;
backend_id: number;
}
export interface UpdateUserData {
name?: string;
email?: string;
is_active?: boolean;
}
export interface UpdateBackendData {
name?: string;
base_url?: string;
api_key?: string;
is_active?: boolean;
}
export interface RequestLog {
id: number;
user_id: number;
backend_id: number;
endpoint: string;
request_model?: string;
response_model?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
status_code: number;
response_time_ms?: number;
error_message?: string;
created_at: string;
}
export interface UsageStats {
id: number;
user_id: number;
backend_id: number;
date: string;
total_requests: number;
total_tokens: number;
}
export interface 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 interface OpenAIChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface OpenAIChatCompletionRequest {
model: string;
messages: OpenAIChatMessage[];
temperature?: number;
max_tokens?: number;
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
stream?: boolean;
}
export interface OpenAIChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: OpenAIChatMessage;
finish_reason: string;
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface OpenAIModel {
id: string;
object: string;
created: number;
owned_by: string;
}