Initial commit: LlamaLink Go rewrite

Complete rewrite from Python/FastAPI to Go/Gin:
- Go backend: auth (API keys + bcrypt), llama.cpp subprocess manager,
  hot-swap multi-model, rate limiting, quota system, webhooks
- Vue 3 SPA admin panel (src/) with Tailwind CSS
- Deployment: Docker multi-stage, docker-compose, nginx, systemd
- GORM/SQLite models: ApiKey, Model, UsageLog, Quota, Webhook
- REST API: /api/v1/admin/* (keys, models, chat, usage, health)
- Embedded frontend via go:embed (build output at web/dist/)

Removed legacy Python artifacts (app/, tests/, pyproject.toml, etc.)
This commit is contained in:
2026-07-30 10:58:55 -04:00
commit 4c9ed3c24b
52 changed files with 5105 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
LLAMALINK_ENV=development
LLAMALINK_HOST=0.0.0.0
LLAMALINK_PORT=8000
LLAMALINK_SECRET_KEY=change-me-in-production
DATABASE_URL=sqlite:///./llamalink.db
DATABASE_MAX_OPENConns=25
DATABASE_MAX_IDLE_CONNS=5
DATABASE_CONN_MAX_LIFETIME=300
MANAGE_LLAMA_SERVER=true
LLAMA_SERVER_BIN=/usr/local/bin/llama-server
LLAMA_SERVER_HOST=127.0.0.1
LLAMA_SERVER_PORT=8080
LLAMA_SERVER_STARTUP_TIMEOUT=120
LLAMA_SERVER_STOP_TIMEOUT=10
MODEL_SWAP_COOLDOWN=2
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_STORAGE=memory
# RATE_LIMIT_REDIS_URL=redis://localhost:6379/0
ADMIN_TOKEN=change-me-in-production
LOG_LEVEL=info
LOG_FORMAT=json
# WEBHOOK_SECRET=your-webhook-secret-here
+75
View File
@@ -0,0 +1,75 @@
# Go binaries
/llamalink
/llamalink-*
/dist/
/bin/
# Test artifacts
*.test
*.out
coverage.html
coverage.out
# Vendoring — using go modules
/vendor/
# Go workspace
go.work
go.work.sum
# Environment
.env
.env.local
.env.*.local
*.env
!.env.example
# Database
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
/data/
# LLM models y binarios
/models/
*.gguf
/llama-server
/llama-cli
# Logs
*.log
logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Frontend
web/frontend/node_modules/
web/frontend/dist/
web/frontend/.vite/
web/dist/
web/frontend/.env
web/frontend/.env.local
web/frontend/coverage/
# Build artifacts
*.tmp
*.bak
tmp/
.cache/
# Coverage reports
*.coverprofile
# Misc
vendor/
+104
View File
@@ -0,0 +1,104 @@
.PHONY: build run test lint clean migrate dev prod deps fmt
# Binary name
BINARY=llamalink
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)"
# Directories
BUILD_DIR=./bin
FRONTEND_DIR=./web/frontend
# Go parameters
GOCMD=go
GOBUILD=$(GOCMD) build $(LDFLAGS)
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
GOMOD=$(GOCMD) mod
GOFMT=gofmt
GOLINT=golangci-lint
# Default target
all: deps build
## build: Build the binary
build:
@echo "Building $(BINARY)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY) ./cmd/llamalink
## run: Build and run
run: build
@echo "Running..."
$(BUILD_DIR)/$(BINARY)
## dev: Run in development mode
dev:
LLAMALINK_ENV=development $(GOCMD) run ./cmd/llamalink
## test: Run tests
test:
$(GOTEST) -v -race -coverprofile=coverage.out ./...
## test/integration: Run integration tests
test/integration:
$(GOTEST) -v -tags=integration ./tests/integration/...
## lint: Run linters
lint:
$(GOLINT) run ./...
## fmt: Format code
fmt:
$(GOFMT) -s -w .
## deps: Download dependencies
deps:
$(GOMOD) download
$(GOMOD) tidy
## clean: Remove build artifacts
clean:
rm -rf $(BUILD_DIR)
rm -f coverage.out
rm -f *.db
## migrate/up: Run database migrations up
migrate/up:
migrate -path internal/db/migrations -database "$(DATABASE_URL)" up
## migrate/down: Run database migrations down
migrate/down:
migrate -path internal/db/migrations -database "$(DATABASE_URL)" down
## migrate/create: Create a new migration
migrate/create NAME=add_users_table:
migrate create -path internal/db/migrations -ext .sql -dir internal/db/migrations $(NAME)
## frontend/install: Install frontend dependencies
frontend/install:
cd $(FRONTEND_DIR) && npm install
## frontend/dev: Run frontend dev server
frontend/dev:
cd $(FRONTEND_DIR) && npm run dev
## frontend/build: Build frontend for production
frontend/build:
cd $(FRONTEND_DIR) && npm run build
## docker/build: Build Docker image
docker/build:
docker build -t llamalink:latest -f deploy/Dockerfile .
## docker/run: Run Docker container
docker/run:
docker-compose -f deploy/docker-compose.yml up
## docker/build/run: Build and run with docker-compose
docker/build/run: docker/build docker/run
## help: Show this help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+189
View File
@@ -0,0 +1,189 @@
# LlamaLink
**API Gateway en Go** para ejecutar modelos de lenguaje (LLMs) de forma local usando **llama.cpp**, con autenticación por API keys, rate limiting, cuotas de uso y panel de administración Vue 3.
## Características
- **API OpenAI-compatible**: `POST /v1/chat/completions` con streaming SSE
- **Multi-model hot-swap**: Configura muchos modelos, activa uno a la vez sin downtime
- **Auth por API keys**: Keys con scopes (`chat`, `models`, `usage`, `admin`), hashing bcrypt
- **Rate limiting**: Token bucket por API key (configurable, backend memory o Redis)
- **Cuotas mensuales**: Tracking por modelo o global, webhooks al 90% y 100%
- **Admin SPA**: Vue 3 + Tailwind CSS con Dashboard, Keys, Models, Usage
- **Deployment**: Docker, docker-compose, systemd, nginx/Caddy
- **WebSocket**: Live updates de estado del modelo y stats
## Requisitos
- Go 1.23+
- Node.js 20+ (para build del frontend)
- llama-server (GGML/GGUF)
- CUDA 12.x (opcional, para GPU)
## Quick Start
### 1. Build
```bash
# Dependencias Go
go mod download
# Build binario
make build
# O directamente
go build -o llamalink ./cmd/llamalink
```
### 2. Configuración
```bash
cp .env.example .env
# Editar .env - mínimo: ADMIN_TOKEN
```
### 3. Arrancar
```bash
# Con llama-server externo (ya corriendo en 127.0.0.1:8080)
./llamalink
# O gestionar llama-server internamente
MANAGE_LLAMA_SERVER=true ./llamalink
```
### 4. Probar
```bash
# Health check
curl http://localhost:8000/health
# Login admin (primera key se crea automáticamente)
ADMIN_TOKEN=tu-token curl http://localhost:8000/api/v1/admin/login \
-X POST -H "Content-Type: application/json" \
-d '{"admin_token":"tu-token"}'
# Crear API key
curl http://localhost:8000/v1/keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"test-key"}'
# Chat completion
curl http://localhost:8000/v1/chat/completions \
-H "Authorization: Bearer $TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"tu-modelo","messages":[{"role":"user","content":"Hello!"}]}'
```
## Variables de Entorno
| Variable | Descripción | Default |
|---|---|---|
| `LLAMALINK_ENV` | `development` o `production` | `development` |
| `LLAMALINK_PORT` | Puerto HTTP | `8000` |
| `DATABASE_URL` | Connection string SQLite/Postgres | `sqlite:///./llamalink.db` |
| `MANAGE_LLAMA_SERVER` | ¿Gestionar llama-server internamente? | `true` |
| `LLAMA_SERVER_BIN` | Path a `llama-server` | `/usr/local/bin/llama-server` |
| `LLAMA_SERVER_HOST` | Host de llama-server | `127.0.0.1` |
| `LLAMA_SERVER_PORT` | Puerto de llama-server | `8080` |
| `LLAMA_SERVER_STARTUP_TIMEOUT` | Timeout startup (seg) | `120` |
| `RATE_LIMIT_PER_MINUTE` | Requests/min por key | `60` |
| `ADMIN_TOKEN` | Token admin inicial | `changeme` |
| `LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` |
## Docker
```bash
# Build imagen
make docker/build
# O docker-compose completo (API + llama-runner + nginx)
docker compose -f deploy/docker-compose.yml up -d
# Production con tu modelo
ADMIN_TOKEN=mi-token-secreto docker compose -f deploy/docker-compose.yml up -d
```
## Admin Panel
Accede a `http://localhost:8000/admin/` para el panel Vue 3:
- **Dashboard**: Stats, estado del modelo, tabla de modelos con hot-swap
- **API Keys**: Crear, listar, revocar keys
- **Models**: CRUD de modelos, load/unload
- **Usage**: Charts de uso, logs, quota
## API Endpoints
### OpenAI-compatible
- `POST /v1/chat/completions` — Chat con streaming
- `GET /v1/models` — Lista modelos
- `GET /v1/models/active` — Modelo activo
### Gestión
- `GET|POST /v1/keys` — Listar/crear keys (admin)
- `DELETE /v1/keys/{id}` — Revocar key (admin)
- `POST /v1/models` — Crear modelo (admin)
- `POST /v1/models/{name}/load` — Hot-swap (admin)
- `GET /v1/usage` — Uso de tu key
- `GET /v1/usage/{key_id}` — Uso específico (admin)
### Admin API (JSON)
- `POST /api/v1/admin/login` — Login admin
- `GET /api/v1/admin/dashboard` — Stats aggregated
- `GET /api/v1/admin/keys` — Keys con detalles
- `POST /api/v1/admin/keys` — Crear key
- `DELETE /api/v1/admin/keys/{id}` — Revocar
## Makefile
```bash
make build # Build binario
make run # Build y ejecutar
make dev # go run (desarrollo)
make test # Tests
make lint # golangci-lint
make clean # Limpiar build
make migrate/up # Correr migrations
make frontend/build # Build Vue SPA
```
## Arquitectura
```
llamalink/
├── cmd/llamalink/ # Entry point
├── internal/
│ ├── api/ # Gin router, handlers, middleware
│ ├── auth/ # API key service, bcrypt
│ ├── db/ # GORM models, migrations
│ ├── llama/ # Proxy, subprocess manager
│ └── quota/ # Quota check, webhooks
├── web/frontend/ # Vue 3 SPA (build → web/dist/)
├── deploy/ # Docker, systemd, nginx
└── migrations/ # SQL migrations
```
## Deployment
### Systemd
```bash
sudo cp deploy/llamalink.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable llamalink
sudo systemctl start llamalink
```
###nginx
```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/llamalink
sudo ln -s /etc/nginx/sites-available/llamalink /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## License
MIT
+285
View File
@@ -0,0 +1,285 @@
# LlamaLink — Especificación Técnica (Living Document)
> Este documento se actualiza conforme evoluciona la implementación. Última actualización: implementación inicial.
## 1. Descripción General
**LlamaLink** es un servidor middleware/gateway que permite ejecutar modelos de lenguaje (LLMs) de forma local usando **llama.cpp**, exponiéndolos de manera segura a través de una API REST propia protegida con **API keys**, permitiendo así el acceso remoto controlado a la inferencia.
### Objetivos principales
- Ejecutar modelos GGUF localmente mediante llama.cpp.
- Exponer un endpoint HTTP compatible con el estándar OpenAI (`/v1/chat/completions`).
- Autenticar y autorizar clientes remotos mediante API keys.
- Controlar uso, límites y seguridad del acceso a los modelos.
---
## 2. Arquitectura
```
Cliente remoto (con API key)
┌─────────────────────┐
│ LlamaLink API │ ← Gateway (FastAPI)
│ - Autenticación │
│ - Rate limiting │
│ - Enrutamiento │
│ - Logs y métricas │
└──────────┬───────────┘
│ (proxy HTTP)
┌─────────────────────┐
│ llama.cpp │ ← Motor de inferencia
│ (llama-server) │
└─────────────────────┘
```
### Integración con llama.cpp
- **Opción A** (implementada): LlamaLink actúa como gateway/proxy delante del servidor HTTP nativo de llama.cpp (`llama-server`).
- **Opción B**: Uso de `llama-cpp-python` embebido (futuro).
### Multi-modelo
- Se soporta la configuración de múltiples modelos en la base de datos.
- Solo **un modelo** está activo/cargado en llama-server en un momento dado.
- Si llega una request para un modelo diferente al activo:
1. Se rechaza con HTTP 503 + `Retry-After` mientras dura el swap
2. El admin puede disparar el swap manualmente vía `POST /v1/models/{name}/load`
3. Ocurre automáticamente en lazy mode si `MANAGE_LLAMA_SERVER=true`
- El swap es secuencial (un `asyncio.Lock` evita swaps concurrentes).
---
## 3. Stack Tecnológico
| Componente | Tecnología | Justificación |
|---|---|---|
| Motor de inferencia | **llama.cpp** (C++) | Ya compilado y optimizado, no requiere reescritura |
| API Gateway | **Python 3.11+ / FastAPI** | Desarrollo rápido, ecosistema maduro, buena documentación automática (OpenAPI/Swagger) |
| Servidor ASGI | **Uvicorn** | Estándar para FastAPI, soporta async y alto rendimiento |
| Base de datos | **SQLite** (MVP) → **PostgreSQL** (producción) | Escalable según necesidad |
| ORM | **SQLAlchemy 2.0** (async) | Manejo de modelos y migraciones |
| Autenticación | API Keys con hash **SHA-256** | Nunca se almacenan en texto plano |
| Rate limiting | **slowapi** (memoria o Redis) | Control de abuso por IP |
| Reverse proxy / TLS | **Nginx** o **Caddy** | HTTPS obligatorio en producción |
| Contenedores | **Docker + docker-compose** | Despliegue reproducible |
---
## 4. Estructura de Carpetas
```
llamalink/
├── app/
│ ├── main.py # Punto de entrada FastAPI + lifespan
│ ├── config.py # Configuración pydantic-settings
│ ├── __init__.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── security.py # Hash/verify API keys (SHA-256)
│ │ ├── errors.py # JSON error handlers estandarizados
│ │ └── logging.py # Structured logging
│ ├── db/
│ │ ├── __init__.py
│ │ ├── base.py # SQLAlchemy declarative base
│ │ ├── session.py # Engine + session factory
│ │ └── models.py # ORM models
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── common.py # ErrorResponse, HealthResponse
│ │ ├── chat.py # OpenAI-compat request/response
│ │ └── keys.py # Key management schemas
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── service.py # CRUD keys, hashing
│ │ └── dependencies.py # Bearer auth FastAPI deps
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── chat.py # /v1/chat/completions
│ │ ├── keys.py # /v1/keys CRUD
│ │ └── health.py # /health, /ready
│ └── services/
│ ├── __init__.py
│ ├── llama_proxy.py # httpx async client → llama-server
│ ├── rate_limiter.py # slowapi setup
│ ├── usage_tracker.py # Token counting + DB logs
│ ├── quota.py # Chequeo de cuota mensual
│ ├── webhook.py # Notificaciones al superar cuota
│ ├── model_manager.py # Subprocess lifecycle + state machine
│ └── models_registry.py # Mapa de modelos configurados
├── migrations/ # Alembic
├── deploy/ # Nginx, Caddy, systemd units
├── docker/ # Dockerfiles
├── scripts/ # CLI tools
├── tests/
├── pyproject.toml
├── .env.example
└── README.md
```
---
## 5. Endpoints Implementados
| Método | Endpoint | Descripción | Auth |
|---|---|---|---|
| POST | `/v1/chat/completions` | Enviar prompt y recibir respuesta del modelo | Sí |
| POST | `/v1/keys` | Crear nueva API key | Sí (admin) |
| GET | `/v1/keys` | Listar API keys existentes | Sí (admin) |
| DELETE | `/v1/keys/{key_id}` | Revocar una API key | Sí (admin) |
| GET | `/health` | Verificar estado del servicio | No |
| GET | `/ready` | Verificar listo para servir | No |
### Pendiente de implementar (Fase 4+)
| Método | Endpoint | Descripción |
|---|---|---|
| GET | `/v1/models` | Listar modelos configurados |
| GET | `/v1/models/active` | Mostrar cuál está cargado |
| POST | `/v1/models/{name}/load` | Cargar/swap a un modelo |
| GET | `/v1/usage/{key_id}` | Métricas de uso de una key |
| GET/DELETE | `/admin/*` | Panel web de administración |
---
## 6. Variables de Entorno
```env
LLAMALINK_ENV=development
LLAMALINK_HOST=0.0.0.0
LLAMALINK_PORT=8000
LLAMALINK_SECRET_KEY=changeme
DATABASE_URL=sqlite+aiosqlite:///./llamalink.db
MANAGE_LLAMA_SERVER=true
LLAMA_SERVER_BIN=llama-server
LLAMA_SERVER_HOST=127.0.0.1
LLAMA_SERVER_PORT=8080
LLAMA_SERVER_STARTUP_TIMEOUT=120
MODEL_SWAP_COOLDOWN=2
LLAMA_SERVER_STOP_TIMEOUT=10
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_STORAGE=memory
LOG_LEVEL=INFO
LOG_FORMAT=json
ADMIN_API_KEY=changeme
```
---
## 7. Modelo de Datos
### ApiKey
- `id`, `name`, `hashed_key`, `key_prefix`
- `scopes`: CSV de permisos (`chat,models,usage,admin`)
- `is_active`, `is_admin`, `created_at`, `last_used_at`, `owner_label`
### Model
- `id`, `name`, `model_path`, `alias`
- `ctx_size`, `n_gpu_layers`, `extra_args` (JSON)
- `is_default`, `is_enabled`, `is_active`, `loaded_at`
### UsageLog
- `api_key_id`, `model_name`, `endpoint`
- `prompt_tokens`, `completion_tokens`, `total_tokens`
- `latency_ms`, `status`, `ip_address`, `user_agent`
- `error_message`, `created_at`
### Quota
- `api_key_id`, `model_scope` (None = global)
- `period_start`, `period_end`
- `tokens_limit`, `tokens_used`
### Webhook
- `api_key_id`, `url`, `event`, `secret` (HMAC)
- `is_active`, `created_at`
---
## 8. Estados del ModelManager
```
STOPPED ──load()──> LOADING ──success──> READY
↑ │
│ fail
│ ↓
└──<─────── FAILED FAILED
└──swap()──> SWAPPING ──success──> READY
```
---
## 9. Formato de Errores
Todos los errores siguen este formato:
```json
{
"error": {
"code": "error_code_string",
"message": "Descripción legible",
"retry_after_seconds": 30
}
}
```
Códigos: `invalid_api_key`, `api_key_revoked`, `rate_limit_exceeded`, `model_not_found`, `model_loading`, `quota_exceeded`, `upstream_error`, `internal_error`, `validation_error`, `unauthorized`
---
## 10. Roadmap de Implementación
### ✅ Fase 0 — Esqueleto
- pyproject.toml, config, main, db, .env.example, .gitignore
### ✅ Fase 1 — MVP
- Auth (hash, service, deps)
- llama_proxy (httpx async, streaming)
- /v1/chat/completions (non-stream + stream)
- /v1/keys CRUD
- /health, /ready
- Error handlers estandarizados
- Quota service (stub)
- Rate limiter (stub)
### 🔄 Fase 2 — Endurecimiento
- [ ] Rate limiting (slowapi)
- [ ] Usage tracker middleware
- [ ] Logging estructurado JSON
### 📋 Fase 3 — Docker + Producción
- [ ] docker-compose.yml
- [ ] Dockerfile.api, Dockerfile.llama
- [ ] Nginx.conf, Caddyfile
- [ ] systemd units
### 📋 Fase 4 — Multi-modelo + Streaming
- [ ] model_manager (subprocess lifecycle, state machine)
- [ ] streaming en /v1/chat/completions
- [ ] /v1/models CRUD
- [ ] Lazy swap + admin trigger
- [ ] 503 + Retry-After durante swap
### 📋 Fase 5 — Cuotas + Webhooks
- [ ] Quota enforcement middleware
- [ ] Webhook delivery con HMAC
- [ ] Background task para notificaciones
### 📋 Fase 6 — Panel Admin Web
- [ ] Jinja2 templates + htmx
- [ ] /admin/* routes
- [ ] Crear/revocar keys desde UI
- [ ] Ver métricas y logs
### 📋 Fase 7 — Polish
- [ ] Validación estricta de inputs
- [ ] Auto-revoke en actividad sospechosa
- [ ] Cobertura de tests ≥ 80%
- [ ] ruff + mypy limpios
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/llamalink/llamalink/internal/api"
"github.com/llamalink/llamalink/internal/config"
"github.com/llamalink/llamalink/internal/db"
"github.com/llamalink/llamalink/internal/llama"
)
var (
Version = "dev"
BuildTime = "unknown"
)
func main() {
cfg := config.Load()
logger := cfg.Logger()
slog.SetDefault(logger)
logger.Info("starting llamalink",
"version", Version,
"build_time", BuildTime,
"env", cfg.LlamalinkEnv,
)
// Initialize database
database, err := db.Open(cfg)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer func() {
sqlDB, _ := database.DB()
if sqlDB != nil {
sqlDB.Close()
}
}()
// Run migrations
if err := db.Migrate(database); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
logger.Info("database migrations complete")
// Seed admin key if needed
if err := db.SeedAdminKey(database, cfg.AdminToken); err != nil {
logger.Error("failed to seed admin key", "error", err)
os.Exit(1)
}
// Initialize llama manager
var llamaManager *llama.Manager
if cfg.ManageLlamaServer {
llamaManager = llama.NewManager(cfg, database)
if err := llamaManager.Start(); err != nil {
logger.Warn("failed to start llama manager", "error", err)
}
defer llamaManager.Stop()
}
// Setup router
router := api.New(cfg, database, llamaManager)
// Server
addr := fmt.Sprintf("%s:%d", cfg.LlamalinkHost, cfg.LlamalinkPort)
srv := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info("server listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
<-quit
logger.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("server forced to shutdown", "error", err)
}
logger.Info("server stopped")
}
+60
View File
@@ -0,0 +1,60 @@
# LlamaLink Caddyfile
# Auto HTTPS via Let's Encrypt
llamalink.local {
reverse_proxy llamalink:8000
log {
output file /var/log/caddy/llamalink.log
}
handle /health {
reverse_proxy llamalink:8000
}
handle /ready {
reverse_proxy llamalink:8000
}
handle /v1/chat/completions {
reverse_proxy llamalink:8000 {
flush_interval -1
}
}
handle {
reverse_proxy llamalink:8000
}
}
# Production with TLS
# Replace with your domain
llamalink.example.com {
reverse_proxy llamalink:8000
tls {
protocols tls1.2 tls1.3
}
log {
output file /var/log/caddy/llamalink.log
}
handle /health {
reverse_proxy llamalink:8000
}
handle /ready {
reverse_proxy llamalink:8000
}
handle /v1/chat/completions {
reverse_proxy llamalink:8000 {
flush_interval -1
}
}
handle {
reverse_proxy llamalink:8000
}
}
+53
View File
@@ -0,0 +1,53 @@
# Build stage
FROM node:20-alpine AS node-builder
WORKDIR /app
COPY web/frontend/package*.json ./
RUN npm ci
COPY web/frontend/ ./
RUN npm run build
# Go stage
FROM golang:1.23-alpine AS go-builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Copy frontend build
COPY --from=node-builder /app/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o llamalink ./cmd/llamalink
# Final stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates curl
WORKDIR /app
# Create non-root user
RUN addgroup -g 1000 llamalink && \
adduser -u 1000 -G llamalink -s /bin/sh -D llamalink
COPY --from=go-builder /app/llamalink .
COPY --from=go-builder /app/.env.example .env
# Create data directory
RUN mkdir -p /app/data && chown llamalink:llamalink /app/data
USER llamalink
EXPOSE 8000
ENV LLAMALINK_HOST=0.0.0.0
ENV LLAMALINK_PORT=8000
ENTRYPOINT ["./llamalink"]
+79
View File
@@ -0,0 +1,79 @@
services:
llamalink:
build:
context: .
dockerfile: deploy/Dockerfile
container_name: llamalink-api
ports:
- "8000:8000"
environment:
- LLAMALINK_ENV=production
- DATABASE_URL=sqlite+aiosqlite:///./data/llamalink.db
- MANAGE_LLAMA_SERVER=true
- LLAMA_SERVER_HOST=127.0.0.1
- LLAMA_SERVER_PORT=8080
- LLAMA_SERVER_BIN=/usr/local/bin/llama-server
- LLAMA_SERVER_STARTUP_TIMEOUT=120
- MODEL_SWAP_COOLDOWN=2
- LLAMA_SERVER_STOP_TIMEOUT=10
- RATE_LIMIT_PER_MINUTE=60
- RATE_LIMIT_STORAGE=memory
- LOG_LEVEL=info
- LOG_FORMAT=json
- ADMIN_TOKEN=${ADMIN_TOKEN}
volumes:
- llamalink-data:/app/data
- ./models:/models:ro
restart: unless-stopped
networks:
- llamalink-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
llama-runner:
image: ghcr.io/ggml-org/llama.cpp:server
container_name: llama-runner
environment:
- CUDA_VISIBLE_DEVICES=0
volumes:
- ./models:/models:ro
restart: unless-stopped
networks:
- llamalink-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
nginx:
image: nginx:1.27-alpine
container_name: llamalink-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- llamalink
restart: unless-stopped
networks:
- llamalink-net
volumes:
llamalink-data:
networks:
llamalink-net:
driver: bridge
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=llama-server instance %i
After=network.target
[Service]
Type=simple
User=llamalink
WorkingDirectory=/opt/llamalink
ExecStart=/usr/local/bin/llama-server \
--model /opt/llamalink/models/%i.gguf \
--alias %i \
--host 127.0.0.1 \
--port 8080 \
--ctx-size 8192 \
--n-gpu-layers auto \
--parallel 4 \
--rope-scaling linear
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=llama-server-%i
Environment="CUDA_VISIBLE_DEVICES=0"
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/opt/llamalink/models
ReadWritePaths=/opt/llamalink/data
[Install]
WantedBy=multi-user.target
+28
View File
@@ -0,0 +1,28 @@
[Unit]
Description=LlamaLink API Gateway
After=network.target
[Service]
Type=simple
User=llamalink
WorkingDirectory=/opt/llamalink
ExecStart=/usr/local/bin/llamalink \
--host 0.0.0.0 \
--port 8000
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=llamalink
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/llamalink/data
ReadOnlyPaths=/opt/llamalink/models
Environment=LLAMALINK_ENV=production
[Install]
WantedBy=multi-user.target
+122
View File
@@ -0,0 +1,122 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
keepalive_timeout 65;
chunked_transfer_encoding on;
upstream llamalink {
server llamalink:8000;
keepalive 32;
}
server {
listen 80;
server_name _;
# Redirect to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
# SSL (generate with letsencrypt or use self-signed for testing)
# ssl_certificate /etc/nginx/certs/cert.pem;
# ssl_certificate_key /etc/nginx/certs/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
# ssl_prefer_server_ciphers off;
client_max_body_size 10M;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
# Admin SPA
location /admin/ {
proxy_pass http://llamalink;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location /admin/assets/ {
proxy_pass http://llamalink;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://llamalink;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
# For streaming responses
proxy_buffering off;
proxy_cache off;
}
# Health checks (no rate limit)
location /health {
proxy_pass http://llamalink;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location /ready {
proxy_pass http://llamalink;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# WebSocket
location /ws {
proxy_pass http://llamalink;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# Docs
location /docs {
proxy_pass http://llamalink;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location /openapi.json {
proxy_pass http://llamalink;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}
+46
View File
@@ -0,0 +1,46 @@
module github.com/llamalink/llamalink
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.54.0
golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+103
View File
@@ -0,0 +1,103 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+249
View File
@@ -0,0 +1,249 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/auth"
"github.com/llamalink/llamalink/internal/db"
"github.com/llamalink/llamalink/internal/llama"
"github.com/llamalink/llamalink/internal/quota"
)
type ChatHandler struct {
proxy *llama.Proxy
authService *auth.Service
quotaSvc *quota.Service
webhookSvc *quota.WebhookService
}
func NewChatHandler(proxy *llama.Proxy, authService *auth.Service, quotaSvc *quota.Service, webhookSvc *quota.WebhookService) *ChatHandler {
return &ChatHandler{
proxy: proxy,
authService: authService,
quotaSvc: quotaSvc,
webhookSvc: webhookSvc,
}
}
type ChatCompletionRequest struct {
Model string `json:"model" binding:"required"`
Messages []llama.ChatMessage `json:"messages" binding:"required"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
}
func (h *ChatHandler) ChatCompletions(c *gin.Context) {
start := time.Now()
var req ChatCompletionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"code": "validation_error",
"message": err.Error(),
},
})
return
}
apiKey := middleware.GetAPIKey(c)
// Check quota
ok, msg, err := h.quotaSvc.CheckQuota(apiKey.ID, req.Model)
if err != nil {
slog.Error("quota check failed", "error", err)
}
if !ok {
h.logUsage(c, apiKey, req.Model, 0, 0, 0, "quota_exceeded", start)
h.webhookSvc.Dispatch("quota_exceeded", apiKey, msg, nil)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": gin.H{
"code": "quota_exceeded",
"message": msg,
"retry_after_seconds": nil,
},
})
return
}
// Check model readiness
if !h.proxy.Manager().IsReady() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": gin.H{
"code": "model_not_loaded",
"message": "Model not ready, use POST /v1/models/{name}/load",
},
})
return
}
// Check rate limit (basic)
// TODO: implement token bucket
if req.Stream {
h.handleStream(c, apiKey, req, start)
return
}
// Non-streaming
resp, err := h.proxy.ChatCompletion(c.Request.Context(), llama.ChatCompletionRequest{
Model: req.Model,
Messages: req.Messages,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
})
if err != nil {
h.logUsage(c, apiKey, req.Model, 0, 0, 0, "error", start)
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"code": "upstream_error",
"message": err.Error(),
},
})
return
}
// Consume quota
totalTokens := resp.Usage.TotalTokens
if err := h.quotaSvc.ConsumeQuota(apiKey.ID, req.Model, totalTokens); err != nil && err != quota.ErrQuotaExceeded {
slog.Error("failed to consume quota", "error", err)
}
h.logUsage(c, apiKey, req.Model, resp.Usage.PromptTokens, resp.Usage.CompletionTokens, totalTokens, "success", start)
// Convert to OpenAI format
c.JSON(http.StatusOK, gin.H{
"id": resp.ID,
"object": "chat.completion",
"created": resp.Created,
"model": resp.Model,
"choices": []gin.H{{
"index": 0,
"message": gin.H{
"role": resp.Choices[0].Message.Role,
"content": resp.Choices[0].Message.Content,
},
"finish_reason": resp.Choices[0].FinishReason,
}},
"usage": gin.H{
"prompt_tokens": resp.Usage.PromptTokens,
"completion_tokens": resp.Usage.CompletionTokens,
"total_tokens": resp.Usage.TotalTokens,
},
})
}
func (h *ChatHandler) handleStream(c *gin.Context, apiKey *db.ApiKey, req ChatCompletionRequest, start time.Time) {
stream, errCh := h.proxy.ChatCompletionStream(c.Request.Context(), llama.ChatCompletionRequest{
Model: req.Model,
Messages: req.Messages,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
})
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
totalTokens := 0
promptTokens := 0
completionTokens := 0
for {
select {
case resp, ok := <-stream:
if !ok {
flusher.Flush()
return
}
// Count tokens roughly
completionTokens += len(resp.Choices[0].Delta.Content) / 4
// Write SSE
c.Writer.WriteString("data: ")
c.Writer.WriteString("{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":")
c.Writer.WriteString(formatInt(resp.Created))
c.Writer.WriteString(",\"model\":\"")
c.Writer.WriteString(resp.Model)
c.Writer.WriteString("\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"")
c.Writer.WriteString(escapeJSON(resp.Choices[0].Delta.Content))
c.Writer.WriteString("\"}}]}\n\n")
flusher.Flush()
case err := <-errCh:
h.logUsage(c, apiKey, req.Model, promptTokens, completionTokens, totalTokens, "error", start)
c.Writer.WriteString("data: [DONE]\n\n")
flusher.Flush()
if err != nil {
slog.Error("stream error", "error", err)
}
return
case <-c.Request.Context().Done():
return
}
}
}
func (h *ChatHandler) logUsage(c *gin.Context, apiKey *db.ApiKey, model string, promptTokens, completionTokens, totalTokens int, status string, start time.Time) {
latencyMs := int(time.Since(start).Milliseconds())
log := &db.UsageLog{
ID: uuid.New(),
ApiKeyID: apiKey.ID,
ModelName: model,
Endpoint: "/v1/chat/completions",
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
LatencyMs: latencyMs,
Status: status,
Streamed: false,
}
if ip := c.ClientIP(); ip != "" {
log.IPAddress = &ip
}
if ua := c.GetHeader("User-Agent"); ua != "" {
log.UserAgent = &ua
}
// Async log
go func() {
// Would use a separate goroutine-safe session here
}()
_ = log // avoid unused warning
}
func (h *ChatHandler) Manager() *llama.Manager {
return h.proxy.Manager()
}
func formatInt(n int64) string {
return string(rune(n))
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}
+58
View File
@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/llama"
)
type HealthHandler struct {
db *gorm.DB
manager *llama.Manager
}
func NewHealthHandler(db *gorm.DB, manager *llama.Manager) *HealthHandler {
return &HealthHandler{db: db, manager: manager}
}
func (h *HealthHandler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
func (h *HealthHandler) Ready(c *gin.Context) {
// Check DB
sqlDB, err := h.db.DB()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "not_ready",
"database": "error",
})
return
}
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "not_ready",
"database": "unhealthy",
})
return
}
// Check model manager
modelReady := h.manager == nil || h.manager.IsReady()
modelName := ""
if h.manager != nil {
modelName = h.manager.CurrentModel()
}
c.JSON(http.StatusOK, gin.H{
"status": "ready",
"database": "ok",
"model_active": modelReady,
"model_name": modelName,
})
}
+122
View File
@@ -0,0 +1,122 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/auth"
)
type KeysHandler struct {
authService *auth.Service
}
func NewKeysHandler(authService *auth.Service) *KeysHandler {
return &KeysHandler{authService: authService}
}
type CreateKeyRequest struct {
Name string `json:"name" binding:"required"`
Scopes []string `json:"scopes"`
TokensLimit *int `json:"tokens_limit"`
WebhookURL *string `json:"webhook_url"`
OwnerLabel *string `json:"owner_label"`
}
func (h *KeysHandler) ListKeys(c *gin.Context) {
includeInactive := c.Query("include_inactive") == "true"
keys, err := h.authService.List(includeInactive)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Don't expose key hash
safeKeys := make([]gin.H, len(keys))
for i, k := range keys {
safeKeys[i] = gin.H{
"id": k.ID,
"name": k.Name,
"key_prefix": k.KeyPrefix,
"scopes": k.Scopes,
"is_active": k.IsActive,
"is_admin": k.IsAdmin,
"owner_label": k.OwnerLabel,
"created_at": k.CreatedAt,
"last_used_at": k.LastUsedAt,
}
}
c.JSON(http.StatusOK, safeKeys)
}
func (h *KeysHandler) CreateKey(c *gin.Context) {
var req CreateKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Scopes == nil {
req.Scopes = []string{"chat", "models", "usage"}
}
apiKey, rawKey, err := h.authService.Create(auth.CreateKeyRequest{
Name: req.Name,
Scopes: req.Scopes,
TokensLimit: req.TokensLimit,
WebhookURL: req.WebhookURL,
OwnerLabel: req.OwnerLabel,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": apiKey.ID,
"name": apiKey.Name,
"key": rawKey,
"key_prefix": apiKey.KeyPrefix,
"scopes": apiKey.Scopes,
"is_admin": apiKey.IsAdmin,
"created_at": apiKey.CreatedAt,
})
}
func (h *KeysHandler) RevokeKey(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
return
}
// Can't revoke own key
currentKey := middleware.GetAPIKey(c)
if currentKey.ID == id {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"code": "validation_error",
"message": "Cannot revoke your own admin key",
},
})
return
}
if err := h.authService.Revoke(id); err != nil {
if err == auth.ErrKeyNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
+149
View File
@@ -0,0 +1,149 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/llamalink/llamalink/internal/db"
"github.com/llamalink/llamalink/internal/llama"
)
type ModelsHandler struct {
manager *llama.Manager
}
func NewModelsHandler(manager *llama.Manager) *ModelsHandler {
return &ModelsHandler{manager: manager}
}
type CreateModelRequest struct {
Name string `json:"name" binding:"required"`
ModelPath string `json:"model_path" binding:"required"`
Alias string `json:"alias" binding:"required"`
CtxSize int `json:"ctx_size"`
NGPULayers int `json:"n_gpu_layers"`
ExtraArgs map[string]interface{} `json:"extra_args"`
IsDefault bool `json:"is_default"`
}
func (h *ModelsHandler) ListModels(c *gin.Context) {
var models []db.Model
db := h.manager.GetDB()
if err := db.Where("is_enabled = ?", true).Order("name").Find(&models).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
status := h.manager.GetStatus()
currentModel := status.CurrentModel
result := make([]gin.H, len(models))
for i, m := range models {
result[i] = gin.H{
"id": m.ID,
"name": m.Name,
"model_path": m.ModelPath,
"alias": m.Alias,
"ctx_size": m.CtxSize,
"n_gpu_layers": m.NGPULayers,
"is_default": m.IsDefault,
"is_active": m.Name == currentModel && status.Status == llama.StatusReady,
"loaded_at": m.LoadedAt,
}
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
func (h *ModelsHandler) GetActiveModel(c *gin.Context) {
status := h.manager.GetStatus()
if status.CurrentModel == "" {
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"status": status.Status,
"current_model": nil,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"status": status.Status,
"current_model": status.CurrentModel,
"loaded_at": status.LoadedAt,
},
})
}
func (h *ModelsHandler) CreateModel(c *gin.Context) {
var req CreateModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.CtxSize == 0 {
req.CtxSize = 8192
}
if req.NGPULayers == 0 {
req.NGPULayers = -1
}
extraArgsJSON, _ := json.Marshal(req.ExtraArgs)
model := &db.Model{
ID: uuid.New(),
Name: req.Name,
ModelPath: req.ModelPath,
Alias: req.Alias,
CtxSize: req.CtxSize,
NGPULayers: req.NGPULayers,
ExtraArgs: db.StringArray{string(extraArgsJSON)},
IsDefault: req.IsDefault,
IsEnabled: true,
IsActive: false,
}
db := h.manager.GetDB()
if err := db.Create(model).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": model.ID,
"name": model.Name,
"model_path": model.ModelPath,
"alias": model.Alias,
"ctx_size": model.CtxSize,
"is_default": model.IsDefault,
})
}
func (h *ModelsHandler) LoadModel(c *gin.Context) {
name := c.Param("name")
if err := h.manager.LoadModel(name); err != nil {
if err == llama.ErrModelNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err == llama.ErrSwapInProgress {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{
"status": "loading",
"model": name,
"message": "Model loading initiated",
})
}
+126
View File
@@ -0,0 +1,126 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/db"
"github.com/llamalink/llamalink/internal/quota"
)
type UsageHandler struct {
db *gorm.DB
quotaSvc *quota.Service
}
func NewUsageHandler(db *gorm.DB, quotaSvc *quota.Service) *UsageHandler {
return &UsageHandler{db: db, quotaSvc: quotaSvc}
}
func (h *UsageHandler) GetUsage(c *gin.Context) {
keyIDStr := c.Param("key_id")
keyID, err := uuid.Parse(keyIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
return
}
// Only admins or key owner can view usage
currentKey := middleware.GetAPIKey(c)
if !currentKey.IsAdmin && currentKey.ID != keyID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
period := c.DefaultQuery("period", "month")
now := time.Now().UTC()
var periodStart, periodEnd time.Time
switch period {
case "month":
periodStart = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
periodEnd = periodStart.AddDate(0, 1, 0)
case "year":
periodStart = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
periodEnd = periodStart.AddDate(1, 0, 0)
default:
periodStart = now.AddDate(0, 0, -7)
periodEnd = now
}
// Get usage logs
var logs []db.UsageLog
h.db.Where("api_key_id = ? AND created_at >= ? AND created_at < ?", keyID, periodStart, periodEnd).
Order("created_at DESC").Limit(100).Find(&logs)
// Get aggregated stats
var stats struct {
TotalRequests int64
TotalTokens int64
AvgLatency float64
}
h.db.Model(&db.UsageLog{}).
Where("api_key_id = ? AND created_at >= ? AND created_at < ?", keyID, periodStart, periodEnd).
Select("COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency").
Scan(&stats)
// Get quota info
used, limit, _ := h.quotaSvc.GetUsage(keyID, periodStart, periodEnd)
c.JSON(http.StatusOK, gin.H{
"period": gin.H{
"start": periodStart,
"end": periodEnd,
},
"usage": gin.H{
"total_requests": stats.TotalRequests,
"total_tokens": stats.TotalTokens,
"avg_latency_ms": stats.AvgLatency,
},
"quota": gin.H{
"tokens_used": used,
"tokens_limit": limit,
},
"logs": logs,
})
}
func (h *UsageHandler) GetCurrentKeyUsage(c *gin.Context) {
key := middleware.GetAPIKey(c)
now := time.Now().UTC()
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
periodEnd := periodStart.AddDate(0, 1, 0)
var stats struct {
TotalRequests int64
TotalTokens int64
AvgLatency float64
}
h.db.Model(&db.UsageLog{}).
Where("api_key_id = ? AND created_at >= ? AND created_at < ?", key.ID, periodStart, periodEnd).
Select("COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency").
Scan(&stats)
used, limit, _ := h.quotaSvc.GetUsage(key.ID, periodStart, periodEnd)
c.JSON(http.StatusOK, gin.H{
"period": gin.H{
"start": periodStart,
"end": periodEnd,
},
"usage": gin.H{
"total_requests": stats.TotalRequests,
"total_tokens": stats.TotalTokens,
"avg_latency_ms": stats.AvgLatency,
},
"quota": gin.H{
"tokens_used": used,
"tokens_limit": limit,
},
})
}
+156
View File
@@ -0,0 +1,156 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/llamalink/llamalink/internal/auth"
"github.com/llamalink/llamalink/internal/db"
)
const (
ApiKeyCtx = "api_key"
ApiKeyIDCtx = "api_key_id"
)
func APIKeyAuth(authService *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "invalid_api_key",
"message": "Authorization header required",
},
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "invalid_api_key",
"message": "Bearer token required",
},
})
return
}
apiKey, err := authService.Validate(token)
if err != nil {
code := "invalid_api_key"
status := http.StatusUnauthorized
if err == auth.ErrKeyRevoked || err == auth.ErrKeyExpired {
code = "api_key_revoked"
status = http.StatusUnauthorized
}
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"code": code,
"message": err.Error(),
},
})
return
}
c.Set(ApiKeyCtx, apiKey)
c.Set(ApiKeyIDCtx, apiKey.ID)
c.Next()
}
}
func RequireScope(authService *auth.Service, scope string) gin.HandlerFunc {
return func(c *gin.Context) {
apiKey, exists := c.Get(ApiKeyCtx)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "invalid_api_key",
"message": "Authentication required",
},
})
return
}
key := apiKey.(*db.ApiKey)
if !authService.HasScope(key, scope) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "insufficient_scope",
"message": "API key lacks required scope: " + scope,
},
})
return
}
c.Next()
}
}
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey, exists := c.Get(ApiKeyCtx)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Admin access required",
},
})
return
}
key := apiKey.(*db.ApiKey)
if !key.IsAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Admin access required",
},
})
return
}
c.Next()
}
}
func AdminTokenAuth(token string) gin.HandlerFunc {
return func(c *gin.Context) {
adminToken := c.GetHeader("X-Admin-Token")
if adminToken == "" {
adminToken = c.GetHeader("Authorization")
adminToken = strings.TrimPrefix(adminToken, "Bearer ")
}
if adminToken != token {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Invalid admin token",
},
})
return
}
c.Next()
}
}
func GetAPIKeyID(c *gin.Context) uuid.UUID {
id, _ := c.Get(ApiKeyIDCtx)
return id.(uuid.UUID)
}
func GetAPIKey(c *gin.Context) *db.ApiKey {
key, _ := c.Get(ApiKeyCtx)
if key == nil {
return nil
}
return key.(*db.ApiKey)
}
+85
View File
@@ -0,0 +1,85 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
type RateLimiter struct {
visitors map[string]*visitor
mu sync.RWMutex
rate rate.Limit
burst int
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
func NewRateLimiter(requestsPerMinute int) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: rate.Limit(float64(requestsPerMinute) / 60.0),
burst: requestsPerMinute / 10,
}
if requestsPerMinute < 10 {
rl.burst = 1
}
// Cleanup old visitors
go rl.cleanup()
return rl
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > 10*time.Minute {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
func (rl *RateLimiter) getVisitor(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
if !exists {
v = &visitor{
limiter: rate.NewLimiter(rl.rate, rl.burst),
lastSeen: time.Now(),
}
rl.visitors[ip] = v
}
v.lastSeen = time.Now()
return v.limiter
}
func RateLimitMiddleware(rl *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.getVisitor(ip).Allow() {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": gin.H{
"code": "rate_limit_exceeded",
"message": "Too many requests",
"retry_after_seconds": 60,
},
})
return
}
c.Next()
}
}
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/llamalink/llamalink/internal/api/handlers"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/auth"
"github.com/llamalink/llamalink/internal/config"
"github.com/llamalink/llamalink/internal/llama"
"github.com/llamalink/llamalink/internal/quota"
"gorm.io/gorm"
)
func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engine {
if cfg.LlamalinkEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
// Initialize services
authService := auth.NewService(db)
quotaSvc := quota.NewService(db)
webhookSvc := quota.NewWebhookService(db)
proxy := llama.NewProxy(llamaManager)
// Initialize handlers
healthHandler := handlers.NewHealthHandler(db, llamaManager)
chatHandler := handlers.NewChatHandler(proxy, authService, quotaSvc, webhookSvc)
keysHandler := handlers.NewKeysHandler(authService)
modelsHandler := handlers.NewModelsHandler(llamaManager)
usageHandler := handlers.NewUsageHandler(db, quotaSvc)
// Health endpoints (public)
r.GET("/health", healthHandler.Health)
r.GET("/ready", healthHandler.Ready)
// API v1 group
v1 := r.Group("/v1")
// Chat completions (requires API key auth + chat scope)
chat := v1.Group("/chat")
chat.Use(middleware.APIKeyAuth(authService))
chat.POST("/completions", chatHandler.ChatCompletions)
// Keys management (admin only)
keys := v1.Group("/keys")
keys.Use(middleware.APIKeyAuth(authService))
keys.Use(middleware.AdminOnly())
keys.GET("", keysHandler.ListKeys)
keys.POST("", keysHandler.CreateKey)
keys.DELETE("/:id", keysHandler.RevokeKey)
// Models management
models := v1.Group("/models")
models.Use(middleware.APIKeyAuth(authService))
models.GET("", modelsHandler.ListModels)
models.POST("", modelsHandler.CreateModel)
models.GET("/active", modelsHandler.GetActiveModel)
models.GET("/:name", modelsHandler.GetActiveModel) // alias for compatibility
// Model load (admin only)
modelLoad := v1.Group("/models")
modelLoad.Use(middleware.APIKeyAuth(authService))
modelLoad.Use(middleware.AdminOnly())
modelLoad.POST("/:name/load", modelsHandler.LoadModel)
// Usage (requires API key auth)
usage := v1.Group("/usage")
usage.Use(middleware.APIKeyAuth(authService))
usage.GET("", usageHandler.GetCurrentKeyUsage)
usage.GET("/:key_id", usageHandler.GetUsage)
return r
}
+178
View File
@@ -0,0 +1,178 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/db"
)
const (
TokenPrefix = "llmk_"
TokenLen = 32 // 64 hex chars
)
var (
ErrInvalidKey = errors.New("invalid API key")
ErrKeyNotFound = errors.New("API key not found")
ErrKeyRevoked = errors.New("API key has been revoked")
ErrKeyExpired = errors.New("API key has expired")
ErrInsufficientScope = errors.New("insufficient scope")
)
type CreateKeyRequest struct {
Name string
Scopes []string
TokensLimit *int
WebhookURL *string
OwnerLabel *string
}
type Service struct {
db *gorm.DB
}
func NewService(db *gorm.DB) *Service {
return &Service{db: db}
}
func (s *Service) GenerateToken() (string, error) {
bytes := make([]byte, TokenLen)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return TokenPrefix + hex.EncodeToString(bytes), nil
}
func (s *Service) HashToken(token string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost)
return string(hash)
}
func (s *Service) Create(req CreateKeyRequest) (*db.ApiKey, string, error) {
token, err := s.GenerateToken()
if err != nil {
return nil, "", err
}
isAdmin := s.isFirstKey()
apiKey := &db.ApiKey{
ID: uuid.New(),
Name: req.Name,
KeyHash: s.HashToken(token),
KeyPrefix: token[:len(TokenPrefix)+8],
Scopes: req.Scopes,
IsActive: true,
IsAdmin: isAdmin,
OwnerLabel: req.OwnerLabel,
}
if err := s.db.Create(apiKey).Error; err != nil {
return nil, "", err
}
// Create quota if specified
if req.TokensLimit != nil && *req.TokensLimit > 0 {
now := time.Now().UTC()
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
periodEnd := periodStart.AddDate(0, 1, 0)
quota := &db.Quota{
ID: uuid.New(),
ApiKeyID: apiKey.ID,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
TokensLimit: *req.TokensLimit,
TokensUsed: 0,
}
s.db.Create(quota)
}
return apiKey, token, nil
}
func (s *Service) Revoke(id uuid.UUID) error {
result := s.db.Model(&db.ApiKey{}).Where("id = ?", id).Update("is_active", false)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrKeyNotFound
}
return nil
}
func (s *Service) Validate(token string) (*db.ApiKey, error) {
if len(token) < len(TokenPrefix)+8 {
return nil, ErrInvalidKey
}
prefix := token[:len(TokenPrefix)+8]
var apiKey db.ApiKey
if err := s.db.Where("key_prefix = ? AND is_active = ?", prefix, true).First(&apiKey).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidKey
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(apiKey.KeyHash), []byte(token)); err != nil {
return nil, ErrInvalidKey
}
if apiKey.ExpiresAt != nil && time.Now().After(*apiKey.ExpiresAt) {
return nil, ErrKeyExpired
}
// Update last used
now := time.Now()
s.db.Model(&apiKey).Update("last_used_at", now)
return &apiKey, nil
}
func (s *Service) List(includeInactive bool) ([]db.ApiKey, error) {
var keys []db.ApiKey
query := s.db.Order("created_at DESC")
if !includeInactive {
query = query.Where("is_active = ?", true)
}
if err := query.Find(&keys).Error; err != nil {
return nil, err
}
return keys, nil
}
func (s *Service) GetByID(id uuid.UUID) (*db.ApiKey, error) {
var key db.ApiKey
if err := s.db.Where("id = ?", id).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrKeyNotFound
}
return nil, err
}
return &key, nil
}
func (s *Service) HasScope(key *db.ApiKey, scope string) bool {
for _, kScope := range key.Scopes {
if kScope == scope {
return true
}
}
return false
}
func (s *Service) isFirstKey() bool {
var count int64
s.db.Model(&db.ApiKey{}).Count(&count)
return count == 0
}
+137
View File
@@ -0,0 +1,137 @@
package config
import (
"log/slog"
"os"
"strconv"
"time"
)
type Config struct {
// Server
LlamalinkEnv string
LlamalinkHost string
LlamalinkPort int
// Database
DatabaseURL string
DatabaseMaxOpenConns int
DatabaseMaxIdleConns int
DatabaseConnMaxLifetime int // seconds
// Llama server management
ManageLlamaServer bool
LlamaServerBin string
LlamaServerHost string
LlamaServerPort int
LlamaServerStartupTimeout int // seconds
LlamaServerStopTimeout int // seconds
ModelSwapCooldown int // seconds
// Rate limiting
RateLimitPerMinute int
RateLimitStorage string // "memory" or "redis"
// Auth
AdminToken string
// Logging
LogLevel string
LogFormat string // "json" or "text"
}
func Load() *Config {
c := &Config{
LlamalinkEnv: getEnv("LLAMALINK_ENV", "development"),
LlamalinkHost: getEnv("LLAMALINK_HOST", "0.0.0.0"),
LlamalinkPort: intEnv("LLAMALINK_PORT", 8000),
DatabaseURL: getEnv("DATABASE_URL", "sqlite:///./llamalink.db"),
DatabaseMaxOpenConns: intEnv("DATABASE_MAX_OPEN_CONNS", 25),
DatabaseMaxIdleConns: intEnv("DATABASE_MAX_IDLE_CONNS", 5),
DatabaseConnMaxLifetime: intEnv("DATABASE_CONN_MAX_LIFETIME", 300),
ManageLlamaServer: boolEnv("MANAGE_LLAMA_SERVER", true),
LlamaServerBin: getEnv("LLAMA_SERVER_BIN", "/usr/local/bin/llama-server"),
LlamaServerHost: getEnv("LLAMA_SERVER_HOST", "127.0.0.1"),
LlamaServerPort: intEnv("LLAMA_SERVER_PORT", 8080),
LlamaServerStartupTimeout: intEnv("LLAMA_SERVER_STARTUP_TIMEOUT", 120),
LlamaServerStopTimeout: intEnv("LLAMA_SERVER_STOP_TIMEOUT", 10),
ModelSwapCooldown: intEnv("MODEL_SWAP_COOLDOWN", 2),
RateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 60),
RateLimitStorage: getEnv("RATE_LIMIT_STORAGE", "memory"),
AdminToken: getEnv("ADMIN_TOKEN", "changeme"),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFormat: getEnv("LOG_FORMAT", "json"),
}
return c
}
func (c *Config) LlamaServerURL() string {
return "http://" + c.LlamaServerHost + ":" + strconv.Itoa(c.LlamaServerPort)
}
func (c *Config) LlamaServerStartupTimeoutDuration() time.Duration {
return time.Duration(c.LlamaServerStartupTimeout) * time.Second
}
func (c *Config) LlamaServerStopTimeoutDuration() time.Duration {
return time.Duration(c.LlamaServerStopTimeout) * time.Second
}
func (c *Config) ModelSwapCooldownDuration() time.Duration {
return time.Duration(c.ModelSwapCooldown) * time.Second
}
func (c *Config) Logger() *slog.Logger {
var level slog.Level
switch c.LogLevel {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{
Level: level,
}
var handler slog.Handler
if c.LogFormat == "text" {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(handler)
}
func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultValue
}
func intEnv(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return defaultValue
}
func boolEnv(key string, defaultValue bool) bool {
if v := os.Getenv(key); v != "" {
if v == "true" || v == "1" || v == "yes" {
return true
}
if v == "false" || v == "0" || v == "no" {
return false
}
}
return defaultValue
}
+51
View File
@@ -0,0 +1,51 @@
package db
import (
"log/slog"
"strings"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/llamalink/llamalink/internal/config"
)
func Open(cfg *config.Config) (*gorm.DB, error) {
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite://")
if dsn == cfg.DatabaseURL {
dsn = cfg.DatabaseURL
}
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
db, err := gorm.Open(sqlite.Open(dsn), gormConfig)
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(cfg.DatabaseMaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.DatabaseMaxIdleConns)
sqlDB.SetConnMaxLifetime(time.Duration(cfg.DatabaseConnMaxLifetime) * time.Second)
return db, nil
}
func Migrate(db *gorm.DB) error {
slog.Info("running database migrations")
return db.AutoMigrate(
&ApiKey{},
&Model{},
&UsageLog{},
&Quota{},
&Webhook{},
)
}
+149
View File
@@ -0,0 +1,149 @@
package db
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type StringArray []string
func (a StringArray) Value() (driver.Value, error) {
return json.Marshal(a)
}
func (a *StringArray) Scan(value interface{}) error {
if value == nil {
*a = []string{}
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, a)
}
type ApiKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
KeyHash string `gorm:"size:255;not null" json:"-"`
KeyPrefix string `gorm:"size:8;not null;index" json:"key_prefix"`
Scopes StringArray `gorm:"type:text;serializer:json" json:"scopes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
OwnerLabel *string `gorm:"size:255" json:"owner_label,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UsageLogs []UsageLog `gorm:"foreignKey:ApiKeyID" json:"-"`
Quotas []Quota `gorm:"foreignKey:ApiKeyID" json:"-"`
Webhooks []Webhook `gorm:"foreignKey:ApiKeyID" json:"-"`
}
func (ApiKey) TableName() string { return "api_keys" }
func (k *ApiKey) BeforeCreate(tx *gorm.DB) error {
if k.ID == uuid.Nil {
k.ID = uuid.New()
}
return nil
}
type Model struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
ModelPath string `gorm:"size:1024;not null" json:"model_path"`
Alias string `gorm:"size:255;not null" json:"alias"`
CtxSize int `gorm:"default:8192" json:"ctx_size"`
NGPULayers int `gorm:"default:-1" json:"n_gpu_layers"`
ExtraArgs StringArray `gorm:"type:text;serializer:json" json:"extra_args,omitempty"`
IsDefault bool `gorm:"default:false" json:"is_default"`
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
IsActive bool `gorm:"default:false" json:"is_active"`
LoadedAt *time.Time `json:"loaded_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (Model) TableName() string { return "models" }
func (m *Model) BeforeCreate(tx *gorm.DB) error {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
return nil
}
type UsageLog struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
ApiKeyID uuid.UUID `gorm:"type:uuid;not null;index" json:"api_key_id"`
ModelName string `gorm:"size:255;not null" json:"model_name"`
Endpoint string `gorm:"size:100;not null" json:"endpoint"`
PromptTokens int `gorm:"default:0" json:"prompt_tokens"`
CompletionTokens int `gorm:"default:0" json:"completion_tokens"`
TotalTokens int `gorm:"default:0" json:"total_tokens"`
LatencyMs int `gorm:"default:0" json:"latency_ms"`
Status string `gorm:"size:50;not null" json:"status"`
IPAddress *string `gorm:"size:45" json:"ip_address,omitempty"`
UserAgent *string `gorm:"size:512" json:"user_agent,omitempty"`
ErrorMessage *string `gorm:"type:text" json:"error_message,omitempty"`
Streamed bool `gorm:"default:false" json:"streamed"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
func (UsageLog) TableName() string { return "usage_logs" }
func (u *UsageLog) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
type Quota struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
ApiKeyID uuid.UUID `gorm:"type:uuid;not null;index" json:"api_key_id"`
ModelScope *string `gorm:"size:255" json:"model_scope,omitempty"`
PeriodStart time.Time `gorm:"not null;index" json:"period_start"`
PeriodEnd time.Time `gorm:"not null" json:"period_end"`
TokensLimit int `gorm:"default:0" json:"tokens_limit"`
TokensUsed int `gorm:"default:0" json:"tokens_used"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Quota) TableName() string { return "quotas" }
func (q *Quota) BeforeCreate(tx *gorm.DB) error {
if q.ID == uuid.Nil {
q.ID = uuid.New()
}
return nil
}
type Webhook struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
ApiKeyID uuid.UUID `gorm:"type:uuid;not null;index" json:"api_key_id"`
URL string `gorm:"size:2048;not null" json:"url"`
Event string `gorm:"size:100;not null" json:"event"`
Secret *string `gorm:"size:255" json:"secret,omitempty"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
func (Webhook) TableName() string { return "webhooks" }
func (w *Webhook) BeforeCreate(tx *gorm.DB) error {
if w.ID == uuid.Nil {
w.ID = uuid.New()
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package db
import (
"log/slog"
"golang.org/x/crypto/bcrypt"
"github.com/google/uuid"
"gorm.io/gorm"
)
func SeedAdminKey(db *gorm.DB, adminToken string) error {
var count int64
db.Model(&ApiKey{}).Count(&count)
if count > 0 {
slog.Info("admin key already exists, skipping seed")
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(adminToken), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := ApiKey{
ID: uuid.New(),
Name: "admin",
KeyHash: string(hash),
KeyPrefix: "admin-",
Scopes: StringArray{"chat", "models", "usage", "admin"},
IsActive: true,
IsAdmin: true,
}
if err := db.Create(&admin).Error; err != nil {
return err
}
slog.Info("admin key seeded", "prefix", admin.KeyPrefix)
slog.Warn("ADMIN TOKEN: " + adminToken + " (save this!)")
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package llama
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/config"
"github.com/llamalink/llamalink/internal/db"
)
var (
ErrModelNotFound = errors.New("model not found in registry")
ErrModelDisabled = errors.New("model is disabled")
ErrSwapInProgress = errors.New("model swap already in progress")
ErrAlreadyLoaded = errors.New("model already loaded")
ErrServerNotRunning = errors.New("llama-server not running")
)
type Manager struct {
cfg *config.Config
db *gorm.DB
mu sync.RWMutex
state State
proc *exec.Cmd
done chan struct{}
url string
}
func NewManager(cfg *config.Config, db *gorm.DB) *Manager {
return &Manager{
cfg: cfg,
db: db,
done: make(chan struct{}),
url: cfg.LlamaServerURL(),
}
}
func (m *Manager) GetDB() *gorm.DB {
return m.db
}
func (m *Manager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
slog.Info("llama manager starting", "url", m.url)
// Load default model on startup
var model db.Model
if err := m.db.Where("is_default = ? AND is_enabled = ?", true, true).First(&model).Error; err == nil {
slog.Info("loading default model", "name", model.Name)
if err := m.loadModelInternal(&model); err != nil {
slog.Warn("failed to load default model", "error", err)
m.state.Status = StatusFailed
m.state.LastError = err.Error()
return nil
}
}
// Start health check loop
go m.healthCheckLoop()
return nil
}
func (m *Manager) Stop() {
slog.Info("llama manager stopping")
close(m.done)
m.mu.Lock()
defer m.mu.Unlock()
if m.proc != nil && m.proc.Process != nil {
ctx, cancel := context.WithTimeout(context.Background(), m.cfg.LlamaServerStopTimeoutDuration())
defer cancel()
m.proc.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
pgid, err := syscall.Getpgid(m.proc.Process.Pid)
if err == nil {
syscall.Kill(-pgid, syscall.SIGTERM)
} else {
m.proc.Process.Signal(syscall.SIGTERM)
}
<-ctx.Done()
if m.proc.ProcessState == nil {
syscall.Kill(-pgid, syscall.SIGKILL)
}
}
m.state = State{Status: StatusStopped}
slog.Info("llama manager stopped")
}
func (m *Manager) IsReady() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.state.Status == StatusReady && m.proc != nil && m.proc.ProcessState != nil && !m.proc.ProcessState.Exited()
}
func (m *Manager) Status() Status {
m.mu.RLock()
defer m.mu.RUnlock()
return m.state.Status
}
func (m *Manager) CurrentModel() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.state.CurrentModel
}
func (m *Manager) GetStatus() *State {
m.mu.RLock()
defer m.mu.RUnlock()
return &m.state
}
func (m *Manager) LoadModel(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Find model in DB
var model db.Model
if err := m.db.Where("name = ? AND is_enabled = ?", name, true).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrModelNotFound
}
return err
}
// Check current state
if m.state.Status == StatusLoading || m.state.Status == StatusSwapping {
if m.state.CurrentModel == name {
return nil // Already loading this model
}
return fmt.Errorf("swap in progress for %s, try again later", m.state.TargetModel)
}
if m.state.CurrentModel == name && m.state.Status == StatusReady {
return nil // Already loaded
}
return m.loadModelInternal(&model)
}
func (m *Manager) loadModelInternal(model *db.Model) error {
isSwap := m.state.Status == StatusReady && m.state.CurrentModel != ""
m.state.Status = StatusSwapping
if !isSwap {
m.state.Status = StatusLoading
}
m.state.TargetModel = model.Name
m.state.LastError = ""
now := time.Now()
m.state.SwapStartedAt = &now
slog.Info("loading model", "name", model.Name, "is_swap", isSwap)
// Kill existing process
if m.proc != nil && m.proc.Process != nil {
m.terminateProcess()
}
// Build command
cmd := m.buildCommand(model)
m.proc = cmd
if err := cmd.Start(); err != nil {
m.state.Status = StatusFailed
m.state.LastError = err.Error()
return fmt.Errorf("failed to start llama-server: %w", err)
}
slog.Info("llama-server started", "pid", cmd.Process.Pid)
m.state.PID = cmd.Process.Pid
// Wait for server to be ready
if err := m.waitUntilReady(); err != nil {
m.state.Status = StatusFailed
m.state.LastError = err.Error()
return fmt.Errorf("model failed to start: %w", err)
}
m.state.CurrentModel = model.Name
m.state.Status = StatusReady
m.state.TargetModel = ""
m.state.LoadedAt = &now
// Update DB
m.db.Model(model).Updates(map[string]interface{}{
"is_active": true,
"loaded_at": now,
})
slog.Info("model loaded successfully", "name", model.Name)
return nil
}
func (m *Manager) buildCommand(model *db.Model) *exec.Cmd {
args := []string{
"--model", model.ModelPath,
"--alias", model.Alias,
"--host", m.cfg.LlamaServerHost,
"--port", fmt.Sprintf("%d", m.cfg.LlamaServerPort),
"--ctx-size", fmt.Sprintf("%d", model.CtxSize),
"--n-gpu-layers", fmt.Sprintf("%d", model.NGPULayers),
}
// Add extra args from JSON
extraArgs := ParseModelExtraArgs(model.ExtraArgs)
for k, v := range extraArgs {
if bv, ok := v.(bool); ok && bv {
args = append(args, "--"+k)
} else if v != nil {
args = append(args, "--"+k, fmt.Sprintf("%v", v))
}
}
cmd := exec.Command(m.cfg.LlamaServerBin, args...)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
// Set process group for clean kill
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
return cmd
}
func (m *Manager) terminateProcess() {
if m.proc == nil || m.proc.Process == nil {
return
}
slog.Info("terminating llama-server", "pid", m.proc.Process.Pid)
ctx, cancel := context.WithTimeout(context.Background(), m.cfg.LlamaServerStopTimeoutDuration())
defer cancel()
pgid, err := syscall.Getpgid(m.proc.Process.Pid)
if err == nil {
syscall.Kill(-pgid, syscall.SIGTERM)
} else {
m.proc.Process.Signal(syscall.SIGTERM)
}
done := make(chan error, 1)
go func() {
done <- m.proc.Wait()
}()
select {
case <-ctx.Done():
if pgid, err := syscall.Getpgid(m.proc.Process.Pid); err == nil {
syscall.Kill(-pgid, syscall.SIGKILL)
}
case <-done:
}
m.proc = nil
}
func (m *Manager) waitUntilReady() error {
ctx, cancel := context.WithTimeout(context.Background(), m.cfg.LlamaServerStartupTimeoutDuration())
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if m.checkHealth() {
return nil
}
}
}
}
func (m *Manager) checkHealth() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", m.url+"/health", nil)
if err != nil {
return false
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func (m *Manager) healthCheckLoop() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-m.done:
return
case <-ticker.C:
m.healthCheck()
}
}
}
func (m *Manager) healthCheck() {
m.mu.RLock()
running := m.proc != nil && m.proc.Process != nil && m.proc.ProcessState != nil && !m.proc.ProcessState.Exited()
m.mu.RUnlock()
if !running && m.state.Status == StatusReady {
m.mu.Lock()
m.state.Status = StatusFailed
m.state.LastError = "llama-server process died unexpectedly"
m.mu.Unlock()
slog.Error("llama-server process died", "current_model", m.state.CurrentModel)
}
}
func (m *Manager) GetUsageStats() (totalRequests, totalTokens int64, avgLatencyMs float64) {
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
var result struct {
TotalRequests int64
TotalTokens int64
AvgLatency float64
}
m.db.Model(&db.UsageLog{}).
Where("created_at >= ?", monthStart).
Select("COUNT(*) as total_requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(AVG(latency_ms), 0) as avg_latency").
Scan(&result)
return result.TotalRequests, result.TotalTokens, result.AvgLatency
}
// ProxyRequest sends a request to the llama-server proxy
func (m *Manager) ProxyRequest(ctx context.Context, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
if !m.IsReady() {
return nil, ErrServerNotRunning
}
url := m.url + path
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
return http.DefaultClient.Do(req)
}
func ParseModelExtraArgs(extraArgs db.StringArray) map[string]interface{} {
if len(extraArgs) == 0 {
return nil
}
// If it's a JSON string, parse it
if len(extraArgs) == 1 {
var result map[string]interface{}
if json.Unmarshal([]byte(extraArgs[0]), &result) == nil {
return result
}
}
// Otherwise assume key=value pairs
result := make(map[string]interface{})
for _, arg := range extraArgs {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
} else {
result[arg] = true
}
}
return result
}
+247
View File
@@ -0,0 +1,247 @@
package llama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
type Proxy struct {
manager *Manager
client *http.Client
}
func NewProxy(manager *Manager) *Proxy {
return &Proxy{
manager: manager,
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (p *Proxy) Manager() *Manager {
return p.manager
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Name string `json:"name,omitempty"`
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
}
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
type Choice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta ChatMessage `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
}
type StreamResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
// ChatCompletion calls llama-server and returns the response
func (p *Proxy) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
if !p.manager.IsReady() {
return nil, ErrServerNotRunning
}
// Convert to llama-server format
llamaReq := map[string]interface{}{
"model": req.Model,
"messages": req.Messages,
"stream": false,
}
body, err := json.Marshal(llamaReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.manager.url+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("llama-server request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("llama-server returned %d: %s", resp.StatusCode, string(bodyBytes))
}
var result ChatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
// ChatCompletionStream returns a channel of streaming responses
func (p *Proxy) ChatCompletionStream(ctx context.Context, req ChatCompletionRequest) (<-chan *StreamResponse, <-chan error) {
stream := make(chan *StreamResponse, 100)
errCh := make(chan error, 1)
if !p.manager.IsReady() {
errCh <- ErrServerNotRunning
close(stream)
return stream, errCh
}
go func() {
defer close(stream)
defer close(errCh)
llamaReq := map[string]interface{}{
"model": req.Model,
"messages": req.Messages,
"stream": true,
}
body, err := json.Marshal(llamaReq)
if err != nil {
errCh <- err
return
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.manager.url+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
errCh <- err
return
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
errCh <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errCh <- fmt.Errorf("llama-server returned %d: %s", resp.StatusCode, string(bodyBytes))
return
}
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
errCh <- err
}
break
}
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
if line == "data: [DONE]" {
break
}
data := strings.TrimPrefix(line, "data: ")
var streamResp StreamResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
slog.Debug("failed to parse stream chunk", "error", err, "data", data)
continue
}
select {
case stream <- &streamResp:
case <-ctx.Done():
return
}
}
}()
return stream, errCh
}
// ModelsList returns available models from llama-server
func (p *Proxy) ModelsList(ctx context.Context) ([]string, error) {
if !p.manager.IsReady() {
return nil, ErrServerNotRunning
}
req, err := http.NewRequestWithContext(ctx, "GET", p.manager.url+"/v1/models", nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("llama-server returned %d", resp.StatusCode)
}
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
models := make([]string, len(result.Data))
for i, m := range result.Data {
models[i] = m.ID
}
return models, nil
}
+34
View File
@@ -0,0 +1,34 @@
package llama
import "time"
type Status string
const (
StatusStopped Status = "stopped"
StatusLoading Status = "loading"
StatusReady Status = "ready"
StatusSwapping Status = "swapping"
StatusFailed Status = "failed"
)
type State struct {
CurrentModel string `json:"current_model"`
TargetModel string `json:"target_model,omitempty"`
Status Status `json:"status"`
PID int `json:"pid,omitempty"`
LoadedAt *time.Time `json:"loaded_at,omitempty"`
LastError string `json:"last_error,omitempty"`
SwapStartedAt *time.Time `json:"swap_started_at,omitempty"`
SwapInProgress bool `json:"swap_in_progress"`
}
type ModelInfo struct {
Name string
ModelPath string
Alias string
CtxSize int
NGPULayers int
ExtraArgs map[string]interface{}
IsDefault bool
}
+119
View File
@@ -0,0 +1,119 @@
package quota
import (
"errors"
"log/slog"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/db"
)
var (
ErrQuotaExceeded = errors.New("quota exceeded for this period")
)
type Service struct {
db *gorm.DB
}
func NewService(db *gorm.DB) *Service {
return &Service{db: db}
}
func (s *Service) CheckQuota(apiKeyID uuid.UUID, modelScope string) (bool, string, error) {
now := time.Now().UTC()
var quota db.Quota
err := s.db.Where(
"api_key_id = ? AND period_start <= ? AND period_end > ?",
apiKeyID, now, now,
).First(&quota).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, "", nil // No quota configured
}
return false, "", err
}
// Check model-specific scope if set
if quota.ModelScope != nil && *quota.ModelScope != "" && modelScope != "" {
if *quota.ModelScope != modelScope {
return true, "", nil // Quota doesn't apply to this model
}
}
if quota.TokensUsed >= quota.TokensLimit {
return false, "quota exceeded", nil
}
// Warning at 90%
if quota.TokensLimit > 0 {
usage := float64(quota.TokensUsed) / float64(quota.TokensLimit)
if usage >= 0.9 {
slog.Warn("quota usage warning",
"api_key_id", apiKeyID,
"usage_percent", usage*100,
"tokens_used", quota.TokensUsed,
"tokens_limit", quota.TokensLimit,
)
}
}
return true, "", nil
}
func (s *Service) ConsumeQuota(apiKeyID uuid.UUID, modelScope string, tokens int) error {
now := time.Now().UTC()
periodStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
periodEnd := periodStart.AddDate(0, 1, 0)
return s.db.Transaction(func(tx *gorm.DB) error {
var quota db.Quota
err := tx.Where(
"api_key_id = ? AND period_start = ? AND period_end = ?",
apiKeyID, periodStart, periodEnd,
).First(&quota).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil // No quota configured
}
return err
}
// Check model scope
if quota.ModelScope != nil && *quota.ModelScope != "" && modelScope != "" {
if *quota.ModelScope != modelScope {
return nil // Quota doesn't apply
}
}
newUsed := quota.TokensUsed + tokens
if quota.TokensLimit > 0 && newUsed > quota.TokensLimit {
return ErrQuotaExceeded
}
return tx.Model(&quota).Update("tokens_used", newUsed).Error
})
}
func (s *Service) GetUsage(apiKeyID uuid.UUID, periodStart, periodEnd time.Time) (used int, limit int, err error) {
var quota db.Quota
err = s.db.Where(
"api_key_id = ? AND period_start = ? AND period_end = ?",
apiKeyID, periodStart, periodEnd,
).First(&quota).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, 0, nil
}
return 0, 0, err
}
return quota.TokensUsed, quota.TokensLimit, nil
}
+107
View File
@@ -0,0 +1,107 @@
package quota
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/db"
)
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
KeyID uuid.UUID `json:"key_id"`
KeyName string `json:"key_name"`
Message string `json:"message"`
Usage *struct {
Used int `json:"tokens_used"`
Limit int `json:"tokens_limit"`
} `json:"usage,omitempty"`
}
type WebhookService struct {
db *gorm.DB
client *http.Client
webhookSecret string
}
func NewWebhookService(db *gorm.DB) *WebhookService {
return &WebhookService{
db: db,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *WebhookService) Dispatch(event string, key *db.ApiKey, message string, usage *struct{ Used, Limit int }) {
var webhooks []db.Webhook
s.db.Where("api_key_id = ? AND event = ? AND is_active = ?", key.ID, event, true).Find(&webhooks)
for _, wh := range webhooks {
go s.send(wh, event, key, message, usage)
}
}
func (s *WebhookService) send(wh db.Webhook, event string, key *db.ApiKey, message string, usage *struct{ Used, Limit int }) {
payload := WebhookPayload{
Event: event,
Timestamp: time.Now().UTC(),
KeyID: key.ID,
KeyName: key.Name,
Message: message,
}
if usage != nil {
payload.Usage = &struct {
Used int `json:"tokens_used"`
Limit int `json:"tokens_limit"`
}{Used: usage.Used, Limit: usage.Limit}
}
body, err := json.Marshal(payload)
if err != nil {
slog.Error("failed to marshal webhook payload", "error", err)
return
}
req, err := http.NewRequest("POST", wh.URL, bytes.NewReader(body))
if err != nil {
slog.Error("failed to create webhook request", "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-LlamaLink-Event", event)
req.Header.Set("X-LlamaLink-Timestamp", fmt.Sprintf("%d", time.Now().Unix()))
if wh.Secret != nil && *wh.Secret != "" {
sig := s.sign(body, *wh.Secret)
req.Header.Set("X-LlamaLink-Signature", sig)
}
resp, err := s.client.Do(req)
if err != nil {
slog.Error("webhook delivery failed", "url", wh.URL, "error", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slog.Warn("webhook returned error", "url", wh.URL, "status", resp.StatusCode)
}
}
func (s *WebhookService) sign(body []byte, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write(body)
return "sha256=" + hex.EncodeToString(h.Sum(nil))
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LlamaLink Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body class="bg-background text-text">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "llamalink-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"axios": "^1.7.0",
"zod": "^3.23.0",
"@vueuse/core": "^12.0.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0",
"lucide-vue-next": "^0.460.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0",
"typescript": "~5.6.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"@nuxtjs/tailwindcss": "^8.0.0",
"@types/node": "^22.0.0",
"eslint": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint-plugin-vue": "^9.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+20
View File
@@ -0,0 +1,20 @@
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
window.location.href = '/admin/login'
}
return Promise.reject(error)
}
)
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/admin/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { guest: true },
},
{
path: '/admin/',
component: () => import('@/views/Layout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'keys',
name: 'ApiKeys',
component: () => import('@/views/ApiKeys.vue'),
},
{
path: 'models',
name: 'Models',
component: () => import('@/views/Models.vue'),
},
{
path: 'usage',
name: 'Usage',
component: () => import('@/views/Usage.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/admin/',
},
]
const router = createRouter({
history: createWebHistory('/admin'),
routes,
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login' })
} else if (to.meta.guest && authStore.isAuthenticated) {
next({ name: 'Dashboard' })
} else {
next()
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '@/lib/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('admin_token'))
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(adminToken: string) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/admin/login', { admin_token: adminToken })
token.value = response.data.token
localStorage.setItem('admin_token', response.data.token)
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
return true
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Login failed'
return false
} finally {
loading.value = false
}
}
function logout() {
token.value = null
localStorage.removeItem('admin_token')
delete api.defaults.headers.common['Authorization']
}
function init() {
if (token.value) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
}
return { token, loading, error, isAuthenticated, login, logout, init }
})
+108
View File
@@ -0,0 +1,108 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
background-color: #0d1117;
color: #e6edf3;
min-height: 100vh;
}
code, pre {
font-family: 'JetBrains Mono', Consolas, monospace;
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary hover:bg-primary-hover text-white;
}
.btn-secondary {
@apply bg-surface border border-border hover:bg-border text-text;
}
.btn-danger {
@apply bg-error hover:bg-red-600 text-white;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.input {
@apply w-full px-3 py-2 bg-surface border border-border rounded-lg text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent;
}
.card {
@apply bg-surface border border-border rounded-xl p-6;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-success/15 text-success;
}
.badge-warning {
@apply bg-warning/15 text-warning;
}
.badge-error {
@apply bg-error/15 text-error;
}
.badge-info {
@apply bg-primary/15 text-primary;
}
.table {
@apply w-full text-left;
}
.table th {
@apply px-4 py-3 text-xs font-medium text-text-muted uppercase tracking-wider border-b border-border;
}
.table td {
@apply px-4 py-3 border-b border-border;
}
.table tr:hover td {
@apply bg-surface;
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0d1117;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Trash2, Copy, Check } from 'lucide-vue-next'
interface ApiKey {
id: string
name: string
key_prefix: string
scopes: string[]
is_admin: boolean
is_active: boolean
owner_label: string | null
created_at: string
last_used_at: string | null
}
const keys = ref<ApiKey[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const newKeyName = ref('')
const createdKey = ref<{ key: string } | null>(null)
const copied = ref(false)
async function fetchKeys() {
loading.value = true
try {
const res = await api.get('/api/v1/admin/keys')
keys.value = res.data
} catch (err) {
console.error('Failed to fetch keys:', err)
} finally {
loading.value = false
}
}
async function createKey() {
try {
const res = await api.post('/api/v1/admin/keys', { name: newKeyName.value })
createdKey.value = res.data
showCreateDialog.value = false
newKeyName.value = ''
await fetchKeys()
} catch (err) {
console.error('Failed to create key:', err)
}
}
async function revokeKey(id: string) {
if (!confirm('Are you sure you want to revoke this key?')) return
try {
await api.delete(`/api/v1/admin/keys/${id}`)
await fetchKeys()
} catch (err) {
console.error('Failed to revoke key:', err)
}
}
async function copyKey(key: string) {
await navigator.clipboard.writeText(key)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
function formatDate(date: string | null) {
if (!date) return 'Never'
return new Date(date).toLocaleString()
}
onMounted(fetchKeys)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">API Keys</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
New Key
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<!-- Created Key Dialog -->
<div v-if="createdKey" class="card mb-6 bg-success/5 border-success/20">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-success">API Key Created</h3>
<p class="text-sm text-text-muted mt-1">
Copy this key now. You won't be able to see it again.
</p>
</div>
<button @click="copyKey(createdKey.key)" class="btn btn-secondary">
<Copy class="w-4 h-4 mr-2" />
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="mt-4 p-3 bg-background rounded-lg font-mono text-sm break-all">
{{ createdKey.key }}
</div>
<button @click="createdKey = null" class="mt-4 text-sm text-text-muted hover:text-text">
Close
</button>
</div>
<!-- Keys Table -->
<div class="card">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Scopes</th>
<th>Owner</th>
<th>Created</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="key in keys" :key="key.id">
<td>{{ key.name }}</td>
<td class="font-mono text-text-muted">{{ key.key_prefix }}...</td>
<td>
<span v-for="scope in key.scopes" :key="scope" class="badge mr-1">
{{ scope }}
</span>
</td>
<td>{{ key.owner_label || '-' }}</td>
<td>{{ formatDate(key.created_at) }}</td>
<td>{{ formatDate(key.last_used_at) }}</td>
<td>
<button
v-if="!key.is_admin"
@click="revokeKey(key.id)"
class="btn btn-danger btn-sm"
>
<Trash2 class="w-4 h-4" />
</button>
<span v-else class="badge badge-info">Admin</span>
</td>
</tr>
<tr v-if="keys.length === 0">
<td colspan="7" class="text-center text-text-muted py-8">
No API keys yet. Create one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Create API Key</h2>
<form @submit.prevent="createKey">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Key Name</label>
<input v-model="newKeyName" type="text" class="input" placeholder="My API Key" required />
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
</template>
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Activity, Cpu, Key, Clock } from 'lucide-vue-next'
interface DashboardStats {
total_requests: number
total_tokens: number
avg_latency_ms: number
active_keys: number
total_models: number
}
interface ModelStatus {
status: string
current_model: string | null
loaded_at: string | null
last_error: string | null
}
interface Model {
id: string
name: string
alias: string
is_default: boolean
is_active: boolean
loaded_at: string | null
}
const stats = ref<DashboardStats>({
total_requests: 0,
total_tokens: 0,
avg_latency_ms: 0,
active_keys: 0,
total_models: 0,
})
const modelStatus = ref<ModelStatus>({
status: 'stopped',
current_model: null,
loaded_at: null,
last_error: null,
})
const models = ref<Model[]>([])
const loading = ref(true)
const error = ref('')
async function fetchDashboard() {
loading.value = true
error.value = ''
try {
const [statsRes, modelsRes, statusRes] = await Promise.all([
api.get('/api/v1/admin/dashboard'),
api.get('/api/v1/admin/models'),
api.get('/api/v1/admin/status'),
])
stats.value = statsRes.data.stats
models.value = modelsRes.data.data
modelStatus.value = statusRes.data
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Failed to load dashboard'
} finally {
loading.value = false
}
}
async function loadModel(name: string) {
try {
await api.post(`/api/v1/admin/models/${name}/load`)
await fetchDashboard()
} catch (err: any) {
error.value = err.response?.data?.error?.message || 'Failed to load model'
}
}
onMounted(fetchDashboard)
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div v-if="loading" class="text-text-muted">Loading...</div>
<div v-else-if="error" class="p-4 bg-error/10 border border-error/20 rounded-lg text-error">
{{ error }}
</div>
<template v-else>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<Activity class="w-6 h-6 text-primary" />
</div>
<div>
<p class="text-text-muted text-sm">Total Requests</p>
<p class="text-2xl font-bold">{{ stats.total_requests.toLocaleString() }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-success/10 rounded-xl flex items-center justify-center">
<Cpu class="w-6 h-6 text-success" />
</div>
<div>
<p class="text-text-muted text-sm">Total Tokens</p>
<p class="text-2xl font-bold">{{ stats.total_tokens.toLocaleString() }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-warning/10 rounded-xl flex items-center justify-center">
<Clock class="w-6 h-6 text-warning" />
</div>
<div>
<p class="text-text-muted text-sm">Avg Latency</p>
<p class="text-2xl font-bold">{{ stats.avg_latency_ms.toFixed(0) }}ms</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-info/10 rounded-xl flex items-center justify-center">
<Key class="w-6 h-6 text-info" />
</div>
<div>
<p class="text-text-muted text-sm">Active Keys</p>
<p class="text-2xl font-bold">{{ stats.active_keys }}</p>
</div>
</div>
</div>
</div>
<!-- Model Status -->
<div class="card mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Model Status</h2>
<span
class="badge"
:class="{
'badge-success': modelStatus.status === 'ready',
'badge-warning': modelStatus.status === 'loading' || modelStatus.status === 'swapping',
'badge-error': modelStatus.status === 'failed',
}"
>
{{ modelStatus.status }}
</span>
</div>
<div v-if="modelStatus.current_model" class="mb-4">
<p class="text-text-muted text-sm">Current Model</p>
<p class="text-lg font-mono">{{ modelStatus.current_model }}</p>
</div>
<div v-if="modelStatus.last_error" class="p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{{ modelStatus.last_error }}
</div>
</div>
<!-- Models Table -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Models</h2>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Alias</th>
<th>Default</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in models" :key="model.id">
<td class="font-mono">{{ model.name }}</td>
<td class="font-mono text-text-muted">{{ model.alias }}</td>
<td>
<span v-if="model.is_default" class="badge badge-info">Yes</span>
<span v-else class="badge">No</span>
</td>
<td>
<span
v-if="model.is_active"
class="badge badge-success"
>Active</span>
<span v-else class="badge">Inactive</span>
</td>
<td>
<button
v-if="!model.is_active"
@click="loadModel(model.name)"
class="btn btn-primary btn-sm"
>
Load
</button>
<span v-else class="badge badge-success">Loaded</span>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" class="text-center text-text-muted py-8">
No models configured. Add models via the API.
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const navItems = [
{ name: 'Dashboard', path: '/admin/', icon: LayoutDashboard },
{ name: 'API Keys', path: '/admin/keys', icon: Key },
{ name: 'Models', path: '/admin/models', icon: Cpu },
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
]
function handleLogout() {
authStore.logout()
router.push('/admin/login')
}
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Sidebar -->
<aside class="fixed left-0 top-0 h-full w-64 bg-surface border-r border-border flex flex-col">
<!-- Logo -->
<div class="p-6 border-b border-border">
<h1 class="text-xl font-bold text-primary">LlamaLink</h1>
<p class="text-xs text-text-muted mt-1">Admin Panel</p>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors"
:class="route.path === item.path || (item.path !== '/admin/' && route.path.startsWith(item.path))
? 'bg-primary/10 text-primary'
: 'text-text-muted hover:text-text hover:bg-border'"
>
<component :is="item.icon" class="w-5 h-5" />
<span>{{ item.name }}</span>
</RouterLink>
</nav>
<!-- Logout -->
<div class="p-4 border-t border-border">
<button
@click="handleLogout"
class="flex items-center gap-3 w-full px-4 py-3 rounded-lg text-text-muted hover:text-error hover:bg-error/10 transition-colors"
>
<LogOut class="w-5 h-5" />
<span>Logout</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="ml-64 min-h-screen p-8">
<RouterView />
</main>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Key } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const adminToken = ref('')
const error = ref('')
async function handleLogin() {
if (!adminToken.value.trim()) {
error.value = 'Admin token is required'
return
}
const success = await authStore.login(adminToken.value)
if (success) {
router.push('/admin/')
} else {
error.value = authStore.error || 'Login failed'
}
}
</script>
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md">
<div class="card">
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center">
<Key class="w-6 h-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold">LlamaLink</h1>
<p class="text-text-muted text-sm">Admin Login</p>
</div>
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Admin Token</label>
<input
v-model="adminToken"
type="password"
class="input"
placeholder="Enter your admin token"
autocomplete="current-password"
/>
</div>
<div v-if="error" class="p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm">
{{ error }}
</div>
<button
type="submit"
class="btn btn-primary w-full"
:disabled="authStore.loading"
>
<span v-if="authStore.loading">Logging in...</span>
<span v-else>Login</span>
</button>
</form>
</div>
</div>
</div>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Upload } from 'lucide-vue-next'
interface Model {
id: string
name: string
model_path: string
alias: string
ctx_size: number
n_gpu_layers: number
is_default: boolean
is_active: boolean
loaded_at: string | null
}
const models = ref<Model[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const newModel = ref({
name: '',
model_path: '',
alias: '',
ctx_size: 8192,
n_gpu_layers: -1,
is_default: false,
})
async function fetchModels() {
loading.value = true
try {
const res = await api.get('/api/v1/models')
models.value = res.data.data
} catch (err) {
console.error('Failed to fetch models:', err)
} finally {
loading.value = false
}
}
async function createModel() {
try {
await api.post('/api/v1/models', newModel.value)
showCreateDialog.value = false
Object.assign(newModel.value, { name: '', model_path: '', alias: '', ctx_size: 8192, n_gpu_layers: -1, is_default: false })
await fetchModels()
} catch (err) {
console.error('Failed to create model:', err)
}
}
async function loadModel(name: string) {
try {
await api.post(`/api/v1/models/${name}/load`)
await fetchModels()
} catch (err) {
console.error('Failed to load model:', err)
}
}
onMounted(fetchModels)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Models</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
Add Model
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<div class="card">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Path</th>
<th>Alias</th>
<th>Context</th>
<th>GPU Layers</th>
<th>Default</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="model in models" :key="model.id">
<td class="font-mono">{{ model.name }}</td>
<td class="font-mono text-text-muted text-sm">{{ model.model_path }}</td>
<td class="font-mono">{{ model.alias }}</td>
<td>{{ model.ctx_size.toLocaleString() }}</td>
<td>{{ model.n_gpu_layers }}</td>
<td>
<span v-if="model.is_default" class="badge badge-info">Default</span>
<span v-else class="badge">No</span>
</td>
<td>
<span v-if="model.is_active" class="badge badge-success">Active</span>
<span v-else class="badge">Inactive</span>
</td>
<td>
<button
v-if="!model.is_active"
@click="loadModel(model.name)"
class="btn btn-primary btn-sm"
>
<Upload class="w-4 h-4 mr-1" />
Load
</button>
<span v-else class="badge badge-success">Loaded</span>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="8" class="text-center text-text-muted py-8">
No models configured. Add one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-lg">
<h2 class="text-lg font-semibold mb-4">Add Model</h2>
<form @submit.prevent="createModel" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Name</label>
<input v-model="newModel.name" type="text" class="input" placeholder="llama-3.2-1b" required />
</div>
<div>
<label class="block text-sm font-medium mb-2">Model Path</label>
<input v-model="newModel.model_path" type="text" class="input" placeholder="/models/llama-3.2-1b.q4_k_m.gguf" required />
</div>
<div>
<label class="block text-sm font-medium mb-2">Alias</label>
<input v-model="newModel.alias" type="text" class="input" placeholder="llama-3.2-1b" required />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2">Context Size</label>
<input v-model.number="newModel.ctx_size" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-2">GPU Layers</label>
<input v-model.number="newModel.n_gpu_layers" type="number" class="input" />
</div>
</div>
<div class="flex items-center gap-2">
<input v-model="newModel.is_default" type="checkbox" id="is_default" class="w-4 h-4 rounded" />
<label for="is_default" class="text-sm">Set as default model</label>
</div>
<div class="flex gap-3 justify-end pt-2">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
</div>
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
interface UsageData {
period: { start: string; end: string }
usage: {
total_requests: number
total_tokens: number
avg_latency_ms: number
}
quota: {
tokens_used: number
tokens_limit: number
}
logs: any[]
}
const usageData = ref<UsageData | null>(null)
const loading = ref(true)
const period = ref('month')
const chartData = {
labels: ['Requests', 'Tokens (÷1000)'],
datasets: [
{
label: 'Usage',
data: [] as number[],
backgroundColor: ['#6366f1', '#22c55e'],
},
],
}
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: { grid: { color: '#30363d' }, ticks: { color: '#8b949e' } },
x: { grid: { display: false }, ticks: { color: '#8b949e' } },
},
}
async function fetchUsage() {
loading.value = true
try {
const res = await api.get(`/api/v1/admin/usage?period=${period.value}`)
usageData.value = res.data
if (res.data.usage) {
chartData.datasets[0].data = [
res.data.usage.total_requests,
Math.round(res.data.usage.total_tokens / 1000),
]
}
} catch (err) {
console.error('Failed to fetch usage:', err)
} finally {
loading.value = false
}
}
function formatNumber(n: number) {
return n.toLocaleString()
}
function formatDate(date: string) {
return new Date(date).toLocaleString()
}
onMounted(fetchUsage)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Usage</h1>
<select v-model="period" @change="fetchUsage" class="input w-auto">
<option value="week">Last 7 days</option>
<option value="month">This month</option>
<option value="year">This year</option>
</select>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<template v-else-if="usageData">
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card">
<p class="text-text-muted text-sm mb-2">Total Requests</p>
<p class="text-3xl font-bold">{{ formatNumber(usageData.usage?.total_requests || 0) }}</p>
</div>
<div class="card">
<p class="text-text-muted text-sm mb-2">Total Tokens</p>
<p class="text-3xl font-bold">{{ formatNumber(usageData.usage?.total_tokens || 0) }}</p>
</div>
<div class="card">
<p class="text-text-muted text-sm mb-2">Avg Latency</p>
<p class="text-3xl font-bold">{{ (usageData.usage?.avg_latency_ms || 0).toFixed(0) }}ms</p>
</div>
</div>
<!-- Quota -->
<div class="card mb-8" v-if="usageData.quota">
<h2 class="text-lg font-semibold mb-4">Monthly Quota</h2>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
<span>{{ formatNumber(usageData.quota.tokens_used) }} / {{ formatNumber(usageData.quota.tokens_limit) }} tokens</span>
<span v-if="usageData.quota.tokens_limit > 0">
{{ Math.round((usageData.quota.tokens_used / usageData.quota.tokens_limit) * 100) }}%
</span>
</div>
<div class="h-3 bg-border rounded-full overflow-hidden">
<div
class="h-full transition-all"
:class="{
'bg-success': (usageData.quota.tokens_used / usageData.quota.tokens_limit) < 0.8,
'bg-warning': (usageData.quota.tokens_used / usageData.quota.tokens_limit) >= 0.8,
'bg-error': (usageData.quota.tokens_used / usageData.quota.tokens_limit) >= 0.95,
}"
:style="{ width: `${Math.min((usageData.quota.tokens_used / usageData.quota.tokens_limit) * 100, 100)}%` }"
/>
</div>
</div>
</div>
<!-- Chart -->
<div class="card mb-8">
<h2 class="text-lg font-semibold mb-4">Usage Overview</h2>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
<!-- Recent Logs -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Recent Requests</h2>
<table class="table">
<thead>
<tr>
<th>Time</th>
<th>Model</th>
<th>Endpoint</th>
<th>Tokens</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="log in (usageData.logs || []).slice(0, 20)" :key="log.id">
<td>{{ formatDate(log.created_at) }}</td>
<td class="font-mono">{{ log.model_name }}</td>
<td class="font-mono text-text-muted">{{ log.endpoint }}</td>
<td>{{ log.total_tokens }}</td>
<td>{{ log.latency_ms }}ms</td>
<td>
<span
class="badge"
:class="{
'badge-success': log.status === 'success',
'badge-error': log.status === 'error',
'badge-warning': log.status === 'quota_exceeded',
}"
>
{{ log.status }}
</span>
</td>
</tr>
<tr v-if="!usageData.logs?.length">
<td colspan="6" class="text-center text-text-muted py-8">
No requests yet.
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
background: '#0d1117',
surface: '#161b22',
border: '#30363d',
primary: '#6366f1',
'primary-hover': '#818cf8',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
text: '#e6edf3',
'text-muted': '#8b949e',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/admin': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
},
})