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