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
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
)
func deriveKey(secret string) []byte {
h := sha256.Sum256([]byte(secret))
return h[:]
}
func Encrypt(plaintext, secret string) (string, error) {
key := deriveKey(secret)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func Decrypt(encoded, secret string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
key := deriveKey(secret)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", h)
}
func GenerateToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b)[:n], nil
}
+93
View File
@@ -0,0 +1,93 @@
package loginlimit
import (
"sync"
"time"
)
// Limiter tracks failed admin login attempts per client IP.
type Limiter struct {
mu sync.Mutex
attempts map[string]*record
maxAttempts int
lockout time.Duration
}
type record struct {
failures int
lockedUntil time.Time
}
func New(maxAttempts int, lockout time.Duration) *Limiter {
if maxAttempts <= 0 {
maxAttempts = 5
}
if lockout <= 0 {
lockout = 15 * time.Minute
}
return &Limiter{
attempts: make(map[string]*record),
maxAttempts: maxAttempts,
lockout: lockout,
}
}
// Check reports whether the IP is locked and how long to wait.
func (l *Limiter) Check(ip string) (locked bool, retryAfter time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
r := l.attempts[ip]
if r == nil {
return false, 0
}
now := time.Now()
if now.Before(r.lockedUntil) {
return true, r.lockedUntil.Sub(now)
}
if r.failures >= l.maxAttempts {
delete(l.attempts, ip)
}
return false, 0
}
// RecordFailure increments the failure count and locks the IP when the limit is reached.
func (l *Limiter) RecordFailure(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
r := l.attempts[ip]
if r == nil {
r = &record{}
l.attempts[ip] = r
}
if now.Before(r.lockedUntil) {
return
}
if r.failures >= l.maxAttempts && !r.lockedUntil.IsZero() && now.After(r.lockedUntil) {
r.failures = 0
r.lockedUntil = time.Time{}
}
r.failures++
if r.failures >= l.maxAttempts {
r.lockedUntil = now.Add(l.lockout)
}
}
// Configure updates rate limit parameters at runtime.
func (l *Limiter) Configure(maxAttempts int, lockout time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
if maxAttempts > 0 {
l.maxAttempts = maxAttempts
}
if lockout > 0 {
l.lockout = lockout
}
}
// Reset clears failure state after a successful login.
func (l *Limiter) Reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, ip)
}
+36
View File
@@ -0,0 +1,36 @@
package loginlimit
import (
"testing"
"time"
)
func TestLimiterLockout(t *testing.T) {
l := New(3, time.Minute)
ip := "192.168.1.1"
for i := 0; i < 2; i++ {
if locked, _ := l.Check(ip); locked {
t.Fatalf("unexpected lock at attempt %d", i)
}
l.RecordFailure(ip)
}
if locked, _ := l.Check(ip); locked {
t.Fatal("should not lock before max attempts")
}
l.RecordFailure(ip)
locked, retry := l.Check(ip)
if !locked {
t.Fatal("expected lock after max failures")
}
if retry <= 0 {
t.Fatal("expected positive retry duration")
}
l.Reset(ip)
if locked, _ := l.Check(ip); locked {
t.Fatal("expected unlock after reset")
}
}
+30
View File
@@ -0,0 +1,30 @@
package auth
import (
"strings"
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// HashIngressKey stores a SHA-256 hex digest for O(1) cache lookup.
func HashIngressKey(key string) string {
return HashToken(key)
}
func IsLegacyIngressHash(hash string) bool {
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$")
}
// CheckIngressKey verifies legacy bcrypt-hashed ingress keys.
func CheckIngressKey(hash, key string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)) == nil
}
+49
View File
@@ -0,0 +1,49 @@
package balancer
import (
"math/rand"
"github.com/rose_cat707/luminary/internal/model"
)
type KeyCandidate struct {
Key model.ProviderKey
Weight float64
}
func SelectWeighted(candidates []KeyCandidate) *model.ProviderKey {
if len(candidates) == 0 {
return nil
}
if len(candidates) == 1 {
k := candidates[0].Key
return &k
}
var total float64
for _, c := range candidates {
w := c.Weight
if w <= 0 {
w = 1
}
total += w
}
if total <= 0 {
k := candidates[0].Key
return &k
}
r := rand.Float64() * total
var acc float64
for _, c := range candidates {
w := c.Weight
if w <= 0 {
w = 1
}
acc += w
if r <= acc {
k := c.Key
return &k
}
}
k := candidates[len(candidates)-1].Key
return &k
}
+32
View File
@@ -0,0 +1,32 @@
package balancer_test
import (
"testing"
"github.com/rose_cat707/luminary/internal/balancer"
"github.com/rose_cat707/luminary/internal/model"
)
func TestSelectWeighted(t *testing.T) {
candidates := []balancer.KeyCandidate{
{Key: model.ProviderKey{ID: 1, Name: "a"}, Weight: 1},
{Key: model.ProviderKey{ID: 2, Name: "b"}, Weight: 3},
}
counts := map[uint]int{}
for i := 0; i < 1000; i++ {
k := balancer.SelectWeighted(candidates)
if k == nil {
t.Fatal("nil key")
}
counts[k.ID]++
}
if counts[2] <= counts[1] {
t.Fatalf("expected heavier weight key selected more often: %v", counts)
}
}
func TestSelectWeightedEmpty(t *testing.T) {
if k := balancer.SelectWeighted(nil); k != nil {
t.Fatal("expected nil")
}
}
+11
View File
@@ -0,0 +1,11 @@
package gateway
import "errors"
var (
ErrModelNotAllowed = errors.New("model not allowed")
ErrBudgetExceeded = errors.New("budget exceeded")
ErrRateLimitExceeded = errors.New("rate limit exceeded")
ErrInvalidJSON = errors.New("invalid json")
ErrModelRequired = errors.New("model is required")
)
+160
View File
@@ -0,0 +1,160 @@
package gateway
import (
"context"
"fmt"
"sort"
"time"
"github.com/rose_cat707/luminary/internal/balancer"
"github.com/rose_cat707/luminary/internal/limiter"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
"github.com/rose_cat707/luminary/internal/store/cache"
)
type attempt struct {
provider *model.Provider
key *model.ProviderKey
apiKey string
}
func (s *Service) buildAttempts(providers []model.Provider, modelName string, route *routeTarget) ([]attempt, error) {
var attempts []attempt
var decryptFailures int
var eligibleKeys int
for i := range providers {
p := providers[i]
keys := s.listEligibleKeys(p.ID, modelName, route)
eligibleKeys += len(keys)
for _, k := range keys {
plain, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
decryptFailures++
continue
}
attempts = append(attempts, attempt{provider: &p, key: &k, apiKey: plain})
}
}
if len(attempts) == 0 {
if decryptFailures > 0 {
return nil, fmt.Errorf("no available provider keys: %d key(s) failed to decrypt (check encryption_key in system settings)", decryptFailures)
}
if eligibleKeys == 0 {
return nil, fmt.Errorf("no available provider keys: none match model %q or are disabled/rate-limited", modelName)
}
return nil, fmt.Errorf("no available provider keys")
}
sort.SliceStable(attempts, func(i, j int) bool {
return attempts[i].key.Weight > attempts[j].key.Weight
})
return attempts, nil
}
func (s *Service) listEligibleKeys(providerID uint, modelName string, route *routeTarget) []model.ProviderKey {
if route != nil && route.ProviderKeyID > 0 && route.ProviderID == providerID {
if k, ok := s.cache.GetProviderKey(route.ProviderKeyID); ok && k.Enabled && limiter.IsProviderKeyAvailable(k) {
return []model.ProviderKey{k}
}
}
var candidates []balancer.KeyCandidate
for _, k := range s.cache.ListProviderKeys(providerID) {
if !limiter.IsProviderKeyAvailable(k) {
continue
}
allowed := cache.ParseJSONList(k.ModelsJSON)
if modelName != "" && !cache.MatchesList(allowed, "*") {
upstream := s.resolveUpstreamModelID(providerID, modelName)
if !cache.MatchesList(allowed, modelName) && !cache.MatchesList(allowed, upstream) {
continue
}
}
candidates = append(candidates, balancer.KeyCandidate{Key: k, Weight: k.Weight})
}
// collect unique keys by weighted random sampling order
seen := map[uint]bool{}
var out []model.ProviderKey
for len(out) < len(candidates) {
selected := balancer.SelectWeighted(candidates)
if selected == nil {
break
}
if seen[selected.ID] {
// remove from candidates
filtered := candidates[:0]
for _, c := range candidates {
if c.Key.ID != selected.ID {
filtered = append(filtered, c)
}
}
candidates = filtered
if len(candidates) == 0 {
break
}
continue
}
seen[selected.ID] = true
out = append(out, *selected)
filtered := candidates[:0]
for _, c := range candidates {
if c.Key.ID != selected.ID {
filtered = append(filtered, c)
}
}
candidates = filtered
}
return out
}
func (s *Service) forwardWithFailover(ctx context.Context, attempts []attempt, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, *attempt, error) {
var lastErr error
var lastAtt *attempt
var lastResp *provider.ChatResponse
maxRetries := s.settings.GatewayMaxRetries()
if maxRetries < 1 {
maxRetries = 1
}
backoffMs := s.settings.GatewayRetryBackoffMs()
for _, att := range attempts {
if !s.canServeEndpoint(att.provider, endpoint) {
continue
}
for try := 0; try < maxRetries; try++ {
if try > 0 {
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
}
resp, err := s.forward(ctx, att.provider, att.apiKey, endpoint, body)
cur := att
lastAtt = &cur
if resp != nil {
lastResp = resp
}
if err != nil {
lastErr = err
continue
}
if resp == nil {
lastErr = fmt.Errorf("empty upstream response")
continue
}
if resp.StatusCode < 400 {
return resp, &att, nil
}
lastErr = fmt.Errorf("%s", describeForwardError(nil, resp))
if passThroughUpstreamStatus(resp.StatusCode) {
return resp, &att, nil
}
if tryNextKeyOnUpstreamStatus(resp.StatusCode) {
break // try next provider key
}
if retryableUpstreamStatus(resp.StatusCode) {
continue // retry same key
}
return resp, &att, nil
}
}
if lastErr == nil {
lastErr = fmt.Errorf("all providers failed")
}
return lastResp, lastAtt, lastErr
}
+190
View File
@@ -0,0 +1,190 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
func (s *Service) healthCheckEndpoints(p *model.Provider) []provider.EndpointType {
var out []provider.EndpointType
if s.providerAllowsEndpoint(p, provider.EndpointListModels) {
out = append(out, provider.EndpointListModels)
}
switch p.Category {
case model.CategoryEmbedding:
if s.providerAllowsEndpoint(p, provider.EndpointEmbeddings) {
out = append(out, provider.EndpointEmbeddings)
}
case model.CategoryRerank:
if s.providerAllowsEndpoint(p, provider.EndpointRerank) {
out = append(out, provider.EndpointRerank)
}
}
return out
}
func (s *Service) runHealthChecks(
ctx context.Context,
p *model.Provider,
keys []model.ProviderKey,
cfg provider.ProviderConfig,
) (model.ProviderStatus, int64, string) {
endpoints := s.healthCheckEndpoints(p)
if len(endpoints) == 0 {
return model.ProviderUnhealthy, 0, "no health check endpoint enabled"
}
start := time.Now()
var lastErr string
for _, ep := range endpoints {
status, _, msg := s.runHealthCheck(ctx, p, keys, cfg, ep)
if status == model.ProviderHealthy {
return model.ProviderHealthy, time.Since(start).Milliseconds(), ""
}
if msg != "" {
lastErr = msg
}
}
if lastErr == "" {
lastErr = "health check failed"
}
return model.ProviderUnhealthy, time.Since(start).Milliseconds(), lastErr
}
func (s *Service) runHealthCheck(
ctx context.Context,
p *model.Provider,
keys []model.ProviderKey,
cfg provider.ProviderConfig,
endpoint provider.EndpointType,
) (model.ProviderStatus, int64, string) {
start := time.Now()
latency := func() int64 { return time.Since(start).Milliseconds() }
switch endpoint {
case provider.EndpointListModels:
if len(keys) == 0 && p.Type == model.ProviderOllama {
if err := s.openai.HealthCheckWithConfig(ctx, "", cfg); err == nil {
return model.ProviderHealthy, latency(), ""
}
return model.ProviderUnhealthy, latency(), "health check failed"
}
for _, k := range keys {
if !k.Enabled {
continue
}
apiKey, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
continue
}
var checkErr error
if p.Type == model.ProviderAnthropic {
checkErr = s.anthropic.HealthCheckWithConfig(ctx, apiKey, cfg)
} else {
checkErr = s.openai.HealthCheckWithConfig(ctx, apiKey, cfg)
}
if checkErr == nil {
return model.ProviderHealthy, latency(), ""
}
}
return model.ProviderUnhealthy, latency(), "health check failed"
case provider.EndpointEmbeddings, provider.EndpointRerank:
body, err := healthProbeBody(s, p, endpoint)
if err != nil {
return model.ProviderUnhealthy, latency(), err.Error()
}
if len(keys) == 0 && p.Type == model.ProviderOllama {
ok, msg := s.probeEndpoint(ctx, "", cfg, endpoint, body)
if ok {
return model.ProviderHealthy, latency(), ""
}
return model.ProviderUnhealthy, latency(), msg
}
var lastErr string
for _, k := range keys {
if !k.Enabled {
continue
}
apiKey, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
continue
}
if ok, msg := s.probeEndpoint(ctx, apiKey, cfg, endpoint, body); ok {
return model.ProviderHealthy, latency(), ""
} else if msg != "" {
lastErr = msg
}
}
if lastErr == "" {
lastErr = "health check failed"
}
return model.ProviderUnhealthy, latency(), lastErr
default:
return model.ProviderUnhealthy, latency(), "unsupported health check endpoint"
}
}
func (s *Service) probeEndpoint(
ctx context.Context,
apiKey string,
cfg provider.ProviderConfig,
endpoint provider.EndpointType,
body []byte,
) (bool, string) {
resp, err := s.openai.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body)
if err != nil {
return false, err.Error()
}
if resp == nil {
return false, "empty response"
}
if isHealthCheckHTTPStatus(resp.StatusCode) {
return true, ""
}
return false, fmt.Sprintf("%s: HTTP %d", endpoint, resp.StatusCode)
}
func healthProbeBody(s *Service, p *model.Provider, endpoint provider.EndpointType) ([]byte, error) {
modelID := probeModelID(s, p.ID, endpoint)
switch endpoint {
case provider.EndpointEmbeddings:
return json.Marshal(map[string]string{"model": modelID, "input": "."})
case provider.EndpointRerank:
return json.Marshal(map[string]any{
"model": modelID,
"query": "ping",
"documents": []string{"ping"},
})
default:
return nil, fmt.Errorf("unsupported probe endpoint %s", endpoint)
}
}
func probeModelID(s *Service, providerID uint, endpoint provider.EndpointType) string {
for _, m := range s.cache.GetModels(providerID) {
if m.Enabled && m.ModelID != "" {
return m.ModelID
}
}
switch endpoint {
case provider.EndpointEmbeddings:
return "text-embedding-3-small"
case provider.EndpointRerank:
return "rerank-multilingual-v3.0"
default:
return "health-check"
}
}
func isHealthCheckHTTPStatus(code int) bool {
if code >= 200 && code < 300 {
return true
}
// Upstream reachable but rejected payload/model — still counts as healthy.
return code == 400 || code == 422
}
+51
View File
@@ -0,0 +1,51 @@
package gateway
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
func TestIsHealthCheckHTTPStatus(t *testing.T) {
cases := map[int]bool{
200: true,
204: true,
400: true,
422: true,
401: false,
404: false,
500: false,
}
for code, want := range cases {
if got := isHealthCheckHTTPStatus(code); got != want {
t.Fatalf("code %d: got %v want %v", code, got, want)
}
}
}
func TestHealthCheckEndpointsEmbedding(t *testing.T) {
s := &Service{}
p := &model.Provider{
Category: model.CategoryEmbedding,
Type: model.ProviderOpenAI,
AllowedEndpointsJSON: `["embeddings"]`,
}
eps := s.healthCheckEndpoints(p)
if len(eps) != 1 || eps[0] != provider.EndpointEmbeddings {
t.Fatalf("got %v", eps)
}
}
func TestHealthCheckEndpointsEmbeddingWithListModels(t *testing.T) {
s := &Service{}
p := &model.Provider{
Category: model.CategoryEmbedding,
Type: model.ProviderOpenAI,
AllowedEndpointsJSON: `["list_models","embeddings"]`,
}
eps := s.healthCheckEndpoints(p)
if len(eps) != 2 || eps[0] != provider.EndpointListModels || eps[1] != provider.EndpointEmbeddings {
t.Fatalf("got %v", eps)
}
}
+90
View File
@@ -0,0 +1,90 @@
package gateway
import (
"encoding/json"
"fmt"
"strings"
"github.com/rose_cat707/luminary/internal/provider"
)
const maxLogBodyBytes = 16384
func truncateLogBody(b []byte) string {
if len(b) == 0 {
return ""
}
if len(b) <= maxLogBodyBytes {
return string(b)
}
return string(b[:maxLogBodyBytes]) + "\n...(truncated)"
}
func extractModelName(body []byte) string {
if len(body) == 0 {
return ""
}
var payload struct {
Model string `json:"model"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
return payload.Model
}
func describeForwardError(err error, resp *provider.ChatResponse) string {
if err != nil {
return err.Error()
}
if resp == nil {
return "unknown error"
}
if resp.StatusCode < 400 {
return ""
}
if msg := extractAPIErrorMessage(resp.Body); msg != "" {
return fmt.Sprintf("status %d: %s", resp.StatusCode, msg)
}
if len(resp.Body) > 0 {
return fmt.Sprintf("status %d: %s", resp.StatusCode, truncateLogBody(resp.Body))
}
return fmt.Sprintf("status %d", resp.StatusCode)
}
func extractAPIErrorMessage(body []byte) string {
if len(body) == 0 {
return ""
}
var openAI struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
if err := json.Unmarshal(body, &openAI); err == nil {
if openAI.Error.Message != "" {
if openAI.Error.Type != "" {
return openAI.Error.Type + ": " + openAI.Error.Message
}
return openAI.Error.Message
}
}
var simple struct {
Message string `json:"message"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &simple); err == nil {
if simple.Message != "" {
return simple.Message
}
if simple.Error != "" {
return simple.Error
}
}
s := strings.TrimSpace(string(body))
if len(s) > 240 {
return s[:240] + "..."
}
return s
}
+25
View File
@@ -0,0 +1,25 @@
package gateway
import (
"testing"
"github.com/rose_cat707/luminary/internal/provider"
)
func TestExtractAPIErrorMessage_OpenAI(t *testing.T) {
body := []byte(`{"error":{"message":"Invalid API key","type":"invalid_request_error"}}`)
got := extractAPIErrorMessage(body)
if got != "invalid_request_error: Invalid API key" {
t.Fatalf("got %q", got)
}
}
func TestDescribeForwardError_WithBody(t *testing.T) {
got := describeForwardError(nil, &provider.ChatResponse{
StatusCode: 400,
Body: []byte(`{"error":{"message":"model not found"}}`),
})
if got != "status 400: model not found" {
t.Fatalf("got %q", got)
}
}
+53
View File
@@ -0,0 +1,53 @@
package gateway
import (
"encoding/json"
"github.com/rose_cat707/luminary/internal/model"
)
// IngressModelID returns the model identifier exposed to ingress clients.
func IngressModelID(m model.ModelEntry) string {
return m.IngressID()
}
func modelEntryMatches(m model.ModelEntry, name string) bool {
return m.MatchesIngressName(name)
}
func (s *Service) resolveUpstreamModelID(providerID uint, ingressModel string) string {
if ingressModel == "" {
return ""
}
for _, m := range s.cache.GetModels(providerID) {
if modelEntryMatches(m, ingressModel) {
return m.ModelID
}
}
return ingressModel
}
func rewriteModelInBody(body []byte, upstreamModel string) ([]byte, error) {
if upstreamModel == "" || len(body) == 0 {
return body, nil
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return body, nil
}
current, _ := payload["model"].(string)
if current == "" || current == upstreamModel {
return body, nil
}
payload["model"] = upstreamModel
return json.Marshal(payload)
}
func (s *Service) rewriteBodyForProvider(providerID uint, body []byte) ([]byte, error) {
ingressModel := extractModelName(body)
if ingressModel == "" {
return body, nil
}
upstream := s.resolveUpstreamModelID(providerID, ingressModel)
return rewriteModelInBody(body, upstream)
}
+32
View File
@@ -0,0 +1,32 @@
package gateway
import (
"encoding/json"
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestIngressModelID(t *testing.T) {
if got := IngressModelID(model.ModelEntry{ModelID: "text-embedding-3-small", Alias: "my-embed"}); got != "my-embed" {
t.Fatalf("got %q", got)
}
if got := (model.ModelEntry{ModelID: "gpt-4o"}).IngressID(); got != "gpt-4o" {
t.Fatalf("got %q", got)
}
}
func TestRewriteModelInBody(t *testing.T) {
body := []byte(`{"model":"my-embed","input":"hi"}`)
out, err := rewriteModelInBody(body, "text-embedding-3-small")
if err != nil {
t.Fatal(err)
}
var payload map[string]string
if err := json.Unmarshal(out, &payload); err != nil {
t.Fatal(err)
}
if payload["model"] != "text-embedding-3-small" {
t.Fatalf("got %q", payload["model"])
}
}
+104
View File
@@ -0,0 +1,104 @@
package gateway
import (
"strings"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
type routeTarget struct {
ProviderID uint
ProviderKeyID uint // 0 = auto load balance
}
func (s *Service) resolveRoute(ingressID uint, modelName string) *routeTarget {
rules := s.cache.ListRoutingRules(ingressID)
var best *model.RoutingRule
for i := range rules {
r := rules[i]
if !r.Enabled {
continue
}
if !matchPattern(r.ModelPattern, modelName) {
continue
}
if best == nil || r.Priority > best.Priority {
best = &r
}
}
if best == nil {
return nil
}
return &routeTarget{ProviderID: best.ProviderID, ProviderKeyID: best.ProviderKeyID}
}
func matchPattern(pattern, model string) bool {
if pattern == "" || pattern == "*" {
return true
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*"))
}
return pattern == model
}
func (s *Service) findProvidersForModel(modelName string, allowed []string, category model.ProviderCategory, route *routeTarget) ([]model.Provider, error) {
if route != nil {
if p, ok := s.cache.GetProvider(route.ProviderID); ok && p.Category == category && p.Enabled {
if cache.MatchesList(allowed, p.Name) || cache.MatchesList(allowed, string(p.Type)) || cache.MatchesList(allowed, "*") {
return []model.Provider{p}, nil
}
}
}
var matched []model.Provider
for _, p := range s.cache.ListProviders() {
if p.Category != category || !model.IsCategorySupported(p.Category) || !p.Enabled {
continue
}
if !cache.MatchesList(allowed, p.Name) && !cache.MatchesList(allowed, string(p.Type)) {
continue
}
if s.providerSupportsModel(&p, modelName) {
matched = append(matched, p)
}
}
if len(matched) == 0 {
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
if p, ok := s.cache.GetProviderByName(parts[0]); ok && p.Category == category && p.Enabled {
if s.providerSupportsModel(&p, parts[1]) {
return []model.Provider{p}, nil
}
}
}
return nil, errNoProvider(modelName)
}
return matched, nil
}
func (s *Service) providerSupportsModel(p *model.Provider, modelName string) bool {
models := s.cache.GetModels(p.ID)
if len(models) == 0 {
for _, k := range s.cache.ListProviderKeys(p.ID) {
allowed := cache.ParseJSONList(k.ModelsJSON)
if cache.MatchesList(allowed, modelName) || cache.MatchesList(allowed, "*") {
return true
}
}
return false
}
for _, m := range models {
if m.Enabled && modelEntryMatches(m, modelName) {
return true
}
}
return false
}
func errNoProvider(model string) error {
return &gatewayError{msg: "no provider found for model " + model}
}
type gatewayError struct{ msg string }
func (e *gatewayError) Error() string { return e.msg }
+475
View File
@@ -0,0 +1,475 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/limiter"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/pricing"
"github.com/rose_cat707/luminary/internal/provider"
"github.com/rose_cat707/luminary/internal/provider/anthropic"
"github.com/rose_cat707/luminary/internal/provider/comfyui"
"github.com/rose_cat707/luminary/internal/provider/openai"
"github.com/rose_cat707/luminary/internal/settings"
"github.com/rose_cat707/luminary/internal/store/cache"
)
type Service struct {
cache *cache.Store
settings *settings.Runtime
openai *openai.Client
anthropic *anthropic.Client
streamClient *provider.HTTPClientWrapper
}
func New(c *cache.Store, rt *settings.Runtime) *Service {
return &Service{
cache: c,
settings: rt,
openai: openai.New(),
anthropic: anthropic.New(),
streamClient: &provider.HTTPClientWrapper{Client: provider.NewStreamHTTPClient()},
}
}
func (s *Service) ResolveIngressKey(plainKey string) (*model.IngressKey, error) {
k, ok := s.cache.LookupIngressKey(plainKey)
if !ok {
return nil, fmt.Errorf("invalid api key")
}
if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
return nil, fmt.Errorf("api key expired")
}
return k, nil
}
func (s *Service) providerConfig(p *model.Provider) provider.ProviderConfig {
return provider.ProviderConfig{
Type: p.Type,
Category: p.Category,
BaseURL: p.BaseURL,
EndpointPaths: provider.ParseEndpointPaths(p.EndpointPathsJSON),
}
}
func (s *Service) DecryptAPIKey(enc string) (string, error) {
return auth.Decrypt(enc, s.settings.EncryptionKey())
}
func (s *Service) providerAllowsEndpoint(p *model.Provider, endpoint provider.EndpointType) bool {
allowed := provider.ParseAllowedEndpoints(p.AllowedEndpointsJSON, p.Category)
return provider.IsEndpointAllowed(allowed, endpoint)
}
func (s *Service) canServeEndpoint(p *model.Provider, endpoint provider.EndpointType) bool {
if s.providerAllowsEndpoint(p, endpoint) {
return true
}
return endpoint == provider.EndpointResponses && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion)
}
func (s *Service) shouldAdaptResponses(p *model.Provider) bool {
return !s.providerAllowsEndpoint(p, provider.EndpointResponses) && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion)
}
func (s *Service) forward(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
var err error
body, err = s.rewriteBodyForProvider(p.ID, body)
if err != nil {
return nil, err
}
actualEndpoint := endpoint
actualBody := body
adaptResponses := false
if endpoint == provider.EndpointResponses {
if s.shouldAdaptResponses(p) {
adapted, err := provider.ResponsesToChatCompletion(body)
if err != nil {
return nil, err
}
actualBody = adapted
actualEndpoint = provider.EndpointChatCompletion
adaptResponses = true
} else if !s.providerAllowsEndpoint(p, endpoint) {
return nil, fmt.Errorf("endpoint %s not enabled on provider %s", endpoint, p.Name)
}
} else if !s.providerAllowsEndpoint(p, endpoint) {
return nil, fmt.Errorf("endpoint %s not enabled on provider %s", endpoint, p.Name)
}
resp, err := s.forwardRaw(ctx, p, apiKey, actualEndpoint, actualBody)
if err != nil {
return nil, err
}
if endpoint == provider.EndpointResponses && !adaptResponses && resp != nil && resp.StatusCode == http.StatusNotFound && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion) {
adaptedBody, adaptErr := provider.ResponsesToChatCompletion(body)
if adaptErr == nil {
if resp2, err2 := s.forwardRaw(ctx, p, apiKey, provider.EndpointChatCompletion, adaptedBody); err2 == nil && resp2 != nil {
resp = resp2
adaptResponses = true
}
}
}
if adaptResponses && resp != nil && resp.StatusCode < 400 {
converted, convErr := provider.ChatCompletionToResponses(resp.Body)
if convErr == nil {
resp.Body = converted
}
}
return resp, nil
}
func (s *Service) forwardRaw(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
cfg := s.providerConfig(p)
if p.Type == model.ProviderAnthropic && endpoint == provider.EndpointChatCompletion {
return s.anthropic.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body)
}
return s.openai.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body)
}
func (s *Service) forwardStream(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte, w io.Writer, flusher http.Flusher) (*provider.StreamResult, error) {
var err error
body, err = s.rewriteBodyForProvider(p.ID, body)
if err != nil {
return nil, err
}
actualEndpoint := endpoint
actualBody := body
adaptResponses := false
if endpoint == provider.EndpointResponses {
if s.shouldAdaptResponses(p) {
adapted, err := provider.ResponsesToChatCompletion(body)
if err != nil {
return nil, err
}
actualBody = adapted
actualEndpoint = provider.EndpointChatCompletion
adaptResponses = true
} else if !s.providerAllowsEndpoint(p, endpoint) {
return nil, fmt.Errorf("endpoint %s not enabled", endpoint)
}
} else if !s.providerAllowsEndpoint(p, endpoint) {
return nil, fmt.Errorf("endpoint %s not enabled", endpoint)
}
cfg := s.providerConfig(p)
url := cfg.ResolveEndpointURL(actualEndpoint)
if adaptResponses {
pr, pw := io.Pipe()
errCh := make(chan error, 1)
go func() {
_, err := provider.ForwardStreamHTTP(ctx, s.streamClient.Client, "POST", url, apiKey, p.Type, actualBody, pw, nil)
_ = pw.Close()
errCh <- err
}()
result, err := provider.AdaptResponsesStream(body, pr, w, flusher)
if streamErr := <-errCh; err == nil && streamErr != nil {
err = streamErr
}
return result, err
}
return provider.ForwardStreamHTTP(ctx, s.streamClient.Client, "POST", url, apiKey, p.Type, actualBody, w, flusher)
}
type forwardParams struct {
ingress *model.IngressKey
endpoint provider.EndpointType
category model.ProviderCategory
body []byte
stream bool
}
func (s *Service) prepareForward(params forwardParams) (modelName string, providers []model.Provider, route *routeTarget, err error) {
if len(params.body) > 0 {
var payload map[string]any
if err := json.Unmarshal(params.body, &payload); err != nil {
return "", nil, nil, fmt.Errorf("%w: %v", ErrInvalidJSON, err)
}
modelName, _ = payload["model"].(string)
}
if params.endpoint != provider.EndpointListModels && modelName == "" {
return "", nil, nil, ErrModelRequired
}
allowedProviders := cache.ParseJSONList(params.ingress.AllowedProvidersJSON)
if modelName != "" {
allowedModels := cache.ParseJSONList(params.ingress.AllowedModelsJSON)
if !cache.MatchesList(allowedModels, modelName) && !cache.MatchesList(allowedModels, "*") {
return "", nil, nil, ErrModelNotAllowed
}
}
if !limiter.CheckIngressBudget(params.ingress, 0) {
return "", nil, nil, ErrBudgetExceeded
}
if !limiter.CheckRateLimit(s.cache, params.ingress.ID, params.ingress.RateLimitRPM, params.ingress.RateLimitTPM, 1000) {
return "", nil, nil, ErrRateLimitExceeded
}
route = s.resolveRoute(params.ingress.ID, modelName)
providers, err = s.findProvidersForModel(modelName, allowedProviders, params.category, route)
return modelName, providers, route, err
}
func (s *Service) recordUsage(ingress *model.IngressKey, att *attempt, clientIP, modelName, endpoint string, stream bool, tokensIn, tokensOut int, latency int64, status, errMsg, requestBody, responseBody string) {
var providerKeyID, providerID uint
if att != nil {
providerKeyID = att.key.ID
providerID = att.provider.ID
}
cost := pricing.Estimate(modelName, tokensIn, tokensOut, endpoint)
s.cache.RecordUsage(cache.UsageDelta{
IngressKeyID: ingress.ID,
ProviderKeyID: providerKeyID,
ProviderID: providerID,
ClientIP: clientIP,
Endpoint: endpoint,
Model: modelName,
TokensIn: tokensIn,
TokensOut: tokensOut,
Cost: cost,
LatencyMs: latency,
Status: status,
Stream: stream,
ErrorMsg: errMsg,
RequestBody: requestBody,
ResponseBody: responseBody,
BudgetUsed: cost,
Requests: 1,
Tokens: int64(tokensIn + tokensOut),
})
}
func (s *Service) ForwardEndpoint(ctx context.Context, ingress *model.IngressKey, endpoint provider.EndpointType, rawBody []byte, clientIP string) (*provider.ChatResponse, error) {
category := categoryForEndpoint(endpoint)
modelName, providers, route, err := s.prepareForward(forwardParams{
ingress: ingress, endpoint: endpoint, category: category, body: rawBody,
})
if err != nil {
s.recordUsage(ingress, nil, clientIP, modelNameOrBody(rawBody, modelName), string(endpoint), false, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "")
return nil, err
}
attempts, err := s.buildAttempts(providers, modelName, route)
if err != nil {
s.recordUsage(ingress, nil, clientIP, modelName, string(endpoint), false, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "")
return nil, err
}
start := time.Now()
resp, att, err := s.forwardWithFailover(ctx, attempts, endpoint, rawBody)
latency := time.Since(start).Milliseconds()
status := "success"
if err != nil || (resp != nil && resp.StatusCode >= 400) {
status = "error"
}
errMsg := describeForwardError(err, resp)
tokensIn, tokensOut := 0, 0
var respBody []byte
if resp != nil {
tokensIn, tokensOut = resp.TokensIn, resp.TokensOut
respBody = resp.Body
}
s.recordUsage(ingress, att, clientIP, modelName, string(endpoint), false, tokensIn, tokensOut, latency, status, errMsg, truncateLogBody(rawBody), truncateLogBody(respBody))
if err != nil && resp != nil && resp.StatusCode >= 400 {
return resp, nil
}
return resp, err
}
func modelNameOrBody(body []byte, modelName string) string {
if modelName != "" {
return modelName
}
return extractModelName(body)
}
func (s *Service) ForwardStream(ctx context.Context, ingress *model.IngressKey, endpoint provider.EndpointType, rawBody []byte, clientIP string, w io.Writer, flusher http.Flusher) error {
category := categoryForEndpoint(endpoint)
modelName, providers, route, err := s.prepareForward(forwardParams{
ingress: ingress, endpoint: endpoint, category: category, body: rawBody, stream: true,
})
if err != nil {
s.recordUsage(ingress, nil, clientIP, modelNameOrBody(rawBody, modelName), string(endpoint), true, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "")
return err
}
attempts, err := s.buildAttempts(providers, modelName, route)
if err != nil {
s.recordUsage(ingress, nil, clientIP, modelName, string(endpoint), true, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "")
return err
}
var lastErr error
var lastAtt *attempt
maxRetries := s.settings.GatewayMaxRetries()
if maxRetries < 1 {
maxRetries = 1
}
backoffMs := s.settings.GatewayRetryBackoffMs()
for _, att := range attempts {
if !s.canServeEndpoint(att.provider, endpoint) {
continue
}
for try := 0; try < maxRetries; try++ {
if try > 0 {
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
}
start := time.Now()
result, err := s.forwardStream(ctx, att.provider, att.apiKey, endpoint, rawBody, w, flusher)
latency := time.Since(start).Milliseconds()
lastAtt = &att
if err != nil {
lastErr = err
continue
}
if result == nil {
lastErr = fmt.Errorf("empty upstream stream response")
continue
}
if result.StatusCode < 400 {
s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "success", "", truncateLogBody(rawBody), "[SSE stream]")
return nil
}
errMsg := describeForwardError(nil, &provider.ChatResponse{StatusCode: result.StatusCode, Body: result.Body})
lastErr = fmt.Errorf("%s", errMsg)
respBody := "[SSE stream]"
if len(result.Body) > 0 {
respBody = truncateLogBody(result.Body)
}
if passThroughUpstreamStatus(result.StatusCode) {
s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "error", errMsg, truncateLogBody(rawBody), respBody)
return nil
}
if tryNextKeyOnUpstreamStatus(result.StatusCode) {
break
}
if retryableUpstreamStatus(result.StatusCode) {
continue
}
s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "error", errMsg, truncateLogBody(rawBody), respBody)
return nil
}
}
if lastErr == nil {
lastErr = fmt.Errorf("all providers failed")
}
s.recordUsage(ingress, lastAtt, clientIP, modelName, string(endpoint), true, 0, 0, 0, "error", lastErr.Error(), truncateLogBody(rawBody), "[SSE stream]")
return lastErr
}
func categoryForEndpoint(ep provider.EndpointType) model.ProviderCategory {
switch ep {
case provider.EndpointEmbeddings:
return model.CategoryEmbedding
case provider.EndpointRerank:
return model.CategoryRerank
default:
return model.CategoryLLM
}
}
func listModelsCategories() []model.ProviderCategory {
return []model.ProviderCategory{
model.CategoryLLM,
model.CategoryEmbedding,
model.CategoryRerank,
}
}
func providerInListModelsCategory(cat model.ProviderCategory) bool {
for _, c := range listModelsCategories() {
if c == cat {
return true
}
}
return false
}
func (s *Service) ListModels(ctx context.Context, ingress *model.IngressKey) ([]map[string]any, error) {
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
var result []map[string]any
var hasLLM bool
for _, p := range s.cache.ListProviders() {
if !providerInListModelsCategory(p.Category) {
continue
}
if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) {
continue
}
if !s.providerAllowsEndpoint(&p, provider.EndpointListModels) {
continue
}
if p.Category == model.CategoryLLM {
hasLLM = true
}
for _, m := range s.cache.GetModels(p.ID) {
if m.Enabled {
result = append(result, map[string]any{
"id": IngressModelID(m), "object": "model", "owned_by": p.Name,
})
}
}
}
if len(result) == 0 && hasLLM {
result = append(result, map[string]any{"id": "gpt-4o-mini", "object": "model", "owned_by": "luminary"})
}
return result, nil
}
func (s *Service) FetchModels(ctx context.Context, providerID uint) ([]provider.ModelInfo, error) {
p, ok := s.cache.GetProvider(providerID)
if !ok {
return nil, fmt.Errorf("provider not found")
}
if !s.providerAllowsEndpoint(&p, provider.EndpointListModels) {
return nil, fmt.Errorf("list_models endpoint not enabled")
}
keys := s.cache.ListProviderKeys(providerID)
var firstKey string
for _, k := range keys {
if k.Enabled {
plain, err := s.DecryptAPIKey(k.APIKeyEnc)
if err == nil {
firstKey = plain
break
}
}
}
if firstKey == "" && p.Type != model.ProviderOllama {
return nil, fmt.Errorf("no enabled keys")
}
cfg := s.providerConfig(&p)
if p.Type == model.ProviderAnthropic {
return s.anthropic.ListModelsWithConfig(ctx, firstKey, cfg)
}
return s.openai.ListModelsWithConfig(ctx, firstKey, cfg)
}
func (s *Service) HealthCheckProvider(ctx context.Context, providerID uint) (model.ProviderStatus, int64, string) {
p, ok := s.cache.GetProvider(providerID)
if !ok {
return model.ProviderUnhealthy, 0, "not found"
}
if !model.IsCategorySupported(p.Category) {
return model.ProviderUnhealthy, 0, "category not supported"
}
if p.Type == model.ProviderComfyUI {
start := time.Now()
if err := comfyui.New(p.BaseURL).HealthCheck(ctx); err != nil {
return model.ProviderUnhealthy, time.Since(start).Milliseconds(), err.Error()
}
return model.ProviderHealthy, time.Since(start).Milliseconds(), ""
}
keys := s.cache.ListProviderKeys(providerID)
if len(keys) == 0 && p.Type != model.ProviderOllama {
return model.ProviderUnhealthy, 0, "no provider keys configured"
}
cfg := s.providerConfig(&p)
return s.runHealthChecks(ctx, &p, keys, cfg)
}
+43
View File
@@ -0,0 +1,43 @@
package gateway
import "net/http"
// retryableUpstreamStatus reports HTTP codes that should trigger retry / failover.
func retryableUpstreamStatus(code int) bool {
switch code {
case http.StatusTooManyRequests,
http.StatusRequestTimeout,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return code >= 500
}
}
// tryNextKeyOnUpstreamStatus reports auth/rate errors where another provider key may work.
func tryNextKeyOnUpstreamStatus(code int) bool {
switch code {
case http.StatusUnauthorized,
http.StatusForbidden,
http.StatusTooManyRequests,
http.StatusPaymentRequired:
return true
default:
return retryableUpstreamStatus(code)
}
}
// passThroughUpstreamStatus reports client errors that should be returned as-is.
func passThroughUpstreamStatus(code int) bool {
switch code {
case http.StatusBadRequest,
http.StatusNotFound,
http.StatusUnprocessableEntity,
http.StatusUnsupportedMediaType:
return true
default:
return false
}
}
+27
View File
@@ -0,0 +1,27 @@
package gateway
import (
"net/http"
"testing"
)
func TestRetryableUpstreamStatus(t *testing.T) {
if !retryableUpstreamStatus(http.StatusTooManyRequests) {
t.Fatal("429 should be retryable")
}
if !retryableUpstreamStatus(http.StatusServiceUnavailable) {
t.Fatal("503 should be retryable")
}
if retryableUpstreamStatus(http.StatusBadRequest) {
t.Fatal("400 should not be retryable")
}
}
func TestTryNextKeyOnUpstreamStatus(t *testing.T) {
if !tryNextKeyOnUpstreamStatus(http.StatusTooManyRequests) {
t.Fatal("429 should try next key")
}
if !tryNextKeyOnUpstreamStatus(http.StatusUnauthorized) {
t.Fatal("401 should try next key")
}
}
+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)
}
+30
View File
@@ -0,0 +1,30 @@
package limiter
import (
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func IsProviderKeyAvailable(k model.ProviderKey) bool {
if !k.Enabled {
return false
}
if k.MaxRequestsPerDay > 0 && k.RequestsToday >= k.MaxRequestsPerDay {
return false
}
if k.MaxTokensPerDay > 0 && k.TokensToday >= k.MaxTokensPerDay {
return false
}
return true
}
func CheckIngressBudget(k *model.IngressKey, estimatedCost float64) bool {
if k.BudgetLimit <= 0 {
return true
}
return k.BudgetUsed+estimatedCost <= k.BudgetLimit
}
func CheckRateLimit(c *cache.Store, ingressKeyID uint, rpm, tpm, tokens int) bool {
return c.CheckRateLimit(ingressKeyID, rpm, tpm, tokens)
}
+88
View File
@@ -0,0 +1,88 @@
package middleware
import (
"net"
"strings"
"sync"
"github.com/gin-gonic/gin"
)
var (
trustedMu sync.RWMutex
trustedProxies []*net.IPNet
trustedIPs []net.IP
)
// ConfigureTrustedProxies sets CIDRs/IPs allowed to set X-Forwarded-For / X-Real-IP.
// When empty, only the direct remote address is used.
func ConfigureTrustedProxies(cidrs []string) {
trustedMu.Lock()
defer trustedMu.Unlock()
trustedProxies = nil
trustedIPs = nil
for _, cidr := range cidrs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if strings.Contains(cidr, "/") {
_, network, err := net.ParseCIDR(cidr)
if err == nil {
trustedProxies = append(trustedProxies, network)
}
continue
}
if ip := net.ParseIP(cidr); ip != nil {
trustedIPs = append(trustedIPs, ip)
}
}
}
func isTrustedProxy(ip net.IP) bool {
trustedMu.RLock()
defer trustedMu.RUnlock()
if len(trustedProxies) == 0 && len(trustedIPs) == 0 {
return false
}
for _, tip := range trustedIPs {
if tip.Equal(ip) {
return true
}
}
for _, network := range trustedProxies {
if network.Contains(ip) {
return true
}
}
return false
}
func directRemoteIP(ctx *gin.Context) string {
host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr)
if err != nil {
return strings.TrimSpace(ctx.Request.RemoteAddr)
}
return host
}
// ClientIP returns the client IP. Forwarded headers are honored only when the
// direct remote address is a configured trusted proxy.
func ClientIP(ctx *gin.Context) string {
remote := directRemoteIP(ctx)
remoteIP := net.ParseIP(remote)
if remoteIP == nil || !isTrustedProxy(remoteIP) {
if remoteIP != nil {
return remoteIP.String()
}
return ctx.ClientIP()
}
if xff := ctx.GetHeader("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := ctx.GetHeader("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return remoteIP.String()
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestClientIP_IgnoresXFFWithoutTrustedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
ConfigureTrustedProxies(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.RemoteAddr = "203.0.113.10:12345"
c.Request.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := ClientIP(c); got != "203.0.113.10" {
t.Fatalf("got %s want direct remote", got)
}
}
func TestClientIP_UsesXFFFromTrustedProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
ConfigureTrustedProxies([]string{"127.0.0.1"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.RemoteAddr = "127.0.0.1:12345"
c.Request.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := ClientIP(c); got != "1.2.3.4" {
t.Fatalf("got %s want forwarded client", got)
}
}
+31
View File
@@ -0,0 +1,31 @@
package middleware
import (
"net"
"testing"
)
func TestIPMatchesRule_LoopbackEquivalence(t *testing.T) {
v4 := net.ParseIP("127.0.0.1")
v6 := net.ParseIP("::1")
if !ipMatchesRule("127.0.0.1", v6) {
t.Fatal("::1 should match 127.0.0.1 allow rule")
}
if !ipMatchesRule("::1", v4) {
t.Fatal("127.0.0.1 should match ::1 allow rule")
}
if !ipMatchesRule("127.0.0.0/8", v6) {
t.Fatal("::1 should match 127.0.0.0/8 via loopback equivalence")
}
}
func TestIPMatchesRule_CIDR(t *testing.T) {
ip := net.ParseIP("10.0.0.5")
if !ipMatchesRule("10.0.0.0/24", ip) {
t.Fatal("expected CIDR match")
}
if ipMatchesRule("192.168.1.0/24", ip) {
t.Fatal("expected no match")
}
}
+166
View File
@@ -0,0 +1,166 @@
package middleware
import (
"net"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func IPFilter(c *cache.Store, scope model.IPRuleScope) gin.HandlerFunc {
return func(ctx *gin.Context) {
rules := c.ListIPRules()
if len(rules) == 0 {
ctx.Next()
return
}
clientIP := clientIP(ctx)
ip := net.ParseIP(clientIP)
if ip == nil {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid client ip"})
return
}
var allowRules, denyRules []model.IPRule
for _, r := range rules {
if !r.Enabled {
continue
}
if r.Scope != scope && r.Scope != model.IPScopeAll {
continue
}
if r.Type == model.IPRuleAllow {
allowRules = append(allowRules, r)
} else {
denyRules = append(denyRules, r)
}
}
for _, r := range denyRules {
if ipMatchesRule(r.CIDR, ip) {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip denied"})
return
}
}
if len(allowRules) > 0 {
allowed := false
for _, r := range allowRules {
if ipMatchesRule(r.CIDR, ip) {
allowed = true
break
}
}
if !allowed {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip not allowed"})
return
}
}
ctx.Next()
}
}
func cidrContains(cidr string, ip net.IP) bool {
if !strings.Contains(cidr, "/") {
return ip.String() == cidr
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return network.Contains(ip)
}
// ipMatchesRule checks CIDR/IP match, treating IPv4/IPv6 loopback as equivalent.
func ipMatchesRule(cidr string, ip net.IP) bool {
if cidrContains(cidr, ip) {
return true
}
if !ip.IsLoopback() {
return false
}
alt := loopbackAlt(ip)
return alt != nil && cidrContains(cidr, alt)
}
func loopbackAlt(ip net.IP) net.IP {
if v4 := ip.To4(); v4 != nil && v4.IsLoopback() {
return net.ParseIP("::1")
}
if ip.Equal(net.ParseIP("::1")) {
return net.ParseIP("127.0.0.1")
}
return nil
}
func clientIP(ctx *gin.Context) string {
return ClientIP(ctx)
}
const sessionCookie = "luminary_session"
type SessionValidator func(token string) (bool, time.Time)
func AdminAuth(validate SessionValidator) gin.HandlerFunc {
return func(ctx *gin.Context) {
token := extractToken(ctx)
if token == "" {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
ok, expires := validate(token)
if !ok || time.Now().After(expires) {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
ctx.Set("session_token", token)
ctx.Next()
}
}
func extractToken(ctx *gin.Context) string {
if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
if cookie, err := ctx.Cookie(sessionCookie); err == nil {
return cookie
}
return ""
}
func SessionCookieName() string { return sessionCookie }
func IngressAuth(gw IngressKeyResolver) gin.HandlerFunc {
return func(ctx *gin.Context) {
key := extractIngressKey(ctx)
if key == "" {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"})
return
}
ingress, err := gw.ResolveIngressKey(key)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
return
}
ctx.Set("ingress_key", ingress)
ctx.Next()
}
}
func extractIngressKey(ctx *gin.Context) string {
if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ctx.GetHeader("x-api-key")
}
type IngressKeyResolver interface {
ResolveIngressKey(key string) (*model.IngressKey, error)
}
func Logger() gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n"
})
}
+26
View File
@@ -0,0 +1,26 @@
package model
// ProviderCategory 出口服务分类
type ProviderCategory string
const (
CategoryLLM ProviderCategory = "llm" // 语言模型
CategoryEmbedding ProviderCategory = "embedding" // 向量嵌入
CategoryRerank ProviderCategory = "rerank" // 重排序
CategorySpeech ProviderCategory = "speech" // 语音合成(预留)
CategoryImage ProviderCategory = "image" // 图像生成
CategoryAudio ProviderCategory = "audio" // 音频转写(预留)
)
func SupportedCategories() []ProviderCategory {
return []ProviderCategory{CategoryLLM, CategoryEmbedding, CategoryRerank, CategoryImage}
}
func IsCategorySupported(c ProviderCategory) bool {
for _, s := range SupportedCategories() {
if s == c {
return true
}
}
return false
}
+243
View File
@@ -0,0 +1,243 @@
package model
import (
"time"
)
type ProviderType string
const (
ProviderOpenAI ProviderType = "openai"
ProviderAnthropic ProviderType = "anthropic"
ProviderCustom ProviderType = "custom"
ProviderAzureOpenAI ProviderType = "azure_openai"
ProviderOpenRouter ProviderType = "openrouter"
ProviderOllama ProviderType = "ollama"
ProviderComfyUI ProviderType = "comfyui"
)
type WorkflowType string
const (
WorkflowTxt2Img WorkflowType = "txt2img"
WorkflowImg2Img WorkflowType = "img2img"
)
type PredictionStatus string
const (
PredictionStarting PredictionStatus = "starting"
PredictionProcessing PredictionStatus = "processing"
PredictionSucceeded PredictionStatus = "succeeded"
PredictionFailed PredictionStatus = "failed"
PredictionCanceled PredictionStatus = "canceled"
)
type ProviderStatus string
const (
ProviderHealthy ProviderStatus = "healthy"
ProviderUnhealthy ProviderStatus = "unhealthy"
ProviderUnknown ProviderStatus = "unknown"
)
type Provider struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;not null" json:"name"`
Type ProviderType `gorm:"not null" json:"type"`
Category ProviderCategory `gorm:"not null;default:llm" json:"category"`
BaseURL string `json:"base_url"`
AllowedEndpointsJSON string `gorm:"default:'[\"list_models\",\"chat_completion\"]'" json:"allowed_endpoints_json"`
EndpointPathsJSON string `gorm:"default:'{}'" json:"endpoint_paths_json"`
Status ProviderStatus `gorm:"default:unknown" json:"status"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Keys []ProviderKey `gorm:"foreignKey:ProviderID" json:"keys,omitempty"`
Models []ModelEntry `gorm:"foreignKey:ProviderID" json:"models,omitempty"`
}
type ProviderKey struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderID uint `gorm:"index;not null" json:"provider_id"`
Name string `gorm:"not null" json:"name"`
APIKeyEnc string `gorm:"not null" json:"-"`
Weight float64 `gorm:"default:1" json:"weight"`
ModelsJSON string `gorm:"default:'[\"*\"]'" json:"models_json"`
Enabled bool `gorm:"default:true" json:"enabled"`
MaxRequestsPerDay int `gorm:"default:0" json:"max_requests_per_day"`
MaxTokensPerDay int64 `gorm:"default:0" json:"max_tokens_per_day"`
RequestsToday int `gorm:"default:0" json:"requests_today"`
TokensToday int64 `gorm:"default:0" json:"tokens_today"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ModelEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderID uint `gorm:"index;not null" json:"provider_id"`
ModelID string `gorm:"not null" json:"model_id"`
Alias string `gorm:"index" json:"alias"`
DisplayName string `json:"display_name"`
WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"`
WorkflowJSON string `gorm:"type:text" json:"workflow_json"`
InputBindingJSON string `gorm:"type:text" json:"input_binding_json"`
VersionHash string `gorm:"index" json:"version_hash"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type IngressKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Prefix string `gorm:"not null" json:"prefix"`
Enabled bool `gorm:"default:true" json:"enabled"`
BudgetLimit float64 `gorm:"default:0" json:"budget_limit"`
BudgetUsed float64 `gorm:"default:0" json:"budget_used"`
RateLimitRPM int `gorm:"default:0" json:"rate_limit_rpm"`
RateLimitTPM int `gorm:"default:0" json:"rate_limit_tpm"`
AllowedProvidersJSON string `gorm:"default:'[\"*\"]'" json:"allowed_providers_json"`
AllowedModelsJSON string `gorm:"default:'[\"*\"]'" json:"allowed_models_json"`
RequestCount int64 `gorm:"default:0" json:"request_count"`
TokenCount int64 `gorm:"default:0" json:"token_count"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AdminUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type AdminSession struct {
ID uint `gorm:"primaryKey" json:"id"`
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type IPRuleType string
const (
IPRuleAllow IPRuleType = "allow"
IPRuleDeny IPRuleType = "deny"
)
type IPRuleScope string
const (
IPScopeAdmin IPRuleScope = "admin"
IPScopeProxy IPRuleScope = "proxy"
IPScopeAll IPRuleScope = "all"
)
type IPRule struct {
ID uint `gorm:"primaryKey" json:"id"`
CIDR string `gorm:"column:cidr;not null" json:"cidr"`
Type IPRuleType `gorm:"not null" json:"type"`
Scope IPRuleScope `gorm:"not null;default:proxy" json:"scope"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type UsageRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
IngressKeyID uint `gorm:"index" json:"ingress_key_id"`
ProviderID uint `gorm:"index" json:"provider_id"`
ProviderKeyID uint `gorm:"index" json:"provider_key_id"`
ClientIP string `gorm:"index" json:"client_ip"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
TokensIn int `json:"tokens_in"`
TokensOut int `json:"tokens_out"`
Cost float64 `json:"cost"`
LatencyMs int64 `json:"latency_ms"`
Status string `json:"status"`
Stream bool `json:"stream"`
ErrorMsg string `json:"error_msg"`
RequestBody string `gorm:"type:text" json:"request_body,omitempty"`
ResponseBody string `gorm:"type:text" json:"response_body,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
const (
MaxUsageLogRecords = 500
EndpointAdminLogin = "admin_login"
EndpointPrediction = "prediction"
)
type Prediction struct {
ID string `gorm:"primaryKey" json:"id"`
IngressKeyID uint `gorm:"index;not null" json:"ingress_key_id"`
ProviderID uint `gorm:"index;not null" json:"provider_id"`
ProviderKeyID uint `gorm:"index" json:"provider_key_id"`
ModelEntryID uint `gorm:"index" json:"model_entry_id"`
Version string `json:"version"`
Model string `json:"model"`
InputJSON string `gorm:"type:text" json:"input_json"`
Status PredictionStatus `gorm:"index;not null;default:starting" json:"status"`
ComfyPromptID string `json:"comfy_prompt_id"`
ClientID string `json:"client_id"`
OutputJSON string `gorm:"type:text" json:"output_json"`
Error string `gorm:"type:text" json:"error"`
Logs string `gorm:"type:text" json:"logs"`
MetricsJSON string `gorm:"type:text" json:"metrics_json"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
}
type StoredImage struct {
ID string `gorm:"primaryKey" json:"id"`
PredictionID string `gorm:"index;not null" json:"prediction_id"`
Filename string `json:"filename"`
LocalPath string `json:"-"`
Mime string `json:"mime"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
// RoutingRule Virtual Key 路由:按模型匹配绑定 Provider / ProviderKey
type RoutingRule struct {
ID uint `gorm:"primaryKey" json:"id"`
IngressKeyID uint `gorm:"index;not null" json:"ingress_key_id"`
ModelPattern string `gorm:"not null" json:"model_pattern"`
ProviderID uint `gorm:"not null" json:"provider_id"`
ProviderKeyID uint `gorm:"default:0" json:"provider_key_id"`
Priority int `gorm:"default:0" json:"priority"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
// SystemSettings singleton (id=1) for runtime configuration managed via admin UI.
type SystemSettings struct {
ID uint `gorm:"primaryKey" json:"id"`
EncryptionKey string `gorm:"not null" json:"-"`
SessionTTLSeconds int64 `gorm:"not null;default:86400" json:"session_ttl_seconds"`
LoginMaxAttempts int `gorm:"not null;default:5" json:"login_max_attempts"`
LoginLockoutSeconds int64 `gorm:"not null;default:900" json:"login_lockout_seconds"`
TrustedProxiesJSON string `gorm:"default:'[]'" json:"trusted_proxies_json"`
UsageFlushSeconds int64 `gorm:"not null;default:5" json:"usage_flush_seconds"`
HealthCheckSeconds int64 `gorm:"not null;default:30" json:"health_check_seconds"`
GatewayMaxRetries int `gorm:"not null;default:3" json:"gateway_max_retries"`
GatewayRetryBackoffMs int `gorm:"not null;default:200" json:"gateway_retry_backoff_ms"`
ImageStoragePath string `gorm:"default:'images'" json:"image_storage_path"`
PublicBaseURL string `json:"public_base_url"`
SignedURLTTLSeconds int64 `gorm:"not null;default:3600" json:"signed_url_ttl_seconds"`
PredictionWaitSeconds int64 `gorm:"not null;default:60" json:"prediction_wait_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type HealthCheckRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderID uint `gorm:"index" json:"provider_id"`
Status ProviderStatus `json:"status"`
LatencyMs int64 `json:"latency_ms"`
ErrorMsg string `json:"error_msg"`
CheckedAt time.Time `gorm:"index" json:"checked_at"`
}
+20
View File
@@ -0,0 +1,20 @@
package model
// IngressID returns the model name exposed to ingress API clients.
func (m ModelEntry) IngressID() string {
if m.Alias != "" {
return m.Alias
}
return m.ModelID
}
// MatchesIngressName reports whether the entry matches an ingress model identifier.
func (m ModelEntry) MatchesIngressName(name string) bool {
if name == "" || !m.Enabled {
return false
}
if m.ModelID == name || m.VersionHash == name {
return true
}
return m.Alias != "" && m.Alias == name
}
+175
View File
@@ -0,0 +1,175 @@
package monitor
import (
"context"
"log"
"time"
"github.com/rose_cat707/luminary/internal/gateway"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/settings"
"github.com/rose_cat707/luminary/internal/store/cache"
sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite"
"gorm.io/gorm"
)
type Service struct {
gateway *gateway.Service
cache *cache.Store
db *gorm.DB
settings *settings.Runtime
stop chan struct{}
healthRestart chan struct{}
flushRestart chan struct{}
flushFn func([]cache.UsageDelta)
}
func New(gw *gateway.Service, c *cache.Store, store *sqlitestore.Store, rt *settings.Runtime) *Service {
return &Service{
gateway: gw,
cache: c,
db: store.DB(),
settings: rt,
stop: make(chan struct{}),
healthRestart: make(chan struct{}, 1),
flushRestart: make(chan struct{}, 1),
}
}
func (s *Service) Start(flushFn func([]cache.UsageDelta)) {
s.flushFn = flushFn
go s.healthLoop()
go s.usageFlushLoop()
go s.dailyResetLoop()
}
func (s *Service) Stop() {
close(s.stop)
}
func (s *Service) NotifySettingsChanged() {
select {
case s.healthRestart <- struct{}{}:
default:
}
select {
case s.flushRestart <- struct{}{}:
default:
}
}
func (s *Service) healthInterval() time.Duration {
interval := s.settings.HealthCheckInterval()
if interval < time.Second {
return 30 * time.Second
}
return interval
}
func (s *Service) flushInterval() time.Duration {
interval := s.settings.UsageFlushInterval()
if interval < time.Second {
return 5 * time.Second
}
return interval
}
func (s *Service) healthLoop() {
var ticker *time.Ticker
resetTicker := func() {
if ticker != nil {
ticker.Stop()
}
ticker = time.NewTicker(s.healthInterval())
}
resetTicker()
defer ticker.Stop()
s.runChecks()
for {
select {
case <-ticker.C:
s.runChecks()
case <-s.healthRestart:
resetTicker()
case <-s.stop:
return
}
}
}
func (s *Service) usageFlushLoop() {
var ticker *time.Ticker
resetTicker := func() {
if ticker != nil {
ticker.Stop()
}
ticker = time.NewTicker(s.flushInterval())
}
resetTicker()
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flushUsage()
case <-s.flushRestart:
resetTicker()
case <-s.stop:
s.flushUsage()
return
}
}
}
func (s *Service) flushUsage() {
if s.flushFn == nil {
return
}
deltas := s.cache.DrainUsageDeltas()
if len(deltas) > 0 {
s.flushFn(deltas)
}
}
func (s *Service) runChecks() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, p := range s.cache.ListProviders() {
status, latency, errMsg := s.gateway.HealthCheckProvider(ctx, p.ID)
rec := model.HealthCheckRecord{
ProviderID: p.ID,
Status: status,
LatencyMs: latency,
ErrorMsg: errMsg,
CheckedAt: time.Now(),
}
s.cache.SetHealthStatus(p.ID, rec)
if err := s.db.Create(&rec).Error; err != nil {
log.Printf("save health check: %v", err)
}
}
}
func (s *Service) dailyResetLoop() {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
timer := time.NewTimer(time.Until(next))
select {
case <-timer.C:
s.resetDailyCounters()
case <-s.stop:
timer.Stop()
return
}
}
}
func (s *Service) resetDailyCounters() {
s.cache.ResetProviderKeyDailyCounts()
if err := s.db.Model(&model.ProviderKey{}).Updates(map[string]any{
"requests_today": 0,
"tokens_today": 0,
}).Error; err != nil {
log.Printf("reset daily provider key counters: %v", err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package prediction
import (
"encoding/json"
"fmt"
)
type NodeField struct {
Node string `json:"node"`
Field string `json:"field"`
}
type InputBinding map[string]*NodeField
func ParseBinding(raw string) (InputBinding, error) {
if raw == "" {
return InputBinding{}, nil
}
var b InputBinding
if err := json.Unmarshal([]byte(raw), &b); err != nil {
return nil, err
}
return b, nil
}
func (b InputBinding) Validate(workflow map[string]any, required []string) error {
for _, name := range required {
f, ok := b[name]
if !ok || f == nil || f.Node == "" || f.Field == "" {
return fmt.Errorf("required binding missing: %s", name)
}
}
for name, f := range b {
if f == nil || f.Node == "" || f.Field == "" {
continue
}
if err := validateNodeField(workflow, f.Node, f.Field); err != nil {
return fmt.Errorf("binding %s: %w", name, err)
}
}
return nil
}
func validateNodeField(workflow map[string]any, nodeID, field string) error {
node, ok := workflow[nodeID].(map[string]any)
if !ok {
return fmt.Errorf("node %s not found", nodeID)
}
inputs, ok := node["inputs"].(map[string]any)
if !ok {
return fmt.Errorf("node %s has no inputs", nodeID)
}
if _, ok := inputs[field]; !ok {
return fmt.Errorf("field %s not found on node %s", field, nodeID)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package prediction
import (
"sync"
)
type Event struct {
Status string `json:"status"`
Logs string `json:"logs,omitempty"`
Metrics map[string]any `json:"metrics,omitempty"`
Output any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Done bool `json:"-"`
}
type Hub struct {
mu sync.RWMutex
subs map[string]map[chan Event]struct{}
}
func NewHub() *Hub {
return &Hub{subs: make(map[string]map[chan Event]struct{})}
}
func (h *Hub) Subscribe(predictionID string) chan Event {
ch := make(chan Event, 16)
h.mu.Lock()
if h.subs[predictionID] == nil {
h.subs[predictionID] = make(map[chan Event]struct{})
}
h.subs[predictionID][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(predictionID string, ch chan Event) {
h.mu.Lock()
if m := h.subs[predictionID]; m != nil {
delete(m, ch)
if len(m) == 0 {
delete(h.subs, predictionID)
}
}
h.mu.Unlock()
close(ch)
}
func (h *Hub) Publish(predictionID string, ev Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs[predictionID] {
select {
case ch <- ev:
default:
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package prediction
import (
"crypto/rand"
"encoding/json"
"fmt"
"math/big"
)
func BuildPrompt(workflowJSON string, binding InputBinding, input map[string]any) (map[string]any, error) {
var workflow map[string]any
if err := json.Unmarshal([]byte(workflowJSON), &workflow); err != nil {
return nil, fmt.Errorf("invalid workflow: %w", err)
}
prompt := deepCopyMap(workflow)
for name, target := range binding {
if target == nil || target.Node == "" || target.Field == "" {
continue
}
val, ok := input[name]
if !ok {
continue
}
if name == "seed" {
if iv, ok := val.(int); ok && iv < 0 {
n, _ := rand.Int(rand.Reader, big.NewInt(1<<31-1))
val = int(n.Int64())
}
}
node, ok := prompt[target.Node].(map[string]any)
if !ok {
return nil, fmt.Errorf("node %s not found", target.Node)
}
inputs, ok := node["inputs"].(map[string]any)
if !ok {
return nil, fmt.Errorf("node %s inputs missing", target.Node)
}
inputs[target.Field] = val
}
return prompt, nil
}
func deepCopyMap(src map[string]any) map[string]any {
b, _ := json.Marshal(src)
var dst map[string]any
_ = json.Unmarshal(b, &dst)
return dst
}
+122
View File
@@ -0,0 +1,122 @@
package prediction
import (
"fmt"
"github.com/rose_cat707/luminary/internal/model"
)
type ParamType string
const (
ParamString ParamType = "string"
ParamInt ParamType = "int"
ParamFloat ParamType = "float"
)
type ParamDef struct {
Name string `json:"name"`
Label string `json:"label"`
Type ParamType `json:"type"`
Required bool `json:"required"`
Default any `json:"default,omitempty"`
}
var Txt2ImgParams = []ParamDef{
{Name: "prompt", Label: "正向提示词", Type: ParamString, Required: true},
{Name: "negative_prompt", Label: "负向提示词", Type: ParamString, Default: ""},
{Name: "seed", Label: "随机种子", Type: ParamInt, Default: -1},
{Name: "width", Label: "宽度", Type: ParamInt, Default: 1024},
{Name: "height", Label: "高度", Type: ParamInt, Default: 1024},
{Name: "steps", Label: "采样步数", Type: ParamInt, Default: 20},
{Name: "cfg_scale", Label: "CFG", Type: ParamFloat, Default: 7.0},
}
var Img2ImgParams = []ParamDef{
{Name: "prompt", Label: "正向提示词", Type: ParamString, Required: true},
{Name: "negative_prompt", Label: "负向提示词", Type: ParamString, Default: ""},
{Name: "image", Label: "输入图 URL", Type: ParamString, Required: true},
{Name: "seed", Label: "随机种子", Type: ParamInt, Default: -1},
{Name: "steps", Label: "采样步数", Type: ParamInt, Default: 20},
{Name: "cfg_scale", Label: "CFG", Type: ParamFloat, Default: 7.0},
{Name: "denoise", Label: "重绘幅度", Type: ParamFloat, Default: 0.75},
}
func ParamsForWorkflowType(wt model.WorkflowType) []ParamDef {
switch wt {
case model.WorkflowImg2Img:
return Img2ImgParams
default:
return Txt2ImgParams
}
}
func RequiredBindings(wt model.WorkflowType) []string {
switch wt {
case model.WorkflowImg2Img:
return []string{"prompt", "image"}
default:
return []string{"prompt"}
}
}
func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) {
defs := ParamsForWorkflowType(wt)
out := make(map[string]any, len(defs))
for _, d := range defs {
v, ok := input[d.Name]
if !ok || v == nil {
if d.Required {
return nil, fmt.Errorf("missing required field: %s", d.Name)
}
if d.Default != nil {
out[d.Name] = d.Default
}
continue
}
coerced, err := coerceValue(d, v)
if err != nil {
return nil, fmt.Errorf("%s: %w", d.Name, err)
}
out[d.Name] = coerced
}
return out, nil
}
func coerceValue(d ParamDef, v any) (any, error) {
switch d.Type {
case ParamString:
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("expected string")
}
if d.Required && s == "" {
return nil, fmt.Errorf("cannot be empty")
}
return s, nil
case ParamInt:
switch n := v.(type) {
case float64:
return int(n), nil
case int:
return n, nil
case int64:
return int(n), nil
default:
return nil, fmt.Errorf("expected integer")
}
case ParamFloat:
switch n := v.(type) {
case float64:
return n, nil
case int:
return float64(n), nil
case int64:
return float64(n), nil
default:
return nil, fmt.Errorf("expected number")
}
default:
return v, nil
}
}
+475
View File
@@ -0,0 +1,475 @@
package prediction
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"sync"
"time"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider/comfyui"
"github.com/rose_cat707/luminary/internal/storage/imagestore"
"github.com/rose_cat707/luminary/internal/store/cache"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
cache *cache.Store
images *imagestore.Store
hub *Hub
publicURL string
waitSec int64
mu sync.Mutex
running map[string]context.CancelFunc
}
func NewService(db *gorm.DB, c *cache.Store, images *imagestore.Store, publicURL string, waitSec int64) *Service {
if waitSec <= 0 {
waitSec = 60
}
return &Service{
db: db, cache: c, images: images, hub: NewHub(),
publicURL: strings.TrimRight(publicURL, "/"), waitSec: waitSec,
running: make(map[string]context.CancelFunc),
}
}
func (s *Service) Hub() *Hub { return s.hub }
func NewPredictionID() (string, error) {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type CreateRequest struct {
Version string `json:"version"`
Input map[string]any `json:"input"`
}
func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req CreateRequest, clientIP string, wait bool) (*model.Prediction, error) {
if req.Version == "" {
return nil, fmt.Errorf("version is required")
}
entry, provider, err := s.resolveModel(req.Version, ingress)
if err != nil {
return nil, err
}
if !provider.Enabled {
return nil, fmt.Errorf("provider disabled")
}
input, err := ValidateInput(entry.WorkflowType, req.Input)
if err != nil {
return nil, err
}
binding, err := ParseBinding(entry.InputBindingJSON)
if err != nil {
return nil, err
}
var workflow map[string]any
_ = json.Unmarshal([]byte(entry.WorkflowJSON), &workflow)
if err := binding.Validate(workflow, RequiredBindings(entry.WorkflowType)); err != nil {
return nil, fmt.Errorf("model binding invalid: %w", err)
}
id, err := NewPredictionID()
if err != nil {
return nil, err
}
inputJSON, _ := json.Marshal(input)
p := model.Prediction{
ID: id,
IngressKeyID: ingress.ID,
ProviderID: provider.ID,
ModelEntryID: entry.ID,
Version: req.Version,
Model: entry.ModelID,
InputJSON: string(inputJSON),
Status: model.PredictionStarting,
ClientID: "luminary-" + id,
CreatedAt: time.Now(),
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
runCtx, cancel := context.WithCancel(context.Background())
s.mu.Lock()
s.running[id] = cancel
s.mu.Unlock()
go s.execute(runCtx, &p, entry, &provider, binding, input, clientIP)
if wait {
deadline := time.After(time.Duration(s.waitSec) * time.Second)
for {
select {
case <-deadline:
return s.Get(ctx, id, ingress)
case <-time.After(200 * time.Millisecond):
cur, err := s.Get(ctx, id, ingress)
if err != nil {
return nil, err
}
if cur.Status == model.PredictionSucceeded || cur.Status == model.PredictionFailed || cur.Status == model.PredictionCanceled {
return cur, nil
}
}
}
}
return s.toAPI(&p), nil
}
func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) {
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
var candidates []model.ModelEntry
for _, p := range s.cache.ListProviders() {
if p.Category != model.CategoryImage || !p.Enabled {
continue
}
if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") {
continue
}
for _, m := range s.cache.GetModels(p.ID) {
if !m.Enabled {
continue
}
ingressID := m.IngressID()
if !m.MatchesIngressName(version) {
continue
}
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
continue
}
candidates = append(candidates, m)
}
}
if len(candidates) == 0 {
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version)
}
m := candidates[0]
p, ok := s.cache.GetProvider(m.ProviderID)
if !ok {
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("provider not found")
}
return m, p, nil
}
func (s *Service) execute(ctx context.Context, p *model.Prediction, entry model.ModelEntry, provider *model.Provider, binding InputBinding, input map[string]any, clientIP string) {
defer func() {
s.mu.Lock()
delete(s.running, p.ID)
s.mu.Unlock()
}()
start := time.Now()
s.updateStatus(p, model.PredictionProcessing, "", nil)
s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing)})
client := comfyui.New(provider.BaseURL)
runInput := cloneMap(input)
if entry.WorkflowType == model.WorkflowImg2Img {
imageURL, _ := runInput["image"].(string)
data, err := downloadURL(ctx, imageURL)
if err != nil {
s.fail(p, clientIP, start, fmt.Errorf("download image: %w", err))
return
}
name := path.Base(imageURL)
if name == "" || name == "." || name == "/" {
name = "input.png"
}
uploaded, err := client.UploadImage(ctx, data, name)
if err != nil {
s.fail(p, clientIP, start, fmt.Errorf("upload image: %w", err))
return
}
runInput["image"] = uploaded
}
prompt, err := BuildPrompt(entry.WorkflowJSON, binding, runInput)
if err != nil {
s.fail(p, clientIP, start, err)
return
}
wsCtx, wsCancel := context.WithCancel(ctx)
defer wsCancel()
go client.Watch(wsCtx, p.ClientID, func(ev comfyui.ProgressEvent) {
switch ev.Type {
case "progress":
if ev.Max > 0 {
logs := fmt.Sprintf("progress %.0f/%.0f", ev.Value, ev.Max)
s.appendLogs(p, logs)
s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing), Logs: logs})
}
case "execution_error":
s.appendLogs(p, ev.ErrorText)
}
})
submit, err := client.SubmitPrompt(ctx, prompt, p.ClientID)
if err != nil {
s.fail(p, clientIP, start, err)
return
}
p.ComfyPromptID = submit.PromptID
now := time.Now()
p.StartedAt = &now
s.db.Model(p).Updates(map[string]any{"comfy_prompt_id": p.ComfyPromptID, "started_at": p.StartedAt})
for {
select {
case <-ctx.Done():
_ = client.Interrupt(context.Background())
s.updateStatus(p, model.PredictionCanceled, "canceled", nil)
s.hub.Publish(p.ID, Event{Status: string(model.PredictionCanceled), Done: true})
s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "canceled", "canceled")
return
case <-time.After(2 * time.Second):
history, err := client.GetHistory(ctx, p.ComfyPromptID)
if err != nil {
continue
}
if _, ok := history[p.ComfyPromptID]; !ok {
continue
}
images := comfyui.CollectOutputImages(history, p.ComfyPromptID)
if len(images) == 0 {
continue
}
var urls []string
for _, img := range images {
data, mime, err := client.DownloadView(ctx, img.Filename, img.Subfolder, img.Type)
if err != nil {
s.fail(p, clientIP, start, err)
return
}
stored, err := s.images.Save(p.ID, img.Filename, data, mime)
if err != nil {
s.fail(p, clientIP, start, err)
return
}
urls = append(urls, s.images.SignURL(stored.ID))
}
outJSON, _ := json.Marshal(urls)
p.OutputJSON = string(outJSON)
completed := time.Now()
p.CompletedAt = &completed
metrics, _ := json.Marshal(map[string]any{
"predict_time": time.Since(start).Seconds(),
"total_time": completed.Sub(p.CreatedAt).Seconds(),
})
p.MetricsJSON = string(metrics)
s.updateStatus(p, model.PredictionSucceeded, "", &completed)
s.hub.Publish(p.ID, Event{
Status: string(model.PredictionSucceeded), Output: urls,
Metrics: map[string]any{"predict_time": time.Since(start).Seconds()},
Done: true,
})
s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "success", "")
return
}
}
}
func (s *Service) fail(p *model.Prediction, clientIP string, start time.Time, err error) {
completed := time.Now()
p.CompletedAt = &completed
p.Error = err.Error()
s.updateStatus(p, model.PredictionFailed, err.Error(), &completed)
s.hub.Publish(p.ID, Event{Status: string(model.PredictionFailed), Error: err.Error(), Done: true})
prov, _ := s.cache.GetProvider(p.ProviderID)
s.recordUsage(ingressByID(s.cache, p.IngressKeyID), &prov, clientIP, p, start, "error", err.Error())
}
func (s *Service) updateStatus(p *model.Prediction, status model.PredictionStatus, errMsg string, completed *time.Time) {
p.Status = status
if errMsg != "" {
p.Error = errMsg
}
updates := map[string]any{"status": status, "error": p.Error, "logs": p.Logs, "output_json": p.OutputJSON, "metrics_json": p.MetricsJSON}
if completed != nil {
updates["completed_at"] = completed
}
s.db.Model(p).Where("id = ?", p.ID).Updates(updates)
}
func (s *Service) appendLogs(p *model.Prediction, line string) {
if p.Logs != "" {
p.Logs += "\n"
}
p.Logs += line
s.db.Model(p).Where("id = ?", p.ID).Update("logs", p.Logs)
}
func (s *Service) Get(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) {
var p model.Prediction
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
return nil, fmt.Errorf("prediction not found")
}
if ingress != nil && p.IngressKeyID != ingress.ID {
return nil, fmt.Errorf("prediction not found")
}
return s.toAPI(&p), nil
}
func (s *Service) List(ctx context.Context, ingress *model.IngressKey, cursor string, limit int) ([]*model.Prediction, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
q := s.db.Where("ingress_key_id = ?", ingress.ID).Order("created_at desc").Limit(limit)
if cursor != "" {
q = q.Where("id < ?", cursor)
}
var rows []model.Prediction
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*model.Prediction, 0, len(rows))
for i := range rows {
out = append(out, s.toAPI(&rows[i]))
}
return out, nil
}
func (s *Service) Cancel(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) {
p, err := s.Get(ctx, id, ingress)
if err != nil {
return nil, err
}
if p.Status == model.PredictionSucceeded || p.Status == model.PredictionFailed || p.Status == model.PredictionCanceled {
return p, nil
}
s.mu.Lock()
if cancel, ok := s.running[id]; ok {
cancel()
}
s.mu.Unlock()
return s.Get(ctx, id, ingress)
}
func (s *Service) toAPI(p *model.Prediction) *model.Prediction {
return p
}
func (s *Service) APIView(p *model.Prediction, baseURL string) map[string]any {
var input any
_ = json.Unmarshal([]byte(p.InputJSON), &input)
var output any
if p.OutputJSON != "" {
_ = json.Unmarshal([]byte(p.OutputJSON), &output)
}
var metrics any
if p.MetricsJSON != "" {
_ = json.Unmarshal([]byte(p.MetricsJSON), &metrics)
}
base := strings.TrimRight(baseURL, "/")
return map[string]any{
"id": p.ID,
"version": p.Version,
"input": input,
"output": output,
"error": nilIfEmpty(p.Error),
"status": p.Status,
"created_at": p.CreatedAt.UTC().Format(time.RFC3339Nano),
"started_at": formatTime(p.StartedAt),
"completed_at": formatTime(p.CompletedAt),
"logs": p.Logs,
"metrics": metrics,
"urls": map[string]string{
"get": fmt.Sprintf("%s/v1/predictions/%s", base, p.ID),
"cancel": fmt.Sprintf("%s/v1/predictions/%s/cancel", base, p.ID),
"stream": fmt.Sprintf("%s/v1/predictions/%s/stream", base, p.ID),
},
}
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func formatTime(t *time.Time) any {
if t == nil {
return nil
}
return t.UTC().Format(time.RFC3339Nano)
}
func downloadURL(ctx context.Context, raw string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func cloneMap(in map[string]any) map[string]any {
b, _ := json.Marshal(in)
var out map[string]any
_ = json.Unmarshal(b, &out)
return out
}
func ingressByID(c *cache.Store, id uint) *model.IngressKey {
for _, k := range c.ListIngressKeys() {
if k.ID == id {
kCopy := k
return &kCopy
}
}
return nil
}
func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provider, clientIP string, p *model.Prediction, start time.Time, status, errMsg string) {
if ingress == nil {
return
}
var providerID uint
if provider != nil {
providerID = provider.ID
}
s.cache.RecordUsage(cache.UsageDelta{
IngressKeyID: ingress.ID,
ProviderID: providerID,
ClientIP: clientIP,
Endpoint: model.EndpointPrediction,
Model: p.ID,
LatencyMs: time.Since(start).Milliseconds(),
Status: status,
ErrorMsg: errMsg,
RequestBody: p.InputJSON,
ResponseBody: p.OutputJSON,
Requests: 1,
})
}
func (s *Service) GetDBPrediction(id string) (*model.Prediction, error) {
var p model.Prediction
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
return nil, err
}
return &p, nil
}
+268
View File
@@ -0,0 +1,268 @@
package prediction
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/rose_cat707/luminary/internal/model"
)
type WorkflowNode struct {
ID string `json:"id"`
ClassType string `json:"class_type"`
Title string `json:"title"`
InjectableFields []string `json:"injectable_fields"`
}
type BindingCandidate struct {
Node string `json:"node"`
Field string `json:"field"`
}
type BindingSuggestion struct {
Param string `json:"param"`
Node string `json:"node,omitempty"`
Field string `json:"field,omitempty"`
Type ParamType `json:"type"`
DefaultFromWorkflow any `json:"default_from_workflow,omitempty"`
Confidence string `json:"confidence"`
Candidates []BindingCandidate `json:"candidates"`
}
type AnalyzeResult struct {
VersionHashPreview string `json:"version_hash_preview"`
Nodes []WorkflowNode `json:"nodes"`
Suggestions []BindingSuggestion `json:"suggestions"`
}
func HashWorkflowJSON(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func AnalyzeWorkflow(workflowJSON string, wt model.WorkflowType) (*AnalyzeResult, error) {
var workflow map[string]any
if err := json.Unmarshal([]byte(workflowJSON), &workflow); err != nil {
return nil, fmt.Errorf("invalid workflow json: %w", err)
}
nodes := parseNodes(workflow)
suggestions := suggestBindings(workflow, nodes, wt)
return &AnalyzeResult{
VersionHashPreview: HashWorkflowJSON(workflowJSON),
Nodes: nodes,
Suggestions: suggestions,
}, nil
}
func parseNodes(workflow map[string]any) []WorkflowNode {
var out []WorkflowNode
for id, raw := range workflow {
node, ok := raw.(map[string]any)
if !ok {
continue
}
classType, _ := node["class_type"].(string)
title := ""
if meta, ok := node["_meta"].(map[string]any); ok {
title, _ = meta["title"].(string)
}
var fields []string
if inputs, ok := node["inputs"].(map[string]any); ok {
for k, v := range inputs {
if isInjectableValue(v) {
fields = append(fields, k)
}
}
}
out = append(out, WorkflowNode{
ID: id, ClassType: classType, Title: title, InjectableFields: fields,
})
}
return out
}
func isInjectableValue(v any) bool {
switch v.(type) {
case string, float64, bool, int, int64:
return true
default:
return false
}
}
func suggestBindings(workflow map[string]any, nodes []WorkflowNode, wt model.WorkflowType) []BindingSuggestion {
defs := ParamsForWorkflowType(wt)
var clipNodes []WorkflowNode
var samplerNodes []WorkflowNode
var latentNodes []WorkflowNode
var loadImageNodes []WorkflowNode
for _, n := range nodes {
switch n.ClassType {
case "CLIPTextEncode":
clipNodes = append(clipNodes, n)
case "KSampler", "KSamplerAdvanced", "RandomNoise":
samplerNodes = append(samplerNodes, n)
case "EmptyLatentImage":
latentNodes = append(latentNodes, n)
case "LoadImage":
loadImageNodes = append(loadImageNodes, n)
}
}
var out []BindingSuggestion
for _, d := range defs {
s := BindingSuggestion{Param: d.Name, Type: d.Type, Confidence: "low"}
switch d.Name {
case "prompt":
s = suggestCLIP(clipNodes, workflow, false)
case "negative_prompt":
s = suggestCLIP(clipNodes, workflow, true)
case "seed":
s = suggestSamplerField(samplerNodes, workflow, "seed", d.Type)
if s.Node == "" {
s = suggestSamplerField(samplerNodes, workflow, "noise_seed", d.Type)
}
case "steps":
s = suggestSamplerField(samplerNodes, workflow, "steps", d.Type)
case "cfg_scale":
s = suggestSamplerField(samplerNodes, workflow, "cfg", d.Type)
case "denoise":
s = suggestSamplerField(samplerNodes, workflow, "denoise", d.Type)
case "width":
s = suggestLatentField(latentNodes, workflow, "width", d.Type)
case "height":
s = suggestLatentField(latentNodes, workflow, "height", d.Type)
case "image":
s = suggestLoadImage(loadImageNodes, workflow, d.Type)
default:
s.Param = d.Name
s.Type = d.Type
}
if s.Param == "" {
s.Param = d.Name
s.Type = d.Type
}
if s.Node != "" {
s.Confidence = "high"
}
out = append(out, s)
}
return out
}
func suggestCLIP(nodes []WorkflowNode, workflow map[string]any, negative bool) BindingSuggestion {
s := BindingSuggestion{Param: "prompt", Type: ParamString, Confidence: "low"}
if negative {
s.Param = "negative_prompt"
}
var candidates []BindingCandidate
var picked *WorkflowNode
for i := range nodes {
n := nodes[i]
titleLower := strings.ToLower(n.Title)
isNeg := strings.Contains(titleLower, "negative") || strings.Contains(titleLower, "neg")
if !hasField(n, "text") {
continue
}
candidates = append(candidates, BindingCandidate{Node: n.ID, Field: "text"})
if negative && isNeg && picked == nil {
picked = &nodes[i]
}
if !negative && !isNeg && picked == nil {
picked = &nodes[i]
}
}
if picked == nil && len(candidates) > 0 {
idx := 0
if negative && len(candidates) > 1 {
idx = 1
}
for i := range nodes {
if nodes[i].ID == candidates[idx].Node {
picked = &nodes[i]
break
}
}
}
s.Candidates = candidates
if picked != nil {
s.Node = picked.ID
s.Field = "text"
s.DefaultFromWorkflow = nodeFieldValue(workflow, picked.ID, "text")
}
return s
}
func suggestSamplerField(nodes []WorkflowNode, workflow map[string]any, field string, pt ParamType) BindingSuggestion {
paramName := field
if field == "cfg" {
paramName = "cfg_scale"
}
s := BindingSuggestion{Param: paramName, Type: pt, Confidence: "low"}
var candidates []BindingCandidate
for _, n := range nodes {
if hasField(n, field) {
candidates = append(candidates, BindingCandidate{Node: n.ID, Field: field})
if s.Node == "" {
s.Node = n.ID
s.Field = field
s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, field)
}
}
}
s.Candidates = candidates
return s
}
func suggestLatentField(nodes []WorkflowNode, workflow map[string]any, field string, pt ParamType) BindingSuggestion {
s := BindingSuggestion{Param: field, Type: pt, Confidence: "low"}
for _, n := range nodes {
if hasField(n, field) {
s.Candidates = append(s.Candidates, BindingCandidate{Node: n.ID, Field: field})
if s.Node == "" {
s.Node = n.ID
s.Field = field
s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, field)
}
}
}
return s
}
func suggestLoadImage(nodes []WorkflowNode, workflow map[string]any, pt ParamType) BindingSuggestion {
s := BindingSuggestion{Param: "image", Type: pt, Confidence: "low"}
for _, n := range nodes {
if hasField(n, "image") {
s.Candidates = append(s.Candidates, BindingCandidate{Node: n.ID, Field: "image"})
if s.Node == "" {
s.Node = n.ID
s.Field = "image"
s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, "image")
}
}
}
return s
}
func hasField(n WorkflowNode, field string) bool {
for _, f := range n.InjectableFields {
if f == field {
return true
}
}
return false
}
func nodeFieldValue(workflow map[string]any, nodeID, field string) any {
node, ok := workflow[nodeID].(map[string]any)
if !ok {
return nil
}
inputs, ok := node["inputs"].(map[string]any)
if !ok {
return nil
}
return inputs[field]
}
+66
View File
@@ -0,0 +1,66 @@
package pricing
import "strings"
// 价格单位:美元 / 1M tokens
type ModelPrice struct {
InputPer1M float64
OutputPer1M float64
}
var catalog = map[string]ModelPrice{
"gpt-4o": {InputPer1M: 2.5, OutputPer1M: 10},
"gpt-4o-mini": {InputPer1M: 0.15, OutputPer1M: 0.6},
"gpt-4-turbo": {InputPer1M: 10, OutputPer1M: 30},
"gpt-3.5-turbo": {InputPer1M: 0.5, OutputPer1M: 1.5},
"claude-3-5-sonnet": {InputPer1M: 3, OutputPer1M: 15},
"claude-3-5-haiku": {InputPer1M: 0.8, OutputPer1M: 4},
"claude-3-opus": {InputPer1M: 15, OutputPer1M: 75},
"text-embedding-3-small": {InputPer1M: 0.02, OutputPer1M: 0},
"text-embedding-3-large": {InputPer1M: 0.13, OutputPer1M: 0},
"text-embedding-ada-002": {InputPer1M: 0.1, OutputPer1M: 0},
"bge-reranker-v2-m3": {InputPer1M: 0.1, OutputPer1M: 0},
"rerank-english-v3.0": {InputPer1M: 2.0, OutputPer1M: 0},
"rerank-multilingual-v3.0": {InputPer1M: 2.0, OutputPer1M: 0},
}
func Estimate(model string, tokensIn, tokensOut int, endpoint string) float64 {
if strings.Contains(endpoint, "embeddings") || strings.Contains(endpoint, "rerank") {
tokensOut = 0
}
price, ok := lookup(model)
if !ok {
// fallback: average small model pricing
price = ModelPrice{InputPer1M: 0.5, OutputPer1M: 1.5}
}
inCost := float64(tokensIn) / 1_000_000 * price.InputPer1M
outCost := float64(tokensOut) / 1_000_000 * price.OutputPer1M
return inCost + outCost
}
func lookup(model string) (ModelPrice, bool) {
if p, ok := catalog[model]; ok {
return p, true
}
// strip provider prefix
if i := strings.LastIndex(model, "/"); i >= 0 {
if p, ok := catalog[model[i+1:]]; ok {
return p, true
}
}
// prefix match
for k, v := range catalog {
if strings.HasPrefix(model, k) {
return v, true
}
}
return ModelPrice{}, false
}
func ListCatalog() map[string]ModelPrice {
out := make(map[string]ModelPrice, len(catalog))
for k, v := range catalog {
out[k] = v
}
return out
}
+283
View File
@@ -0,0 +1,283 @@
package anthropic
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
type Client struct {
http *provider.HTTPClientWrapper
}
func New() *Client {
return &Client{http: provider.NewHTTPClientWrapper()}
}
func (c *Client) Type() string { return "anthropic" }
func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) {
return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{
Type: model.ProviderAnthropic,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
})
}
func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) {
cfg.Type = model.ProviderAnthropic
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
url := cfg.ResolveEndpointURL(provider.EndpointListModels)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, model.ProviderAnthropic, nil, nil)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("anthropic list models: status %d: %s", resp.StatusCode, string(resp.Body))
}
var out struct {
Data []struct {
ID string `json:"id"`
Name string `json:"display_name"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, err
}
models := make([]provider.ModelInfo, 0, len(out.Data))
for _, m := range out.Data {
name := m.Name
if name == "" {
name = m.ID
}
models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: name})
}
return models, nil
}
func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) {
cfg := provider.ProviderConfig{
Type: model.ProviderAnthropic,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic),
}
anthropicReq, err := convertOpenAIToAnthropic(req)
if err != nil {
return nil, err
}
body, err := json.Marshal(anthropicReq)
if err != nil {
return nil, err
}
url := cfg.ResolveEndpointURL(provider.EndpointChatCompletion)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body}, nil
}
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, req.Model)
return &provider.ChatResponse{
StatusCode: 200,
Body: openAIResp,
TokensIn: tokensIn,
TokensOut: tokensOut,
}, nil
}
func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
cfg.Type = model.ProviderAnthropic
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic)
if endpoint == provider.EndpointChatCompletion {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err == nil {
modelName, _ := payload["model"].(string)
anthropicReq, err := convertOpenAIToAnthropic(&provider.ChatRequest{Model: modelName, Raw: body})
if err == nil {
body, _ = json.Marshal(anthropicReq)
}
}
}
url := cfg.ResolveEndpointURL(endpoint)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil)
if err != nil {
return nil, err
}
if endpoint == provider.EndpointChatCompletion && resp.StatusCode < 400 {
var payload map[string]any
modelName := ""
_ = json.Unmarshal(body, &payload)
if m, ok := payload["model"].(string); ok {
modelName = m
}
openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, modelName)
return &provider.ChatResponse{StatusCode: 200, Body: openAIResp, TokensIn: tokensIn, TokensOut: tokensOut}, nil
}
return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body, TokensIn: resp.TokensIn, TokensOut: resp.TokensOut}, nil
}
func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error {
_, err := c.ListModels(ctx, apiKey, baseURL)
return err
}
func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error {
_, err := c.ListModelsWithConfig(ctx, apiKey, cfg)
return err
}
// convertOpenAIToAnthropic and convertAnthropicToOpenAI unchanged below
func convertOpenAIToAnthropic(req *provider.ChatRequest) (map[string]any, error) {
var payload map[string]any
raw := req.Raw
if len(raw) == 0 {
b, _ := json.Marshal(req)
raw = b
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
model, _ := payload["model"].(string)
if model == "" {
model = req.Model
}
msgs, _ := payload["messages"].([]any)
var system string
var anthropicMsgs []map[string]any
for _, m := range msgs {
msg, _ := m.(map[string]any)
role, _ := msg["role"].(string)
if role == "system" {
system = extractOpenAIContent(msg["content"])
continue
}
if role != "assistant" {
role = "user"
}
content := convertOpenAIContentBlocks(msg["content"])
anthropicMsgs = append(anthropicMsgs, map[string]any{"role": role, "content": content})
}
out := map[string]any{
"model": model,
"max_tokens": 4096,
"messages": anthropicMsgs,
}
if system != "" {
out["system"] = system
}
if maxTok, ok := payload["max_tokens"].(float64); ok {
out["max_tokens"] = int(maxTok)
}
return out, nil
}
func extractOpenAIContent(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
var parts []string
for _, p := range v {
part, ok := p.(map[string]any)
if !ok {
continue
}
if text, ok := part["text"].(string); ok {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func convertOpenAIContentBlocks(content any) any {
switch v := content.(type) {
case string:
if v == "" {
return ""
}
return v
case []any:
blocks := make([]map[string]any, 0, len(v))
for _, p := range v {
part, ok := p.(map[string]any)
if !ok {
continue
}
partType, _ := part["type"].(string)
switch partType {
case "text":
if text, ok := part["text"].(string); ok {
blocks = append(blocks, map[string]any{"type": "text", "text": text})
}
case "image_url":
if imageURL, ok := part["image_url"].(map[string]any); ok {
if url, ok := imageURL["url"].(string); ok && url != "" {
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "url",
"url": url,
},
})
}
}
case "image":
if source, ok := part["source"].(map[string]any); ok {
blocks = append(blocks, map[string]any{"type": "image", "source": source})
}
default:
if text, ok := part["text"].(string); ok {
blocks = append(blocks, map[string]any{"type": "text", "text": text})
}
}
}
if len(blocks) == 1 {
if text, ok := blocks[0]["text"].(string); ok && blocks[0]["type"] == "text" {
return text
}
}
if len(blocks) > 0 {
return blocks
}
return ""
default:
return ""
}
}
func convertAnthropicToOpenAI(body []byte, model string) ([]byte, int, int) {
var resp struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(body, &resp)
text := ""
if len(resp.Content) > 0 {
text = resp.Content[0].Text
}
openAI := map[string]any{
"id": "chatcmpl-luminary",
"object": "chat.completion",
"model": model,
"choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": text}, "finish_reason": "stop"}},
"usage": map[string]int{
"prompt_tokens": resp.Usage.InputTokens,
"completion_tokens": resp.Usage.OutputTokens,
"total_tokens": resp.Usage.InputTokens + resp.Usage.OutputTokens,
},
}
b, _ := json.Marshal(openAI)
return b, resp.Usage.InputTokens, resp.Usage.OutputTokens
}
@@ -0,0 +1,26 @@
package anthropic
import "testing"
func TestConvertOpenAIContentBlocks_TextAndImage(t *testing.T) {
content := []any{
map[string]any{"type": "text", "text": "describe this"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/a.png"}},
}
blocks, ok := convertOpenAIContentBlocks(content).([]map[string]any)
if !ok || len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %#v", convertOpenAIContentBlocks(content))
}
if blocks[0]["text"] != "describe this" {
t.Fatalf("unexpected text block: %#v", blocks[0])
}
if blocks[1]["type"] != "image" {
t.Fatalf("unexpected image block: %#v", blocks[1])
}
}
func TestExtractOpenAIContent_String(t *testing.T) {
if got := extractOpenAIContent("hello"); got != "hello" {
t.Fatalf("got %q", got)
}
}
+290
View File
@@ -0,0 +1,290 @@
package comfyui
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
)
type Client struct {
BaseURL string
HTTPClient *http.Client
}
func New(baseURL string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{Timeout: 120 * time.Second},
}
}
type PromptResponse struct {
PromptID string `json:"prompt_id"`
Number int `json:"number"`
}
func (c *Client) SubmitPrompt(ctx context.Context, prompt map[string]any, clientID string) (*PromptResponse, error) {
body, _ := json.Marshal(map[string]any{
"prompt": prompt,
"client_id": clientID,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/prompt", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("comfyui prompt %d: %s", resp.StatusCode, string(data))
}
var out PromptResponse
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) Interrupt(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/interrupt", nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("comfyui interrupt %d: %s", resp.StatusCode, string(data))
}
return nil
}
type HistoryEntry struct {
Outputs map[string]HistoryNodeOutput `json:"outputs"`
Status map[string]any `json:"status"`
}
type HistoryNodeOutput struct {
Images []OutputImage `json:"images"`
}
type OutputImage struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
Type string `json:"type"`
}
func (c *Client) GetHistory(ctx context.Context, promptID string) (map[string]HistoryEntry, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/history/"+promptID, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("comfyui history %d: %s", resp.StatusCode, string(data))
}
var out map[string]HistoryEntry
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) DownloadView(ctx context.Context, filename, subfolder, imageType string) ([]byte, string, error) {
q := url.Values{}
q.Set("filename", filename)
if subfolder != "" {
q.Set("subfolder", subfolder)
}
if imageType == "" {
imageType = "output"
}
q.Set("type", imageType)
u := c.BaseURL + "/view?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, "", err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
if resp.StatusCode >= 400 {
return nil, "", fmt.Errorf("comfyui view %d", resp.StatusCode)
}
mime := resp.Header.Get("Content-Type")
if mime == "" {
mime = "image/png"
}
return data, mime, nil
}
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("image", filename)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
_ = w.WriteField("overwrite", "true")
_ = w.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", fmt.Errorf("comfyui upload %d: %s", resp.StatusCode, string(body))
}
var out struct {
Name string `json:"name"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", err
}
return out.Name, nil
}
func (c *Client) HealthCheck(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/system_stats", nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
type ProgressEvent struct {
Type string
Data map[string]any
Node string
Value float64
Max float64
PromptID string
ErrorText string
}
func (c *Client) wsURL(clientID string) string {
u, _ := url.Parse(c.BaseURL)
scheme := "ws"
if u.Scheme == "https" {
scheme = "wss"
}
return fmt.Sprintf("%s://%s/ws?clientId=%s", scheme, u.Host, url.QueryEscape(clientID))
}
func (c *Client) Watch(ctx context.Context, clientID string, onEvent func(ProgressEvent)) error {
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL(clientID), nil)
if err != nil {
return err
}
defer conn.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
ev := ProgressEvent{Type: envelope.Type}
var data map[string]any
_ = json.Unmarshal(envelope.Data, &data)
ev.Data = data
if envelope.Type == "progress" {
if n, ok := data["node"].(string); ok {
ev.Node = n
}
if v, ok := data["value"].(float64); ok {
ev.Value = v
}
if m, ok := data["max"].(float64); ok {
ev.Max = m
}
}
if envelope.Type == "executing" {
if n, ok := data["node"].(string); ok {
ev.Node = n
}
if pid, ok := data["prompt_id"].(string); ok {
ev.PromptID = pid
}
}
if envelope.Type == "execution_error" {
if msgs, ok := data["exception_message"].(string); ok {
ev.ErrorText = msgs
}
}
onEvent(ev)
}
}()
select {
case <-ctx.Done():
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
<-done
return ctx.Err()
case <-done:
return nil
}
}
func CollectOutputImages(history map[string]HistoryEntry, promptID string) []OutputImage {
entry, ok := history[promptID]
if !ok {
return nil
}
var images []OutputImage
for _, out := range entry.Outputs {
images = append(images, out.Images...)
}
return images
}
+16
View File
@@ -0,0 +1,16 @@
package custom
import (
"github.com/rose_cat707/luminary/internal/provider/openai"
)
// Custom providers use OpenAI-compatible API at a custom base URL.
type Client struct {
*openai.Client
}
func New() *Client {
return &Client{Client: openai.New()}
}
func (c *Client) Type() string { return "custom" }
+176
View File
@@ -0,0 +1,176 @@
package provider
import (
"strings"
"github.com/rose_cat707/luminary/internal/model"
)
// EndpointType 接口类型,对应入口 /v1/* 与上游 path 映射
type EndpointType string
const (
EndpointListModels EndpointType = "list_models"
EndpointTextCompletion EndpointType = "text_completion"
EndpointChatCompletion EndpointType = "chat_completion"
EndpointResponses EndpointType = "responses"
EndpointEmbeddings EndpointType = "embeddings"
EndpointRerank EndpointType = "rerank"
EndpointSpeech EndpointType = "speech"
EndpointTranscription EndpointType = "transcription"
EndpointImageGeneration EndpointType = "image_generation"
)
type EndpointMeta struct {
Key EndpointType `json:"key"`
Label string `json:"label"`
Method string `json:"method"`
IngressPath string `json:"ingress_path"`
Categories []model.ProviderCategory `json:"categories"`
}
var AllEndpoints = []EndpointMeta{
{Key: EndpointListModels, Label: "List Models", Method: "GET", IngressPath: "/v1/models", Categories: []model.ProviderCategory{model.CategoryLLM, model.CategoryEmbedding, model.CategoryRerank}},
{Key: EndpointTextCompletion, Label: "Text Completion", Method: "POST", IngressPath: "/v1/completions", Categories: []model.ProviderCategory{model.CategoryLLM}},
{Key: EndpointChatCompletion, Label: "Chat Completion", Method: "POST", IngressPath: "/v1/chat/completions", Categories: []model.ProviderCategory{model.CategoryLLM}},
{Key: EndpointResponses, Label: "Responses", Method: "POST", IngressPath: "/v1/responses", Categories: []model.ProviderCategory{model.CategoryLLM}},
{Key: EndpointEmbeddings, Label: "Embeddings", Method: "POST", IngressPath: "/v1/embeddings", Categories: []model.ProviderCategory{model.CategoryEmbedding}},
{Key: EndpointRerank, Label: "Rerank", Method: "POST", IngressPath: "/v1/rerank", Categories: []model.ProviderCategory{model.CategoryRerank}},
{Key: EndpointSpeech, Label: "Speech", Method: "POST", IngressPath: "/v1/audio/speech", Categories: []model.ProviderCategory{model.CategorySpeech}},
{Key: EndpointTranscription, Label: "Transcription", Method: "POST", IngressPath: "/v1/audio/transcriptions", Categories: []model.ProviderCategory{model.CategoryAudio}},
{Key: EndpointImageGeneration, Label: "Image Generation", Method: "POST", IngressPath: "/v1/images/generations", Categories: []model.ProviderCategory{model.CategoryImage}},
}
// DefaultPaths 各 Provider 类型的默认上游 path(相对 base_url
var DefaultPaths = map[model.ProviderType]map[EndpointType]string{
model.ProviderOpenAI: {
EndpointListModels: "/models",
EndpointTextCompletion: "/completions",
EndpointChatCompletion: "/chat/completions",
EndpointResponses: "/responses",
EndpointEmbeddings: "/embeddings",
EndpointRerank: "/rerank",
EndpointSpeech: "/audio/speech",
EndpointTranscription: "/audio/transcriptions",
EndpointImageGeneration: "/images/generations",
},
model.ProviderAnthropic: {
EndpointListModels: "/models",
EndpointChatCompletion: "/messages",
},
model.ProviderCustom: {
EndpointListModels: "/models",
EndpointTextCompletion: "/completions",
EndpointChatCompletion: "/chat/completions",
EndpointResponses: "/responses",
EndpointEmbeddings: "/embeddings",
EndpointRerank: "/rerank",
EndpointSpeech: "/audio/speech",
EndpointTranscription: "/audio/transcriptions",
EndpointImageGeneration: "/images/generations",
},
model.ProviderAzureOpenAI: {
EndpointListModels: "/models",
EndpointChatCompletion: "/chat/completions",
EndpointResponses: "/responses",
EndpointEmbeddings: "/embeddings",
EndpointRerank: "/rerank",
},
model.ProviderOpenRouter: {
EndpointListModels: "/models",
EndpointChatCompletion: "/chat/completions",
EndpointResponses: "/responses",
EndpointEmbeddings: "/embeddings",
EndpointRerank: "/rerank",
},
model.ProviderOllama: {
EndpointListModels: "/models",
EndpointChatCompletion: "/chat/completions",
EndpointEmbeddings: "/embeddings",
EndpointRerank: "/rerank",
},
model.ProviderComfyUI: {},
}
func DefaultPath(providerType model.ProviderType, endpoint EndpointType) string {
if m, ok := DefaultPaths[providerType]; ok {
if p, ok := m[endpoint]; ok {
return p
}
}
if p, ok := DefaultPaths[model.ProviderOpenAI][endpoint]; ok {
return p
}
return ""
}
// DefaultEndpointsForCategory 创建出口时默认启用的 endpoint keys
func DefaultEndpointsForCategory(cat model.ProviderCategory) []string {
switch cat {
case model.CategoryEmbedding:
return []string{string(EndpointListModels), string(EndpointEmbeddings)}
case model.CategoryRerank:
return []string{string(EndpointListModels), string(EndpointRerank)}
case model.CategoryImage:
return []string{}
default:
return []string{string(EndpointListModels), string(EndpointChatCompletion)}
}
}
func EndpointsForCategory(cat model.ProviderCategory) []EndpointMeta {
var out []EndpointMeta
for _, e := range AllEndpoints {
for _, c := range e.Categories {
if c == cat {
out = append(out, e)
break
}
}
}
return out
}
// ResolveURL 解析最终请求 URLoverride 可为相对 path 或完整 URL
func ResolveURL(baseURL, override, defaultPath string) string {
if override != "" {
if strings.HasPrefix(override, "http://") || strings.HasPrefix(override, "https://") {
return override
}
return joinURL(baseURL, override)
}
return joinURL(baseURL, defaultPath)
}
func joinURL(base, path string) string {
base = strings.TrimRight(base, "/")
if path == "" {
return base
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if base == "" {
return path
}
return base + path
}
func CategoryLabel(cat model.ProviderCategory) string {
switch cat {
case model.CategoryLLM:
return "语言模型"
case model.CategoryEmbedding:
return "向量嵌入"
case model.CategoryRerank:
return "重排序"
case model.CategorySpeech:
return "语音合成"
case model.CategoryImage:
return "图像生成"
case model.CategoryAudio:
return "音频转写"
default:
return string(cat)
}
}
+198
View File
@@ -0,0 +1,198 @@
package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/rose_cat707/luminary/internal/model"
)
type ForwardRequest struct {
Endpoint EndpointType
Method string
Body []byte
Headers map[string]string
}
type ForwardResponse struct {
StatusCode int
Body []byte
TokensIn int
TokensOut int
}
type ProviderConfig struct {
Type model.ProviderType
Category model.ProviderCategory
BaseURL string
EndpointPaths map[string]string // endpoint key -> path override
}
func ParseEndpointPaths(jsonStr string) map[string]string {
out := map[string]string{}
if jsonStr == "" || jsonStr == "{}" {
return out
}
_ = json.Unmarshal([]byte(jsonStr), &out)
return out
}
func ParseAllowedEndpoints(jsonStr string, category model.ProviderCategory) []string {
var out []string
if jsonStr == "" {
return DefaultEndpointsForCategory(category)
}
_ = json.Unmarshal([]byte(jsonStr), &out)
if len(out) == 0 {
return DefaultEndpointsForCategory(category)
}
return out
}
func (cfg *ProviderConfig) ResolveEndpointURL(endpoint EndpointType) string {
override := cfg.EndpointPaths[string(endpoint)]
defaultPath := DefaultPath(cfg.Type, endpoint)
return ResolveURL(cfg.BaseURL, override, defaultPath)
}
func ForwardHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, extraHeaders map[string]string) (*ForwardResponse, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return nil, err
}
setAuthHeaders(req, apiKey, providerType)
req.Header.Set("Content-Type", "application/json")
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
tokensIn, tokensOut := parseUsageJSON(respBody)
return &ForwardResponse{
StatusCode: resp.StatusCode,
Body: respBody,
TokensIn: tokensIn,
TokensOut: tokensOut,
}, nil
}
func setAuthHeaders(req *http.Request, apiKey string, providerType model.ProviderType) {
switch providerType {
case model.ProviderAnthropic:
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
case model.ProviderAzureOpenAI:
if apiKey != "" {
req.Header.Set("api-key", apiKey)
}
case model.ProviderOllama:
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
default:
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
}
}
func parseUsageJSON(body []byte) (int, int) {
var u struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(body, &u)
in := u.Usage.PromptTokens
out := u.Usage.CompletionTokens
if in == 0 {
in = u.Usage.InputTokens
}
if out == 0 {
out = u.Usage.OutputTokens
}
return in, out
}
func DefaultBaseURL(providerType model.ProviderType) string {
switch providerType {
case model.ProviderAnthropic:
return "https://api.anthropic.com/v1"
case model.ProviderAzureOpenAI:
return "" // must be configured per deployment
case model.ProviderOpenRouter:
return "https://openrouter.ai/api/v1"
case model.ProviderOllama:
return "http://127.0.0.1:11434/v1"
default:
return "https://api.openai.com/v1"
}
}
func IsOpenAICompatible(t model.ProviderType) bool {
switch t {
case model.ProviderOpenAI, model.ProviderCustom, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama:
return true
default:
return false
}
}
func NormalizeBaseURL(baseURL string, providerType model.ProviderType) string {
if baseURL == "" {
return DefaultBaseURL(providerType)
}
return baseURL
}
func IsEndpointAllowed(allowed []string, endpoint EndpointType) bool {
for _, a := range allowed {
if a == string(endpoint) || a == "*" {
return true
}
}
return false
}
func NewHTTPClient() *http.Client {
return &http.Client{Timeout: 120 * time.Second}
}
func ValidateCategoryEndpoints(category model.ProviderCategory, allowed []string) error {
if !model.IsCategorySupported(category) {
return fmt.Errorf("category %s is not supported yet", category)
}
valid := EndpointsForCategory(category)
validSet := map[string]bool{}
for _, e := range valid {
validSet[string(e.Key)] = true
}
for _, a := range allowed {
if a == "*" {
continue
}
if !validSet[a] {
return fmt.Errorf("endpoint %s is not available for category %s", a, category)
}
}
return nil
}
+11
View File
@@ -0,0 +1,11 @@
package provider
import "net/http"
type HTTPClientWrapper struct {
Client *http.Client
}
func NewHTTPClientWrapper() *HTTPClientWrapper {
return &HTTPClientWrapper{Client: NewHTTPClient()}
}
+94
View File
@@ -0,0 +1,94 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
type Client struct {
http *provider.HTTPClientWrapper
}
func New() *Client {
return &Client{http: provider.NewHTTPClientWrapper()}
}
func (c *Client) Type() string { return "openai" }
func (c *Client) cfg(baseURL string) provider.ProviderConfig {
return provider.ProviderConfig{
Type: model.ProviderOpenAI,
Category: model.CategoryLLM,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
}
}
func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) {
return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{
Type: model.ProviderOpenAI,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
})
}
func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) {
cfg.Type = model.ProviderOpenAI
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI)
url := cfg.ResolveEndpointURL(provider.EndpointListModels)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, cfg.Type, nil, nil)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("list models: %s", string(resp.Body))
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(resp.Body, &out); err != nil {
return nil, err
}
models := make([]provider.ModelInfo, 0, len(out.Data))
for _, m := range out.Data {
models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: m.ID})
}
return models, nil
}
func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) {
return c.ForwardWithConfig(ctx, apiKey, provider.ProviderConfig{
Type: model.ProviderOpenAI,
BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI),
}, provider.EndpointChatCompletion, req.Raw)
}
func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
cfg.Type = model.ProviderOpenAI
cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI)
url := cfg.ResolveEndpointURL(endpoint)
resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, cfg.Type, body, nil)
if err != nil {
return nil, err
}
return &provider.ChatResponse{
StatusCode: resp.StatusCode,
Body: resp.Body,
TokensIn: resp.TokensIn,
TokensOut: resp.TokensOut,
}, nil
}
func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error {
_, err := c.ListModels(ctx, apiKey, baseURL)
return err
}
func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error {
_, err := c.ListModelsWithConfig(ctx, apiKey, cfg)
return err
}
+33
View File
@@ -0,0 +1,33 @@
package provider
import (
"context"
"encoding/json"
)
type ModelInfo struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages json.RawMessage `json:"messages"`
Stream bool `json:"stream"`
Raw json.RawMessage `json:"-"`
}
type ChatResponse struct {
StatusCode int
Body []byte
Headers map[string]string
TokensIn int
TokensOut int
}
type Client interface {
Type() string
ListModels(ctx context.Context, apiKey, baseURL string) ([]ModelInfo, error)
ChatCompletion(ctx context.Context, apiKey, baseURL string, req *ChatRequest) (*ChatResponse, error)
HealthCheck(ctx context.Context, apiKey, baseURL string) error
}
+640
View File
@@ -0,0 +1,640 @@
package provider
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// CanAdaptResponses reports whether responses ingress can be served via chat completions.
func CanAdaptResponses(allowsResponses, allowsChat bool) bool {
return allowsChat && !allowsResponses
}
// ResponsesToChatCompletion converts an OpenAI Responses API request to chat completions format.
func ResponsesToChatCompletion(body []byte) ([]byte, error) {
var req map[string]json.RawMessage
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("invalid json: %w", err)
}
out := make(map[string]any)
if raw, ok := req["model"]; ok {
var model string
if err := json.Unmarshal(raw, &model); err != nil || model == "" {
return nil, fmt.Errorf("model is required")
}
out["model"] = model
}
messages := make([]map[string]any, 0, 4)
if raw, ok := req["instructions"]; ok {
var instructions string
if err := json.Unmarshal(raw, &instructions); err == nil && instructions != "" {
messages = append(messages, map[string]any{"role": "system", "content": instructions})
} else {
var instructionParts []json.RawMessage
if err := json.Unmarshal(raw, &instructionParts); err == nil {
if text := joinContentPartTexts(instructionParts); text != "" {
messages = append(messages, map[string]any{"role": "system", "content": text})
}
}
}
}
if raw, ok := req["input"]; ok {
inputMsgs, err := parseResponsesInput(raw)
if err != nil {
return nil, err
}
messages = append(messages, inputMsgs...)
}
if len(messages) == 0 {
return nil, fmt.Errorf("input is required")
}
out["messages"] = messages
copyField(req, out, "stream")
copyField(req, out, "temperature")
copyField(req, out, "top_p")
copyField(req, out, "user")
copyField(req, out, "stop")
copyField(req, out, "tools")
copyField(req, out, "tool_choice")
copyField(req, out, "parallel_tool_calls")
copyField(req, out, "seed")
copyField(req, out, "n")
copyField(req, out, "presence_penalty")
copyField(req, out, "frequency_penalty")
copyField(req, out, "logit_bias")
copyField(req, out, "response_format")
if raw, ok := req["max_output_tokens"]; ok {
var v int
if err := json.Unmarshal(raw, &v); err == nil && v > 0 {
out["max_tokens"] = v
}
} else {
copyField(req, out, "max_tokens")
}
if raw, ok := req["text"]; ok {
if rf := responsesTextToResponseFormat(raw); rf != nil {
out["response_format"] = rf
}
}
return json.Marshal(out)
}
func copyField(src map[string]json.RawMessage, dst map[string]any, key string) {
if raw, ok := src[key]; ok && len(raw) > 0 && string(raw) != "null" {
var v any
if json.Unmarshal(raw, &v) == nil {
dst[key] = v
}
}
}
func responsesTextToResponseFormat(raw json.RawMessage) any {
var text struct {
Format struct {
Type string `json:"type"`
} `json:"format"`
}
if err := json.Unmarshal(raw, &text); err != nil || text.Format.Type == "" {
return nil
}
switch text.Format.Type {
case "json_object":
return map[string]any{"type": "json_object"}
case "text":
return map[string]any{"type": "text"}
default:
return map[string]any{"type": text.Format.Type}
}
}
func parseResponsesInput(raw json.RawMessage) ([]map[string]any, error) {
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
if asString == "" {
return nil, fmt.Errorf("input is required")
}
return []map[string]any{{"role": "user", "content": asString}}, nil
}
var asArray []json.RawMessage
if err := json.Unmarshal(raw, &asArray); err != nil {
return nil, fmt.Errorf("invalid input")
}
out := make([]map[string]any, 0, len(asArray))
for _, item := range asArray {
msg, err := parseResponsesInputItem(item)
if err != nil {
return nil, err
}
out = append(out, msg)
}
return out, nil
}
func parseResponsesInputItem(raw json.RawMessage) (map[string]any, error) {
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return map[string]any{"role": "user", "content": asString}, nil
}
var item map[string]json.RawMessage
if err := json.Unmarshal(raw, &item); err != nil {
return nil, fmt.Errorf("invalid input item")
}
itemType := ""
if rawType, ok := item["type"]; ok {
_ = json.Unmarshal(rawType, &itemType)
}
role := "user"
if rawRole, ok := item["role"]; ok {
_ = json.Unmarshal(rawRole, &role)
}
if role == "" {
role = "user"
}
role = normalizeResponsesRole(role)
switch itemType {
case "message":
if rawContent, ok := item["content"]; ok {
content, err := parseResponsesContent(rawContent)
if err != nil {
return nil, err
}
return map[string]any{"role": role, "content": content}, nil
}
case "input_text":
var text string
if rawText, ok := item["text"]; ok {
_ = json.Unmarshal(rawText, &text)
}
if text != "" {
return map[string]any{"role": role, "content": text}, nil
}
case "function_call_output":
return parseFunctionCallOutputItem(item, role)
}
if rawContent, ok := item["content"]; ok {
content, err := parseResponsesContent(rawContent)
if err != nil {
return nil, err
}
return map[string]any{"role": role, "content": content}, nil
}
if rawText, ok := item["text"]; ok {
var text string
if err := json.Unmarshal(rawText, &text); err == nil {
return map[string]any{"role": role, "content": text}, nil
}
}
if rawInput, ok := item["input"]; ok {
var text string
if err := json.Unmarshal(rawInput, &text); err == nil {
return map[string]any{"role": role, "content": text}, nil
}
}
return nil, fmt.Errorf("unsupported input item")
}
func normalizeResponsesRole(role string) string {
switch role {
case "developer":
return "system"
default:
return role
}
}
func parseResponsesContent(raw json.RawMessage) (any, error) {
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return asString, nil
}
var parts []json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
return nil, fmt.Errorf("invalid content")
}
converted := make([]any, 0, len(parts))
for _, part := range parts {
if item, ok := convertResponsesContentPart(part); ok {
converted = append(converted, item)
}
}
if len(converted) == 0 {
return "", nil
}
if len(converted) == 1 {
if text, ok := converted[0].(string); ok {
return text, nil
}
if m, ok := converted[0].(map[string]any); ok {
if m["type"] == "text" {
if text, ok := m["text"].(string); ok {
return text, nil
}
}
}
}
return converted, nil
}
func convertResponsesContentPart(raw json.RawMessage) (any, bool) {
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return asString, true
}
var part map[string]json.RawMessage
if err := json.Unmarshal(raw, &part); err != nil {
return nil, false
}
typ := ""
if rawType, ok := part["type"]; ok {
_ = json.Unmarshal(rawType, &typ)
}
switch typ {
case "input_text", "text", "output_text":
var text string
if rawText, ok := part["text"]; ok {
_ = json.Unmarshal(rawText, &text)
}
if text == "" {
return nil, false
}
return map[string]any{"type": "text", "text": text}, true
case "input_image":
out := map[string]any{"type": "image_url", "image_url": map[string]any{}}
img := out["image_url"].(map[string]any)
if rawURL, ok := part["image_url"]; ok {
var url string
if err := json.Unmarshal(rawURL, &url); err == nil {
img["url"] = url
} else {
var nested map[string]any
if err := json.Unmarshal(rawURL, &nested); err == nil {
for k, v := range nested {
img[k] = v
}
}
}
}
if rawDetail, ok := part["detail"]; ok {
var detail string
if json.Unmarshal(rawDetail, &detail) == nil {
img["detail"] = detail
}
}
if img["url"] == nil {
return nil, false
}
return out, true
case "image_url":
var v any
_ = json.Unmarshal(raw, &v)
return v, true
case "input_file":
var fileID, filename string
if rawID, ok := part["file_id"]; ok {
_ = json.Unmarshal(rawID, &fileID)
}
if rawName, ok := part["filename"]; ok {
_ = json.Unmarshal(rawName, &filename)
}
label := filename
if label == "" {
label = fileID
}
if label == "" {
return nil, false
}
return map[string]any{"type": "text", "text": "[file:" + label + "]"}, true
default:
return nil, false
}
}
func joinContentPartTexts(parts []json.RawMessage) string {
var texts []string
for _, part := range parts {
if item, ok := convertResponsesContentPart(part); ok {
if text, ok := item.(string); ok {
texts = append(texts, text)
} else if m, ok := item.(map[string]any); ok && m["type"] == "text" {
if text, ok := m["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
func parseFunctionCallOutputItem(item map[string]json.RawMessage, role string) (map[string]any, error) {
var output, callID string
if rawOutput, ok := item["output"]; ok {
_ = json.Unmarshal(rawOutput, &output)
}
if rawCallID, ok := item["call_id"]; ok {
_ = json.Unmarshal(rawCallID, &callID)
}
if output == "" {
return nil, fmt.Errorf("unsupported input item")
}
msg := map[string]any{"role": "tool", "content": output}
if callID != "" {
msg["tool_call_id"] = callID
}
if role != "" && role != "user" {
msg["role"] = role
}
return msg, nil
}
// ChatCompletionToResponses converts a chat completions response to Responses API format.
func ChatCompletionToResponses(body []byte) ([]byte, error) {
var chat map[string]any
if err := json.Unmarshal(body, &chat); err != nil {
return nil, err
}
if _, ok := chat["error"]; ok {
return body, nil
}
content := extractChatContent(chat)
chatID, _ := chat["id"].(string)
model, _ := chat["model"].(string)
created := extractCreatedAt(chat)
respID := responsesIDFromChatID(chatID)
msgID := messageIDFromResponseID(respID)
usage := chatUsageToResponses(chat["usage"])
resp := buildResponseObject(respID, msgID, model, created, content, usage, "completed")
return json.Marshal(resp)
}
func extractCreatedAt(chat map[string]any) int64 {
switch v := chat["created"].(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
default:
return 0
}
}
func responsesIDFromChatID(chatID string) string {
if chatID == "" {
return "resp_adapted"
}
if strings.HasPrefix(chatID, "resp_") {
return chatID
}
if strings.HasPrefix(chatID, "chatcmpl-") {
return "resp_" + strings.TrimPrefix(chatID, "chatcmpl-")
}
return "resp_" + chatID
}
func messageIDFromResponseID(respID string) string {
if strings.HasPrefix(respID, "resp_") {
return "msg_" + strings.TrimPrefix(respID, "resp_")
}
return "msg_" + respID
}
func buildOutputTextContent(text string) map[string]any {
return map[string]any{
"type": "output_text",
"text": text,
"annotations": []any{},
}
}
func buildOutputMessage(msgID, content, status string) map[string]any {
return map[string]any{
"type": "message",
"id": msgID,
"role": "assistant",
"status": status,
"content": []map[string]any{buildOutputTextContent(content)},
}
}
func buildResponseObject(respID, msgID, model string, created int64, content string, usage map[string]any, status string) map[string]any {
msgStatus := status
if status == "in_progress" || status == "queued" {
msgStatus = "in_progress"
}
resp := map[string]any{
"id": respID,
"object": "response",
"created_at": created,
"status": status,
"model": model,
"output_text": content,
"error": nil,
"incomplete_details": nil,
"parallel_tool_calls": true,
"output": []map[string]any{buildOutputMessage(msgID, content, msgStatus)},
}
if usage != nil {
resp["usage"] = usage
}
return resp
}
func extractChatContent(chat map[string]any) string {
choices, ok := chat["choices"].([]any)
if !ok || len(choices) == 0 {
return ""
}
choice, ok := choices[0].(map[string]any)
if !ok {
return ""
}
if msg, ok := choice["message"].(map[string]any); ok {
if content, ok := msg["content"].(string); ok {
return content
}
}
if delta, ok := choice["delta"].(map[string]any); ok {
if content, ok := delta["content"].(string); ok {
return content
}
}
return ""
}
func chatUsageToResponses(usage any) map[string]any {
u, ok := usage.(map[string]any)
if !ok {
return nil
}
in, hasIn := numberAsInt(u["prompt_tokens"])
out, hasOut := numberAsInt(u["completion_tokens"])
total, hasTotal := numberAsInt(u["total_tokens"])
if !hasIn && !hasOut && !hasTotal {
return nil
}
if !hasTotal && hasIn && hasOut {
total = in + out
}
return map[string]any{
"input_tokens": in,
"output_tokens": out,
"total_tokens": total,
"input_tokens_details": map[string]any{
"cached_tokens": 0,
},
"output_tokens_details": map[string]any{
"reasoning_tokens": 0,
},
}
}
func numberAsInt(v any) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
default:
return 0, false
}
}
// AdaptResponsesStream wraps a chat-completions SSE stream as Responses API SSE events.
func AdaptResponsesStream(chatBody []byte, upstream io.Reader, w io.Writer, flusher http.Flusher) (*StreamResult, error) {
var req map[string]any
_ = json.Unmarshal(chatBody, &req)
model, _ := req["model"].(string)
result := &StreamResult{StatusCode: http.StatusOK}
respID := "resp_adapted"
msgID := "msg_adapted"
seq := 0
var fullText string
var usage map[string]any
started := false
writeEvent := func(eventType string, payload map[string]any) error {
payload["type"] = eventType
payload["sequence_number"] = seq
seq++
b, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b); err != nil {
return err
}
if flusher != nil {
flusher.Flush()
}
return nil
}
emitCreated := func(status string) error {
response := buildResponseObject(respID, msgID, model, 0, "", nil, status)
response["output"] = []any{}
response["output_text"] = ""
return writeEvent("response.created", map[string]any{"response": response})
}
scanner := bufio.NewScanner(upstream)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
tokensIn, tokensOut := parseStreamChunk([]byte(data))
result.TokensIn += tokensIn
result.TokensOut += tokensOut
var chunk map[string]any
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if id, ok := chunk["id"].(string); ok && id != "" {
respID = responsesIDFromChatID(id)
msgID = messageIDFromResponseID(respID)
}
if m, ok := chunk["model"].(string); ok && m != "" {
model = m
}
if u := chatUsageToResponses(chunk["usage"]); u != nil {
usage = u
}
delta := extractChatContent(chunk)
if delta == "" {
continue
}
if !started {
started = true
if err := emitCreated("in_progress"); err != nil {
return result, err
}
if err := writeEvent("response.in_progress", map[string]any{
"response": buildResponseObject(respID, msgID, model, 0, "", nil, "in_progress"),
}); err != nil {
return result, err
}
if err := writeEvent("response.output_item.added", map[string]any{
"output_index": 0,
"item": map[string]any{
"type": "message", "id": msgID, "role": "assistant", "status": "in_progress", "content": []any{},
},
}); err != nil {
return result, err
}
}
fullText += delta
if err := writeEvent("response.output_text.delta", map[string]any{
"item_id": msgID, "output_index": 0, "content_index": 0, "delta": delta, "logprobs": []any{},
}); err != nil {
return result, err
}
}
if err := scanner.Err(); err != nil {
return result, err
}
if started {
_ = writeEvent("response.output_text.done", map[string]any{
"item_id": msgID, "output_index": 0, "content_index": 0, "text": fullText, "logprobs": []any{},
})
doneItem := buildOutputMessage(msgID, fullText, "completed")
_ = writeEvent("response.output_item.done", map[string]any{
"output_index": 0,
"item": doneItem,
})
final := buildResponseObject(respID, msgID, model, 0, fullText, usage, "completed")
_ = writeEvent("response.completed", map[string]any{"response": final})
}
return result, nil
}
+179
View File
@@ -0,0 +1,179 @@
package provider
import (
"encoding/json"
"strings"
"testing"
)
func TestResponsesToChatCompletion_StringInput(t *testing.T) {
body := []byte(`{"model":"gpt-4o-mini","instructions":"Be brief","input":"Hello"}`)
out, err := ResponsesToChatCompletion(body)
if err != nil {
t.Fatal(err)
}
var req map[string]any
if err := json.Unmarshal(out, &req); err != nil {
t.Fatal(err)
}
if req["model"] != "gpt-4o-mini" {
t.Fatalf("model: %v", req["model"])
}
msgs := req["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages len: %d", len(msgs))
}
sys := msgs[0].(map[string]any)
user := msgs[1].(map[string]any)
if sys["role"] != "system" || sys["content"] != "Be brief" {
t.Fatalf("system: %#v", sys)
}
if user["role"] != "user" || user["content"] != "Hello" {
t.Fatalf("user: %#v", user)
}
}
func TestChatCompletionToResponses(t *testing.T) {
body := []byte(`{"id":"chatcmpl-abc123","model":"gpt-4o-mini","created":123,"choices":[{"message":{"role":"assistant","content":"Hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
out, err := ChatCompletionToResponses(body)
if err != nil {
t.Fatal(err)
}
var resp map[string]any
if err := json.Unmarshal(out, &resp); err != nil {
t.Fatal(err)
}
if resp["object"] != "response" {
t.Fatalf("object: %v", resp["object"])
}
if resp["id"] != "resp_abc123" {
t.Fatalf("id: %v", resp["id"])
}
if resp["output_text"] != "Hi" {
t.Fatalf("output_text: %v", resp["output_text"])
}
output := resp["output"].([]any)
msg := output[0].(map[string]any)
content := msg["content"].([]any)
text := content[0].(map[string]any)
if text["type"] != "output_text" || text["text"] != "Hi" {
t.Fatalf("text: %#v", text)
}
if _, ok := text["annotations"]; !ok {
t.Fatalf("missing annotations: %#v", text)
}
usage := resp["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 3 {
t.Fatalf("usage: %#v", usage)
}
if _, ok := usage["input_tokens_details"]; !ok {
t.Fatalf("missing input_tokens_details")
}
}
func TestResponsesToChatCompletion_InputTextParts(t *testing.T) {
body := []byte(`{
"model":"gpt-4o-mini",
"input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}]
}`)
out, err := ResponsesToChatCompletion(body)
if err != nil {
t.Fatal(err)
}
var req map[string]any
if err := json.Unmarshal(out, &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]any)
user := msgs[0].(map[string]any)
if user["content"] != "Hello" {
t.Fatalf("content: %#v", user["content"])
}
}
func TestResponsesToChatCompletion_InputTextAndImage(t *testing.T) {
body := []byte(`{
"model":"gpt-4o-mini",
"input":[{"role":"user","content":[
{"type":"input_text","text":"describe"},
{"type":"input_image","image_url":"https://example.com/a.png","detail":"auto"}
]}]
}`)
out, err := ResponsesToChatCompletion(body)
if err != nil {
t.Fatal(err)
}
var req map[string]any
if err := json.Unmarshal(out, &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]any)
user := msgs[0].(map[string]any)
parts := user["content"].([]any)
if len(parts) != 2 {
t.Fatalf("parts: %#v", parts)
}
textPart := parts[0].(map[string]any)
if textPart["type"] != "text" || textPart["text"] != "describe" {
t.Fatalf("text part: %#v", textPart)
}
imgPart := parts[1].(map[string]any)
if imgPart["type"] != "image_url" {
t.Fatalf("image part: %#v", imgPart)
}
}
func TestResponsesToChatCompletion_MessageItem(t *testing.T) {
body := []byte(`{
"model":"gpt-4o-mini",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]}]
}`)
out, err := ResponsesToChatCompletion(body)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "input_text") {
t.Fatalf("should not contain input_text: %s", out)
}
var req map[string]any
if err := json.Unmarshal(out, &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]any)
user := msgs[0].(map[string]any)
if user["content"] != "Hi" {
t.Fatalf("content: %#v", user["content"])
}
}
func TestAdaptResponsesStream(t *testing.T) {
upstream := strings.NewReader("data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n")
var out strings.Builder
result, err := AdaptResponsesStream([]byte(`{"model":"gpt-4o-mini","stream":true}`), upstream, &out, nil)
if err != nil {
t.Fatal(err)
}
if result.StatusCode != 200 {
t.Fatalf("status: %d", result.StatusCode)
}
s := out.String()
for _, event := range []string{
"response.created",
"response.in_progress",
"response.output_item.added",
"response.output_text.delta",
"response.output_text.done",
"response.output_item.done",
"response.completed",
} {
if !strings.Contains(s, event) {
t.Fatalf("missing event %s: %s", event, s)
}
}
if !strings.Contains(s, `"delta":"Hi"`) {
t.Fatalf("missing delta text: %s", s)
}
if !strings.Contains(s, `"output_text":"Hi"`) {
t.Fatalf("missing output_text in completed: %s", s)
}
}
+100
View File
@@ -0,0 +1,100 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/rose_cat707/luminary/internal/model"
)
type StreamResult struct {
StatusCode int
TokensIn int
TokensOut int
Body []byte // populated for non-2xx responses
}
func ForwardStreamHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, w io.Writer, flusher http.Flusher) (*StreamResult, error) {
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return nil, err
}
setAuthHeaders(req, apiKey, providerType)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(resp.Body)
if flusher != nil {
w.Write(b)
flusher.Flush()
}
return &StreamResult{StatusCode: resp.StatusCode, Body: b}, nil
}
result := &StreamResult{StatusCode: resp.StatusCode}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
continue
}
tokensIn, tokensOut := parseStreamChunk([]byte(data))
result.TokensIn += tokensIn
result.TokensOut += tokensOut
}
if _, err := w.Write([]byte(line + "\n")); err != nil {
return result, err
}
if flusher != nil {
flusher.Flush()
}
}
return result, scanner.Err()
}
func parseStreamChunk(data []byte) (int, int) {
var chunk struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(data, &chunk); err == nil && chunk.Usage.PromptTokens+chunk.Usage.CompletionTokens > 0 {
return chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens
}
return 0, 0
}
func NewStreamHTTPClient() *http.Client {
return &http.Client{} // no timeout for streams
}
func IsStreamRequest(body []byte) bool {
var payload struct {
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &payload)
return payload.Stream
}
func SupportsStreaming(endpoint EndpointType) bool {
return endpoint == EndpointChatCompletion || endpoint == EndpointResponses
}
+96
View File
@@ -0,0 +1,96 @@
package provider
import "github.com/rose_cat707/luminary/internal/model"
type ProviderTypeMeta struct {
Key model.ProviderType `json:"key"`
Label string `json:"label"`
}
var allProviderTypes = []ProviderTypeMeta{
{Key: model.ProviderOpenAI, Label: "OpenAI"},
{Key: model.ProviderAnthropic, Label: "Anthropic"},
{Key: model.ProviderAzureOpenAI, Label: "Azure OpenAI"},
{Key: model.ProviderOpenRouter, Label: "OpenRouter"},
{Key: model.ProviderOllama, Label: "Ollama"},
{Key: model.ProviderCustom, Label: "Custom (OpenAI compatible)"},
{Key: model.ProviderComfyUI, Label: "ComfyUI"},
}
func ProviderTypeLabel(t model.ProviderType) string {
for _, m := range allProviderTypes {
if m.Key == t {
return m.Label
}
}
return string(t)
}
// TypesForCategory 各出口分类支持的协议类型
func TypesForCategory(cat model.ProviderCategory) []ProviderTypeMeta {
switch cat {
case model.CategoryImage:
return []ProviderTypeMeta{{Key: model.ProviderComfyUI, Label: "ComfyUI"}}
case model.CategoryEmbedding:
return filterTypes(
model.ProviderOpenAI,
model.ProviderAzureOpenAI,
model.ProviderOpenRouter,
model.ProviderOllama,
model.ProviderCustom,
)
case model.CategoryRerank:
return filterTypes(
model.ProviderOpenAI,
model.ProviderAzureOpenAI,
model.ProviderOpenRouter,
model.ProviderOllama,
model.ProviderCustom,
)
default:
return filterTypes(
model.ProviderOpenAI,
model.ProviderAnthropic,
model.ProviderAzureOpenAI,
model.ProviderOpenRouter,
model.ProviderOllama,
model.ProviderCustom,
)
}
}
func filterTypes(keys ...model.ProviderType) []ProviderTypeMeta {
set := map[model.ProviderType]bool{}
for _, k := range keys {
set[k] = true
}
out := make([]ProviderTypeMeta, 0, len(keys))
for _, m := range allProviderTypes {
if set[m.Key] {
out = append(out, m)
}
}
return out
}
func DefaultProviderType(cat model.ProviderCategory) model.ProviderType {
types := TypesForCategory(cat)
if len(types) == 0 {
return model.ProviderOpenAI
}
return types[0].Key
}
func IsTypeValidForCategory(cat model.ProviderCategory, t model.ProviderType) bool {
for _, m := range TypesForCategory(cat) {
if m.Key == t {
return true
}
}
return false
}
// AllProviderTypes 返回全部协议类型(用于管理台展示标签)
func AllProviderTypes() []ProviderTypeMeta {
return append([]ProviderTypeMeta(nil), allProviderTypes...)
}
+30
View File
@@ -0,0 +1,30 @@
package provider
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestTypesForCategory(t *testing.T) {
llm := TypesForCategory(model.CategoryLLM)
if len(llm) != 6 {
t.Fatalf("llm types: got %d want 6", len(llm))
}
image := TypesForCategory(model.CategoryImage)
if len(image) != 1 || image[0].Key != model.ProviderComfyUI {
t.Fatalf("image types: %+v", image)
}
embedding := TypesForCategory(model.CategoryEmbedding)
for _, m := range embedding {
if m.Key == model.ProviderAnthropic {
t.Fatal("anthropic should not support embedding category")
}
}
if !IsTypeValidForCategory(model.CategoryRerank, model.ProviderCustom) {
t.Fatal("custom should be valid for rerank")
}
if IsTypeValidForCategory(model.CategoryRerank, model.ProviderAnthropic) {
t.Fatal("anthropic should not be valid for rerank")
}
}
+66
View File
@@ -0,0 +1,66 @@
package settings
import (
"log"
"os"
"strings"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/model"
"gorm.io/gorm"
)
// encryptionKeyCandidates returns possible keys ordered for repair attempts.
func encryptionKeyCandidates() []string {
seen := map[string]bool{}
var out []string
add := func(key string) {
key = strings.TrimSpace(key)
if key == "" || seen[key] {
return
}
seen[key] = true
out = append(out, key)
}
add(os.Getenv("LUMINARY_ENCRYPTION_KEY"))
add(legacyDefaultEncryptionKey)
return out
}
// EnsureEncryptionKeyWorks verifies provider keys can be decrypted and repairs the
// stored encryption_key from legacy sources when needed.
func (r *Runtime) EnsureEncryptionKeyWorks() {
var sample model.ProviderKey
err := r.db.Where("api_key_enc <> ''").First(&sample).Error
if err == gorm.ErrRecordNotFound {
return
}
if err != nil {
log.Printf("settings: check encryption key: %v", err)
return
}
current := r.EncryptionKey()
if _, err := auth.Decrypt(sample.APIKeyEnc, current); err == nil {
return
}
for _, candidate := range encryptionKeyCandidates() {
if candidate == current {
continue
}
if _, err := auth.Decrypt(sample.APIKeyEnc, candidate); err != nil {
continue
}
key := candidate
if err := r.Update(UpdateInput{EncryptionKey: &key}); err != nil {
log.Printf("settings: repair encryption_key: %v", err)
return
}
log.Printf("settings: repaired encryption_key from legacy source")
return
}
log.Printf("settings: provider keys cannot be decrypted; set the correct encryption_key in admin system settings or re-save provider keys")
}
+22
View File
@@ -0,0 +1,22 @@
package settings
import (
"os"
"strings"
"github.com/rose_cat707/luminary/internal/model"
)
const legacyDefaultEncryptionKey = "change-me-in-production-32bytes!!"
func mergeLegacySettings(s model.SystemSettings) model.SystemSettings {
if key := strings.TrimSpace(os.Getenv("LUMINARY_ENCRYPTION_KEY")); key != "" {
s.EncryptionKey = key
}
return s
}
// LegacyAdminCredentials returns default bootstrap credentials for first admin.
func LegacyAdminCredentials() (username, password string) {
return "admin", "admin123"
}
+262
View File
@@ -0,0 +1,262 @@
package settings
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"sync"
"time"
"github.com/rose_cat707/luminary/internal/middleware"
"github.com/rose_cat707/luminary/internal/model"
"gorm.io/gorm"
)
// Runtime holds mutable system settings loaded from the database.
type Runtime struct {
mu sync.RWMutex
db *gorm.DB
data model.SystemSettings
}
func NewRuntime(db *gorm.DB) *Runtime {
return &Runtime{db: db}
}
func (r *Runtime) Load() error {
var s model.SystemSettings
err := r.db.First(&s, 1).Error
if err == gorm.ErrRecordNotFound {
s = defaultSettings()
s = mergeLegacySettings(s)
if err := r.db.Create(&s).Error; err != nil {
return err
}
} else if err != nil {
return err
}
r.mu.Lock()
r.data = s
r.mu.Unlock()
r.applySideEffects()
return nil
}
// AfterLoad runs post-bootstrap checks that depend on other tables.
func (r *Runtime) AfterLoad() {
r.EnsureEncryptionKeyWorks()
}
func defaultSettings() model.SystemSettings {
key := make([]byte, 32)
_, _ = rand.Read(key)
return model.SystemSettings{
ID: 1,
EncryptionKey: hex.EncodeToString(key),
SessionTTLSeconds: int64((24 * time.Hour).Seconds()),
LoginMaxAttempts: 5,
LoginLockoutSeconds: int64((15 * time.Minute).Seconds()),
TrustedProxiesJSON: "[]",
UsageFlushSeconds: 5,
HealthCheckSeconds: 30,
GatewayMaxRetries: 3,
GatewayRetryBackoffMs: 200,
ImageStoragePath: "images",
SignedURLTTLSeconds: 3600,
PredictionWaitSeconds: 60,
}
}
func (r *Runtime) Snapshot() model.SystemSettings {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data
}
func (r *Runtime) EncryptionKey() string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.EncryptionKey
}
func (r *Runtime) SessionTTL() time.Duration {
r.mu.RLock()
defer r.mu.RUnlock()
return time.Duration(r.data.SessionTTLSeconds) * time.Second
}
func (r *Runtime) LoginMaxAttempts() int {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.LoginMaxAttempts
}
func (r *Runtime) LoginLockout() time.Duration {
r.mu.RLock()
defer r.mu.RUnlock()
return time.Duration(r.data.LoginLockoutSeconds) * time.Second
}
func (r *Runtime) TrustedProxies() []string {
r.mu.RLock()
defer r.mu.RUnlock()
var out []string
_ = json.Unmarshal([]byte(r.data.TrustedProxiesJSON), &out)
return out
}
func (r *Runtime) UsageFlushInterval() time.Duration {
r.mu.RLock()
defer r.mu.RUnlock()
return time.Duration(r.data.UsageFlushSeconds) * time.Second
}
func (r *Runtime) HealthCheckInterval() time.Duration {
r.mu.RLock()
defer r.mu.RUnlock()
return time.Duration(r.data.HealthCheckSeconds) * time.Second
}
func (r *Runtime) GatewayMaxRetries() int {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.GatewayMaxRetries
}
func (r *Runtime) GatewayRetryBackoffMs() int {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.GatewayRetryBackoffMs
}
func (r *Runtime) ImageStoragePath() string {
r.mu.RLock()
defer r.mu.RUnlock()
if r.data.ImageStoragePath == "" {
return "images"
}
return r.data.ImageStoragePath
}
func (r *Runtime) PublicBaseURL() string {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.PublicBaseURL
}
func (r *Runtime) SignedURLTTL() time.Duration {
r.mu.RLock()
defer r.mu.RUnlock()
sec := r.data.SignedURLTTLSeconds
if sec <= 0 {
sec = 3600
}
return time.Duration(sec) * time.Second
}
func (r *Runtime) PredictionWaitSeconds() int64 {
r.mu.RLock()
defer r.mu.RUnlock()
if r.data.PredictionWaitSeconds <= 0 {
return 60
}
return r.data.PredictionWaitSeconds
}
type UpdateInput struct {
EncryptionKey *string `json:"encryption_key"`
SessionTTLSeconds *int64 `json:"session_ttl_seconds"`
LoginMaxAttempts *int `json:"login_max_attempts"`
LoginLockoutSeconds *int64 `json:"login_lockout_seconds"`
TrustedProxies []string `json:"trusted_proxies"`
UsageFlushSeconds *int64 `json:"usage_flush_seconds"`
HealthCheckSeconds *int64 `json:"health_check_seconds"`
GatewayMaxRetries *int `json:"gateway_max_retries"`
GatewayRetryBackoffMs *int `json:"gateway_retry_backoff_ms"`
ImageStoragePath *string `json:"image_storage_path"`
PublicBaseURL *string `json:"public_base_url"`
SignedURLTTLSeconds *int64 `json:"signed_url_ttl_seconds"`
PredictionWaitSeconds *int64 `json:"prediction_wait_seconds"`
}
func (r *Runtime) Update(in UpdateInput) error {
r.mu.Lock()
s := r.data
if in.EncryptionKey != nil && *in.EncryptionKey != "" {
s.EncryptionKey = *in.EncryptionKey
}
if in.SessionTTLSeconds != nil && *in.SessionTTLSeconds > 0 {
s.SessionTTLSeconds = *in.SessionTTLSeconds
}
if in.LoginMaxAttempts != nil && *in.LoginMaxAttempts > 0 {
s.LoginMaxAttempts = *in.LoginMaxAttempts
}
if in.LoginLockoutSeconds != nil && *in.LoginLockoutSeconds > 0 {
s.LoginLockoutSeconds = *in.LoginLockoutSeconds
}
if in.TrustedProxies != nil {
b, _ := json.Marshal(in.TrustedProxies)
s.TrustedProxiesJSON = string(b)
}
if in.UsageFlushSeconds != nil && *in.UsageFlushSeconds > 0 {
s.UsageFlushSeconds = *in.UsageFlushSeconds
}
if in.HealthCheckSeconds != nil && *in.HealthCheckSeconds > 0 {
s.HealthCheckSeconds = *in.HealthCheckSeconds
}
if in.GatewayMaxRetries != nil && *in.GatewayMaxRetries > 0 {
s.GatewayMaxRetries = *in.GatewayMaxRetries
}
if in.GatewayRetryBackoffMs != nil && *in.GatewayRetryBackoffMs >= 0 {
s.GatewayRetryBackoffMs = *in.GatewayRetryBackoffMs
}
if in.ImageStoragePath != nil {
s.ImageStoragePath = *in.ImageStoragePath
}
if in.PublicBaseURL != nil {
s.PublicBaseURL = *in.PublicBaseURL
}
if in.SignedURLTTLSeconds != nil && *in.SignedURLTTLSeconds > 0 {
s.SignedURLTTLSeconds = *in.SignedURLTTLSeconds
}
if in.PredictionWaitSeconds != nil && *in.PredictionWaitSeconds > 0 {
s.PredictionWaitSeconds = *in.PredictionWaitSeconds
}
if err := r.db.Save(&s).Error; err != nil {
return err
}
r.data = s
r.mu.Unlock()
r.applySideEffects()
return nil
}
func (r *Runtime) applySideEffects() {
middleware.ConfigureTrustedProxies(r.TrustedProxies())
}
func (r *Runtime) PublicView() map[string]any {
s := r.Snapshot()
masked := "(已设置)"
if s.EncryptionKey == "" {
masked = "(未设置)"
}
var proxies []string
_ = json.Unmarshal([]byte(s.TrustedProxiesJSON), &proxies)
return map[string]any{
"encryption_key_set": s.EncryptionKey != "",
"encryption_key_hint": masked,
"session_ttl_seconds": s.SessionTTLSeconds,
"login_max_attempts": s.LoginMaxAttempts,
"login_lockout_seconds": s.LoginLockoutSeconds,
"trusted_proxies": proxies,
"usage_flush_seconds": s.UsageFlushSeconds,
"health_check_seconds": s.HealthCheckSeconds,
"gateway_max_retries": s.GatewayMaxRetries,
"gateway_retry_backoff_ms": s.GatewayRetryBackoffMs,
"image_storage_path": s.ImageStoragePath,
"public_base_url": s.PublicBaseURL,
"signed_url_ttl_seconds": s.SignedURLTTLSeconds,
"prediction_wait_seconds": s.PredictionWaitSeconds,
}
}
+93
View File
@@ -0,0 +1,93 @@
package imagestore
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/rose_cat707/luminary/internal/model"
"gorm.io/gorm"
)
type Store struct {
db *gorm.DB
baseDir string
signKey string
publicURL string
ttl time.Duration
}
func New(db *gorm.DB, baseDir, publicURL, signKey string, ttl time.Duration) *Store {
return &Store{db: db, baseDir: baseDir, publicURL: strings.TrimRight(publicURL, "/"), signKey: signKey, ttl: ttl}
}
func (s *Store) EnsureDir() error {
return os.MkdirAll(s.baseDir, 0o755)
}
func (s *Store) Save(predictionID, filename string, data []byte, mime string) (*model.StoredImage, error) {
if err := s.EnsureDir(); err != nil {
return nil, err
}
dir := filepath.Join(s.baseDir, predictionID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
localPath := filepath.Join(dir, filename)
if err := os.WriteFile(localPath, data, 0o644); err != nil {
return nil, err
}
img := model.StoredImage{
ID: uuid.NewString(),
PredictionID: predictionID,
Filename: filename,
LocalPath: localPath,
Mime: mime,
Size: int64(len(data)),
CreatedAt: time.Now(),
}
if err := s.db.Create(&img).Error; err != nil {
return nil, err
}
return &img, nil
}
func (s *Store) Get(id string) (*model.StoredImage, error) {
var img model.StoredImage
if err := s.db.First(&img, "id = ?", id).Error; err != nil {
return nil, err
}
return &img, nil
}
func (s *Store) ListByPrediction(predictionID string) ([]model.StoredImage, error) {
var imgs []model.StoredImage
err := s.db.Where("prediction_id = ?", predictionID).Order("created_at asc").Find(&imgs).Error
return imgs, err
}
func (s *Store) SignURL(imageID string) string {
exp := time.Now().Add(s.ttl).Unix()
sig := s.sign(imageID, exp)
return fmt.Sprintf("%s/files/%s?exp=%d&sig=%s", s.publicURL, imageID, exp, sig)
}
func (s *Store) Verify(imageID string, exp int64, sig string) bool {
if time.Now().Unix() > exp {
return false
}
return hmac.Equal([]byte(s.sign(imageID, exp)), []byte(sig))
}
func (s *Store) sign(imageID string, exp int64) string {
mac := hmac.New(sha256.New, []byte(s.signKey))
mac.Write([]byte(imageID + "|" + strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
+455
View File
@@ -0,0 +1,455 @@
package cache
import (
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/model"
)
type IngressKeySnapshot struct {
Key model.IngressKey
KeyPlain string // only set on create, not persisted in cache long-term
}
type UsageDelta struct {
IngressKeyID uint
ProviderKeyID uint
ProviderID uint
ClientIP string
Endpoint string
Model string
TokensIn int
TokensOut int
Cost float64
LatencyMs int64
Status string
Stream bool
ErrorMsg string
RequestBody string
ResponseBody string
BudgetUsed float64
Requests int64
Tokens int64
}
type Store struct {
mu sync.RWMutex
providers map[uint]*model.Provider
providerKeys map[uint]*model.ProviderKey
providerByName map[string]*model.Provider
models map[uint][]model.ModelEntry
ingressKeys map[uint]*model.IngressKey
ingressByHash map[string]*model.IngressKey
ipRules []model.IPRule
routingRules map[uint][]model.RoutingRule // ingressKeyID -> rules
healthStatus map[uint]model.HealthCheckRecord
usageDeltas []UsageDelta
usageMu sync.Mutex
// rate limit counters: ingressKeyID -> window start + counts
rateMu sync.Mutex
rateWindows map[uint]*rateWindow
}
type rateWindow struct {
start time.Time
requests int
tokens int
}
func New() *Store {
return &Store{
providers: make(map[uint]*model.Provider),
providerKeys: make(map[uint]*model.ProviderKey),
providerByName: make(map[string]*model.Provider),
models: make(map[uint][]model.ModelEntry),
ingressKeys: make(map[uint]*model.IngressKey),
ingressByHash: make(map[string]*model.IngressKey),
routingRules: make(map[uint][]model.RoutingRule),
healthStatus: make(map[uint]model.HealthCheckRecord),
rateWindows: make(map[uint]*rateWindow),
}
}
func (c *Store) LoadProviders(providers []model.Provider) {
c.mu.Lock()
defer c.mu.Unlock()
c.providers = make(map[uint]*model.Provider)
c.providerByName = make(map[string]*model.Provider)
for i := range providers {
p := providers[i]
c.providers[p.ID] = &p
c.providerByName[p.Name] = &p
}
}
func (c *Store) LoadProviderKeys(keys []model.ProviderKey) {
c.mu.Lock()
defer c.mu.Unlock()
c.providerKeys = make(map[uint]*model.ProviderKey)
for i := range keys {
k := keys[i]
c.providerKeys[k.ID] = &k
}
}
func (c *Store) LoadModels(models []model.ModelEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.models = make(map[uint][]model.ModelEntry)
for _, m := range models {
c.models[m.ProviderID] = append(c.models[m.ProviderID], m)
}
}
func (c *Store) LoadIngressKeys(keys []model.IngressKey) {
c.mu.Lock()
defer c.mu.Unlock()
c.ingressKeys = make(map[uint]*model.IngressKey)
c.ingressByHash = make(map[string]*model.IngressKey)
for i := range keys {
k := keys[i]
c.ingressKeys[k.ID] = &k
c.ingressByHash[k.KeyHash] = &k
}
}
func (c *Store) LoadIPRules(rules []model.IPRule) {
c.mu.Lock()
defer c.mu.Unlock()
c.ipRules = rules
}
func (c *Store) GetProvider(id uint) (model.Provider, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
p, ok := c.providers[id]
if !ok {
return model.Provider{}, false
}
return *p, true
}
func (c *Store) GetProviderByName(name string) (model.Provider, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
p, ok := c.providerByName[name]
if !ok {
return model.Provider{}, false
}
return *p, true
}
func (c *Store) ListProviders() []model.Provider {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]model.Provider, 0, len(c.providers))
for _, p := range c.providers {
out = append(out, *p)
}
return out
}
func (c *Store) SetProvider(p model.Provider) {
c.mu.Lock()
defer c.mu.Unlock()
c.providers[p.ID] = &p
c.providerByName[p.Name] = &p
}
func (c *Store) DeleteProvider(id uint) {
c.mu.Lock()
defer c.mu.Unlock()
if p, ok := c.providers[id]; ok {
delete(c.providerByName, p.Name)
}
delete(c.providers, id)
delete(c.models, id)
}
func (c *Store) GetProviderKey(id uint) (model.ProviderKey, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
k, ok := c.providerKeys[id]
if !ok {
return model.ProviderKey{}, false
}
return *k, true
}
func (c *Store) ListProviderKeys(providerID uint) []model.ProviderKey {
c.mu.RLock()
defer c.mu.RUnlock()
var out []model.ProviderKey
for _, k := range c.providerKeys {
if k.ProviderID == providerID {
out = append(out, *k)
}
}
return out
}
func (c *Store) ListAllProviderKeys() []model.ProviderKey {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]model.ProviderKey, 0, len(c.providerKeys))
for _, k := range c.providerKeys {
out = append(out, *k)
}
return out
}
func (c *Store) SetProviderKey(k model.ProviderKey) {
c.mu.Lock()
defer c.mu.Unlock()
c.providerKeys[k.ID] = &k
}
func (c *Store) DeleteProviderKey(id uint) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.providerKeys, id)
}
func (c *Store) GetModels(providerID uint) []model.ModelEntry {
c.mu.RLock()
defer c.mu.RUnlock()
models := c.models[providerID]
if models == nil {
return []model.ModelEntry{}
}
return models
}
func (c *Store) SetModels(providerID uint, models []model.ModelEntry) {
c.mu.Lock()
defer c.mu.Unlock()
c.models[providerID] = models
}
func (c *Store) ListIngressKeys() []model.IngressKey {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]model.IngressKey, 0, len(c.ingressKeys))
for _, k := range c.ingressKeys {
out = append(out, *k)
}
return out
}
func (c *Store) GetIngressKey(id uint) (model.IngressKey, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
k, ok := c.ingressKeys[id]
if !ok {
return model.IngressKey{}, false
}
return *k, true
}
func (c *Store) SetIngressKey(k model.IngressKey) {
c.mu.Lock()
defer c.mu.Unlock()
c.ingressKeys[k.ID] = &k
c.ingressByHash[k.KeyHash] = &k
}
func (c *Store) DeleteIngressKey(id uint) {
c.mu.Lock()
defer c.mu.Unlock()
if k, ok := c.ingressKeys[id]; ok {
delete(c.ingressByHash, k.KeyHash)
}
delete(c.ingressKeys, id)
}
func (c *Store) LookupIngressKey(plain string) (*model.IngressKey, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
hash := auth.HashIngressKey(plain)
if k, ok := c.ingressByHash[hash]; ok && k.Enabled {
cp := *k
return &cp, true
}
for _, k := range c.ingressKeys {
if !k.Enabled || !auth.IsLegacyIngressHash(k.KeyHash) {
continue
}
if auth.CheckIngressKey(k.KeyHash, plain) {
cp := *k
return &cp, true
}
}
return nil, false
}
func (c *Store) ResetProviderKeyDailyCounts() {
c.mu.Lock()
defer c.mu.Unlock()
for _, k := range c.providerKeys {
k.RequestsToday = 0
k.TokensToday = 0
}
}
func (c *Store) ListIPRules() []model.IPRule {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]model.IPRule, len(c.ipRules))
copy(out, c.ipRules)
return out
}
func (c *Store) SetIPRules(rules []model.IPRule) {
c.mu.Lock()
defer c.mu.Unlock()
c.ipRules = rules
}
func (c *Store) SetHealthStatus(providerID uint, rec model.HealthCheckRecord) {
c.mu.Lock()
defer c.mu.Unlock()
c.healthStatus[providerID] = rec
if p, ok := c.providers[providerID]; ok {
p.Status = rec.Status
}
}
func (c *Store) GetHealthStatus() map[uint]model.HealthCheckRecord {
c.mu.RLock()
defer c.mu.RUnlock()
out := make(map[uint]model.HealthCheckRecord, len(c.healthStatus))
for k, v := range c.healthStatus {
out[k] = v
}
return out
}
func (c *Store) RecordUsage(delta UsageDelta) {
c.mu.Lock()
if k, ok := c.ingressKeys[delta.IngressKeyID]; ok {
k.BudgetUsed += delta.BudgetUsed
k.RequestCount += delta.Requests
k.TokenCount += delta.Tokens
}
if pk, ok := c.providerKeys[delta.ProviderKeyID]; ok {
pk.RequestsToday += int(delta.Requests)
pk.TokensToday += delta.Tokens
}
c.mu.Unlock()
c.usageMu.Lock()
c.usageDeltas = append(c.usageDeltas, delta)
c.usageMu.Unlock()
}
func (c *Store) DrainUsageDeltas() []UsageDelta {
c.usageMu.Lock()
defer c.usageMu.Unlock()
if len(c.usageDeltas) == 0 {
return nil
}
out := c.usageDeltas
c.usageDeltas = nil
return out
}
func (c *Store) CheckRateLimit(ingressKeyID uint, rpm, tpm int, tokens int) bool {
if rpm <= 0 && tpm <= 0 {
return true
}
c.rateMu.Lock()
defer c.rateMu.Unlock()
now := time.Now()
w, ok := c.rateWindows[ingressKeyID]
if !ok || now.Sub(w.start) >= time.Minute {
c.rateWindows[ingressKeyID] = &rateWindow{start: now, requests: 1, tokens: tokens}
return true
}
if rpm > 0 && w.requests >= rpm {
return false
}
if tpm > 0 && w.tokens+tokens > tpm {
return false
}
w.requests++
w.tokens += tokens
return true
}
func ParseJSONList(s string) []string {
var out []string
if s == "" {
return []string{"*"}
}
_ = json.Unmarshal([]byte(s), &out)
if len(out) == 0 {
return []string{"*"}
}
return out
}
func MatchesList(list []string, value string) bool {
for _, item := range list {
if item == "*" || item == value {
return true
}
}
return false
}
func ModelsToJSON(models []string) string {
b, _ := json.Marshal(models)
return string(b)
}
func (c *Store) LoadRoutingRules(rules []model.RoutingRule) {
c.mu.Lock()
defer c.mu.Unlock()
c.routingRules = make(map[uint][]model.RoutingRule)
for _, r := range rules {
c.routingRules[r.IngressKeyID] = append(c.routingRules[r.IngressKeyID], r)
}
}
func (c *Store) ListRoutingRules(ingressKeyID uint) []model.RoutingRule {
c.mu.RLock()
defer c.mu.RUnlock()
out := c.routingRules[ingressKeyID]
cp := make([]model.RoutingRule, len(out))
copy(cp, out)
return cp
}
func (c *Store) SetRoutingRules(ingressKeyID uint, rules []model.RoutingRule) {
c.mu.Lock()
defer c.mu.Unlock()
c.routingRules[ingressKeyID] = rules
}
func (c *Store) DeleteRoutingRulesForIngress(ingressKeyID uint) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.routingRules, ingressKeyID)
}
type Counter struct {
v atomic.Int64
}
func (c *Counter) Add(n int64) {
c.v.Add(n)
}
func (c *Counter) Load() int64 {
return c.v.Load()
}
+136
View File
@@ -0,0 +1,136 @@
package sqlite
import (
"time"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/settings"
"github.com/rose_cat707/luminary/internal/store/cache"
"gorm.io/gorm"
)
type Repository struct {
db *gorm.DB
cache *cache.Store
}
func NewRepository(store *Store, c *cache.Store) *Repository {
return &Repository{db: store.DB(), cache: c}
}
func (r *Repository) BootstrapFromDB() error {
var providers []model.Provider
if err := r.db.Find(&providers).Error; err != nil {
return err
}
r.cache.LoadProviders(providers)
var keys []model.ProviderKey
if err := r.db.Find(&keys).Error; err != nil {
return err
}
r.cache.LoadProviderKeys(keys)
var models []model.ModelEntry
if err := r.db.Find(&models).Error; err != nil {
return err
}
r.cache.LoadModels(models)
var ingress []model.IngressKey
if err := r.db.Find(&ingress).Error; err != nil {
return err
}
r.cache.LoadIngressKeys(ingress)
var rules []model.IPRule
if err := r.db.Find(&rules).Error; err != nil {
return err
}
r.cache.LoadIPRules(rules)
var routing []model.RoutingRule
if err := r.db.Find(&routing).Error; err != nil {
return err
}
r.cache.LoadRoutingRules(routing)
r.PruneUsageRecords(model.MaxUsageLogRecords)
return nil
}
func (r *Repository) EnsureAdmin() error {
var count int64
r.db.Model(&model.AdminUser{}).Count(&count)
if count > 0 {
return nil
}
username, password := settings.LegacyAdminCredentials()
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
return r.db.Create(&model.AdminUser{Username: username, PasswordHash: hash}).Error
}
func (r *Repository) FlushUsage(deltas []cache.UsageDelta) {
for _, d := range deltas {
r.db.Create(&model.UsageRecord{
IngressKeyID: d.IngressKeyID,
ProviderID: d.ProviderID,
ProviderKeyID: d.ProviderKeyID,
ClientIP: d.ClientIP,
Endpoint: d.Endpoint,
Model: d.Model,
TokensIn: d.TokensIn,
TokensOut: d.TokensOut,
Cost: d.Cost,
LatencyMs: d.LatencyMs,
Status: d.Status,
Stream: d.Stream,
ErrorMsg: d.ErrorMsg,
RequestBody: d.RequestBody,
ResponseBody: d.ResponseBody,
CreatedAt: time.Now(),
})
if d.IngressKeyID > 0 {
r.db.Model(&model.IngressKey{}).Where("id = ?", d.IngressKeyID).Updates(map[string]any{
"budget_used": gorm.Expr("budget_used + ?", d.BudgetUsed),
"request_count": gorm.Expr("request_count + ?", d.Requests),
"token_count": gorm.Expr("token_count + ?", d.Tokens),
})
}
if d.ProviderKeyID > 0 {
r.db.Model(&model.ProviderKey{}).Where("id = ?", d.ProviderKeyID).Updates(map[string]any{
"requests_today": gorm.Expr("requests_today + ?", d.Requests),
"tokens_today": gorm.Expr("tokens_today + ?", d.Tokens),
})
}
}
r.PruneUsageRecords(model.MaxUsageLogRecords)
}
func (r *Repository) RecordRequestLog(rec model.UsageRecord) {
if rec.CreatedAt.IsZero() {
rec.CreatedAt = time.Now()
}
r.db.Create(&rec)
r.PruneUsageRecords(model.MaxUsageLogRecords)
}
func (r *Repository) PruneUsageRecords(max int) {
if max <= 0 {
return
}
var count int64
r.db.Model(&model.UsageRecord{}).Count(&count)
if count <= int64(max) {
return
}
var keepIDs []uint
r.db.Model(&model.UsageRecord{}).Order("created_at desc").Limit(max).Pluck("id", &keepIDs)
if len(keepIDs) == 0 {
return
}
r.db.Where("id NOT IN ?", keepIDs).Delete(&model.UsageRecord{})
}
+109
View File
@@ -0,0 +1,109 @@
package sqlite
import (
"fmt"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"github.com/rose_cat707/luminary/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Store struct {
db *gorm.DB
}
func New(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := gorm.Open(sqlite.Open(path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
if err := ensureIPRulesSchema(db); err != nil {
return nil, fmt.Errorf("migrate ip_rules: %w", err)
}
if err := db.AutoMigrate(
&model.Provider{},
&model.ProviderKey{},
&model.ModelEntry{},
&model.IngressKey{},
&model.AdminUser{},
&model.AdminSession{},
&model.UsageRecord{},
&model.HealthCheckRecord{},
&model.RoutingRule{},
&model.SystemSettings{},
&model.Prediction{},
&model.StoredImage{},
); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return &Store{db: db}, nil
}
// ensureIPRulesSchema manages ip_rules without GORM AutoMigrate (avoids bad table rebuilds).
func ensureIPRulesSchema(db *gorm.DB) error {
if !tableExists(db, "ip_rules") {
return db.Exec(`
CREATE TABLE ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cidr TEXT NOT NULL,
type TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'proxy',
enabled NUMERIC DEFAULT 1,
created_at DATETIME
);
`).Error
}
if !tableHasColumn(db, "ip_rules", "c_id_r") {
return nil
}
cidrExpr := "c_id_r"
if tableHasColumn(db, "ip_rules", "cidr") {
cidrExpr = "COALESCE(NULLIF(cidr, ''), c_id_r)"
}
return db.Exec(`
CREATE TABLE ip_rules_mig (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cidr TEXT NOT NULL,
type TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'proxy',
enabled NUMERIC DEFAULT 1,
created_at DATETIME
);
INSERT INTO ip_rules_mig (id, cidr, type, scope, enabled, created_at)
SELECT id, ` + cidrExpr + `, type, scope, enabled, created_at
FROM ip_rules;
DROP TABLE ip_rules;
ALTER TABLE ip_rules_mig RENAME TO ip_rules;
`).Error
}
func tableHasColumn(db *gorm.DB, table, column string) bool {
var n int64
db.Raw(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&n)
return n > 0
}
func tableExists(db *gorm.DB, table string) bool {
var n int64
db.Raw(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
return n > 0
}
func (s *Store) DB() *gorm.DB {
return s.db
}
+69
View File
@@ -0,0 +1,69 @@
package sqlite
import (
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/rose_cat707/luminary/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestPreMigrateIPRulesTable_LegacySchema(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legacy.db")
legacy, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err := legacy.Exec(`
CREATE TABLE ip_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
c_id_r TEXT NOT NULL,
type TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'all',
enabled NUMERIC DEFAULT 1,
created_at DATETIME
);
INSERT INTO ip_rules (c_id_r, type, scope, enabled) VALUES ('10.0.0.1', 'allow', 'proxy', 1);
`).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := legacy.DB()
sqlDB.Close()
store, err := New(path)
if err != nil {
t.Fatalf("New() with legacy schema: %v", err)
}
var rules []model.IPRule
if err := store.DB().Find(&rules).Error; err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].CIDR != "10.0.0.1" {
t.Fatalf("unexpected rules: %+v", rules)
}
if store.DB().Migrator().HasColumn(&model.IPRule{}, "c_id_r") {
t.Fatal("legacy c_id_r column should be removed")
}
if tableHasColumn(store.DB(), "ip_rules", "c_id_r") {
t.Fatal("legacy c_id_r column should be removed")
}
}
func TestNew_FreshDatabase(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fresh.db")
store, err := New(path)
if err != nil {
t.Fatal(err)
}
if err := store.DB().Create(&model.IPRule{CIDR: "127.0.0.1", Type: model.IPRuleAllow, Scope: model.IPScopeProxy, Enabled: true}).Error; err != nil {
t.Fatal(err)
}
_ = os.Remove(path)
}