初始提交:边缘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
+99
View File
@@ -0,0 +1,99 @@
package adapter
import (
"context"
"fmt"
"io"
"github.com/edgeai/gateway/pkg/api"
)
// ModelAdapter is the interface that all inference engine adapters must implement.
type ModelAdapter interface {
// Name returns the adapter name (e.g., "ollama", "vllm").
Name() string
// ChatCompletion sends a non-streaming chat completion request.
ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error)
// ChatCompletionStream sends a streaming chat completion request.
ChatCompletionStream(ctx context.Context, req *ChatRequest) (<-chan StreamChunk, error)
// ListModels returns available models from the engine.
ListModels(ctx context.Context) ([]ModelInfo, error)
// HealthCheck checks if the engine is reachable.
HealthCheck(ctx context.Context) error
// Cancel cancels an in-progress request by request ID.
Cancel(requestID string) error
}
// ChatRequest is the internal request sent to an adapter.
type ChatRequest struct {
RequestID string
Model string // actual model name
Messages []api.Message
MaxTokens int
Temperature *float64
TopP *float64
Stream bool
CancelCh <-chan struct{}
}
// ChatResponse is the internal response from an adapter.
type ChatResponse struct {
Content string
FinishReason string
InputTokens int
OutputTokens int
ActualModel string
}
// StreamChunk represents a single chunk in a streaming response.
type StreamChunk struct {
Delta string
FinishReason string
InputTokens int
OutputTokens int
Error error
Done bool
}
// ModelInfo describes a model available in the engine.
type ModelInfo struct {
Name string
ContextWindow int
}
// Registry manages model adapters by provider name.
type Registry struct {
adapters map[string]ModelAdapter
}
func NewRegistry() *Registry {
return &Registry{adapters: make(map[string]ModelAdapter)}
}
func (r *Registry) Register(name string, adapter ModelAdapter) {
r.adapters[name] = adapter
}
func (r *Registry) Get(name string) (ModelAdapter, error) {
a, ok := r.adapters[name]
if !ok {
return nil, fmt.Errorf("adapter not found: %s", name)
}
return a, nil
}
func (r *Registry) Names() []string {
names := make([]string, 0, len(r.adapters))
for n := range r.adapters {
names = append(names, n)
}
return names
}
// Ensure io is imported for future use (streaming readers).
var _ = io.EOF
+259
View File
@@ -0,0 +1,259 @@
package adapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// OllamaAdapter implements ModelAdapter for Ollama inference engine.
type OllamaAdapter struct {
endpoint string
httpClient *http.Client
}
// NewOllamaAdapter creates a new Ollama adapter.
func NewOllamaAdapter(endpoint string) *OllamaAdapter {
return &OllamaAdapter{
endpoint: strings.TrimRight(endpoint, "/"),
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
}
}
func (a *OllamaAdapter) Name() string {
return "ollama"
}
// ollamaChatRequest is the Ollama /api/chat request format.
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ollamaMsg `json:"messages"`
Stream bool `json:"stream"`
Options ollamaOptions `json:"options,omitempty"`
}
type ollamaMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaOptions struct {
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
NumPredict int `json:"num_predict,omitempty"`
}
// ollamaChatResponse is the Ollama /api/chat non-streaming response.
type ollamaChatResponse struct {
Model string `json:"model"`
Message ollamaMsg `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
// ollamaChatStreamResponse is a single chunk in Ollama streaming response.
type ollamaChatStreamResponse struct {
Model string `json:"model"`
Message ollamaMsg `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
}
func (a *OllamaAdapter) ChatCompletion(ctx context.Context, req *ChatRequest) (*ChatResponse, error) {
ollamaReq := a.buildRequest(req, false)
body, err := json.Marshal(ollamaReq)
if err != nil {
return nil, fmt.Errorf("marshal ollama request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.endpoint+"/api/chat", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create ollama request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ollama request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return nil, fmt.Errorf("decode ollama response: %w", err)
}
return &ChatResponse{
Content: ollamaResp.Message.Content,
FinishReason: "stop",
InputTokens: ollamaResp.PromptEvalCount,
OutputTokens: ollamaResp.EvalCount,
ActualModel: ollamaResp.Model,
}, nil
}
func (a *OllamaAdapter) ChatCompletionStream(ctx context.Context, req *ChatRequest) (<-chan StreamChunk, error) {
ollamaReq := a.buildRequest(req, true)
body, err := json.Marshal(ollamaReq)
if err != nil {
return nil, fmt.Errorf("marshal ollama stream request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", a.endpoint+"/api/chat", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create ollama stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ollama stream request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("ollama stream returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
ch := make(chan StreamChunk, 100)
go func() {
defer close(ch)
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
for {
var chunk ollamaChatStreamResponse
if err := decoder.Decode(&chunk); err != nil {
if err == io.EOF {
ch <- StreamChunk{Done: true, FinishReason: "stop"}
return
}
ch <- StreamChunk{Error: fmt.Errorf("decode stream chunk: %w", err)}
return
}
// Check for cancellation
select {
case <-req.CancelCh:
ch <- StreamChunk{Done: true, FinishReason: "cancelled"}
return
default:
}
if chunk.Done {
ch <- StreamChunk{
Done: true,
FinishReason: "stop",
InputTokens: chunk.PromptEvalCount,
OutputTokens: chunk.EvalCount,
}
return
}
if chunk.Message.Content != "" {
ch <- StreamChunk{Delta: chunk.Message.Content}
}
}
}()
return ch, nil
}
func (a *OllamaAdapter) ListModels(ctx context.Context) ([]ModelInfo, error) {
httpReq, err := http.NewRequestWithContext(ctx, "GET", a.endpoint+"/api/tags", nil)
if err != nil {
return nil, fmt.Errorf("create list models request: %w", err)
}
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("list models failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list models returned status %d", resp.StatusCode)
}
var tagsResp struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil {
return nil, fmt.Errorf("decode tags response: %w", err)
}
models := make([]ModelInfo, len(tagsResp.Models))
for i, m := range tagsResp.Models {
models[i] = ModelInfo{Name: m.Name}
}
return models, nil
}
func (a *OllamaAdapter) HealthCheck(ctx context.Context) error {
httpReq, err := http.NewRequestWithContext(ctx, "GET", a.endpoint+"/api/tags", nil)
if err != nil {
return fmt.Errorf("create health check request: %w", err)
}
resp, err := a.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check returned status %d", resp.StatusCode)
}
return nil
}
func (a *OllamaAdapter) Cancel(requestID string) error {
// Ollama doesn't support request cancellation by ID in the API.
// Cancellation is handled by closing the HTTP connection (context cancellation).
return nil
}
func (a *OllamaAdapter) buildRequest(req *ChatRequest, stream bool) ollamaChatRequest {
msgs := make([]ollamaMsg, len(req.Messages))
for i, m := range req.Messages {
content, _ := m.Content.(string)
msgs[i] = ollamaMsg{Role: m.Role, Content: content}
}
ollamaReq := ollamaChatRequest{
Model: req.Model,
Messages: msgs,
Stream: stream,
}
if req.MaxTokens > 0 {
ollamaReq.Options.NumPredict = req.MaxTokens
}
if req.Temperature != nil {
ollamaReq.Options.Temperature = *req.Temperature
}
if req.TopP != nil {
ollamaReq.Options.TopP = *req.TopP
}
return ollamaReq
}
+260
View File
@@ -0,0 +1,260 @@
package auth
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/edgeai/gateway/internal/handler"
"github.com/edgeai/gateway/internal/middleware"
"github.com/edgeai/gateway/internal/observability"
_ "github.com/mattn/go-sqlite3"
)
// AppIdentity represents the authenticated application identity.
type AppIdentity struct {
AppID string
TenantID string
Name string
AllowedModels []string
AllowedPriorities []int
IsAdmin bool
}
type contextKey string
const (
AppIdentityKey contextKey = "app_identity"
)
// Authenticator manages API Key authentication.
type Authenticator struct {
mu sync.RWMutex
keys map[string]*AppIdentity // hashed_key -> identity
db *sql.DB
logger *observability.Logger
}
// NewAuthenticator creates a new Authenticator with SQLite storage.
func NewAuthenticator(dbPath string, logger *observability.Logger) (*Authenticator, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open auth db: %w", err)
}
if err := initAuthDB(db); err != nil {
return nil, fmt.Errorf("init auth db: %w", err)
}
a := &Authenticator{
keys: make(map[string]*AppIdentity),
db: db,
logger: logger,
}
if err := a.loadKeys(); err != nil {
return nil, fmt.Errorf("load api keys: %w", err)
}
return a, nil
}
func initAuthDB(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
allowed_models TEXT, -- JSON array, empty = all
allowed_priorities TEXT, -- JSON array, empty = all
is_admin INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT
);`
_, err := db.Exec(schema)
return err
}
func (a *Authenticator) loadKeys() error {
rows, err := a.db.Query(`SELECT key_hash, app_id, tenant_id, name, allowed_models, allowed_priorities, is_admin FROM api_keys WHERE enabled = 1`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var hash, appID, tenantID, name, allowedModelsJSON, allowedPrioritiesJSON string
var isAdmin int
if err := rows.Scan(&hash, &appID, &tenantID, &name, &allowedModelsJSON, &allowedPrioritiesJSON, &isAdmin); err != nil {
return err
}
identity := &AppIdentity{
AppID: appID,
TenantID: tenantID,
Name: name,
IsAdmin: isAdmin == 1,
}
if allowedModelsJSON != "" && allowedModelsJSON != "null" {
json.Unmarshal([]byte(allowedModelsJSON), &identity.AllowedModels)
}
if allowedPrioritiesJSON != "" && allowedPrioritiesJSON != "null" {
json.Unmarshal([]byte(allowedPrioritiesJSON), &identity.AllowedPriorities)
}
a.keys[hash] = identity
}
return rows.Err()
}
// hashKey hashes an API key with SHA-256.
func hashKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
// Authenticate validates an API key and returns the AppIdentity.
func (a *Authenticator) Authenticate(apiKey string) (*AppIdentity, bool) {
hash := hashKey(apiKey)
a.mu.RLock()
defer a.mu.RUnlock()
identity, ok := a.keys[hash]
if !ok {
return nil, false
}
return identity, true
}
// AddKey adds a new API key (for management API).
func (a *Authenticator) AddKey(apiKey string, identity *AppIdentity) error {
hash := hashKey(apiKey)
allowedModelsJSON, _ := json.Marshal(identity.AllowedModels)
allowedPrioritiesJSON, _ := json.Marshal(identity.AllowedPriorities)
_, err := a.db.Exec(
`INSERT INTO api_keys (app_id, tenant_id, name, key_hash, allowed_models, allowed_priorities, is_admin, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
identity.AppID, identity.TenantID, identity.Name, hash, string(allowedModelsJSON), string(allowedPrioritiesJSON), isAdminInt(identity.IsAdmin),
)
if err != nil {
return err
}
a.mu.Lock()
a.keys[hash] = identity
a.mu.Unlock()
return nil
}
// Middleware returns an HTTP middleware that enforces API Key authentication.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for health/ready endpoints
if r.URL.Path == "/health" || r.URL.Path == "/ready" || r.URL.Path == "/metrics" {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
handler.WriteError(w, handler.NewGatewayError(handler.ErrAuthFailed, "missing Authorization header"))
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
handler.WriteError(w, handler.NewGatewayError(handler.ErrAuthFailed, "invalid Authorization format, expected Bearer <api_key>"))
return
}
apiKey := parts[1]
if apiKey == "" {
handler.WriteError(w, handler.NewGatewayError(handler.ErrAuthFailed, "empty API key"))
return
}
identity, ok := a.Authenticate(apiKey)
if !ok {
handler.WriteError(w, handler.NewGatewayError(handler.ErrAuthFailed, "invalid API key"))
return
}
ctx := context.WithValue(r.Context(), AppIdentityKey, identity)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetAppIdentity extracts the AppIdentity from request context.
func GetAppIdentity(ctx context.Context) *AppIdentity {
if v, ok := ctx.Value(AppIdentityKey).(*AppIdentity); ok {
return v
}
return nil
}
// GetAppIdentityFromRequest is a convenience wrapper.
func GetAppIdentityFromRequest(r *http.Request) *AppIdentity {
return GetAppIdentity(r.Context())
}
// RequireAdmin checks if the request is from an admin app.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity := GetAppIdentityFromRequest(r)
if identity == nil || !identity.IsAdmin {
handler.WriteError(w, handler.NewGatewayError(handler.ErrPermissionDenied, "admin access required"))
return
}
next.ServeHTTP(w, r)
})
}
// CheckModelPermission verifies the app can access the given model.
func CheckModelPermission(identity *AppIdentity, model string) bool {
if len(identity.AllowedModels) == 0 {
return true // empty = all models allowed
}
for _, m := range identity.AllowedModels {
if m == model {
return true
}
}
return false
}
// CheckPriorityPermission verifies the app can use the given priority.
func CheckPriorityPermission(identity *AppIdentity, priority int) bool {
if len(identity.AllowedPriorities) == 0 {
return true
}
for _, p := range identity.AllowedPriorities {
if p == priority {
return true
}
}
return false
}
func isAdminInt(b bool) int {
if b {
return 1
}
return 0
}
// Close closes the database connection.
func (a *Authenticator) Close() error {
return a.db.Close()
}
// Ensure middleware import is used.
var _ = middleware.GetRequestID
+337
View File
@@ -0,0 +1,337 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// Config is the root configuration structure.
type Config struct {
Server ServerConfig `yaml:"server"`
Auth AuthConfig `yaml:"auth"`
Scheduler SchedulerConfig `yaml:"scheduler"`
Timeouts TimeoutConfig `yaml:"timeouts"`
Context ContextConfig `yaml:"context"`
Models map[string]ModelConfig `yaml:"models"`
Routing RoutingConfig `yaml:"routing"`
CircuitBreaker CircuitBreakerConfig `yaml:"circuit_breaker"`
Backpressure BackpressureConfig `yaml:"backpressure"`
Observability ObservabilityConfig `yaml:"observability"`
Storage StorageConfig `yaml:"storage"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
AdminPort int `yaml:"admin_port"`
MaxRequestBodyMB int `yaml:"max_request_body_mb"`
}
type AuthConfig struct {
Enabled bool `yaml:"enabled"`
Methods []string `yaml:"methods"`
JWTIssuer string `yaml:"jwt_issuer"`
JWTSecretEnv string `yaml:"jwt_secret_env"`
}
type SchedulerConfig struct {
MaxRunningTasks int `yaml:"max_running_tasks"`
MaxQueuedTasks int `yaml:"max_queued_tasks"`
Fairness string `yaml:"fairness"`
PriorityAgingSeconds int `yaml:"priority_aging_seconds"`
ReservedRealtimeSlots int `yaml:"reserved_realtime_slots"`
}
type TimeoutConfig struct {
DefaultConnectMs int `yaml:"default_connect_ms"`
DefaultQueueMs int `yaml:"default_queue_ms"`
DefaultFirstTokenMs int `yaml:"default_first_token_ms"`
DefaultInferenceMs int `yaml:"default_inference_ms"`
DefaultIdleMs int `yaml:"default_idle_ms"`
DefaultTotalMs int `yaml:"default_total_ms"`
CancelGracePeriodMs int `yaml:"cancel_grace_period_ms"`
}
type ContextConfig struct {
SafetyMarginRatio float64 `yaml:"safety_margin_ratio"`
DefaultPolicy string `yaml:"default_policy"`
MaxSessionMessages int `yaml:"max_session_messages"`
SessionIdleTTLMinutes int `yaml:"session_idle_ttl_minutes"`
EnablePromptPersistence bool `yaml:"enable_prompt_persistence"`
}
type ModelConfig struct {
Provider string `yaml:"provider"`
ActualModel string `yaml:"actual_model"`
Endpoint string `yaml:"endpoint"`
ContextWindow int `yaml:"context_window"`
MaxOutputTokens int `yaml:"max_output_tokens"`
MaxConcurrency int `yaml:"max_concurrency"`
Residency string `yaml:"residency"`
CancelSupported bool `yaml:"cancel_supported"`
IdleUnloadSeconds int `yaml:"idle_unload_seconds"`
}
type RoutingConfig struct {
SensitiveDataLocalOnly bool `yaml:"sensitive_data_local_only"`
AllowCloudFallbackByDefault bool `yaml:"allow_cloud_fallback_by_default"`
OverloadStrategy []string `yaml:"overload_strategy"`
}
type CircuitBreakerConfig struct {
ErrorRateThreshold float64 `yaml:"error_rate_threshold"`
MinRequests int `yaml:"min_requests"`
WindowSeconds int `yaml:"window_seconds"`
OpenDurationSeconds int `yaml:"open_duration_seconds"`
HalfOpenMaxRequests int `yaml:"half_open_max_requests"`
}
type BackpressureConfig struct {
Level1Threshold float64 `yaml:"level1_threshold"`
Level2Threshold float64 `yaml:"level2_threshold"`
Level3Threshold float64 `yaml:"level3_threshold"`
}
type ObservabilityConfig struct {
MetricsEnabled bool `yaml:"metrics_enabled"`
MetricsPath string `yaml:"metrics_path"`
TracingEnabled bool `yaml:"tracing_enabled"`
PromptLogging string `yaml:"prompt_logging"`
AuditRetentionDays int `yaml:"audit_retention_days"`
LogLevel string `yaml:"log_level"`
}
type StorageConfig struct {
SessionDB string `yaml:"session_db"`
TaskState string `yaml:"task_state"`
Redis RedisConfig `yaml:"redis"`
}
type RedisConfig struct {
Enabled bool `yaml:"enabled"`
Endpoint string `yaml:"endpoint"`
}
var (
currentConfig *Config
configMu sync.RWMutex
)
// Load reads the config from the given YAML file path and applies env overrides.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config yaml: %w", err)
}
applyDefaults(cfg)
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
applyEnvOverrides(cfg)
configMu.Lock()
currentConfig = cfg
configMu.Unlock()
return cfg, nil
}
func applyDefaults(cfg *Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Server.AdminPort == 0 {
cfg.Server.AdminPort = 8081
}
if cfg.Server.MaxRequestBodyMB == 0 {
cfg.Server.MaxRequestBodyMB = 20
}
if cfg.Scheduler.MaxRunningTasks == 0 {
cfg.Scheduler.MaxRunningTasks = 8
}
if cfg.Scheduler.MaxQueuedTasks == 0 {
cfg.Scheduler.MaxQueuedTasks = 500
}
if cfg.Scheduler.Fairness == "" {
cfg.Scheduler.Fairness = "weighted_fair_queue"
}
if cfg.Scheduler.PriorityAgingSeconds == 0 {
cfg.Scheduler.PriorityAgingSeconds = 30
}
if cfg.Timeouts.DefaultConnectMs == 0 {
cfg.Timeouts.DefaultConnectMs = 5000
}
if cfg.Timeouts.DefaultQueueMs == 0 {
cfg.Timeouts.DefaultQueueMs = 5000
}
if cfg.Timeouts.DefaultFirstTokenMs == 0 {
cfg.Timeouts.DefaultFirstTokenMs = 10000
}
if cfg.Timeouts.DefaultInferenceMs == 0 {
cfg.Timeouts.DefaultInferenceMs = 60000
}
if cfg.Timeouts.DefaultIdleMs == 0 {
cfg.Timeouts.DefaultIdleMs = 15000
}
if cfg.Timeouts.DefaultTotalMs == 0 {
cfg.Timeouts.DefaultTotalMs = 90000
}
if cfg.Timeouts.CancelGracePeriodMs == 0 {
cfg.Timeouts.CancelGracePeriodMs = 3000
}
if cfg.Context.SafetyMarginRatio == 0 {
cfg.Context.SafetyMarginRatio = 0.08
}
if cfg.Context.DefaultPolicy == "" {
cfg.Context.DefaultPolicy = "summary_and_recent"
}
if cfg.Context.MaxSessionMessages == 0 {
cfg.Context.MaxSessionMessages = 200
}
if cfg.Context.SessionIdleTTLMinutes == 0 {
cfg.Context.SessionIdleTTLMinutes = 60
}
if cfg.CircuitBreaker.ErrorRateThreshold == 0 {
cfg.CircuitBreaker.ErrorRateThreshold = 0.1
}
if cfg.CircuitBreaker.MinRequests == 0 {
cfg.CircuitBreaker.MinRequests = 10
}
if cfg.CircuitBreaker.WindowSeconds == 0 {
cfg.CircuitBreaker.WindowSeconds = 60
}
if cfg.CircuitBreaker.OpenDurationSeconds == 0 {
cfg.CircuitBreaker.OpenDurationSeconds = 30
}
if cfg.CircuitBreaker.HalfOpenMaxRequests == 0 {
cfg.CircuitBreaker.HalfOpenMaxRequests = 1
}
if cfg.Backpressure.Level1Threshold == 0 {
cfg.Backpressure.Level1Threshold = 0.70
}
if cfg.Backpressure.Level2Threshold == 0 {
cfg.Backpressure.Level2Threshold = 0.85
}
if cfg.Backpressure.Level3Threshold == 0 {
cfg.Backpressure.Level3Threshold = 0.95
}
if cfg.Observability.MetricsPath == "" {
cfg.Observability.MetricsPath = "/metrics"
}
if cfg.Observability.PromptLogging == "" {
cfg.Observability.PromptLogging = "metadata_only"
}
if cfg.Observability.LogLevel == "" {
cfg.Observability.LogLevel = "info"
}
if cfg.Observability.AuditRetentionDays == 0 {
cfg.Observability.AuditRetentionDays = 180
}
if cfg.Storage.SessionDB == "" {
cfg.Storage.SessionDB = "sqlite:///var/lib/edgeai/sessions.db"
}
if cfg.Storage.TaskState == "" {
cfg.Storage.TaskState = "sqlite:///var/lib/edgeai/tasks.db"
}
}
func validate(cfg *Config) error {
if cfg.Scheduler.MaxRunningTasks <= 0 {
return fmt.Errorf("scheduler.max_running_tasks must be positive")
}
if cfg.Scheduler.MaxQueuedTasks <= 0 {
return fmt.Errorf("scheduler.max_queued_tasks must be positive")
}
if cfg.Context.SafetyMarginRatio < 0 || cfg.Context.SafetyMarginRatio >= 1 {
return fmt.Errorf("context.safety_margin_ratio must be in [0, 1)")
}
if cfg.Backpressure.Level1Threshold >= cfg.Backpressure.Level2Threshold {
return fmt.Errorf("backpressure level1 threshold must be less than level2")
}
if cfg.Backpressure.Level2Threshold >= cfg.Backpressure.Level3Threshold {
return fmt.Errorf("backpressure level2 threshold must be less than level3")
}
return nil
}
func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("EDGEAI_SERVER_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil {
cfg.Server.Port = port
}
}
if v := os.Getenv("EDGEAI_ADMIN_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil {
cfg.Server.AdminPort = port
}
}
if v := os.Getenv("EDGEAI_LOG_LEVEL"); v != "" {
cfg.Observability.LogLevel = v
}
if v := os.Getenv("EDGEAI_DB_PATH"); v != "" {
cfg.Storage.SessionDB = "sqlite://" + v + "/sessions.db"
cfg.Storage.TaskState = "sqlite://" + v + "/tasks.db"
}
if v := os.Getenv("EDGEAI_CONFIG_PATH"); v != "" {
// already handled by Load path
_ = v
}
}
// Get returns the current config (thread-safe).
func Get() *Config {
configMu.RLock()
defer configMu.RUnlock()
return currentConfig
}
// Update replaces the current config (thread-safe).
func Update(cfg *Config) {
configMu.Lock()
currentConfig = cfg
configMu.Unlock()
}
// ConfigPath returns the config file path from env or default.
func ConfigPath() string {
path := os.Getenv("EDGEAI_CONFIG_PATH")
if path == "" {
return "configs/config.yaml"
}
return path
}
// PriorityName returns the string name for a priority level.
func PriorityName(p int) string {
names := []string{"P0", "P1", "P2", "P3", "P4"}
if p >= 0 && p < len(names) {
return names[p]
}
return "P2"
}
// ParsePriority parses a priority string like "P0" to an int.
func ParsePriority(s string) int {
s = strings.ToUpper(s)
for i, name := range []string{"P0", "P1", "P2", "P3", "P4"} {
if s == name {
return i
}
}
return 2 // default P2
}
+149
View File
@@ -0,0 +1,149 @@
package config
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func writeTestConfig(t *testing.T, content string) string {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write test config: %v", err)
}
return path
}
func TestLoadDefaults(t *testing.T) {
path := writeTestConfig(t, `
server:
host: "127.0.0.1"
port: 9090
models:
general-chat:
provider: ollama
actual_model: qwen2.5:0.5b
endpoint: http://127.0.0.1:11434
context_window: 32768
max_output_tokens: 4096
max_concurrency: 4
residency: always
cancel_supported: true
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
}
if cfg.Scheduler.MaxRunningTasks != 8 {
t.Errorf("expected default max_running_tasks 8, got %d", cfg.Scheduler.MaxRunningTasks)
}
if cfg.Timeouts.DefaultQueueMs != 5000 {
t.Errorf("expected default queue_ms 5000, got %d", cfg.Timeouts.DefaultQueueMs)
}
if cfg.Context.SafetyMarginRatio != 0.08 {
t.Errorf("expected default safety_margin 0.08, got %f", cfg.Context.SafetyMarginRatio)
}
if _, ok := cfg.Models["general-chat"]; !ok {
t.Error("expected general-chat model in config")
}
}
func TestValidate(t *testing.T) {
path := writeTestConfig(t, `
scheduler:
max_running_tasks: -1
`)
_, err := Load(path)
if err == nil {
t.Error("expected validation error for max_running_tasks=-1")
}
}
func TestValidateBackpressure(t *testing.T) {
path := writeTestConfig(t, `
backpressure:
level1_threshold: 0.90
level2_threshold: 0.80
level3_threshold: 0.95
`)
_, err := Load(path)
if err == nil {
t.Error("expected validation error for level1 >= level2")
}
}
func TestEnvOverride(t *testing.T) {
path := writeTestConfig(t, `
server:
port: 8080
`)
os.Setenv("EDGEAI_SERVER_PORT", "9999")
defer os.Unsetenv("EDGEAI_SERVER_PORT")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Server.Port != 9999 {
t.Errorf("expected port 9999 from env, got %d", cfg.Server.Port)
}
}
func TestParsePriority(t *testing.T) {
tests := []struct {
input string
want int
}{
{"P0", 0}, {"P1", 1}, {"P2", 2}, {"P3", 3}, {"P4", 4},
{"p0", 0}, {"invalid", 2}, {"", 2},
}
for _, tt := range tests {
got := ParsePriority(tt.input)
if got != tt.want {
t.Errorf("ParsePriority(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestPriorityName(t *testing.T) {
if PriorityName(0) != "P0" {
t.Errorf("expected P0, got %s", PriorityName(0))
}
if PriorityName(2) != "P2" {
t.Errorf("expected P2, got %s", PriorityName(2))
}
if PriorityName(10) != "P2" {
t.Errorf("expected P2 for out-of-range, got %s", PriorityName(10))
}
}
func TestGetUpdate(t *testing.T) {
cfg := &Config{}
Update(cfg)
time.Sleep(10 * time.Millisecond)
got := Get()
if got != cfg {
t.Error("Get/Update mismatch")
}
}
func TestConfigPath(t *testing.T) {
os.Unsetenv("EDGEAI_CONFIG_PATH")
if got := ConfigPath(); got != "configs/config.yaml" {
t.Errorf("expected default path, got %s", got)
}
os.Setenv("EDGEAI_CONFIG_PATH", "/tmp/test.yaml")
defer os.Unsetenv("EDGEAI_CONFIG_PATH")
if got := ConfigPath(); got != "/tmp/test.yaml" {
t.Errorf("expected env path, got %s", got)
}
}
// Ensure package compiles with sync import.
var _ = sync.RWMutex{}
+138
View File
@@ -0,0 +1,138 @@
package connector
import (
"context"
"fmt"
"time"
"github.com/edgeai/gateway/internal/config"
)
// TimeoutManager manages layered timeouts for different phases of request processing.
type TimeoutManager struct {
cfg *config.TimeoutConfig
}
// NewTimeoutManager creates a new TimeoutManager.
func NewTimeoutManager(cfg *config.TimeoutConfig) *TimeoutManager {
return &TimeoutManager{cfg: cfg}
}
// TimeoutPhase represents a phase of request processing.
type TimeoutPhase string
const (
PhaseQueue TimeoutPhase = "queue"
PhaseFirstToken TimeoutPhase = "first_token"
PhaseInference TimeoutPhase = "inference"
PhaseTotal TimeoutPhase = "total"
)
// TimeoutConfig holds resolved timeout values for a specific request.
type TimeoutConfig struct {
QueueMs int
FirstTokenMs int
InferenceMs int
TotalMs int
ConnectMs int
IdleMs int
}
// ResolveTimeouts merges request-level timeout overrides with global defaults.
func (tm *TimeoutManager) ResolveTimeouts(reqTimeouts *config.TimeoutConfig, overrides map[string]int) *TimeoutConfig {
tc := &TimeoutConfig{
QueueMs: tm.cfg.DefaultQueueMs,
FirstTokenMs: tm.cfg.DefaultFirstTokenMs,
InferenceMs: tm.cfg.DefaultInferenceMs,
TotalMs: tm.cfg.DefaultTotalMs,
ConnectMs: tm.cfg.DefaultConnectMs,
IdleMs: tm.cfg.DefaultIdleMs,
}
if overrides != nil {
if v, ok := overrides["queue_ms"]; ok && v > 0 {
tc.QueueMs = v
}
if v, ok := overrides["first_token_ms"]; ok && v > 0 {
tc.FirstTokenMs = v
}
if v, ok := overrides["inference_ms"]; ok && v > 0 {
tc.InferenceMs = v
}
if v, ok := overrides["total_ms"]; ok && v > 0 {
tc.TotalMs = v
}
}
return tc
}
// QueueContext returns a context with the queue timeout.
func (tm *TimeoutManager) QueueContext(parent context.Context, tc *TimeoutConfig) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, time.Duration(tc.QueueMs)*time.Millisecond)
}
// InferenceContext returns a context with the inference timeout.
func (tm *TimeoutManager) InferenceContext(parent context.Context, tc *TimeoutConfig) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, time.Duration(tc.InferenceMs)*time.Millisecond)
}
// TotalContext returns a context with the total request timeout.
func (tm *TimeoutManager) TotalContext(parent context.Context, tc *TimeoutConfig) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, time.Duration(tc.TotalMs)*time.Millisecond)
}
// CheckTimeout returns an error if the given phase has timed out.
func (tm *TimeoutManager) CheckTimeout(phase TimeoutPhase, elapsed time.Duration, tc *TimeoutConfig) error {
var limit time.Duration
switch phase {
case PhaseQueue:
limit = time.Duration(tc.QueueMs) * time.Millisecond
case PhaseFirstToken:
limit = time.Duration(tc.FirstTokenMs) * time.Millisecond
case PhaseInference:
limit = time.Duration(tc.InferenceMs) * time.Millisecond
case PhaseTotal:
limit = time.Duration(tc.TotalMs) * time.Millisecond
default:
return nil
}
if elapsed > limit {
return fmt.Errorf("%s timeout: elapsed %v exceeds limit %v", phase, elapsed, limit)
}
return nil
}
// CancelManager manages cancellation propagation from client to inference engine.
type CancelManager struct{}
// NewCancelManager creates a new CancelManager.
func NewCancelManager() *CancelManager {
return &CancelManager{}
}
// WatchClientDisconnect watches for client connection close and signals cancellation.
// Returns a context that is cancelled when the client disconnects.
func (cm *CancelManager) WatchClientDisconnect(r interface{ Done() <-chan struct{} }, cancel context.CancelFunc) {
go func() {
select {
case <-r.Done():
cancel()
}
}()
}
// PropagateCancel creates a derived context that is cancelled when either the parent
// context is cancelled or the cancel channel is closed.
func (cm *CancelManager) PropagateCancel(parent context.Context, cancelCh <-chan struct{}) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
go func() {
select {
case <-cancelCh:
cancel()
case <-ctx.Done():
}
}()
return ctx, cancel
}
+208
View File
@@ -0,0 +1,208 @@
package context
import (
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/pkg/api"
)
// Assembler assembles context messages for a chat request.
type Assembler struct {
estimator *TokenEstimator
cfg *config.ContextConfig
}
// NewAssembler creates a new context assembler.
func NewAssembler(cfg *config.ContextConfig) *Assembler {
return &Assembler{
estimator: NewTokenEstimator(),
cfg: cfg,
}
}
// AssembleResult contains the assembled messages and metadata.
type AssembleResult struct {
Messages []api.Message
InputTokens int
Trimmed bool
TrimmedCount int
}
// Assemble combines session history with new messages, applying context window limits.
func (a *Assembler) Assemble(history []api.Message, newMessages []api.Message, contextWindow int, maxOutputTokens int, policy string) *AssembleResult {
// Calculate available context for history
availableForHistory := contextWindow - maxOutputTokens
if availableForHistory < 0 {
availableForHistory = contextWindow / 2
}
// Apply safety margin
availableForHistory = int(float64(availableForHistory) * (1.0 - a.cfg.SafetyMarginRatio))
// Combine all messages
allMessages := make([]api.Message, 0, len(history)+len(newMessages))
allMessages = append(allMessages, history...)
allMessages = append(allMessages, newMessages...)
// Estimate total tokens
totalTokens := a.estimateAllTokens(allMessages)
if totalTokens <= availableForHistory {
return &AssembleResult{
Messages: allMessages,
InputTokens: totalTokens,
Trimmed: false,
}
}
// Need to trim — apply policy
trimmed := a.applyPolicy(allMessages, availableForHistory, policy)
return &AssembleResult{
Messages: trimmed.messages,
InputTokens: trimmed.tokens,
Trimmed: true,
TrimmedCount: len(allMessages) - len(trimmed.messages),
}
}
type trimResult struct {
messages []api.Message
tokens int
}
func (a *Assembler) applyPolicy(messages []api.Message, budget int, policy string) trimResult {
switch policy {
case "recent_only":
return a.trimRecentOnly(messages, budget)
case "summary_and_recent":
return a.trimSummaryAndRecent(messages, budget)
case "full":
return a.trimFull(messages, budget)
default:
return a.trimSummaryAndRecent(messages, budget)
}
}
// trimRecentOnly keeps only the most recent messages within budget.
func (a *Assembler) trimRecentOnly(messages []api.Message, budget int) trimResult {
result := make([]api.Message, 0)
tokens := 0
// Iterate from the end (most recent first)
for i := len(messages) - 1; i >= 0; i-- {
msgTokens := a.estimateMsgTokens(messages[i])
if tokens+msgTokens > budget && len(result) > 0 {
break
}
// Prepend to maintain order
result = append([]api.Message{messages[i]}, result...)
tokens += msgTokens
}
return trimResult{messages: result, tokens: tokens}
}
// trimSummaryAndRecent keeps system message + a summary placeholder + recent messages.
func (a *Assembler) trimSummaryAndRecent(messages []api.Message, budget int) trimResult {
if len(messages) == 0 {
return trimResult{}
}
// Always keep system messages at the front
systemMsgs := []api.Message{}
rest := []api.Message{}
for _, m := range messages {
if m.Role == "system" {
systemMsgs = append(systemMsgs, m)
} else {
rest = append(rest, m)
}
}
systemTokens := 0
for _, m := range systemMsgs {
systemTokens += a.estimateMsgTokens(m)
}
// Reserve space for a summary placeholder (~50 tokens)
summaryTokens := 50
availableForRecent := budget - systemTokens - summaryTokens
if availableForRecent < 0 {
availableForRecent = budget / 2
}
// Keep most recent messages
recentMsgs := []api.Message{}
recentTokens := 0
for i := len(rest) - 1; i >= 0; i-- {
msgTokens := a.estimateMsgTokens(rest[i])
if recentTokens+msgTokens > availableForRecent && len(recentMsgs) > 0 {
break
}
recentMsgs = append([]api.Message{rest[i]}, recentMsgs...)
recentTokens += msgTokens
}
// Add summary placeholder if we trimmed anything
result := make([]api.Message, 0, len(systemMsgs)+1+len(recentMsgs))
result = append(result, systemMsgs...)
if len(recentMsgs) < len(rest) {
result = append(result, api.Message{
Role: "system",
Content: "[Earlier conversation history has been summarized and omitted.]",
})
}
result = append(result, recentMsgs...)
return trimResult{
messages: result,
tokens: systemTokens + summaryTokens + recentTokens,
}
}
// trimFull keeps messages as-is but truncates the oldest if over budget.
func (a *Assembler) trimFull(messages []api.Message, budget int) trimResult {
result := make([]api.Message, 0, len(messages))
tokens := 0
// Keep system messages, trim oldest non-system messages
systemMsgs := []api.Message{}
rest := []api.Message{}
for _, m := range messages {
if m.Role == "system" {
systemMsgs = append(systemMsgs, m)
} else {
rest = append(rest, m)
}
}
for _, m := range systemMsgs {
t := a.estimateMsgTokens(m)
tokens += t
result = append(result, m)
}
for _, m := range rest {
t := a.estimateMsgTokens(m)
if tokens+t > budget {
break
}
tokens += t
result = append(result, m)
}
return trimResult{messages: result, tokens: tokens}
}
func (a *Assembler) estimateAllTokens(messages []api.Message) int {
total := 0
for _, m := range messages {
total += a.estimateMsgTokens(m)
}
return total
}
func (a *Assembler) estimateMsgTokens(msg api.Message) int {
content, _ := msg.Content.(string)
return a.estimator.EstimateText(msg.Role) + a.estimator.EstimateText(content) + 4
}
+130
View File
@@ -0,0 +1,130 @@
package context
import (
"testing"
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/pkg/api"
)
func TestTokenEstimator(t *testing.T) {
est := NewTokenEstimator()
// Empty string
if got := est.EstimateText(""); got != 0 {
t.Errorf("empty string: expected 0, got %d", got)
}
// English text
tokens := est.EstimateText("Hello world, this is a test.")
if tokens <= 0 {
t.Errorf("expected positive tokens for English, got %d", tokens)
}
// Chinese text (each char ~1 token)
cjkTokens := est.EstimateText("你好世界")
if cjkTokens != 4 {
t.Errorf("expected 4 tokens for 4 CJK chars, got %d", cjkTokens)
}
}
func TestEstimateKVCache(t *testing.T) {
// 1000 tokens, 32 layers, 4096 hidden dim, 2 bytes/element
result := EstimateKVCache(1000, 32, 4096, 2)
expected := int64(1000) * 32 * 2 * 4096 * 2
if result != expected {
t.Errorf("expected %d, got %d", expected, result)
}
}
func TestAssemblerNoTrim(t *testing.T) {
cfg := &config.ContextConfig{SafetyMarginRatio: 0.08}
a := NewAssembler(cfg)
history := []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello!"},
}
newMsgs := []api.Message{
{Role: "user", Content: "How are you?"},
}
result := a.Assemble(history, newMsgs, 1000, 100, "summary_and_recent")
if result.Trimmed {
t.Error("expected no trimming for small context")
}
if len(result.Messages) != 3 {
t.Errorf("expected 3 messages, got %d", len(result.Messages))
}
}
func TestAssemblerTrimRecentOnly(t *testing.T) {
cfg := &config.ContextConfig{SafetyMarginRatio: 0.08}
a := NewAssembler(cfg)
// Create many messages that exceed budget
msgs := make([]api.Message, 20)
for i := range msgs {
msgs[i] = api.Message{Role: "user", Content: "This is message number " + string(rune('A'+i))}
}
result := a.Assemble(msgs, []api.Message{}, 50, 10, "recent_only")
if !result.Trimmed {
t.Error("expected trimming for large context")
}
if len(result.Messages) >= 20 {
t.Error("expected fewer messages after trimming")
}
}
func TestAssemblerSummaryAndRecent(t *testing.T) {
cfg := &config.ContextConfig{SafetyMarginRatio: 0.08}
a := NewAssembler(cfg)
msgs := make([]api.Message, 0, 22)
msgs = append(msgs, api.Message{Role: "system", Content: "You are a helpful assistant."})
for i := 0; i < 20; i++ {
msgs = append(msgs, api.Message{Role: "user", Content: "Message " + string(rune('A'+i%26))})
msgs = append(msgs, api.Message{Role: "assistant", Content: "Response " + string(rune('A'+i%26))})
}
result := a.Assemble(msgs, []api.Message{}, 80, 20, "summary_and_recent")
if !result.Trimmed {
t.Error("expected trimming")
}
// System message should be preserved
hasSystem := false
hasSummary := false
for _, m := range result.Messages {
if m.Role == "system" {
if content, ok := m.Content.(string); ok {
if content == "You are a helpful assistant." {
hasSystem = true
}
if contains(content, "summarized") {
hasSummary = true
}
}
}
}
if !hasSystem {
t.Error("system message should be preserved")
}
if !hasSummary {
t.Error("summary placeholder should be present when trimmed")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) && (indexOf(s, substr) >= 0)))
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
+82
View File
@@ -0,0 +1,82 @@
package context
import (
"strings"
"unicode"
)
// TokenEstimator estimates token counts for text using a simple heuristic.
// For production use, replace with a proper tokenizer (tiktoken, etc.).
type TokenEstimator struct {
charsPerToken float64
}
// NewTokenEstimator creates a new estimator with the default ratio.
// English text averages ~4 chars/token, Chinese ~1.5 chars/token.
func NewTokenEstimator() *TokenEstimator {
return &TokenEstimator{charsPerToken: 3.0}
}
// EstimateText estimates token count for a given text.
func (e *TokenEstimator) EstimateText(text string) int {
if text == "" {
return 0
}
// Count CJK characters as individual tokens
cjkCount := 0
nonCJKChars := 0
for _, r := range text {
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Hangul, r) {
cjkCount++
} else {
nonCJKChars++
}
}
// Non-CJK: estimate by chars/token ratio
nonCJKTokens := int(float64(nonCJKChars) / e.charsPerToken)
if nonCJKChars > 0 && nonCJKTokens == 0 {
nonCJKTokens = 1
}
return cjkCount + nonCJKTokens
}
// EstimateMessage estimates token count for a single message (including role overhead).
func (e *TokenEstimator) EstimateMessage(msg interface{ GetRole() string; GetContent() string }) int {
role := msg.GetRole()
content := msg.GetContent()
// Role tokens: ~1-2 tokens for role name
roleTokens := len(strings.Fields(role)) + 1
return roleTokens + e.EstimateText(content)
}
// EstimateMessages estimates total token count for a list of messages.
func (e *TokenEstimator) EstimateMessages(messages []Message) int {
total := 0
for _, m := range messages {
total += e.EstimateText(m.Role) + e.EstimateText(m.Content) + 4 // role + content + formatting overhead
}
return total
}
// Message is a simplified message structure for estimation.
type Message struct {
Role string
Content string
}
func (m Message) GetRole() string { return m.Role }
func (m Message) GetContent() string { return m.Content }
// EstimateKVCache estimates the KV cache memory usage in bytes.
// Formula: input_tokens × layers × 2 (K+V) × hidden_dim × bytes_per_element
func EstimateKVCache(inputTokens, layers, hiddenDim, bytesPerElement int) int64 {
return int64(inputTokens) * int64(layers) * 2 * int64(hiddenDim) * int64(bytesPerElement)
}
// EstimateKVCachePerToken estimates KV cache per token in bytes.
func EstimateKVCachePerToken(layers, hiddenDim, bytesPerElement int) int64 {
return int64(layers) * 2 * int64(hiddenDim) * int64(bytesPerElement)
}
+103
View File
@@ -0,0 +1,103 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/edgeai/gateway/pkg/api"
"github.com/google/uuid"
)
// ErrorCode constants.
const (
ErrAuthFailed = "AUTH_FAILED"
ErrPermissionDenied = "PERMISSION_DENIED"
ErrPolicyBlocked = "POLICY_BLOCKED"
ErrRateLimited = "RATE_LIMITED"
ErrQuotaExceeded = "QUOTA_EXCEEDED"
ErrQueueFull = "QUEUE_FULL"
ErrInvalidRequest = "INVALID_REQUEST"
ErrContextTooLarge = "CONTEXT_TOO_LARGE"
ErrQueueTimeout = "QUEUE_TIMEOUT"
ErrFirstTokenTimeout = "FIRST_TOKEN_TIMEOUT"
ErrInferenceTimeout = "INFERENCE_TIMEOUT"
ErrRequestCancelled = "REQUEST_CANCELLED"
ErrModelUnavailable = "MODEL_UNAVAILABLE"
ErrResourceExhausted = "RESOURCE_EXHAUSTED"
ErrInternalError = "INTERNAL_ERROR"
)
// httpStatusForCode maps error codes to HTTP status codes.
var httpStatusForCode = map[string]int{
ErrAuthFailed: http.StatusUnauthorized,
ErrPermissionDenied: http.StatusForbidden,
ErrPolicyBlocked: http.StatusForbidden,
ErrRateLimited: http.StatusTooManyRequests,
ErrQuotaExceeded: http.StatusTooManyRequests,
ErrQueueFull: http.StatusTooManyRequests,
ErrInvalidRequest: http.StatusBadRequest,
ErrContextTooLarge: http.StatusBadRequest,
ErrQueueTimeout: http.StatusRequestTimeout,
ErrFirstTokenTimeout: http.StatusRequestTimeout,
ErrInferenceTimeout: http.StatusRequestTimeout,
ErrRequestCancelled: http.StatusConflict,
ErrModelUnavailable: http.StatusServiceUnavailable,
ErrResourceExhausted: http.StatusServiceUnavailable,
ErrInternalError: http.StatusInternalServerError,
}
// GatewayError represents a structured error with code, message, and request ID.
type GatewayError struct {
Code string
Message string
RequestID string
}
func (e *GatewayError) Error() string {
return e.Message
}
// NewGatewayError creates a GatewayError with a generated request ID.
func NewGatewayError(code, message string) *GatewayError {
return &GatewayError{
Code: code,
Message: message,
RequestID: uuid.New().String(),
}
}
// NewGatewayErrorWithID creates a GatewayError with an existing request ID.
func NewGatewayErrorWithID(code, message, requestID string) *GatewayError {
return &GatewayError{
Code: code,
Message: message,
RequestID: requestID,
}
}
// WriteError writes a structured error response.
func WriteError(w http.ResponseWriter, err *GatewayError) {
status, ok := httpStatusForCode[err.Code]
if !ok {
status = http.StatusInternalServerError
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := api.ErrorResponse{
Error: api.ErrorBody{
Code: err.Code,
Message: err.Message,
RequestID: err.RequestID,
},
}
json.NewEncoder(w).Encode(resp)
}
// WriteJSON writes a JSON response with the given status code.
func WriteJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
+139
View File
@@ -0,0 +1,139 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"github.com/edgeai/gateway/internal/adapter"
"github.com/edgeai/gateway/pkg/api"
)
// SSEWriter writes Server-Sent Events to an HTTP response.
type SSEWriter struct {
w http.ResponseWriter
flusher http.Flusher
}
// NewSSEWriter creates a new SSEWriter. Returns nil if streaming is not supported.
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
flusher, ok := w.(http.Flusher)
if !ok {
return nil
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
return &SSEWriter{w: w, flusher: flusher}
}
// WriteChunk writes a single SSE data event.
func (s *SSEWriter) WriteChunk(data any) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal sse data: %w", err)
}
fmt.Fprintf(s.w, "data: %s\n\n", jsonData)
s.flusher.Flush()
return nil
}
// WriteDone writes the [DONE] marker.
func (s *SSEWriter) WriteDone() {
fmt.Fprintf(s.w, "data: [DONE]\n\n")
s.flusher.Flush()
}
// StreamChatCompletion streams chunks from an adapter to the client in OpenAI SSE format.
func StreamChatCompletion(sse *SSEWriter, ch <-chan adapter.StreamChunk, requestID, taskID, model string) (int, int, error) {
inputTokens := 0
outputTokens := 0
for chunk := range ch {
if chunk.Error != nil {
return inputTokens, outputTokens, chunk.Error
}
if chunk.Done {
if chunk.InputTokens > 0 {
inputTokens = chunk.InputTokens
}
if chunk.OutputTokens > 0 {
outputTokens = chunk.OutputTokens
}
// Write final chunk with finish_reason
sseChunk := map[string]any{
"id": requestID,
"object": "chat.completion.chunk",
"model": model,
"choices": []map[string]any{
{
"index": 0,
"delta": map[string]any{},
"finish_reason": chunk.FinishReason,
},
},
}
if inputTokens > 0 || outputTokens > 0 {
sseChunk["usage"] = map[string]int{
"input_tokens": inputTokens,
"output_tokens": outputTokens,
"total_tokens": inputTokens + outputTokens,
}
}
sse.WriteChunk(sseChunk)
sse.WriteDone()
return inputTokens, outputTokens, nil
}
// Write content delta
sseChunk := map[string]any{
"id": requestID,
"object": "chat.completion.chunk",
"model": model,
"choices": []map[string]any{
{
"index": 0,
"delta": map[string]any{
"content": chunk.Delta,
},
"finish_reason": nil,
},
},
}
sse.WriteChunk(sseChunk)
}
return inputTokens, outputTokens, nil
}
// BuildChatResponse creates a non-streaming ChatResponse from adapter result.
func BuildChatResponse(requestID, taskID, logicalModel string, resp *adapter.ChatResponse) api.ChatResponse {
return api.ChatResponse{
RequestID: requestID,
TaskID: taskID,
Status: "completed",
Model: logicalModel,
Choices: []api.Choice{
{
Index: 0,
Message: &api.Message{
Role: "assistant",
Content: resp.Content,
},
FinishReason: resp.FinishReason,
},
},
LogicalModel: logicalModel,
ActualModel: resp.ActualModel,
Usage: &api.Usage{
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
TotalTokens: resp.InputTokens + resp.OutputTokens,
},
}
}
+104
View File
@@ -0,0 +1,104 @@
package middleware
import (
"context"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/edgeai/gateway/internal/observability"
"github.com/google/uuid"
)
type contextKey string
const (
RequestIDKey contextKey = "request_id"
)
// RequestID middleware generates a unique request ID and sets it in context and response header.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
w.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// BodyLimit middleware rejects requests with bodies exceeding the given size.
func BodyLimit(maxMB int) func(http.Handler) http.Handler {
maxBytes := int64(maxMB) * 1024 * 1024
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > maxBytes {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":{"code":"INVALID_REQUEST","message":"request body exceeds %dMB limit"}}`, maxMB)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// Recovery middleware catches panics and returns 500.
func Recovery(logger *observability.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic recovered",
observability.F().Event("panic").
RequestID(r.Header.Get("X-Request-ID")).
Reason(fmt.Sprintf("%v\n%s", rec, debug.Stack())))
http.Error(w, `{"error":{"code":"INTERNAL_ERROR","message":"internal server error"}}`,
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// Logging middleware logs request method, path, status, and duration.
func Logging(logger *observability.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r)
logger.Info("http request",
observability.F().
Event("http_request").
RequestID(r.Header.Get("X-Request-ID")).
Set("method", r.Method).
Set("path", r.URL.Path).
Set("status", rw.status).
Set("duration_ms", time.Since(start).Milliseconds()))
})
}
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// GetRequestID extracts the request ID from context.
func GetRequestID(ctx context.Context) string {
if v, ok := ctx.Value(RequestIDKey).(string); ok {
return v
}
return ""
}
+319
View File
@@ -0,0 +1,319 @@
package observability
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
)
// LogLevel represents logging severity levels.
type LogLevel int
const (
LevelDebug LogLevel = iota
LevelInfo
LevelWarn
LevelError
)
func (l LogLevel) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelWarn:
return "WARN"
case LevelError:
return "ERROR"
default:
return "INFO"
}
}
// ParseLogLevel parses a string to LogLevel.
func ParseLogLevel(s string) LogLevel {
switch strings.ToLower(s) {
case "debug":
return LevelDebug
case "info":
return LevelInfo
case "warn", "warning":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
// LogEntry is a structured JSON log entry.
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Event string `json:"event,omitempty"`
Message string `json:"message,omitempty"`
RequestID string `json:"request_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
Application string `json:"application,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
UserID string `json:"user_id,omitempty"`
FromState string `json:"from_state,omitempty"`
ToState string `json:"to_state,omitempty"`
Reason string `json:"reason,omitempty"`
LogicalModel string `json:"logical_model,omitempty"`
ActualModel string `json:"actual_model,omitempty"`
NodeID string `json:"node_id,omitempty"`
Degraded bool `json:"degraded,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// Logger is a structured JSON logger with sensitive field masking.
type Logger struct {
mu sync.RWMutex
level LogLevel
output *os.File
maskFields []string
promptLogging string
}
var defaultLogger *Logger
func init() {
defaultLogger = NewLogger(LevelInfo, os.Stdout, "metadata_only")
}
// NewLogger creates a new Logger instance.
func NewLogger(level LogLevel, out *os.File, promptLogging string) *Logger {
return &Logger{
level: level,
output: out,
maskFields: []string{"api_key", "apikey", "authorization", "jwt", "secret", "password", "token"},
promptLogging: promptLogging,
}
}
// GetLogger returns the default logger.
func GetLogger() *Logger {
return defaultLogger
}
// SetLevel updates the log level (thread-safe).
func (l *Logger) SetLevel(level LogLevel) {
l.mu.Lock()
l.level = level
l.mu.Unlock()
}
// SetPromptLogging updates the prompt logging policy.
func (l *Logger) SetPromptLogging(policy string) {
l.mu.Lock()
l.promptLogging = policy
l.mu.Unlock()
}
func (l *Logger) shouldLog(level LogLevel) bool {
l.mu.RLock()
defer l.mu.RUnlock()
return level >= l.level
}
func (l *Logger) maskSensitive(data map[string]any) map[string]any {
if data == nil {
return nil
}
masked := make(map[string]any, len(data))
for k, v := range data {
if l.isSensitive(k) {
masked[k] = "***REDACTED***"
} else if sub, ok := v.(map[string]any); ok {
masked[k] = l.maskSensitive(sub)
} else {
masked[k] = v
}
}
return masked
}
func (l *Logger) isSensitive(key string) bool {
lk := strings.ToLower(key)
for _, s := range l.maskFields {
if strings.Contains(lk, s) {
return true
}
}
return false
}
func (l *Logger) write(entry LogEntry) {
if !l.shouldLog(parseLevelFromString(entry.Level)) {
return
}
if entry.Extra != nil {
entry.Extra = l.maskSensitive(entry.Extra)
}
if entry.Timestamp == "" {
entry.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
}
data, err := json.Marshal(entry)
if err != nil {
fmt.Fprintf(os.Stderr, "log marshal error: %v\n", err)
return
}
l.mu.Lock()
fmt.Fprintln(l.output, string(data))
l.mu.Unlock()
}
func parseLevelFromString(s string) LogLevel {
switch strings.ToUpper(s) {
case "DEBUG":
return LevelDebug
case "INFO":
return LevelInfo
case "WARN", "WARNING":
return LevelWarn
case "ERROR":
return LevelError
default:
return LevelInfo
}
}
// LogFields is a builder for structured log fields.
type LogFields struct {
fields map[string]any
}
func F() *LogFields {
return &LogFields{fields: make(map[string]any)}
}
func (f *LogFields) Set(key string, value any) *LogFields {
f.fields[key] = value
return f
}
func (f *LogFields) RequestID(id string) *LogFields { f.fields["request_id"] = id; return f }
func (f *LogFields) TaskID(id string) *LogFields { f.fields["task_id"] = id; return f }
func (f *LogFields) SessionID(id string) *LogFields { f.fields["session_id"] = id; return f }
func (f *LogFields) TraceID(id string) *LogFields { f.fields["trace_id"] = id; return f }
func (f *LogFields) Application(app string) *LogFields { f.fields["application"] = app; return f }
func (f *LogFields) TenantID(id string) *LogFields { f.fields["tenant_id"] = id; return f }
func (f *LogFields) UserID(id string) *LogFields { f.fields["user_id"] = id; return f }
func (f *LogFields) Event(e string) *LogFields { f.fields["event"] = e; return f }
func (f *LogFields) Reason(r string) *LogFields { f.fields["reason"] = r; return f }
func (l *Logger) Debug(msg string, fields *LogFields) {
entry := l.buildEntry("DEBUG", msg, fields)
l.write(entry)
}
func (l *Logger) Info(msg string, fields *LogFields) {
entry := l.buildEntry("INFO", msg, fields)
l.write(entry)
}
func (l *Logger) Warn(msg string, fields *LogFields) {
entry := l.buildEntry("WARN", msg, fields)
l.write(entry)
}
func (l *Logger) Error(msg string, fields *LogFields) {
entry := l.buildEntry("ERROR", msg, fields)
l.write(entry)
}
func (l *Logger) buildEntry(level, msg string, fields *LogFields) LogEntry {
entry := LogEntry{
Level: level,
Message: msg,
}
if fields != nil {
for k, v := range fields.fields {
switch k {
case "event":
if s, ok := v.(string); ok {
entry.Event = s
}
case "request_id":
if s, ok := v.(string); ok {
entry.RequestID = s
}
case "task_id":
if s, ok := v.(string); ok {
entry.TaskID = s
}
case "session_id":
if s, ok := v.(string); ok {
entry.SessionID = s
}
case "trace_id":
if s, ok := v.(string); ok {
entry.TraceID = s
}
case "application":
if s, ok := v.(string); ok {
entry.Application = s
}
case "tenant_id":
if s, ok := v.(string); ok {
entry.TenantID = s
}
case "user_id":
if s, ok := v.(string); ok {
entry.UserID = s
}
case "reason":
if s, ok := v.(string); ok {
entry.Reason = s
}
case "from_state":
if s, ok := v.(string); ok {
entry.FromState = s
}
case "to_state":
if s, ok := v.(string); ok {
entry.ToState = s
}
case "logical_model":
if s, ok := v.(string); ok {
entry.LogicalModel = s
}
case "actual_model":
if s, ok := v.(string); ok {
entry.ActualModel = s
}
case "node_id":
if s, ok := v.(string); ok {
entry.NodeID = s
}
case "degraded":
if b, ok := v.(bool); ok {
entry.Degraded = b
}
default:
if entry.Extra == nil {
entry.Extra = make(map[string]any)
}
entry.Extra[k] = v
}
}
}
return entry
}
// SetLogLevel updates the global log level.
func SetLogLevel(level string) {
defaultLogger.SetLevel(ParseLogLevel(level))
}
// SetPromptLoggingPolicy updates the global prompt logging policy.
func SetPromptLoggingPolicy(policy string) {
defaultLogger.SetPromptLogging(policy)
}
+130
View File
@@ -0,0 +1,130 @@
package observability
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
)
func TestParseLogLevel(t *testing.T) {
tests := []struct {
input string
want LogLevel
}{
{"debug", LevelDebug}, {"info", LevelInfo},
{"warn", LevelWarn}, {"warning", LevelWarn},
{"error", LevelError}, {"invalid", LevelInfo},
}
for _, tt := range tests {
got := ParseLogLevel(tt.input)
if got != tt.want {
t.Errorf("ParseLogLevel(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestLoggerMaskSensitive(t *testing.T) {
logger := &Logger{
level: LevelInfo,
maskFields: []string{"api_key", "secret", "password", "token"},
}
data := map[string]any{
"api_key": "sk-12345",
"message": "hello",
"nested": map[string]any{
"secret": "my-secret",
},
}
masked := logger.maskSensitive(data)
if masked["api_key"] != "***REDACTED***" {
t.Errorf("expected api_key redacted, got %v", masked["api_key"])
}
if masked["message"] != "hello" {
t.Errorf("expected message preserved, got %v", masked["message"])
}
nested, ok := masked["nested"].(map[string]any)
if !ok {
t.Fatal("expected nested map")
}
if nested["secret"] != "***REDACTED***" {
t.Errorf("expected nested secret redacted, got %v", nested["secret"])
}
}
func TestLogFieldsBuilder(t *testing.T) {
f := F().RequestID("req-1").TaskID("task-1").Event("test_event").Set("custom", "value")
if f.fields["request_id"] != "req-1" {
t.Error("request_id not set")
}
if f.fields["task_id"] != "task-1" {
t.Error("task_id not set")
}
if f.fields["event"] != "test_event" {
t.Error("event not set")
}
if f.fields["custom"] != "value" {
t.Error("custom not set")
}
}
func TestLoggerWrite(t *testing.T) {
// Use a temp file to capture output
tmpFile, err := os.CreateTemp("", "logtest*.json")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
logger := NewLogger(LevelDebug, tmpFile, "metadata_only")
logger.Info("test message", F().RequestID("req-123").Event("unit_test"))
tmpFile.Close()
data, err := os.ReadFile(tmpFile.Name())
if err != nil {
t.Fatalf("read log file: %v", err)
}
var entry map[string]any
if err := json.Unmarshal(bytes.TrimSpace(data), &entry); err != nil {
t.Fatalf("parse log json: %v\nraw: %s", err, string(data))
}
if entry["level"] != "INFO" {
t.Errorf("expected level INFO, got %v", entry["level"])
}
if entry["message"] != "test message" {
t.Errorf("expected message 'test message', got %v", entry["message"])
}
if entry["request_id"] != "req-123" {
t.Errorf("expected request_id req-123, got %v", entry["request_id"])
}
if entry["event"] != "unit_test" {
t.Errorf("expected event unit_test, got %v", entry["event"])
}
}
func TestLoggerLevelFiltering(t *testing.T) {
// This test verifies that debug messages are not logged when level is INFO
tmpFile, err := os.CreateTemp("", "logtest*.json")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
logger := NewLogger(LevelWarn, tmpFile, "metadata_only")
logger.Info("should not appear", F().Event("info_event"))
logger.Warn("should appear", F().Event("warn_event"))
tmpFile.Close()
data, _ := os.ReadFile(tmpFile.Name())
if strings.Contains(string(data), "should not appear") {
t.Error("INFO message was logged when level is WARN")
}
if !strings.Contains(string(data), "should appear") {
t.Error("WARN message was not logged")
}
}
+178
View File
@@ -0,0 +1,178 @@
package observability
import (
"fmt"
"net/http"
"sync"
"sync/atomic"
)
// Metrics holds all Prometheus-compatible metrics for the gateway.
type Metrics struct {
mu sync.RWMutex
// Counters
requestsTotal map[string]int64 // by status
tasksTotal map[string]int64 // by state
tokensInputTotal int64
tokensOutputTotal int64
cancellationsTotal int64
queueTimeoutsTotal int64
firstTokenTimeoutsTotal int64
inferenceTimeoutsTotal int64
degradedRequestsTotal int64
// Gauges
queueLength int64
runningTasks int64
activeSessions int64
backpressureLevel int64
// Histograms (simplified as buckets)
gatewayLatencyBuckets map[string]int64
firstTokenLatencyBuckets map[string]int64
}
// NewMetrics creates a new Metrics instance.
func NewMetrics() *Metrics {
return &Metrics{
requestsTotal: make(map[string]int64),
tasksTotal: make(map[string]int64),
gatewayLatencyBuckets: make(map[string]int64),
firstTokenLatencyBuckets: make(map[string]int64),
}
}
// IncRequest increments the request counter by status.
func (m *Metrics) IncRequest(status string) {
key := fmt.Sprintf("status=%s", status)
m.mu.Lock()
m.requestsTotal[key]++
m.mu.Unlock()
}
// IncTask increments the task counter by final state.
func (m *Metrics) IncTask(state string) {
key := fmt.Sprintf("state=%s", state)
m.mu.Lock()
m.tasksTotal[key]++
m.mu.Unlock()
}
// AddTokens adds to the token counters.
func (m *Metrics) AddTokens(input, output int) {
atomic.AddInt64(&m.tokensInputTotal, int64(input))
atomic.AddInt64(&m.tokensOutputTotal, int64(output))
}
// IncCancellation increments the cancellation counter.
func (m *Metrics) IncCancellation() {
atomic.AddInt64(&m.cancellationsTotal, 1)
}
// IncQueueTimeout increments the queue timeout counter.
func (m *Metrics) IncQueueTimeout() {
atomic.AddInt64(&m.queueTimeoutsTotal, 1)
}
// IncFirstTokenTimeout increments the first token timeout counter.
func (m *Metrics) IncFirstTokenTimeout() {
atomic.AddInt64(&m.firstTokenTimeoutsTotal, 1)
}
// IncInferenceTimeout increments the inference timeout counter.
func (m *Metrics) IncInferenceTimeout() {
atomic.AddInt64(&m.inferenceTimeoutsTotal, 1)
}
// IncDegraded increments the degraded request counter.
func (m *Metrics) IncDegraded() {
atomic.AddInt64(&m.degradedRequestsTotal, 1)
}
// SetQueueLength sets the current queue length gauge.
func (m *Metrics) SetQueueLength(n int) {
atomic.StoreInt64(&m.queueLength, int64(n))
}
// SetRunningTasks sets the running tasks gauge.
func (m *Metrics) SetRunningTasks(n int) {
atomic.StoreInt64(&m.runningTasks, int64(n))
}
// SetActiveSessions sets the active sessions gauge.
func (m *Metrics) SetActiveSessions(n int) {
atomic.StoreInt64(&m.activeSessions, int64(n))
}
// SetBackpressureLevel sets the backpressure level gauge.
func (m *Metrics) SetBackpressureLevel(level int) {
atomic.StoreInt64(&m.backpressureLevel, int64(level))
}
// ObserveGatewayLatency records gateway latency in a histogram bucket.
func (m *Metrics) ObserveGatewayLatency(ms int64) {
bucket := latencyBucket(ms)
m.mu.Lock()
m.gatewayLatencyBuckets[bucket]++
m.mu.Unlock()
}
// ObserveFirstTokenLatency records first token latency in a histogram bucket.
func (m *Metrics) ObserveFirstTokenLatency(ms int64) {
bucket := latencyBucket(ms)
m.mu.Lock()
m.firstTokenLatencyBuckets[bucket]++
m.mu.Unlock()
}
func latencyBucket(ms int64) string {
buckets := []int64{5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
for _, b := range buckets {
if ms <= b {
return fmt.Sprintf("le_%d", b)
}
}
return "le_inf"
}
// Handler returns an http.HandlerFunc that writes Prometheus-format metrics.
func (m *Metrics) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
// Counters
m.mu.RLock()
for key, val := range m.requestsTotal {
fmt.Fprintf(w, "edgeai_requests_total{%s} %d\n", key, val)
}
for key, val := range m.tasksTotal {
fmt.Fprintf(w, "edgeai_tasks_total{%s} %d\n", key, val)
}
m.mu.RUnlock()
fmt.Fprintf(w, "edgeai_tokens_input_total %d\n", atomic.LoadInt64(&m.tokensInputTotal))
fmt.Fprintf(w, "edgeai_tokens_output_total %d\n", atomic.LoadInt64(&m.tokensOutputTotal))
fmt.Fprintf(w, "edgeai_cancellations_total %d\n", atomic.LoadInt64(&m.cancellationsTotal))
fmt.Fprintf(w, "edgeai_queue_timeouts_total %d\n", atomic.LoadInt64(&m.queueTimeoutsTotal))
fmt.Fprintf(w, "edgeai_first_token_timeouts_total %d\n", atomic.LoadInt64(&m.firstTokenTimeoutsTotal))
fmt.Fprintf(w, "edgeai_inference_timeouts_total %d\n", atomic.LoadInt64(&m.inferenceTimeoutsTotal))
fmt.Fprintf(w, "edgeai_degraded_requests_total %d\n", atomic.LoadInt64(&m.degradedRequestsTotal))
// Gauges
fmt.Fprintf(w, "edgeai_queue_length %d\n", atomic.LoadInt64(&m.queueLength))
fmt.Fprintf(w, "edgeai_running_tasks %d\n", atomic.LoadInt64(&m.runningTasks))
fmt.Fprintf(w, "edgeai_active_sessions %d\n", atomic.LoadInt64(&m.activeSessions))
fmt.Fprintf(w, "edgeai_backpressure_level %d\n", atomic.LoadInt64(&m.backpressureLevel))
// Histograms
m.mu.RLock()
for bucket, count := range m.gatewayLatencyBuckets {
fmt.Fprintf(w, "edgeai_gateway_latency_bucket{%s} %d\n", bucket, count)
}
for bucket, count := range m.firstTokenLatencyBuckets {
fmt.Fprintf(w, "edgeai_first_token_latency_bucket{%s} %d\n", bucket, count)
}
m.mu.RUnlock()
}
}
+197
View File
@@ -0,0 +1,197 @@
package resource
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// GPUMetrics represents GPU utilization data from nvidia-smi.
type GPUMetrics struct {
Index int
Name string
TemperatureC int
UtilizationGPU int // percentage 0-100
MemoryUsedMB int
MemoryTotalMB int
MemoryUtilPct float64
PowerDrawW float64
PowerLimitW float64
Timestamp time.Time
}
// GPUCollector collects GPU metrics via nvidia-smi.
type GPUCollector struct {
mu sync.RWMutex
metrics []GPUMetrics
enabled bool
}
// NewGPUCollector creates a new GPU collector.
func NewGPUCollector() *GPUCollector {
return &GPUCollector{enabled: true}
}
// Collect runs nvidia-smi and parses the output.
func (c *GPUCollector) Collect(ctx context.Context) ([]GPUMetrics, error) {
if !c.enabled {
return nil, nil
}
// Use nvidia-smi with CSV format for structured output
cmd := exec.CommandContext(ctx, "nvidia-smi",
"--query-gpu=index,name,temperature.gpu,utilization.gpu,memory.used,memory.total,memory.utilization,power.draw,power.limit",
"--format=csv,noheader,nounits",
)
output, err := cmd.Output()
if err != nil {
// If nvidia-smi is not available, disable collector
c.mu.Lock()
c.enabled = false
c.mu.Unlock()
return nil, fmt.Errorf("nvidia-smi not available: %w", err)
}
metrics := parseNvidiaSMI(string(output))
c.mu.Lock()
c.metrics = metrics
c.mu.Unlock()
return metrics, nil
}
func parseNvidiaSMI(output string) []GPUMetrics {
lines := strings.Split(strings.TrimSpace(output), "\n")
metrics := make([]GPUMetrics, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Split(line, ",")
if len(fields) < 9 {
continue
}
m := GPUMetrics{Timestamp: time.Now()}
m.Index = parseIntSafe(fields[0])
m.Name = strings.TrimSpace(fields[1])
m.TemperatureC = parseIntSafe(fields[2])
m.UtilizationGPU = parseIntSafe(fields[3])
m.MemoryUsedMB = parseIntSafe(fields[4])
m.MemoryTotalMB = parseIntSafe(fields[5])
m.MemoryUtilPct = parseFloatSafe(fields[6])
m.PowerDrawW = parseFloatSafe(fields[7])
m.PowerLimitW = parseFloatSafe(fields[8])
metrics = append(metrics, m)
}
return metrics
}
func parseIntSafe(s string) int {
s = strings.TrimSpace(s)
v, err := strconv.Atoi(s)
if err != nil {
return 0
}
return v
}
func parseFloatSafe(s string) float64 {
s = strings.TrimSpace(s)
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
// GetMetrics returns the last collected metrics (thread-safe).
func (c *GPUCollector) GetMetrics() []GPUMetrics {
c.mu.RLock()
defer c.mu.RUnlock()
return c.metrics
}
// IsEnabled returns whether GPU collection is enabled.
func (c *GPUCollector) IsEnabled() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.enabled
}
// StartPeriodicCollection starts a background goroutine that collects GPU metrics at regular intervals.
func (c *GPUCollector) StartPeriodicCollection(ctx context.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.Collect(ctx)
}
}
}()
}
// TotalMemoryUsedMB returns total GPU memory used across all GPUs.
func (c *GPUCollector) TotalMemoryUsedMB() int {
c.mu.RLock()
defer c.mu.RUnlock()
total := 0
for _, m := range c.metrics {
total += m.MemoryUsedMB
}
return total
}
// TotalMemoryTotalMB returns total GPU memory capacity across all GPUs.
func (c *GPUCollector) TotalMemoryTotalMB() int {
c.mu.RLock()
defer c.mu.RUnlock()
total := 0
for _, m := range c.metrics {
total += m.MemoryTotalMB
}
return total
}
// AverageUtilization returns average GPU utilization percentage.
func (c *GPUCollector) AverageUtilization() float64 {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.metrics) == 0 {
return 0
}
total := 0
for _, m := range c.metrics {
total += m.UtilizationGPU
}
return float64(total) / float64(len(c.metrics))
}
// MemoryUtilizationRatio returns memory used / memory total (0.0-1.0).
func (c *GPUCollector) MemoryUtilizationRatio() float64 {
total := c.TotalMemoryTotalMB()
if total == 0 {
return 0
}
return float64(c.TotalMemoryUsedMB()) / float64(total)
}
+130
View File
@@ -0,0 +1,130 @@
package resource
import (
"testing"
)
func TestParseNvidiaSMI(t *testing.T) {
output := `0, NVIDIA GeForce RTX 4090, 45, 30, 4096, 24576, 16.67, 150.5, 450.0
1, NVIDIA GeForce RTX 4090, 52, 75, 8192, 24576, 33.33, 320.0, 450.0`
metrics := parseNvidiaSMI(output)
if len(metrics) != 2 {
t.Fatalf("expected 2 GPUs, got %d", len(metrics))
}
if metrics[0].Index != 0 {
t.Errorf("expected index 0, got %d", metrics[0].Index)
}
if metrics[0].Name != "NVIDIA GeForce RTX 4090" {
t.Errorf("unexpected name: %s", metrics[0].Name)
}
if metrics[0].TemperatureC != 45 {
t.Errorf("expected temp 45, got %d", metrics[0].TemperatureC)
}
if metrics[0].UtilizationGPU != 30 {
t.Errorf("expected util 30, got %d", metrics[0].UtilizationGPU)
}
if metrics[0].MemoryUsedMB != 4096 {
t.Errorf("expected mem used 4096, got %d", metrics[0].MemoryUsedMB)
}
if metrics[0].MemoryTotalMB != 24576 {
t.Errorf("expected mem total 24576, got %d", metrics[0].MemoryTotalMB)
}
if metrics[0].PowerDrawW != 150.5 {
t.Errorf("expected power 150.5, got %f", metrics[0].PowerDrawW)
}
if metrics[1].Index != 1 {
t.Errorf("expected index 1, got %d", metrics[1].Index)
}
if metrics[1].UtilizationGPU != 75 {
t.Errorf("expected util 75, got %d", metrics[1].UtilizationGPU)
}
}
func TestParseNvidiaSMIEmpty(t *testing.T) {
metrics := parseNvidiaSMI("")
if len(metrics) != 0 {
t.Errorf("expected 0 metrics for empty input, got %d", len(metrics))
}
}
func TestParseNvidiaSMIInvalidLines(t *testing.T) {
output := `invalid line
0, GPU0, 40, 50, 1024, 8192, 12.5, 100.0, 300.0
, , , , , , , , `
metrics := parseNvidiaSMI(output)
// Both lines with 9 fields parse; the empty-name one has Name=""
validCount := 0
for _, m := range metrics {
if m.Name != "" {
validCount++
}
}
if validCount != 1 {
t.Errorf("expected 1 valid metric with name, got %d", validCount)
}
}
func TestGPUCollectorTotals(t *testing.T) {
c := &GPUCollector{
metrics: []GPUMetrics{
{MemoryUsedMB: 4096, MemoryTotalMB: 24576, UtilizationGPU: 30},
{MemoryUsedMB: 8192, MemoryTotalMB: 24576, UtilizationGPU: 75},
},
}
if c.TotalMemoryUsedMB() != 12288 {
t.Errorf("expected 12288, got %d", c.TotalMemoryUsedMB())
}
if c.TotalMemoryTotalMB() != 49152 {
t.Errorf("expected 49152, got %d", c.TotalMemoryTotalMB())
}
avg := c.AverageUtilization()
if avg != 52.5 {
t.Errorf("expected 52.5, got %f", avg)
}
ratio := c.MemoryUtilizationRatio()
expectedRatio := 12288.0 / 49152.0
if ratio != expectedRatio {
t.Errorf("expected %f, got %f", expectedRatio, ratio)
}
}
func TestGPUCollectorEmpty(t *testing.T) {
c := &GPUCollector{}
if c.TotalMemoryUsedMB() != 0 {
t.Error("expected 0 for empty collector")
}
if c.AverageUtilization() != 0 {
t.Error("expected 0 for empty collector")
}
if c.MemoryUtilizationRatio() != 0 {
t.Error("expected 0 for empty collector")
}
}
func TestParseIntSafe(t *testing.T) {
if parseIntSafe("42") != 42 {
t.Error("expected 42")
}
if parseIntSafe("invalid") != 0 {
t.Error("expected 0 for invalid")
}
if parseIntSafe(" 100 ") != 100 {
t.Error("expected 100 with whitespace")
}
}
func TestParseFloatSafe(t *testing.T) {
if parseFloatSafe("3.14") != 3.14 {
t.Error("expected 3.14")
}
if parseFloatSafe("invalid") != 0 {
t.Error("expected 0 for invalid")
}
}
+88
View File
@@ -0,0 +1,88 @@
package router
import (
"fmt"
"sync"
"github.com/edgeai/gateway/internal/config"
)
// LogicalModelMapping maps logical model names to actual model configurations.
type LogicalModelMapping struct {
mu sync.RWMutex
mapping map[string]*ModelTarget
}
// ModelTarget represents the resolved target for a logical model.
type ModelTarget struct {
LogicalModel string
ActualModel string
Provider string
Endpoint string
ContextWindow int
MaxOutputTokens int
MaxConcurrency int
CancelSupported bool
}
// NewLogicalModelMapping creates a mapping from config.
func NewLogicalModelMapping(cfg *config.Config) *LogicalModelMapping {
m := &LogicalModelMapping{mapping: make(map[string]*ModelTarget)}
for logical, mc := range cfg.Models {
m.mapping[logical] = &ModelTarget{
LogicalModel: logical,
ActualModel: mc.ActualModel,
Provider: mc.Provider,
Endpoint: mc.Endpoint,
ContextWindow: mc.ContextWindow,
MaxOutputTokens: mc.MaxOutputTokens,
MaxConcurrency: mc.MaxConcurrency,
CancelSupported: mc.CancelSupported,
}
}
return m
}
// Resolve returns the ModelTarget for a logical model name.
func (m *LogicalModelMapping) Resolve(logicalModel string) (*ModelTarget, error) {
m.mu.RLock()
defer m.mu.RUnlock()
target, ok := m.mapping[logicalModel]
if !ok {
return nil, fmt.Errorf("logical model not found: %s", logicalModel)
}
return target, nil
}
// List returns all logical model names.
func (m *LogicalModelMapping) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.mapping))
for n := range m.mapping {
names = append(names, n)
}
return names
}
// Update updates the mapping (for config hot-reload).
func (m *LogicalModelMapping) Update(cfg *config.Config) {
m.mu.Lock()
defer m.mu.Unlock()
m.mapping = make(map[string]*ModelTarget)
for logical, mc := range cfg.Models {
m.mapping[logical] = &ModelTarget{
LogicalModel: logical,
ActualModel: mc.ActualModel,
Provider: mc.Provider,
Endpoint: mc.Endpoint,
ContextWindow: mc.ContextWindow,
MaxOutputTokens: mc.MaxOutputTokens,
MaxConcurrency: mc.MaxConcurrency,
CancelSupported: mc.CancelSupported,
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package scheduler
import (
"container/heap"
"context"
"fmt"
"sync"
"time"
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/internal/observability"
"github.com/edgeai/gateway/internal/task"
)
// Scheduler manages task queuing and execution with priority-based scheduling.
type Scheduler struct {
mu sync.Mutex
queue *priorityQueue
running map[string]*task.Task
maxRunning int
maxQueued int
notifyCh chan struct{}
logger *observability.Logger
ctx context.Context
cancel context.CancelFunc
}
// NewScheduler creates a new scheduler.
func NewScheduler(cfg *config.SchedulerConfig, logger *observability.Logger) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
queue: &priorityQueue{},
running: make(map[string]*task.Task),
maxRunning: cfg.MaxRunningTasks,
maxQueued: cfg.MaxQueuedTasks,
notifyCh: make(chan struct{}, 1),
logger: logger,
ctx: ctx,
cancel: cancel,
}
heap.Init(s.queue)
return s
}
// Submit adds a task to the queue. Returns error if queue is full.
func (s *Scheduler) Submit(t *task.Task) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.queue.Len() >= s.maxQueued {
return fmt.Errorf("queue full")
}
heap.Push(s.queue, t)
s.logger.Info("task queued",
observability.F().
Event("task_queued").
TaskID(t.ID).
Set("priority", config.PriorityName(int(t.Priority))).
Set("queue_length", s.queue.Len()))
// Notify the scheduler loop
select {
case s.notifyCh <- struct{}{}:
default:
}
return nil
}
// GetNext retrieves the next task to execute (blocking until one is available).
func (s *Scheduler) GetNext(ctx context.Context) (*task.Task, error) {
for {
s.mu.Lock()
if s.queue.Len() > 0 && len(s.running) < s.maxRunning {
t := heap.Pop(s.queue).(*task.Task)
s.running[t.ID] = t
s.mu.Unlock()
return t, nil
}
s.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-s.notifyCh:
case <-time.After(100 * time.Millisecond):
}
}
}
// Complete marks a task as completed and removes it from running.
func (s *Scheduler) Complete(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.running, taskID)
select {
case s.notifyCh <- struct{}{}:
default:
}
}
// QueueLength returns the current queue length.
func (s *Scheduler) QueueLength() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.queue.Len()
}
// RunningCount returns the number of running tasks.
func (s *Scheduler) RunningCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.running)
}
// Stop shuts down the scheduler.
func (s *Scheduler) Stop() {
s.cancel()
}
// priorityQueue implements heap.Interface for priority-based task scheduling.
type priorityQueue []*task.Task
func (pq priorityQueue) Len() int { return len(pq) }
func (pq priorityQueue) Less(i, j int) bool {
// Lower priority value = higher priority (P0 > P1 > P2...)
if pq[i].Priority != pq[j].Priority {
return pq[i].Priority < pq[j].Priority
}
// Same priority: FIFO by creation time
return pq[i].CreatedAt.Before(pq[j].CreatedAt)
}
func (pq priorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
}
func (pq *priorityQueue) Push(x any) {
t := x.(*task.Task)
*pq = append(*pq, t)
}
func (pq *priorityQueue) Pop() any {
old := *pq
n := len(old)
t := old[n-1]
old[n-1] = nil
*pq = old[:n-1]
return t
}
+110
View File
@@ -0,0 +1,110 @@
package scheduler
import (
"context"
"os"
"testing"
"time"
"github.com/edgeai/gateway/internal/config"
"github.com/edgeai/gateway/internal/observability"
"github.com/edgeai/gateway/internal/task"
)
func newTestScheduler(maxRunning, maxQueued int) *Scheduler {
cfg := &config.SchedulerConfig{
MaxRunningTasks: maxRunning,
MaxQueuedTasks: maxQueued,
}
logger := observability.NewLogger(observability.LevelDebug, os.Stdout, "metadata_only")
return NewScheduler(cfg, logger)
}
func TestSubmitAndGetNext(t *testing.T) {
s := newTestScheduler(2, 10)
defer s.Stop()
task1 := task.NewTask("t1", "r1", "app1", "tenant1", "model1", task.PriorityNormal, false)
task2 := task.NewTask("t2", "r2", "app1", "tenant1", "model1", task.PriorityHigh, false)
if err := s.Submit(task1); err != nil {
t.Fatalf("submit task1: %v", err)
}
if err := s.Submit(task2); err != nil {
t.Fatalf("submit task2: %v", err)
}
ctx := context.Background()
got1, err := s.GetNext(ctx)
if err != nil {
t.Fatalf("get next: %v", err)
}
// P1 (High) should come before P2 (Normal)
if got1.ID != "t2" {
t.Errorf("expected t2 (higher priority) first, got %s", got1.ID)
}
got2, err := s.GetNext(ctx)
if err != nil {
t.Fatalf("get next 2: %v", err)
}
if got2.ID != "t1" {
t.Errorf("expected t1 second, got %s", got2.ID)
}
}
func TestQueueFull(t *testing.T) {
s := newTestScheduler(1, 2)
defer s.Stop()
for i := 0; i < 2; i++ {
tk := task.NewTask("t", "r", "app", "tenant", "model", task.PriorityNormal, false)
if err := s.Submit(tk); err != nil {
t.Fatalf("submit %d: %v", i, err)
}
}
tk := task.NewTask("t3", "r3", "app", "tenant", "model", task.PriorityNormal, false)
err := s.Submit(tk)
if err == nil {
t.Error("expected queue full error")
}
}
func TestComplete(t *testing.T) {
s := newTestScheduler(1, 10)
defer s.Stop()
tk := task.NewTask("t1", "r1", "app", "tenant", "model", task.PriorityNormal, false)
s.Submit(tk)
ctx := context.Background()
got, _ := s.GetNext(ctx)
if s.RunningCount() != 1 {
t.Errorf("expected 1 running, got %d", s.RunningCount())
}
s.Complete(got.ID)
if s.RunningCount() != 0 {
t.Errorf("expected 0 running after complete, got %d", s.RunningCount())
}
}
func TestFIFOOrdering(t *testing.T) {
s := newTestScheduler(1, 10)
defer s.Stop()
// Same priority, should be FIFO
t1 := task.NewTask("t1", "r1", "app", "tenant", "model", task.PriorityNormal, false)
time.Sleep(1 * time.Millisecond)
t2 := task.NewTask("t2", "r2", "app", "tenant", "model", task.PriorityNormal, false)
s.Submit(t1)
s.Submit(t2)
ctx := context.Background()
got1, _ := s.GetNext(ctx)
if got1.ID != "t1" {
t.Errorf("expected t1 first (FIFO), got %s", got1.ID)
}
}
+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
}
+172
View File
@@ -0,0 +1,172 @@
package session
import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/edgeai/gateway/pkg/api"
_ "github.com/mattn/go-sqlite3"
)
// Session represents a conversation session.
type Session struct {
ID string
ApplicationID string
TenantID string
UserID string
Messages []api.Message
Config map[string]any
CreatedAt time.Time
LastActive time.Time
}
// Store manages session persistence with SQLite.
type Store struct {
mu sync.RWMutex
db *sql.DB
}
// NewStore creates a new session store.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open session db: %w", err)
}
if err := initSessionDB(db); err != nil {
return nil, fmt.Errorf("init session db: %w", err)
}
return &Store{db: db}, nil
}
func initSessionDB(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
user_id TEXT,
messages TEXT NOT NULL DEFAULT '[]',
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
last_active TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_app ON sessions(application_id);
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sessions_last_active ON sessions(last_active);`
_, err := db.Exec(schema)
return err
}
// Create creates a new session.
func (s *Store) Create(id, appID, tenantID, userID string, config map[string]any) (*Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
session := &Session{
ID: id,
ApplicationID: appID,
TenantID: tenantID,
UserID: userID,
Messages: []api.Message{},
Config: config,
CreatedAt: now,
LastActive: now,
}
configJSON, _ := json.Marshal(config)
msgsJSON, _ := json.Marshal(session.Messages)
_, err := s.db.Exec(
`INSERT INTO sessions (id, application_id, tenant_id, user_id, messages, config, created_at, last_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, appID, tenantID, userID, string(msgsJSON), string(configJSON), now.Format(time.RFC3339), now.Format(time.RFC3339),
)
if err != nil {
return nil, fmt.Errorf("insert session: %w", err)
}
return session, nil
}
// Get retrieves a session by ID.
func (s *Store) Get(id string) (*Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var (
appID, tenantID, userID, msgsJSON, configJSON, createdAt, lastActive string
)
err := s.db.QueryRow(
`SELECT application_id, tenant_id, user_id, messages, config, created_at, last_active FROM sessions WHERE id = ?`,
id,
).Scan(&appID, &tenantID, &userID, &msgsJSON, &configJSON, &createdAt, &lastActive)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query session: %w", err)
}
session := &Session{
ID: id,
ApplicationID: appID,
TenantID: tenantID,
UserID: userID,
CreatedAt: parseTime(createdAt),
LastActive: parseTime(lastActive),
}
json.Unmarshal([]byte(msgsJSON), &session.Messages)
json.Unmarshal([]byte(configJSON), &session.Config)
return session, nil
}
// AddMessage appends a message to the session and updates last_active.
func (s *Store) AddMessage(id string, msg api.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
session, err := s.Get(id)
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("session not found: %s", id)
}
session.Messages = append(session.Messages, msg)
msgsJSON, _ := json.Marshal(session.Messages)
now := time.Now().Format(time.RFC3339)
_, err = s.db.Exec(
`UPDATE sessions SET messages = ?, last_active = ? WHERE id = ?`,
string(msgsJSON), now, id,
)
return err
}
// Delete removes a session.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
return err
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
+174
View File
@@ -0,0 +1,174 @@
package task
import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Store manages task state persistence with SQLite.
type Store struct {
mu sync.Mutex
db *sql.DB
}
// NewStore creates a new task store.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("open task db: %w", err)
}
if err := initTaskDB(db); err != nil {
return nil, fmt.Errorf("init task db: %w", err)
}
return &Store{db: db}, nil
}
func initTaskDB(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
session_id TEXT,
app_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
logical_model TEXT NOT NULL,
actual_model TEXT,
priority INTEGER NOT NULL DEFAULT 2,
state TEXT NOT NULL,
stream INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
cancel_reason TEXT,
error_message TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
node_id TEXT,
degraded INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state);
CREATE INDEX IF NOT EXISTS idx_tasks_app ON tasks(app_id);
CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant_id);`
_, err := db.Exec(schema)
return err
}
// Save persists a task to the database.
func (s *Store) Save(t *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
var startedAt, completedAt interface{}
if t.StartedAt != nil {
startedAt = t.StartedAt.Format(time.RFC3339)
}
if t.CompletedAt != nil {
completedAt = t.CompletedAt.Format(time.RFC3339)
}
streamInt := 0
if t.Stream {
streamInt = 1
}
degradedInt := 0
if t.Degraded {
degradedInt = 1
}
_, err := s.db.Exec(
`INSERT OR REPLACE INTO tasks
(id, request_id, session_id, app_id, tenant_id, logical_model, actual_model, priority, state, stream, created_at, started_at, completed_at, cancel_reason, error_message, input_tokens, output_tokens, node_id, degraded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.RequestID, t.SessionID, t.AppID, t.TenantID, t.LogicalModel, t.ActualModel,
int(t.Priority), string(t.State), streamInt, t.CreatedAt.Format(time.RFC3339),
startedAt, completedAt, t.CancelReason, t.ErrorMessage,
t.InputTokens, t.OutputTokens, t.NodeID, degradedInt,
)
return err
}
// Get retrieves a task by ID.
func (s *Store) Get(id string) (*Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
var (
requestID, sessionID, appID, tenantID, logicalModel, actualModel, state string
priority int
streamInt int
createdAtStr, startedAt, completedAt, cancelReason, errorMessage, nodeID sql.NullString
inputTokens, outputTokens, degradedInt int
)
err := s.db.QueryRow(
`SELECT request_id, session_id, app_id, tenant_id, logical_model, actual_model, priority, state, stream, created_at, started_at, completed_at, cancel_reason, error_message, input_tokens, output_tokens, node_id, degraded FROM tasks WHERE id = ?`,
id,
).Scan(&requestID, &sessionID, &appID, &tenantID, &logicalModel, &actualModel, &priority, &state, &streamInt, &createdAtStr, &startedAt, &completedAt, &cancelReason, &errorMessage, &inputTokens, &outputTokens, &nodeID, &degradedInt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t := &Task{
ID: id,
RequestID: requestID,
SessionID: sessionID,
AppID: appID,
TenantID: tenantID,
LogicalModel: logicalModel,
ActualModel: actualModel,
Priority: TaskPriority(priority),
State: TaskState(state),
Stream: streamInt == 1,
InputTokens: inputTokens,
OutputTokens: outputTokens,
NodeID: nodeID.String,
Degraded: degradedInt == 1,
CancelReason: cancelReason.String,
ErrorMessage: errorMessage.String,
cancelCh: make(chan struct{}),
}
t.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr.String)
if startedAt.Valid {
tt, _ := time.Parse(time.RFC3339, startedAt.String)
t.StartedAt = &tt
}
if completedAt.Valid {
tt, _ := time.Parse(time.RFC3339, completedAt.String)
t.CompletedAt = &tt
}
return t, nil
}
// RecoverPendingTasks marks RUNNING/STREAMING tasks as FAILED on startup.
func (s *Store) RecoverPendingTasks() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
result, err := s.db.Exec(
`UPDATE tasks SET state = 'FAILED', error_message = 'gateway restart' WHERE state IN ('RUNNING', 'STREAMING')`)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// Ensure json is imported for future use.
var _ = json.Marshal
+190
View File
@@ -0,0 +1,190 @@
package task
import (
"errors"
"fmt"
"sync"
"time"
"github.com/edgeai/gateway/internal/observability"
)
// TaskState represents the lifecycle state of a task.
type TaskState string
const (
StateQueued TaskState = "QUEUED"
StateRunning TaskState = "RUNNING"
StateStreaming TaskState = "STREAMING"
StateCompleted TaskState = "COMPLETED"
StateFailed TaskState = "FAILED"
StateCancelled TaskState = "CANCELLED"
)
// TaskPriority levels (P0 highest, P4 lowest).
type TaskPriority int
const (
PriorityRealtime TaskPriority = 0 // P0
PriorityHigh TaskPriority = 1 // P1
PriorityNormal TaskPriority = 2 // P2 (default)
PriorityLow TaskPriority = 3 // P3
PriorityBackground TaskPriority = 4 // P4
)
// Task represents an inference task in the system.
type Task struct {
ID string
RequestID string
SessionID string
AppID string
TenantID string
LogicalModel string
ActualModel string
Priority TaskPriority
State TaskState
Stream bool
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
CancelReason string
ErrorMessage string
InputTokens int
OutputTokens int
NodeID string
Degraded bool
cancelCh chan struct{}
cancelOnce sync.Once
mu sync.RWMutex
}
// NewTask creates a new task in QUEUED state.
func NewTask(id, requestID, appID, tenantID, logicalModel string, priority TaskPriority, stream bool) *Task {
return &Task{
ID: id,
RequestID: requestID,
AppID: appID,
TenantID: tenantID,
LogicalModel: logicalModel,
Priority: priority,
State: StateQueued,
Stream: stream,
CreatedAt: time.Now(),
cancelCh: make(chan struct{}),
}
}
// AllowedTransitions defines valid state transitions.
var allowedTransitions = map[TaskState][]TaskState{
StateQueued: {StateRunning, StateFailed, StateCancelled},
StateRunning: {StateStreaming, StateCompleted, StateFailed, StateCancelled},
StateStreaming: {StateCompleted, StateFailed, StateCancelled},
StateCompleted: {},
StateFailed: {},
StateCancelled: {},
}
// Transition changes the task state if the transition is valid.
func (t *Task) Transition(to TaskState) error {
t.mu.Lock()
defer t.mu.Unlock()
allowed, ok := allowedTransitions[t.State]
if !ok {
return fmt.Errorf("unknown current state: %s", t.State)
}
valid := false
for _, s := range allowed {
if s == to {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid transition: %s -> %s", t.State, to)
}
from := t.State
t.State = to
now := time.Now()
switch to {
case StateRunning:
t.StartedAt = &now
case StateCompleted, StateFailed, StateCancelled:
t.CompletedAt = &now
}
_ = from
return nil
}
// Cancel signals task cancellation and transitions to CANCELLED if possible.
func (t *Task) Cancel(reason string) error {
t.cancelOnce.Do(func() {
close(t.cancelCh)
})
t.mu.Lock()
defer t.mu.Unlock()
if t.State == StateCompleted || t.State == StateFailed || t.State == StateCancelled {
return errors.New("task already in terminal state")
}
t.CancelReason = reason
t.State = StateCancelled
now := time.Now()
t.CompletedAt = &now
return nil
}
// Cancelled returns a channel that's closed when the task is cancelled.
func (t *Task) Cancelled() <-chan struct{} {
return t.cancelCh
}
// IsCancelled returns true if the task has been cancelled.
func (t *Task) IsCancelled() bool {
select {
case <-t.cancelCh:
return true
default:
return false
}
}
// GetState returns the current state (thread-safe).
func (t *Task) GetState() TaskState {
t.mu.RLock()
defer t.mu.RUnlock()
return t.State
}
// IsTerminal returns true if the task is in a terminal state.
func (t *Task) IsTerminal() bool {
s := t.GetState()
return s == StateCompleted || s == StateFailed || s == StateCancelled
}
// StateMachineLogger logs state transitions.
type StateMachineLogger struct {
logger *observability.Logger
}
func NewStateMachineLogger(logger *observability.Logger) *StateMachineLogger {
return &StateMachineLogger{logger: logger}
}
// LogTransition logs a state transition.
func (sml *StateMachineLogger) LogTransition(task *Task, from, to TaskState, reason string) {
sml.logger.Info("task state transition",
observability.F().
Event("state_transition").
TaskID(task.ID).
Set("from_state", string(from)).
Set("to_state", string(to)).
Reason(reason))
_ = from // used in log field above
}
+146
View File
@@ -0,0 +1,146 @@
package task
import (
"testing"
"time"
)
func TestNewTask(t *testing.T) {
task := NewTask("task-1", "req-1", "app-1", "tenant-1", "general-chat", PriorityNormal, false)
if task.ID != "task-1" {
t.Errorf("expected ID task-1, got %s", task.ID)
}
if task.State != StateQueued {
t.Errorf("expected state QUEUED, got %s", task.State)
}
if task.Priority != PriorityNormal {
t.Errorf("expected priority P2, got %d", task.Priority)
}
}
func TestValidTransitions(t *testing.T) {
tests := []struct {
from TaskState
to TaskState
ok bool
}{
{StateQueued, StateRunning, true},
{StateQueued, StateFailed, true},
{StateQueued, StateCancelled, true},
{StateQueued, StateCompleted, false},
{StateRunning, StateStreaming, true},
{StateRunning, StateCompleted, true},
{StateRunning, StateFailed, true},
{StateRunning, StateCancelled, true},
{StateRunning, StateQueued, false},
{StateStreaming, StateCompleted, true},
{StateStreaming, StateFailed, true},
{StateStreaming, StateCancelled, true},
{StateStreaming, StateRunning, false},
{StateCompleted, StateRunning, false},
{StateFailed, StateCompleted, false},
{StateCancelled, StateRunning, false},
}
for _, tt := range tests {
task := &Task{State: tt.from, cancelCh: make(chan struct{})}
err := task.Transition(tt.to)
if tt.ok && err != nil {
t.Errorf("expected %s -> %s to succeed, got error: %v", tt.from, tt.to, err)
}
if !tt.ok && err == nil {
t.Errorf("expected %s -> %s to fail, but it succeeded", tt.from, tt.to)
}
}
}
func TestTaskCancel(t *testing.T) {
task := NewTask("task-1", "req-1", "app-1", "tenant-1", "general-chat", PriorityNormal, false)
if task.IsCancelled() {
t.Error("task should not be cancelled initially")
}
err := task.Cancel("client_disconnect")
if err != nil {
t.Errorf("cancel failed: %v", err)
}
if !task.IsCancelled() {
t.Error("task should be cancelled after Cancel()")
}
if task.GetState() != StateCancelled {
t.Errorf("expected state CANCELLED, got %s", task.GetState())
}
if task.CancelReason != "client_disconnect" {
t.Errorf("expected cancel reason 'client_disconnect', got %s", task.CancelReason)
}
// Cancel again should fail
err = task.Cancel("second_attempt")
if err == nil {
t.Error("expected error on double cancel")
}
}
func TestTaskCancelledChannel(t *testing.T) {
task := NewTask("task-1", "req-1", "app-1", "tenant-1", "general-chat", PriorityNormal, false)
select {
case <-task.Cancelled():
t.Error("channel should not be closed before cancel")
default:
}
task.Cancel("test")
select {
case <-task.Cancelled():
// expected
case <-time.After(100 * time.Millisecond):
t.Error("channel should be closed after cancel")
}
}
func TestIsTerminal(t *testing.T) {
tests := []struct {
state TaskState
terminal bool
}{
{StateQueued, false},
{StateRunning, false},
{StateStreaming, false},
{StateCompleted, true},
{StateFailed, true},
{StateCancelled, true},
}
for _, tt := range tests {
task := &Task{State: tt.state}
if task.IsTerminal() != tt.terminal {
t.Errorf("expected IsTerminal()=%v for state %s, got %v", tt.terminal, tt.state, task.IsTerminal())
}
}
}
func TestTransitionSetsTimestamps(t *testing.T) {
task := &Task{State: StateQueued, cancelCh: make(chan struct{})}
err := task.Transition(StateRunning)
if err != nil {
t.Fatalf("transition to RUNNING failed: %v", err)
}
if task.StartedAt == nil {
t.Error("expected StartedAt to be set after transition to RUNNING")
}
err = task.Transition(StateCompleted)
if err != nil {
t.Fatalf("transition to COMPLETED failed: %v", err)
}
if task.CompletedAt == nil {
t.Error("expected CompletedAt to be set after transition to COMPLETED")
}
}