初始提交:边缘AI算力机统一AI通讯层
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, °radedInt)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user