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
+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))
}