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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user