Initial commit: Luminary AI Gateway
CI / docker (push) Successful in 2m4s

OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress,
ingress key governance, monitoring, and security controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 21:46:16 +08:00
commit 76ba500417
134 changed files with 18988 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/auth/loginlimit"
"github.com/rose_cat707/luminary/internal/gateway"
"github.com/rose_cat707/luminary/internal/middleware"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/monitor"
"github.com/rose_cat707/luminary/internal/prediction"
"github.com/rose_cat707/luminary/internal/settings"
"github.com/rose_cat707/luminary/internal/storage/imagestore"
"github.com/rose_cat707/luminary/internal/store/cache"
sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite"
)
type Handler struct {
repo *sqlitestore.Repository
db *sqlitestore.Store
cache *cache.Store
gateway *gateway.Service
settings *settings.Runtime
monitor *monitor.Service
loginLimit *loginlimit.Limiter
predictions *prediction.Service
images *imagestore.Store
publicURL func() string
}
func New(repo *sqlitestore.Repository, db *sqlitestore.Store, c *cache.Store, gw *gateway.Service, rt *settings.Runtime, mon *monitor.Service, pred *prediction.Service, imgs *imagestore.Store, publicURL func() string) *Handler {
return &Handler{
repo: repo,
db: db,
cache: c,
gateway: gw,
settings: rt,
monitor: mon,
loginLimit: loginlimit.New(rt.LoginMaxAttempts(), rt.LoginLockout()),
predictions: pred,
images: imgs,
publicURL: publicURL,
}
}
func (h *Handler) publicBaseURL() string {
if h.publicURL != nil {
return h.publicURL()
}
return "http://localhost:8293"
}
func (h *Handler) Register(r *gin.RouterGroup) {
r.POST("/login", h.Login)
r.GET("/auth/status", h.AuthStatus)
authGroup := r.Group("", middleware.AdminAuth(h.validateSession))
authGroup.POST("/logout", h.Logout)
authGroup.PUT("/password", h.ChangePassword)
authGroup.GET("/endpoint-meta", h.ListEndpointMeta)
authGroup.GET("/providers", h.ListProviders)
authGroup.POST("/providers", h.CreateProvider)
authGroup.GET("/providers/:id", h.GetProvider)
authGroup.PUT("/providers/:id", h.UpdateProvider)
authGroup.DELETE("/providers/:id", h.DeleteProvider)
authGroup.POST("/providers/:id/sync-models", h.SyncModels)
authGroup.GET("/providers/:id/keys", h.ListProviderKeys)
authGroup.POST("/providers/:id/keys", h.CreateProviderKey)
authGroup.PUT("/provider-keys/:keyId", h.UpdateProviderKey)
authGroup.DELETE("/provider-keys/:keyId", h.DeleteProviderKey)
authGroup.GET("/providers/:id/models", h.ListModels)
authGroup.POST("/providers/:id/models", h.CreateModel)
authGroup.PUT("/providers/:id/models/:modelId", h.UpdateModel)
authGroup.DELETE("/providers/:id/models/:modelId", h.DeleteModel)
authGroup.PUT("/models/:modelId", h.UpdateModel)
authGroup.DELETE("/models/:modelId", h.DeleteModel)
authGroup.GET("/ingress-keys", h.ListIngressKeys)
authGroup.POST("/ingress-keys", h.CreateIngressKey)
authGroup.GET("/ingress-keys/:id", h.GetIngressKey)
authGroup.PUT("/ingress-keys/:id", h.UpdateIngressKey)
authGroup.DELETE("/ingress-keys/:id", h.DeleteIngressKey)
authGroup.GET("/ingress-keys/:id/usage", h.IngressUsage)
authGroup.GET("/ingress-keys/:id/routing-rules", h.ListRoutingRules)
authGroup.POST("/ingress-keys/:id/routing-rules", h.CreateRoutingRule)
authGroup.PUT("/routing-rules/:ruleId", h.UpdateRoutingRule)
authGroup.DELETE("/routing-rules/:ruleId", h.DeleteRoutingRule)
authGroup.GET("/workflows/param-meta", h.GetWorkflowParamMeta)
authGroup.POST("/workflows/analyze", h.AnalyzeWorkflow)
authGroup.GET("/predictions/:id", h.GetPrediction)
authGroup.GET("/images/:id", h.ServeAdminImage)
authGroup.GET("/request-logs", h.ListRequestLogs)
authGroup.GET("/request-logs/:id", h.GetRequestLog)
authGroup.GET("/pricing/catalog", h.PricingCatalog)
authGroup.GET("/ip-rules", h.ListIPRules)
authGroup.POST("/ip-rules", h.CreateIPRule)
authGroup.PUT("/ip-rules/:id", h.UpdateIPRule)
authGroup.DELETE("/ip-rules/:id", h.DeleteIPRule)
authGroup.GET("/settings", h.GetSettings)
authGroup.PUT("/settings", h.UpdateSettings)
authGroup.GET("/dashboard/overview", h.DashboardOverview)
authGroup.GET("/dashboard/providers", h.DashboardProviders)
authGroup.GET("/dashboard/ingress-usage", h.DashboardIngressUsage)
authGroup.GET("/dashboard/egress-usage", h.DashboardEgressUsage)
authGroup.GET("/dashboard/provider-model-usage", h.DashboardProviderModelUsage)
authGroup.GET("/dashboard/client-ip-usage", h.DashboardClientIPUsage)
authGroup.GET("/dashboard/request-trend", h.DashboardRequestTrend)
}
func (h *Handler) validateSession(token string) (bool, time.Time) {
hash := auth.HashToken(token)
var session model.AdminSession
if err := h.db.DB().Where("token_hash = ?", hash).First(&session).Error; err != nil {
return false, time.Time{}
}
return session.ExpiresAt.After(time.Now()), session.ExpiresAt
}
func (h *Handler) Login(c *gin.Context) {
ip := middleware.ClientIP(c)
if locked, retryAfter := h.loginLimit.Check(ip); locked {
sec := int(retryAfter.Seconds()) + 1
c.Header("Retry-After", strconv.Itoa(sec))
h.recordLoginLog(ip, "", "error", "登录尝试次数过多", "", fmt.Sprintf(`{"retry_after_sec":%d}`, sec))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "登录尝试次数过多,请稍后再试",
"retry_after_sec": sec,
})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
h.recordLoginLog(ip, "", "error", "invalid request", "", "")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
reqBody := loginRequestBody(req.Username)
var user model.AdminUser
if err := h.db.DB().Where("username = ?", req.Username).First(&user).Error; err != nil {
h.loginLimit.RecordFailure(ip)
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if !auth.CheckPassword(user.PasswordHash, req.Password) {
h.loginLimit.RecordFailure(ip)
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
h.loginLimit.Reset(ip)
token, err := generateSessionToken()
if err != nil {
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
return
}
session := model.AdminSession{
TokenHash: auth.HashToken(token),
ExpiresAt: time.Now().Add(h.settings.SessionTTL()),
}
if err := h.db.DB().Create(&session).Error; err != nil {
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
return
}
respBody := fmt.Sprintf(`{"expires_at":%q}`, session.ExpiresAt.Format(time.RFC3339))
h.recordLoginLog(ip, req.Username, "success", "", reqBody, respBody)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.SessionCookieName(), token, int(h.settings.SessionTTL().Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": session.ExpiresAt})
}
func loginRequestBody(username string) string {
b, _ := json.Marshal(map[string]string{"username": username})
return string(b)
}
func (h *Handler) recordLoginLog(ip, username, status, errMsg, requestBody, responseBody string) {
h.repo.RecordRequestLog(model.UsageRecord{
ClientIP: ip,
Endpoint: model.EndpointAdminLogin,
Model: username,
Status: status,
ErrorMsg: errMsg,
RequestBody: requestBody,
ResponseBody: responseBody,
})
}
func (h *Handler) Logout(c *gin.Context) {
token, _ := c.Get("session_token")
if t, ok := token.(string); ok {
h.db.DB().Where("token_hash = ?", auth.HashToken(t)).Delete(&model.AdminSession{})
}
c.SetCookie(middleware.SessionCookieName(), "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) AuthStatus(c *gin.Context) {
token := c.GetHeader("Authorization")
if len(token) > 7 {
token = token[7:]
}
if token == "" {
if cookie, err := c.Cookie(middleware.SessionCookieName()); err == nil {
token = cookie
}
}
valid := false
if token != "" {
valid, _ = h.validateSession(token)
}
c.JSON(http.StatusOK, gin.H{"authenticated": valid})
}
func (h *Handler) ChangePassword(c *gin.Context) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.NewPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
var user model.AdminUser
if err := h.db.DB().First(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"})
return
}
if !auth.CheckPassword(user.PasswordHash, req.OldPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid old password"})
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"})
return
}
user.PasswordHash = hash
h.db.DB().Save(&user)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func generateSessionToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+29
View File
@@ -0,0 +1,29 @@
package admin
import (
"fmt"
"strconv"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
"github.com/rose_cat707/luminary/internal/provider/anthropic"
"github.com/rose_cat707/luminary/internal/provider/custom"
"github.com/rose_cat707/luminary/internal/provider/openai"
)
func parseUint(s string) uint {
v, _ := strconv.ParseUint(s, 10, 64)
return uint(v)
}
func (h *Handler) gatewayClient(t model.ProviderType) (provider.Client, error) {
switch t {
case model.ProviderOpenAI, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama:
return openai.New(), nil
case model.ProviderAnthropic:
return anthropic.New(), nil
case model.ProviderCustom:
return custom.New(), nil
default:
return nil, fmt.Errorf("unsupported provider type: %s", t)
}
}
+163
View File
@@ -0,0 +1,163 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func (h *Handler) ListIngressKeys(c *gin.Context) {
keys := h.cache.ListIngressKeys()
c.JSON(http.StatusOK, gin.H{"data": keys})
}
func (h *Handler) CreateIngressKey(c *gin.Context) {
var req struct {
Name string `json:"name"`
BudgetLimit float64 `json:"budget_limit"`
RateLimitRPM int `json:"rate_limit_rpm"`
RateLimitTPM int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
ExpiresAt *time.Time `json:"expires_at"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
plainKey, err := generateIngressKey()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
return
}
hash := auth.HashIngressKey(plainKey)
providersJSON := `["*"]`
modelsJSON := `["*"]`
if req.AllowedProviders != nil {
if len(req.AllowedProviders) == 0 {
providersJSON = `["*"]`
} else {
providersJSON = cache.ModelsToJSON(req.AllowedProviders)
}
}
if req.AllowedModels != nil {
if len(req.AllowedModels) == 0 {
modelsJSON = `["*"]`
} else {
modelsJSON = cache.ModelsToJSON(req.AllowedModels)
}
}
k := model.IngressKey{
Name: req.Name,
KeyHash: hash,
Prefix: plainKey[:12] + "...",
Enabled: true,
BudgetLimit: req.BudgetLimit,
RateLimitRPM: req.RateLimitRPM,
RateLimitTPM: req.RateLimitTPM,
AllowedProvidersJSON: providersJSON,
AllowedModelsJSON: modelsJSON,
ExpiresAt: req.ExpiresAt,
}
if err := h.db.DB().Create(&k).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.cache.SetIngressKey(k)
c.JSON(http.StatusCreated, gin.H{"key": k, "plain_key": plainKey})
}
func (h *Handler) GetIngressKey(c *gin.Context) {
id := parseUint(c.Param("id"))
k, ok := h.cache.GetIngressKey(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, k)
}
func (h *Handler) UpdateIngressKey(c *gin.Context) {
id := parseUint(c.Param("id"))
k, ok := h.cache.GetIngressKey(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
BudgetLimit *float64 `json:"budget_limit"`
RateLimitRPM *int `json:"rate_limit_rpm"`
RateLimitTPM *int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Name != "" {
k.Name = req.Name
}
if req.Enabled != nil {
k.Enabled = *req.Enabled
}
if req.BudgetLimit != nil {
k.BudgetLimit = *req.BudgetLimit
}
if req.RateLimitRPM != nil {
k.RateLimitRPM = *req.RateLimitRPM
}
if req.RateLimitTPM != nil {
k.RateLimitTPM = *req.RateLimitTPM
}
if req.AllowedProviders != nil {
if len(req.AllowedProviders) == 0 {
k.AllowedProvidersJSON = `["*"]`
} else {
k.AllowedProvidersJSON = cache.ModelsToJSON(req.AllowedProviders)
}
}
if req.AllowedModels != nil {
if len(req.AllowedModels) == 0 {
k.AllowedModelsJSON = `["*"]`
} else {
k.AllowedModelsJSON = cache.ModelsToJSON(req.AllowedModels)
}
}
h.db.DB().Save(&k)
h.cache.SetIngressKey(k)
c.JSON(http.StatusOK, k)
}
func (h *Handler) DeleteIngressKey(c *gin.Context) {
id := parseUint(c.Param("id"))
h.db.DB().Where("ingress_key_id = ?", id).Delete(&model.RoutingRule{})
h.db.DB().Delete(&model.IngressKey{}, id)
h.cache.DeleteIngressKey(id)
h.cache.DeleteRoutingRulesForIngress(id)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) IngressUsage(c *gin.Context) {
id := parseUint(c.Param("id"))
var records []model.UsageRecord
h.db.DB().Where("ingress_key_id = ?", id).Order("created_at desc").Limit(50).Find(&records)
k, _ := h.cache.GetIngressKey(id)
c.JSON(http.StatusOK, gin.H{"key": k, "recent": records})
}
func generateIngressKey() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "sk-lum-" + hex.EncodeToString(b), nil
}
+243
View File
@@ -0,0 +1,243 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
)
func (h *Handler) ListIPRules(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": h.cache.ListIPRules()})
}
func (h *Handler) CreateIPRule(c *gin.Context) {
var req struct {
CIDR string `json:"cidr"`
Type model.IPRuleType `json:"type"`
Scope model.IPRuleScope `json:"scope"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.CIDR == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Type == "" {
req.Type = model.IPRuleAllow
}
if req.Scope == "" {
req.Scope = model.IPScopeProxy
}
rule := model.IPRule{CIDR: req.CIDR, Type: req.Type, Scope: req.Scope, Enabled: true}
if req.Enabled != nil {
rule.Enabled = *req.Enabled
}
if err := h.db.DB().Create(&rule).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rules := h.cache.ListIPRules()
rules = append(rules, rule)
h.cache.SetIPRules(rules)
c.JSON(http.StatusCreated, rule)
}
func (h *Handler) UpdateIPRule(c *gin.Context) {
id := parseUint(c.Param("id"))
var rule model.IPRule
if err := h.db.DB().First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
CIDR string `json:"cidr"`
Type model.IPRuleType `json:"type"`
Scope model.IPRuleScope `json:"scope"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.CIDR != "" {
rule.CIDR = req.CIDR
}
if req.Type != "" {
rule.Type = req.Type
}
if req.Scope != "" {
rule.Scope = req.Scope
}
if req.Enabled != nil {
rule.Enabled = *req.Enabled
}
h.db.DB().Save(&rule)
h.reloadIPRules()
c.JSON(http.StatusOK, rule)
}
func (h *Handler) DeleteIPRule(c *gin.Context) {
id := parseUint(c.Param("id"))
h.db.DB().Delete(&model.IPRule{}, id)
h.reloadIPRules()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) reloadIPRules() {
var rules []model.IPRule
h.db.DB().Find(&rules)
h.cache.SetIPRules(rules)
}
func (h *Handler) DashboardOverview(c *gin.Context) {
since := time.Now().Add(-24 * time.Hour)
var totalRequests int64
var totalTokens int64
var errorCount int64
h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ?", since).Count(&totalRequests)
h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ?", since).Select("COALESCE(SUM(tokens_in + tokens_out), 0)").Scan(&totalTokens)
h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ? AND status = ?", since, "error").Count(&errorCount)
activeKeys := len(h.cache.ListIngressKeys())
errorRate := 0.0
if totalRequests > 0 {
errorRate = float64(errorCount) / float64(totalRequests)
}
c.JSON(http.StatusOK, gin.H{
"total_requests_24h": totalRequests,
"total_tokens_24h": totalTokens,
"error_rate": errorRate,
"active_ingress_keys": activeKeys,
"providers": len(h.cache.ListProviders()),
})
}
func (h *Handler) DashboardProviders(c *gin.Context) {
providers := h.cache.ListProviders()
health := h.cache.GetHealthStatus()
type item struct {
model.Provider
Health model.HealthCheckRecord `json:"health"`
}
var out []item
for _, p := range providers {
out = append(out, item{Provider: p, Health: health[p.ID]})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) DashboardIngressUsage(c *gin.Context) {
keys := h.cache.ListIngressKeys()
c.JSON(http.StatusOK, gin.H{"data": keys})
}
func (h *Handler) DashboardEgressUsage(c *gin.Context) {
keys := h.cache.ListAllProviderKeys()
c.JSON(http.StatusOK, gin.H{"data": keys})
}
func (h *Handler) DashboardProviderModelUsage(c *gin.Context) {
since := time.Now().Add(-24 * time.Hour)
type row struct {
ProviderID uint `json:"provider_id"`
Model string `json:"model"`
RequestCount int64 `json:"request_count"`
TokenCount int64 `json:"token_count"`
Cost float64 `json:"cost"`
}
var rows []row
h.db.DB().Model(&model.UsageRecord{}).
Select("provider_id, model, COUNT(*) as request_count, COALESCE(SUM(tokens_in + tokens_out), 0) as token_count, COALESCE(SUM(cost), 0) as cost").
Where("created_at >= ? AND provider_id > 0 AND model != ''", since).
Group("provider_id, model").
Order("request_count desc").
Limit(50).
Scan(&rows)
type item struct {
ProviderID uint `json:"provider_id"`
ProviderName string `json:"provider_name"`
Model string `json:"model"`
RequestCount int64 `json:"request_count"`
TokenCount int64 `json:"token_count"`
Cost float64 `json:"cost"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
name := ""
if p, ok := h.cache.GetProvider(r.ProviderID); ok {
name = p.Name
}
out = append(out, item{
ProviderID: r.ProviderID,
ProviderName: name,
Model: r.Model,
RequestCount: r.RequestCount,
TokenCount: r.TokenCount,
Cost: r.Cost,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) DashboardClientIPUsage(c *gin.Context) {
since := time.Now().Add(-24 * time.Hour)
type item struct {
ClientIP string `json:"client_ip"`
RequestCount int64 `json:"request_count"`
TokenCount int64 `json:"token_count"`
Cost float64 `json:"cost"`
}
var out []item
h.db.DB().Model(&model.UsageRecord{}).
Select("client_ip, COUNT(*) as request_count, COALESCE(SUM(tokens_in + tokens_out), 0) as token_count, COALESCE(SUM(cost), 0) as cost").
Where("created_at >= ? AND client_ip != ''", since).
Group("client_ip").
Order("request_count desc").
Limit(50).
Scan(&out)
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) DashboardRequestTrend(c *gin.Context) {
since := time.Now().Add(-23 * time.Hour).Truncate(time.Hour)
type aggRow struct {
Hour string `gorm:"column:hour"`
Requests int64 `gorm:"column:requests"`
Errors int64 `gorm:"column:errors"`
Tokens int64 `gorm:"column:tokens"`
}
var rows []aggRow
h.db.DB().Model(&model.UsageRecord{}).
Select(`strftime('%Y-%m-%d %H:00', created_at) as hour,
COUNT(*) as requests,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors,
COALESCE(SUM(tokens_in + tokens_out), 0) as tokens`).
Where("created_at >= ?", since).
Group("hour").
Order("hour").
Scan(&rows)
byHour := make(map[string]aggRow, len(rows))
for _, r := range rows {
byHour[r.Hour] = r
}
type bucket struct {
Hour string `json:"hour"`
Requests int64 `json:"requests"`
Errors int64 `json:"errors"`
Tokens int64 `json:"tokens"`
}
out := make([]bucket, 24)
for i := 0; i < 24; i++ {
t := since.Add(time.Duration(i) * time.Hour)
key := t.Format("2006-01-02 15:00")
if r, ok := byHour[key]; ok {
out[i] = bucket{Hour: key, Requests: r.Requests, Errors: r.Errors, Tokens: r.Tokens}
} else {
out[i] = bucket{Hour: key}
}
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
+196
View File
@@ -0,0 +1,196 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func (h *Handler) ListEndpointMeta(c *gin.Context) {
category := model.ProviderCategory(c.Query("category"))
if category == "" {
category = model.CategoryLLM
}
providerType := model.ProviderType(c.Query("type"))
if providerType == "" {
providerType = provider.DefaultProviderType(category)
}
endpoints := provider.EndpointsForCategory(category)
providerTypes := provider.TypesForCategory(category)
type item struct {
provider.EndpointMeta
DefaultPath string `json:"default_path"`
}
out := make([]item, 0, len(endpoints))
for _, e := range endpoints {
out = append(out, item{
EndpointMeta: e,
DefaultPath: provider.DefaultPath(providerType, e.Key),
})
}
c.JSON(http.StatusOK, gin.H{
"categories": []gin.H{
{"key": model.CategoryLLM, "label": provider.CategoryLabel(model.CategoryLLM), "supported": true},
{"key": model.CategoryEmbedding, "label": provider.CategoryLabel(model.CategoryEmbedding), "supported": true},
{"key": model.CategoryRerank, "label": provider.CategoryLabel(model.CategoryRerank), "supported": true},
{"key": model.CategorySpeech, "label": provider.CategoryLabel(model.CategorySpeech), "supported": false},
{"key": model.CategoryImage, "label": provider.CategoryLabel(model.CategoryImage), "supported": true},
{"key": model.CategoryAudio, "label": provider.CategoryLabel(model.CategoryAudio), "supported": false},
},
"provider_types": providerTypes,
"all_provider_types": provider.AllProviderTypes(),
"endpoints": out,
})
}
func (h *Handler) ListProviders(c *gin.Context) {
category := c.Query("category")
providers := h.cache.ListProviders()
if category != "" {
filtered := providers[:0]
for _, p := range providers {
if string(p.Category) == category {
filtered = append(filtered, p)
}
}
providers = filtered
}
out := make([]model.Provider, 0, len(providers))
for _, p := range providers {
p.Models = h.cache.GetModels(p.ID)
out = append(out, p)
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h *Handler) CreateProvider(c *gin.Context) {
var req struct {
Name string `json:"name"`
Type model.ProviderType `json:"type"`
Category model.ProviderCategory `json:"category"`
BaseURL string `json:"base_url"`
AllowedEndpoints []string `json:"allowed_endpoints"`
EndpointPaths map[string]string `json:"endpoint_paths"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" || req.Type == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Category == "" {
req.Category = model.CategoryLLM
}
if !model.IsCategorySupported(req.Category) {
c.JSON(http.StatusBadRequest, gin.H{"error": "category not supported"})
return
}
if !provider.IsTypeValidForCategory(req.Category, req.Type) {
c.JSON(http.StatusBadRequest, gin.H{"error": "protocol type not supported for this category"})
return
}
if h.providerNameTaken(req.Name, 0) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
allowed := req.AllowedEndpoints
if len(allowed) == 0 {
allowed = provider.DefaultEndpointsForCategory(req.Category)
}
if err := provider.ValidateCategoryEndpoints(req.Category, allowed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pathsJSON, _ := json.Marshal(req.EndpointPaths)
if req.EndpointPaths == nil {
pathsJSON = []byte("{}")
}
p := model.Provider{
Name: req.Name,
Type: req.Type,
Category: req.Category,
BaseURL: req.BaseURL,
AllowedEndpointsJSON: cache.ModelsToJSON(allowed),
EndpointPathsJSON: string(pathsJSON),
Status: model.ProviderUnknown,
}
if err := h.db.DB().Create(&p).Error; err != nil {
if h.providerNameTaken(req.Name, 0) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.cache.SetProvider(p)
c.JSON(http.StatusCreated, p)
}
func (h *Handler) GetProvider(c *gin.Context) {
id := parseUint(c.Param("id"))
p, ok := h.cache.GetProvider(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
p.Keys = h.cache.ListProviderKeys(id)
p.Models = h.cache.GetModels(id)
c.JSON(http.StatusOK, p)
}
func (h *Handler) UpdateProvider(c *gin.Context) {
id := parseUint(c.Param("id"))
p, ok := h.cache.GetProvider(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Category model.ProviderCategory `json:"category"`
AllowedEndpoints []string `json:"allowed_endpoints"`
EndpointPaths map[string]string `json:"endpoint_paths"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Name != "" && req.Name != p.Name {
if h.providerNameTaken(req.Name, p.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
p.Name = req.Name
}
if req.BaseURL != "" {
p.BaseURL = req.BaseURL
}
if req.Category != "" {
if !model.IsCategorySupported(req.Category) {
c.JSON(http.StatusBadRequest, gin.H{"error": "category not supported yet"})
return
}
p.Category = req.Category
}
if req.AllowedEndpoints != nil {
if err := provider.ValidateCategoryEndpoints(p.Category, req.AllowedEndpoints); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.AllowedEndpointsJSON = cache.ModelsToJSON(req.AllowedEndpoints)
}
if req.EndpointPaths != nil {
b, _ := json.Marshal(req.EndpointPaths)
p.EndpointPathsJSON = string(b)
}
if req.Enabled != nil {
p.Enabled = *req.Enabled
}
h.db.DB().Save(&p)
h.cache.SetProvider(p)
c.JSON(http.StatusOK, p)
}
+418
View File
@@ -0,0 +1,418 @@
package admin
import (
"encoding/json"
"net/http"
"slices"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/prediction"
"github.com/rose_cat707/luminary/internal/provider"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func (h *Handler) DeleteProvider(c *gin.Context) {
id := parseUint(c.Param("id"))
h.db.DB().Where("provider_id = ?", id).Delete(&model.ProviderKey{})
h.db.DB().Where("provider_id = ?", id).Delete(&model.ModelEntry{})
h.db.DB().Delete(&model.Provider{}, id)
h.cache.DeleteProvider(id)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) SyncModels(c *gin.Context) {
id := parseUint(c.Param("id"))
p, ok := h.cache.GetProvider(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
allowed := provider.ParseAllowedEndpoints(p.AllowedEndpointsJSON, p.Category)
if !slices.Contains(allowed, string(provider.EndpointListModels)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "list_models endpoint not enabled"})
return
}
models, err := h.gateway.FetchModels(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
existing := h.cache.GetModels(id)
existingByModelID := make(map[string]model.ModelEntry, len(existing))
for _, e := range existing {
existingByModelID[e.ModelID] = e
}
upstreamIDs := make(map[string]bool, len(models))
var entries []model.ModelEntry
for _, m := range models {
upstreamIDs[m.ID] = true
if old, ok := existingByModelID[m.ID]; ok {
old.DisplayName = m.DisplayName
if old.DisplayName == "" {
old.DisplayName = m.ID
}
if err := h.db.DB().Save(&old).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
entries = append(entries, old)
continue
}
displayName := m.DisplayName
if displayName == "" {
displayName = m.ID
}
entry := model.ModelEntry{
ProviderID: id,
ModelID: m.ID,
DisplayName: displayName,
Enabled: true,
}
if err := h.db.DB().Create(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
entries = append(entries, entry)
}
for _, e := range existing {
if !upstreamIDs[e.ModelID] {
entries = append(entries, e)
}
}
h.cache.SetModels(id, entries)
c.JSON(http.StatusOK, gin.H{"synced": len(models), "models": entries})
}
func (h *Handler) ListProviderKeys(c *gin.Context) {
id := parseUint(c.Param("id"))
c.JSON(http.StatusOK, gin.H{"data": h.cache.ListProviderKeys(id)})
}
func (h *Handler) CreateProviderKey(c *gin.Context) {
providerID := parseUint(c.Param("id"))
var req struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Weight float64 `json:"weight"`
Models []string `json:"models"`
Enabled *bool `json:"enabled"`
MaxRequestsPerDay int `json:"max_requests_per_day"`
MaxTokensPerDay int64 `json:"max_tokens_per_day"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.APIKey == "" {
var p model.Provider
h.db.DB().First(&p, providerID)
if p.Type != model.ProviderOllama {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
return
}
req.APIKey = "ollama"
}
enc, err := auth.Encrypt(req.APIKey, h.settings.EncryptionKey())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"})
return
}
modelsJSON := cache.ModelsToJSON(req.Models)
if req.Models == nil {
modelsJSON = `["*"]`
}
k := model.ProviderKey{
ProviderID: providerID,
Name: req.Name,
APIKeyEnc: enc,
Weight: req.Weight,
ModelsJSON: modelsJSON,
Enabled: true,
MaxRequestsPerDay: req.MaxRequestsPerDay,
MaxTokensPerDay: req.MaxTokensPerDay,
}
if req.Weight <= 0 {
k.Weight = 1
}
if req.Enabled != nil {
k.Enabled = *req.Enabled
}
if err := h.db.DB().Create(&k).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.cache.SetProviderKey(k)
c.JSON(http.StatusCreated, k)
}
func (h *Handler) UpdateProviderKey(c *gin.Context) {
keyID := parseUint(c.Param("keyId"))
k, ok := h.cache.GetProviderKey(keyID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
Name string `json:"name"`
APIKey string `json:"api_key"`
Weight float64 `json:"weight"`
Models []string `json:"models"`
Enabled *bool `json:"enabled"`
MaxRequestsPerDay int `json:"max_requests_per_day"`
MaxTokensPerDay int64 `json:"max_tokens_per_day"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Name != "" {
k.Name = req.Name
}
if req.APIKey != "" {
enc, err := auth.Encrypt(req.APIKey, h.settings.EncryptionKey())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"})
return
}
k.APIKeyEnc = enc
}
if req.Weight > 0 {
k.Weight = req.Weight
}
if req.Models != nil {
k.ModelsJSON = cache.ModelsToJSON(req.Models)
}
if req.Enabled != nil {
k.Enabled = *req.Enabled
}
if req.MaxRequestsPerDay >= 0 {
k.MaxRequestsPerDay = req.MaxRequestsPerDay
}
if req.MaxTokensPerDay >= 0 {
k.MaxTokensPerDay = req.MaxTokensPerDay
}
h.db.DB().Save(&k)
h.cache.SetProviderKey(k)
c.JSON(http.StatusOK, k)
}
func (h *Handler) DeleteProviderKey(c *gin.Context) {
keyID := parseUint(c.Param("keyId"))
h.db.DB().Delete(&model.ProviderKey{}, keyID)
h.cache.DeleteProviderKey(keyID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) ListModels(c *gin.Context) {
id := parseUint(c.Param("id"))
if _, ok := h.cache.GetProvider(id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": h.cache.GetModels(id)})
}
func (h *Handler) CreateModel(c *gin.Context) {
providerID := parseUint(c.Param("id"))
prov, ok := h.cache.GetProvider(providerID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
var req struct {
ModelID string `json:"model_id"`
Alias string `json:"alias"`
DisplayName string `json:"display_name"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
for _, existing := range h.cache.GetModels(providerID) {
if existing.ModelID == req.ModelID {
c.JSON(http.StatusBadRequest, gin.H{"error": "model already exists"})
return
}
}
if msg := validateModelAlias(h, req.Alias, 0); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
if prov.Category == model.CategoryImage {
if err := validateImageModelCreate(struct {
ModelID, DisplayName string
WorkflowType model.WorkflowType
WorkflowJSON, InputBindingJSON string
InputBinding map[string]*prediction.NodeField
}{req.ModelID, req.DisplayName, req.WorkflowType, req.WorkflowJSON, req.InputBindingJSON, req.InputBinding}); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
bindingJSON := req.InputBindingJSON
if bindingJSON == "" && req.InputBinding != nil {
b, _ := json.Marshal(req.InputBinding)
bindingJSON = string(b)
}
wt := req.WorkflowType
if wt == "" {
wt = model.WorkflowTxt2Img
}
m := model.ModelEntry{
ProviderID: providerID,
ModelID: req.ModelID,
Alias: normalizeAlias(req.Alias),
DisplayName: req.DisplayName,
WorkflowType: wt,
WorkflowJSON: req.WorkflowJSON,
InputBindingJSON: bindingJSON,
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
Enabled: true,
}
if m.DisplayName == "" {
m.DisplayName = req.ModelID
}
if err := h.db.DB().Create(&m).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
models := h.cache.GetModels(providerID)
models = append(models, m)
h.cache.SetModels(providerID, models)
c.JSON(http.StatusCreated, m)
}
func (h *Handler) UpdateModel(c *gin.Context) {
providerID := parseUint(c.Param("id"))
modelID := parseUint(c.Param("modelId"))
var req struct {
Enabled *bool `json:"enabled"`
DisplayName string `json:"display_name"`
Alias *string `json:"alias"`
ModelID string `json:"model_id"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
m, ok := h.findModelEntry(providerID, modelID, req.ModelID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if req.Enabled != nil {
m.Enabled = *req.Enabled
}
if req.DisplayName != "" {
m.DisplayName = req.DisplayName
}
if req.Alias != nil {
normalized := normalizeAlias(*req.Alias)
if msg := validateModelAlias(h, normalized, m.ID); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
m.Alias = normalized
}
if req.ModelID != "" && req.ModelID != m.ModelID {
for _, existing := range h.cache.GetModels(m.ProviderID) {
if existing.ID != m.ID && existing.ModelID == req.ModelID {
c.JSON(http.StatusBadRequest, gin.H{"error": "model already exists"})
return
}
}
m.ModelID = req.ModelID
}
if req.WorkflowType != "" {
m.WorkflowType = req.WorkflowType
}
if req.WorkflowJSON != "" {
m.WorkflowJSON = req.WorkflowJSON
m.VersionHash = prediction.HashWorkflowJSON(req.WorkflowJSON)
}
if req.InputBindingJSON != "" {
m.InputBindingJSON = req.InputBindingJSON
} else if req.InputBinding != nil {
b, _ := json.Marshal(req.InputBinding)
m.InputBindingJSON = string(b)
}
prov, _ := h.cache.GetProvider(m.ProviderID)
if prov.Category == model.CategoryImage && m.WorkflowJSON != "" {
if err := validateImageModelCreate(struct {
ModelID, DisplayName string
WorkflowType model.WorkflowType
WorkflowJSON, InputBindingJSON string
InputBinding map[string]*prediction.NodeField
}{m.ModelID, m.DisplayName, m.WorkflowType, m.WorkflowJSON, m.InputBindingJSON, nil}); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
if err := h.db.DB().Save(&m).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
models := h.cache.GetModels(m.ProviderID)
for i := range models {
if models[i].ID == m.ID {
models[i] = m
break
}
}
h.cache.SetModels(m.ProviderID, models)
c.JSON(http.StatusOK, m)
}
func (h *Handler) findModelEntry(providerID, entryID uint, modelID string) (model.ModelEntry, bool) {
var m model.ModelEntry
if entryID > 0 {
if err := h.db.DB().First(&m, entryID).Error; err == nil {
if providerID > 0 && m.ProviderID != providerID {
return model.ModelEntry{}, false
}
return m, true
}
}
if providerID > 0 && modelID != "" {
if err := h.db.DB().Where("provider_id = ? AND model_id = ?", providerID, modelID).First(&m).Error; err == nil {
return m, true
}
}
return model.ModelEntry{}, false
}
func (h *Handler) DeleteModel(c *gin.Context) {
modelID := parseUint(c.Param("modelId"))
var m model.ModelEntry
if err := h.db.DB().First(&m, modelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if providerID := parseUint(c.Param("id")); providerID > 0 && m.ProviderID != providerID {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := h.db.DB().Delete(&m).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
models := h.cache.GetModels(m.ProviderID)
filtered := models[:0]
for _, item := range models {
if item.ID != modelID {
filtered = append(filtered, item)
}
}
h.cache.SetModels(m.ProviderID, filtered)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+51
View File
@@ -0,0 +1,51 @@
package admin
import (
"strings"
)
func (h *Handler) providerNameTaken(name string, excludeID uint) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, p := range h.cache.ListProviders() {
if p.ID != excludeID && p.Name == name {
return true
}
}
return false
}
func (h *Handler) modelAliasTaken(alias string, excludeModelID uint) bool {
alias = strings.TrimSpace(alias)
if alias == "" {
return false
}
for _, p := range h.cache.ListProviders() {
for _, m := range h.cache.GetModels(p.ID) {
if m.ID == excludeModelID {
continue
}
if m.Alias == alias {
return true
}
}
}
return false
}
func normalizeAlias(alias string) string {
return strings.TrimSpace(alias)
}
func validateModelAlias(h *Handler, alias string, excludeModelID uint) string {
alias = normalizeAlias(alias)
if alias == "" {
return ""
}
if h.modelAliasTaken(alias, excludeModelID) {
return "model alias already exists"
}
return ""
}
+220
View File
@@ -0,0 +1,220 @@
package admin
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/pricing"
)
func (h *Handler) ListRoutingRules(c *gin.Context) {
ingressID := parseUint(c.Param("id"))
c.JSON(http.StatusOK, gin.H{"data": h.cache.ListRoutingRules(ingressID)})
}
func (h *Handler) CreateRoutingRule(c *gin.Context) {
ingressID := parseUint(c.Param("id"))
var req struct {
ModelPattern string `json:"model_pattern"`
ProviderID uint `json:"provider_id"`
ProviderKeyID uint `json:"provider_key_id"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ModelPattern == "" || req.ProviderID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
rule := model.RoutingRule{
IngressKeyID: ingressID,
ModelPattern: req.ModelPattern,
ProviderID: req.ProviderID,
ProviderKeyID: req.ProviderKeyID,
Priority: req.Priority,
Enabled: true,
}
if req.Enabled != nil {
rule.Enabled = *req.Enabled
}
if err := h.db.DB().Create(&rule).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rules := h.cache.ListRoutingRules(ingressID)
rules = append(rules, rule)
h.cache.SetRoutingRules(ingressID, rules)
c.JSON(http.StatusCreated, rule)
}
func (h *Handler) UpdateRoutingRule(c *gin.Context) {
ruleID := parseUint(c.Param("ruleId"))
var rule model.RoutingRule
if err := h.db.DB().First(&rule, ruleID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req struct {
ModelPattern string `json:"model_pattern"`
ProviderID uint `json:"provider_id"`
ProviderKeyID uint `json:"provider_key_id"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.ModelPattern != "" {
rule.ModelPattern = req.ModelPattern
}
if req.ProviderID > 0 {
rule.ProviderID = req.ProviderID
}
if req.ProviderKeyID >= 0 {
rule.ProviderKeyID = req.ProviderKeyID
}
rule.Priority = req.Priority
if req.Enabled != nil {
rule.Enabled = *req.Enabled
}
h.db.DB().Save(&rule)
h.reloadRoutingRules(rule.IngressKeyID)
c.JSON(http.StatusOK, rule)
}
func (h *Handler) DeleteRoutingRule(c *gin.Context) {
ruleID := parseUint(c.Param("ruleId"))
var rule model.RoutingRule
if err := h.db.DB().First(&rule, ruleID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
h.db.DB().Delete(&rule)
h.reloadRoutingRules(rule.IngressKeyID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) reloadRoutingRules(ingressID uint) {
var rules []model.RoutingRule
h.db.DB().Where("ingress_key_id = ?", ingressID).Find(&rules)
h.cache.SetRoutingRules(ingressID, rules)
}
func (h *Handler) ListRequestLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize <= 0 || pageSize > 50 {
pageSize = 20
}
q := h.db.DB().Model(&model.UsageRecord{})
if ingressID := c.Query("ingress_key_id"); ingressID != "" {
q = q.Where("ingress_key_id = ?", ingressID)
}
if status := c.Query("status"); status != "" {
q = q.Where("status = ?", status)
}
if endpoint := c.Query("endpoint"); endpoint != "" {
q = q.Where("endpoint = ?", endpoint)
}
if clientIP := c.Query("client_ip"); clientIP != "" {
q = q.Where("client_ip LIKE ?", "%"+clientIP+"%")
}
if modelName := c.Query("model"); modelName != "" {
q = q.Where("model LIKE ?", "%"+modelName+"%")
}
var total int64
q.Count(&total)
var logs []model.UsageRecord
q.Order("created_at desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&logs)
type item struct {
ID uint `json:"id"`
IngressKeyID uint `json:"ingress_key_id"`
IngressName string `json:"ingress_name"`
ClientIP string `json:"client_ip"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
LatencyMs int64 `json:"latency_ms"`
Status string `json:"status"`
Stream bool `json:"stream"`
ErrorMsg string `json:"error_msg"`
CreatedAt time.Time `json:"created_at"`
}
out := make([]item, 0, len(logs))
for _, log := range logs {
name := ""
if log.Endpoint == model.EndpointAdminLogin {
name = "管理台"
} else if k, ok := h.cache.GetIngressKey(log.IngressKeyID); ok {
name = k.Name
}
out = append(out, item{
ID: log.ID,
IngressKeyID: log.IngressKeyID,
IngressName: name,
ClientIP: log.ClientIP,
Endpoint: log.Endpoint,
Model: log.Model,
LatencyMs: log.LatencyMs,
Status: log.Status,
Stream: log.Stream,
ErrorMsg: log.ErrorMsg,
CreatedAt: log.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"total": total,
"page": page,
"page_size": pageSize,
"max_kept": model.MaxUsageLogRecords,
})
}
func (h *Handler) GetRequestLog(c *gin.Context) {
id := parseUint(c.Param("id"))
var log model.UsageRecord
if err := h.db.DB().First(&log, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
name := ""
if log.Endpoint == model.EndpointAdminLogin {
name = "管理台"
} else if k, ok := h.cache.GetIngressKey(log.IngressKeyID); ok {
name = k.Name
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"id": log.ID,
"ingress_key_id": log.IngressKeyID,
"ingress_name": name,
"client_ip": log.ClientIP,
"endpoint": log.Endpoint,
"model": log.Model,
"latency_ms": log.LatencyMs,
"status": log.Status,
"stream": log.Stream,
"error_msg": log.ErrorMsg,
"request_body": log.RequestBody,
"response_body": log.ResponseBody,
"created_at": log.CreatedAt,
},
})
}
func (h *Handler) PricingCatalog(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": pricing.ListCatalog()})
}
+29
View File
@@ -0,0 +1,29 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/settings"
)
func (h *Handler) GetSettings(c *gin.Context) {
c.JSON(http.StatusOK, h.settings.PublicView())
}
func (h *Handler) UpdateSettings(c *gin.Context) {
var in settings.UpdateInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if err := h.settings.Update(in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.loginLimit.Configure(h.settings.LoginMaxAttempts(), h.settings.LoginLockout())
if h.monitor != nil {
h.monitor.NotifySettingsChanged()
}
c.JSON(http.StatusOK, h.settings.PublicView())
}
+116
View File
@@ -0,0 +1,116 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/prediction"
)
func (h *Handler) GetWorkflowParamMeta(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"txt2img": prediction.Txt2ImgParams,
"img2img": prediction.Img2ImgParams,
})
}
func (h *Handler) AnalyzeWorkflow(c *gin.Context) {
var req struct {
WorkflowJSON string `json:"workflow_json"`
WorkflowType model.WorkflowType `json:"workflow_type"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.WorkflowJSON == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_json required"})
return
}
if req.WorkflowType == "" {
req.WorkflowType = model.WorkflowTxt2Img
}
result, err := prediction.AnalyzeWorkflow(req.WorkflowJSON, req.WorkflowType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func (h *Handler) GetPrediction(c *gin.Context) {
if h.predictions == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
p, err := h.predictions.GetDBPrediction(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
images, _ := h.images.ListByPrediction(p.ID)
c.JSON(http.StatusOK, gin.H{
"prediction": h.predictions.APIView(p, h.publicBaseURL()),
"images": images,
})
}
func (h *Handler) ServeAdminImage(c *gin.Context) {
if h.images == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
img, err := h.images.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("Content-Type", img.Mime)
c.File(img.LocalPath)
}
func bindingFromSuggestions(suggestions []prediction.BindingSuggestion, overrides map[string]*prediction.NodeField) prediction.InputBinding {
binding := prediction.InputBinding{}
for _, s := range suggestions {
if o, ok := overrides[s.Param]; ok && o != nil {
if o.Node != "" && o.Field != "" {
binding[s.Param] = o
}
continue
}
if s.Node != "" && s.Field != "" {
binding[s.Param] = &prediction.NodeField{Node: s.Node, Field: s.Field}
}
}
return binding
}
func validateImageModelCreate(req struct {
ModelID string
DisplayName string
WorkflowType model.WorkflowType
WorkflowJSON string
InputBindingJSON string
InputBinding map[string]*prediction.NodeField
}) error {
if req.WorkflowJSON == "" {
return nil
}
var workflow map[string]any
if err := json.Unmarshal([]byte(req.WorkflowJSON), &workflow); err != nil {
return err
}
var binding prediction.InputBinding
if req.InputBindingJSON != "" {
b, err := prediction.ParseBinding(req.InputBindingJSON)
if err != nil {
return err
}
binding = b
} else if req.InputBinding != nil {
binding = req.InputBinding
}
wt := req.WorkflowType
if wt == "" {
wt = model.WorkflowTxt2Img
}
return binding.Validate(workflow, prediction.RequiredBindings(wt))
}
+43
View File
@@ -0,0 +1,43 @@
package files
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/storage/imagestore"
)
type Handler struct {
store *imagestore.Store
}
func New(store *imagestore.Store) *Handler {
return &Handler{store: store}
}
func (h *Handler) Register(r *gin.Engine) {
r.GET("/files/:id", h.Serve)
}
func (h *Handler) Serve(c *gin.Context) {
id := c.Param("id")
expStr := c.Query("exp")
sig := c.Query("sig")
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid exp"})
return
}
if !h.store.Verify(id, exp, sig) {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
return
}
img, err := h.store.Get(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("Content-Type", img.Mime)
c.File(img.LocalPath)
}
+30
View File
@@ -0,0 +1,30 @@
package proxy
import (
"errors"
"net/http"
"strings"
"github.com/rose_cat707/luminary/internal/gateway"
)
func gatewayHTTPStatus(err error) int {
switch {
case errors.Is(err, gateway.ErrRateLimitExceeded):
return http.StatusTooManyRequests
case errors.Is(err, gateway.ErrModelNotAllowed),
errors.Is(err, gateway.ErrBudgetExceeded):
return http.StatusForbidden
case errors.Is(err, gateway.ErrInvalidJSON),
errors.Is(err, gateway.ErrModelRequired):
return http.StatusBadRequest
}
msg := err.Error()
if strings.Contains(msg, "not enabled") || strings.Contains(msg, "not allowed") {
return http.StatusForbidden
}
if strings.Contains(msg, "invalid api key") || strings.Contains(msg, "expired") {
return http.StatusUnauthorized
}
return http.StatusBadGateway
}
+100
View File
@@ -0,0 +1,100 @@
package proxy
import (
"bufio"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/gateway"
"github.com/rose_cat707/luminary/internal/middleware"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
type Handler struct {
gateway *gateway.Service
}
func New(gw *gateway.Service) *Handler {
return &Handler{gateway: gw}
}
func (h *Handler) Register(r *gin.RouterGroup) {
r.GET("/models", h.ListModels)
r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion))
r.POST("/completions", h.forward(provider.EndpointTextCompletion))
r.POST("/responses", h.forward(provider.EndpointResponses))
r.POST("/embeddings", h.forward(provider.EndpointEmbeddings))
r.POST("/rerank", h.forward(provider.EndpointRerank))
}
func (h *Handler) ingressKey(c *gin.Context) (*model.IngressKey, bool) {
v, ok := c.Get("ingress_key")
if !ok {
return nil, false
}
k, ok := v.(*model.IngressKey)
return k, ok
}
func (h *Handler) ListModels(c *gin.Context) {
ingress, ok := h.ingressKey(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
models, err := h.gateway.ListModels(c.Request.Context(), ingress)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": models})
}
func (h *Handler) forward(endpoint provider.EndpointType) gin.HandlerFunc {
return func(c *gin.Context) {
ingress, ok := h.ingressKey(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if provider.IsStreamRequest(body) && provider.SupportsStreaming(endpoint) {
h.handleStream(c, ingress, endpoint, body)
return
}
clientIP := middleware.ClientIP(c)
resp, err := h.gateway.ForwardEndpoint(c.Request.Context(), ingress, endpoint, body, clientIP)
if err != nil {
c.JSON(gatewayHTTPStatus(err), gin.H{"error": gin.H{"message": err.Error(), "type": "gateway_error"}})
return
}
c.Data(resp.StatusCode, "application/json", resp.Body)
}
}
func (h *Handler) handleStream(c *gin.Context, ingress *model.IngressKey, endpoint provider.EndpointType, body []byte) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
return
}
w := bufio.NewWriter(c.Writer)
clientIP := middleware.ClientIP(c)
err := h.gateway.ForwardStream(c.Request.Context(), ingress, endpoint, body, clientIP, w, flusher)
w.Flush()
if err != nil {
_, _ = c.Writer.Write([]byte("data: {\"error\":\"" + err.Error() + "\"}\n\n"))
flusher.Flush()
}
}
+159
View File
@@ -0,0 +1,159 @@
package replicate
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/prediction"
)
type Handler struct {
svc *prediction.Service
baseURL func() string
}
func New(svc *prediction.Service, baseURL func() string) *Handler {
return &Handler{svc: svc, baseURL: baseURL}
}
func (h *Handler) Register(r *gin.RouterGroup) {
r.POST("/predictions", h.Create)
r.GET("/predictions", h.List)
r.GET("/predictions/:id", h.Get)
r.POST("/predictions/:id/cancel", h.Cancel)
r.GET("/predictions/:id/stream", h.Stream)
}
func (h *Handler) ingress(c *gin.Context) (*model.IngressKey, bool) {
v, ok := c.Get("ingress_key")
if !ok {
return nil, false
}
k, ok := v.(*model.IngressKey)
return k, ok
}
func (h *Handler) Create(c *gin.Context) {
ingress, ok := h.ingress(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"})
return
}
var req prediction.CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
return
}
wait := strings.Contains(c.GetHeader("Prefer"), "wait")
p, err := h.svc.Create(c.Request.Context(), ingress, req, c.ClientIP(), wait)
if err != nil {
code := http.StatusBadRequest
if strings.Contains(err.Error(), "not found") {
code = http.StatusNotFound
}
c.JSON(code, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusCreated, h.svc.APIView(p, h.baseURL()))
}
func (h *Handler) Get(c *gin.Context) {
ingress, ok := h.ingress(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"})
return
}
p, err := h.svc.Get(c.Request.Context(), c.Param("id"), ingress)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, h.svc.APIView(p, h.baseURL()))
}
func (h *Handler) List(c *gin.Context) {
ingress, ok := h.ingress(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"})
return
}
items, err := h.svc.List(c.Request.Context(), ingress, c.Query("cursor"), 100)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
results := make([]map[string]any, 0, len(items))
for _, p := range items {
results = append(results, h.svc.APIView(p, h.baseURL()))
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
func (h *Handler) Cancel(c *gin.Context) {
ingress, ok := h.ingress(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"})
return
}
p, err := h.svc.Cancel(c.Request.Context(), c.Param("id"), ingress)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, h.svc.APIView(p, h.baseURL()))
}
func (h *Handler) Stream(c *gin.Context) {
ingress, ok := h.ingress(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"})
return
}
id := c.Param("id")
if _, err := h.svc.Get(c.Request.Context(), id, ingress); err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return
}
w := bufio.NewWriter(c.Writer)
ch := h.svc.Hub().Subscribe(id)
defer h.svc.Hub().Unsubscribe(id, ch)
for {
select {
case <-c.Request.Context().Done():
return
case ev, open := <-ch:
if !open {
return
}
payload, _ := json.Marshal(map[string]any{
"id": id, "status": ev.Status, "logs": ev.Logs,
"metrics": ev.Metrics, "output": ev.Output, "error": ev.Error,
})
_, _ = fmt.Fprintf(w, "data: %s\n\n", payload)
w.Flush()
flusher.Flush()
if ev.Done {
_, _ = fmt.Fprintf(w, "event: done\ndata: {}\n\n")
w.Flush()
flusher.Flush()
return
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package static
import (
"embed"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
type Handler struct {
content embed.FS
}
func New(content embed.FS) *Handler {
return &Handler{content: content}
}
func (h *Handler) Register(r *gin.Engine) {
sub, err := fs.Sub(h.content, "dist")
if err != nil {
sub = h.content
}
r.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") || strings.HasPrefix(c.Request.URL.Path, "/v1/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
h.serve(c, sub)
})
r.GET("/", func(c *gin.Context) { h.serve(c, sub) })
r.GET("/assets/*filepath", func(c *gin.Context) { h.serve(c, sub) })
}
func (h *Handler) serve(c *gin.Context, sub fs.FS) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(sub, path)
if err != nil {
data, err = fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "UI not built. Run: make build-web")
return
}
path = "index.html"
}
ext := filepath.Ext(path)
ct := mime.TypeByExtension(ext)
if ct == "" {
ct = "text/html"
}
if strings.HasPrefix(path, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
c.Data(http.StatusOK, ct, data)
}