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