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