Files
Luminary/internal/gateway/service.go
T
renjue 76ba500417
CI / docker (push) Successful in 2m4s
Initial commit: Luminary AI Gateway
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>
2026-06-23 21:46:16 +08:00

476 lines
16 KiB
Go

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