初始提交:边缘AI算力机统一AI通讯层
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / security-scan (push) Has been cancelled

This commit is contained in:
freedakgmail
2026-08-03 07:44:05 +08:00
commit 93a469061d
51 changed files with 11565 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
package server
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/edgeai/gateway/internal/adapter"
"github.com/edgeai/gateway/internal/auth"
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/internal/handler"
"github.com/edgeai/gateway/internal/middleware"
"github.com/edgeai/gateway/internal/observability"
"github.com/edgeai/gateway/internal/router"
"github.com/edgeai/gateway/internal/task"
"github.com/edgeai/gateway/pkg/api"
"github.com/google/uuid"
)
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
handler.WriteError(w, handler.NewGatewayError(handler.ErrInvalidRequest, "method not allowed"))
return
}
requestID := middleware.GetRequestID(r.Context())
identity := auth.GetAppIdentityFromRequest(r)
var req api.ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "invalid JSON body", requestID))
return
}
// Validate required fields
if req.Model == "" {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "model is required", requestID))
return
}
if len(req.Messages) == 0 {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "messages is required", requestID))
return
}
// Check model permission
if identity != nil && !auth.CheckModelPermission(identity, req.Model) {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrPermissionDenied, "model not allowed for this application", requestID))
return
}
// Resolve logical model
target, err := s.modelMap.Resolve(req.Model)
if err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrModelUnavailable, err.Error(), requestID))
return
}
// Get adapter
adapterInst, err := s.registry.Get(target.Provider)
if err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrModelUnavailable, err.Error(), requestID))
return
}
// Parse priority
priority := config.ParsePriority(req.Priority)
if priority == 0 && identity != nil && !auth.CheckPriorityPermission(identity, 0) {
priority = int(task.PriorityNormal) // downgrade to P2 if not allowed P0
}
// Create task
taskID := uuid.New().String()
tk := task.NewTask(taskID, requestID, identity.AppID, identity.TenantID, req.Model, task.TaskPriority(priority), req.Stream)
// Submit to scheduler
if err := s.scheduler.Submit(tk); err != nil {
s.metrics.IncRequest("queue_full")
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrQueueFull, "queue is full, please retry later", requestID))
return
}
s.metrics.SetQueueLength(s.scheduler.QueueLength())
// Wait for task to be dequeued
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeouts.DefaultQueueMs)*time.Millisecond)
defer cancel()
dequeued, err := s.scheduler.GetNext(ctx)
if err != nil {
s.scheduler.Complete(tk.ID)
s.metrics.IncRequest("queue_timeout")
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrQueueTimeout, "queue timeout", requestID))
return
}
// Transition to RUNNING
dequeued.Transition(task.StateRunning)
s.metrics.SetRunningTasks(s.scheduler.RunningCount())
// Build adapter request
adapterReq := &adapter.ChatRequest{
RequestID: requestID,
Model: target.ActualModel,
Messages: req.Messages,
MaxTokens: target.MaxOutputTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
CancelCh: dequeued.Cancelled(),
}
if req.MaxOutputTokens > 0 {
adapterReq.MaxTokens = req.MaxOutputTokens
}
if req.Stream {
s.handleStreaming(w, r, adapterInst, adapterReq, dequeued, requestID, target, req.Model)
} else {
s.handleNonStreaming(w, r, adapterInst, adapterReq, dequeued, requestID, target, req.Model)
}
}
func (s *Server) handleStreaming(w http.ResponseWriter, r *http.Request, adapterInst adapter.ModelAdapter, req *adapter.ChatRequest, tk *task.Task, requestID string, target *router.ModelTarget, logicalModel string) {
sse := handler.NewSSEWriter(w)
if sse == nil {
s.scheduler.Complete(tk.ID)
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInternalError, "streaming not supported", requestID))
return
}
tk.Transition(task.StateStreaming)
ch, err := adapterInst.ChatCompletionStream(r.Context(), req)
if err != nil {
s.scheduler.Complete(tk.ID)
tk.Transition(task.StateFailed)
s.metrics.IncTask("failed")
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrModelUnavailable, err.Error(), requestID))
return
}
inputTokens, outputTokens, err := handler.StreamChatCompletion(sse, ch, requestID, tk.ID, logicalModel)
if err != nil {
s.logger.Error("streaming error", observability.F().Event("stream_error").TaskID(tk.ID).Reason(err.Error()))
tk.Transition(task.StateFailed)
s.metrics.IncTask("failed")
} else {
tk.Transition(task.StateCompleted)
s.metrics.IncTask("completed")
}
s.metrics.AddTokens(inputTokens, outputTokens)
s.scheduler.Complete(tk.ID)
s.metrics.SetRunningTasks(s.scheduler.RunningCount())
s.metrics.SetQueueLength(s.scheduler.QueueLength())
s.metrics.IncRequest("stream_ok")
}
func (s *Server) handleNonStreaming(w http.ResponseWriter, r *http.Request, adapterInst adapter.ModelAdapter, req *adapter.ChatRequest, tk *task.Task, requestID string, target *router.ModelTarget, logicalModel string) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(s.cfg.Timeouts.DefaultInferenceMs)*time.Millisecond)
defer cancel()
resp, err := adapterInst.ChatCompletion(ctx, req)
if err != nil {
s.scheduler.Complete(tk.ID)
tk.Transition(task.StateFailed)
s.metrics.IncTask("failed")
s.metrics.IncRequest("error")
if strings.Contains(err.Error(), "timeout") || ctx.Err() != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInferenceTimeout, "inference timeout", requestID))
} else {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrModelUnavailable, err.Error(), requestID))
}
return
}
tk.Transition(task.StateCompleted)
s.metrics.IncTask("completed")
s.metrics.IncRequest("ok")
s.metrics.AddTokens(resp.InputTokens, resp.OutputTokens)
s.scheduler.Complete(tk.ID)
s.metrics.SetRunningTasks(s.scheduler.RunningCount())
s.metrics.SetQueueLength(s.scheduler.QueueLength())
chatResp := handler.BuildChatResponse(requestID, tk.ID, logicalModel, resp)
handler.WriteJSON(w, http.StatusOK, chatResp)
}
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
handler.WriteError(w, handler.NewGatewayError(handler.ErrInvalidRequest, "method not allowed"))
return
}
models := s.modelMap.List()
data := make([]api.ModelInfo, len(models))
for i, m := range models {
data[i] = api.ModelInfo{
ID: m,
Object: "model",
OwnedBy: "edgeai-gateway",
}
}
resp := api.ModelListResponse{
Object: "list",
Data: data,
}
handler.WriteJSON(w, http.StatusOK, resp)
}
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetRequestID(r.Context())
identity := auth.GetAppIdentityFromRequest(r)
switch r.Method {
case http.MethodPost:
var req api.SessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "invalid JSON body", requestID))
return
}
if req.ApplicationID == "" {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "application_id is required", requestID))
return
}
sessionID := uuid.New().String()
tenantID := ""
if identity != nil {
tenantID = identity.TenantID
}
sess, err := s.sessions.Create(sessionID, req.ApplicationID, tenantID, req.UserID, req.Config)
if err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInternalError, err.Error(), requestID))
return
}
resp := api.SessionResponse{
SessionID: sess.ID,
ApplicationID: sess.ApplicationID,
UserID: sess.UserID,
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
LastActive: sess.LastActive.Format(time.RFC3339),
}
handler.WriteJSON(w, http.StatusCreated, resp)
case http.MethodGet:
// List sessions (simplified: return empty for now)
handler.WriteJSON(w, http.StatusOK, map[string]any{"sessions": []any{}})
default:
handler.WriteError(w, handler.NewGatewayError(handler.ErrInvalidRequest, "method not allowed"))
}
}
func (s *Server) handleSessionByID(w http.ResponseWriter, r *http.Request) {
requestID := middleware.GetRequestID(r.Context())
sessionID := strings.TrimPrefix(r.URL.Path, "/v1/sessions/")
switch r.Method {
case http.MethodGet:
sess, err := s.sessions.Get(sessionID)
if err != nil || sess == nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInvalidRequest, "session not found", requestID))
return
}
handler.WriteJSON(w, http.StatusOK, sess)
case http.MethodDelete:
if err := s.sessions.Delete(sessionID); err != nil {
handler.WriteError(w, handler.NewGatewayErrorWithID(handler.ErrInternalError, err.Error(), requestID))
return
}
handler.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
handler.WriteError(w, handler.NewGatewayError(handler.ErrInvalidRequest, "method not allowed"))
}
}
+192
View File
@@ -0,0 +1,192 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/edgeai/gateway/internal/adapter"
"github.com/edgeai/gateway/internal/auth"
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/internal/handler"
"github.com/edgeai/gateway/internal/middleware"
"github.com/edgeai/gateway/internal/observability"
"github.com/edgeai/gateway/internal/router"
"github.com/edgeai/gateway/internal/scheduler"
"github.com/edgeai/gateway/internal/session"
)
// Server is the main HTTP server for the AI gateway.
type Server struct {
cfg *config.Config
logger *observability.Logger
metrics *observability.Metrics
HTTPSrv *http.Server
auth *auth.Authenticator
registry *adapter.Registry
modelMap *router.LogicalModelMapping
scheduler *scheduler.Scheduler
sessions *session.Store
}
// New creates a new Server instance with all components wired.
func New(cfg *config.Config, logger *observability.Logger) (*Server, error) {
// Ensure data directory exists
dbPath := extractDBPath(cfg.Storage.SessionDB)
if dbPath != "" {
os.MkdirAll(filepath.Dir(dbPath), 0755)
}
// Initialize auth
authPath := filepath.Join(filepath.Dir(dbPath), "auth.db")
authenticator, err := auth.NewAuthenticator(authPath, logger)
if err != nil {
return nil, fmt.Errorf("init auth: %w", err)
}
// Initialize session store
sessionStore, err := session.NewStore(dbPath)
if err != nil {
return nil, fmt.Errorf("init session store: %w", err)
}
// Initialize adapter registry
registry := adapter.NewRegistry()
// Initialize logical model mapping
modelMap := router.NewLogicalModelMapping(cfg)
// Register adapters for each unique endpoint
registered := make(map[string]bool)
for _, mc := range cfg.Models {
key := mc.Provider + "|" + mc.Endpoint
if !registered[key] {
switch mc.Provider {
case "ollama":
registry.Register(mc.Provider, adapter.NewOllamaAdapter(mc.Endpoint))
}
registered[key] = true
}
}
// Initialize scheduler
sched := scheduler.NewScheduler(&cfg.Scheduler, logger)
// Initialize metrics
metrics := observability.NewMetrics()
s := &Server{
cfg: cfg,
logger: logger,
metrics: metrics,
auth: authenticator,
registry: registry,
modelMap: modelMap,
scheduler: sched,
sessions: sessionStore,
}
mux := http.NewServeMux()
s.registerRoutes(mux)
// Apply middleware chain (order: Recovery → Logging → RequestID → BodyLimit → Auth → handler)
h := middleware.RequestID(mux)
h = middleware.BodyLimit(cfg.Server.MaxRequestBodyMB)(h)
h = s.auth.Middleware(h)
h = middleware.Logging(logger)(h)
h = middleware.Recovery(logger)(h)
s.HTTPSrv = &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: h,
ReadTimeout: 30 * time.Second,
WriteTimeout: 0, // no write timeout for SSE
IdleTimeout: 120 * time.Second,
}
return s, nil
}
func (s *Server) registerRoutes(mux *http.ServeMux) {
// Health and readiness
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/ready", s.handleReady)
// Metrics
mux.HandleFunc(s.cfg.Observability.MetricsPath, s.metrics.Handler())
// OpenAI-compatible API
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
mux.HandleFunc("/v1/models", s.handleModels)
// Session management
mux.HandleFunc("/v1/sessions", s.handleSessions)
mux.HandleFunc("/v1/sessions/", s.handleSessionByID)
}
// Authenticator returns the authenticator instance (for testing/management).
func (s *Server) Authenticator() *auth.Authenticator {
return s.auth
}
// Start begins listening for HTTP requests.
func (s *Server) Start() error {
s.logger.Info("http server starting", observability.F().
Event("server_start").
Set("addr", s.HTTPSrv.Addr))
return s.HTTPSrv.ListenAndServe()
}
// Shutdown gracefully shuts down the server.
func (s *Server) Shutdown() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.scheduler.Stop()
if s.sessions != nil {
s.sessions.Close()
}
if s.auth != nil {
s.auth.Close()
}
return s.HTTPSrv.Shutdown(ctx)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
handler.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ready := true
reasons := []string{}
for _, name := range s.registry.Names() {
a, _ := s.registry.Get(name)
if err := a.HealthCheck(r.Context()); err != nil {
ready = false
reasons = append(reasons, fmt.Sprintf("%s: %v", name, err))
}
}
if ready {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ready"}`))
} else {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, `{"status":"not_ready","reasons":["%s"]}`, strings.Join(reasons, `","`))
}
}
func extractDBPath(connStr string) string {
if strings.HasPrefix(connStr, "sqlite://") {
return strings.TrimPrefix(connStr, "sqlite://")
}
return connStr
}