init
This commit is contained in:
commit
1cd7941472
46 changed files with 6539 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# only = production
|
||||||
|
prefer-offline=true
|
||||||
477
ARCHITECTURE.md
Normal file
477
ARCHITECTURE.md
Normal 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
33
Dockerfile
Normal 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
121
README.md
Normal 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
12
client/index.html
Normal 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
25
client/package.json
Normal 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
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
18
client/src/App.tsx
Normal 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
80
client/src/api/client.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
55
client/src/components/Layout.tsx
Normal file
55
client/src/components/Layout.tsx
Normal 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
8
client/src/index.tsx
Normal 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);
|
||||||
|
}
|
||||||
108
client/src/routes/Analytics.tsx
Normal file
108
client/src/routes/Analytics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
client/src/routes/Backends.tsx
Normal file
135
client/src/routes/Backends.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
client/src/routes/Dashboard.tsx
Normal file
70
client/src/routes/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
client/src/routes/Permissions.tsx
Normal file
135
client/src/routes/Permissions.tsx
Normal 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
136
client/src/routes/Users.tsx
Normal 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
62
client/src/types/index.ts
Normal 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
23
client/tsconfig.json
Normal 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
19
client/vite.config.ts
Normal 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
BIN
data/analytics.db
Normal file
Binary file not shown.
BIN
data/core.db
Normal file
BIN
data/core.db
Normal file
Binary file not shown.
58
database/analytics-schema.sql
Normal file
58
database/analytics-schema.sql
Normal 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
40
database/schema.sql
Normal 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
25
docker-compose.yml
Normal 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
29
package.json
Normal 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
2407
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
packages:
|
||||||
|
- 'server'
|
||||||
|
- 'client'
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- 'better-sqlite3'
|
||||||
|
- 'esbuild'
|
||||||
92
scripts/dev.js
Normal file
92
scripts/dev.js
Normal 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
31
server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/src/config/analytics-db.ts
Normal file
23
server/src/config/analytics-db.ts
Normal 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;
|
||||||
23
server/src/config/database.ts
Normal file
23
server/src/config/database.ts
Normal 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
38
server/src/index.ts
Normal 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`);
|
||||||
|
});
|
||||||
75
server/src/models/Backend.ts
Normal file
75
server/src/models/Backend.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
server/src/models/Permission.ts
Normal file
61
server/src/models/Permission.ts
Normal 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
80
server/src/models/User.ts
Normal 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
216
server/src/routes/admin.ts
Normal 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;
|
||||||
34
server/src/routes/analytics.ts
Normal file
34
server/src/routes/analytics.ts
Normal 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
106
server/src/routes/api.ts
Normal 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
48
server/src/routes/auth.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
144
server/src/services/AnalyticsService.ts
Normal file
144
server/src/services/AnalyticsService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
server/src/services/RouterService.ts
Normal file
59
server/src/services/RouterService.ts
Normal 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' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
server/src/utils/apiKey.ts
Normal file
9
server/src/utils/apiKey.ts
Normal 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;
|
||||||
|
}
|
||||||
33
server/src/utils/logger.ts
Normal file
33
server/src/utils/logger.ts
Normal 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
20
server/tsconfig.json
Normal 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
131
shared/types.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue