1315 lines
48 KiB
Go
1315 lines
48 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Duration time.Duration
|
|
|
|
func (d *Duration) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err == nil {
|
|
v, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
var n int64
|
|
if err := json.Unmarshal(b, &n); err != nil {
|
|
return errors.New("duration must be a Go duration string or nanoseconds")
|
|
}
|
|
*d = Duration(time.Duration(n))
|
|
return nil
|
|
}
|
|
func (d Duration) Value() time.Duration { return time.Duration(d) }
|
|
func (d Duration) MarshalJSON() ([]byte, error) { return json.Marshal(time.Duration(d).String()) }
|
|
|
|
type Config struct {
|
|
Server ServerConfig `json:"server"`
|
|
Auth AuthConfig `json:"auth"`
|
|
Scheduler SchedulerConfig `json:"scheduler"`
|
|
Quota QuotaConfig `json:"quota"`
|
|
Cost CostConfig `json:"cost"`
|
|
Workers []WorkerConfig `json:"workers"`
|
|
Usage UsageConfig `json:"usage"`
|
|
Native NativeConfig `json:"native"`
|
|
UI UIConfig `json:"ui"`
|
|
PublicDashboard PublicDashboardConfig `json:"public_dashboard"`
|
|
Infrastructure InfrastructureConfig `json:"infrastructure"`
|
|
ModelCapabilities ModelCapabilitiesConfig `json:"model_capabilities"`
|
|
Routing RoutingConfig `json:"routing"`
|
|
Reliability ReliabilityConfig `json:"reliability"`
|
|
ServiceClasses ServiceClassesConfig `json:"service_classes"`
|
|
AutoTuning AutoTuningConfig `json:"auto_tuning"`
|
|
OpenTelemetry OpenTelemetryConfig `json:"opentelemetry"`
|
|
WarmModels WarmModelsConfig `json:"warm_models"`
|
|
Alerts AlertsConfig `json:"alerts"`
|
|
Conversations ConversationsConfig `json:"conversations"`
|
|
BatchJobs BatchJobsConfig `json:"batch_jobs"`
|
|
ModelAliases map[string]ModelAliasConfig `json:"model_aliases"`
|
|
ModelAccess ModelAccessConfig `json:"model_access"`
|
|
Storage StorageConfig `json:"storage"`
|
|
}
|
|
|
|
type ServerConfig struct {
|
|
Listen string `json:"listen"`
|
|
ReadHeaderTimeout Duration `json:"read_header_timeout"`
|
|
IdleTimeout Duration `json:"idle_timeout"`
|
|
MaxRequestDuration Duration `json:"max_request_duration"`
|
|
MaxBodyBytes int64 `json:"max_body_bytes"`
|
|
MetricsPublic bool `json:"metrics_public"`
|
|
TLSCert string `json:"tls_cert"`
|
|
TLSKey string `json:"tls_key"`
|
|
}
|
|
|
|
type AuthConfig struct {
|
|
OIDC OIDCConfig `json:"oidc"`
|
|
APIKeys []APIKeyConfig `json:"api_keys"`
|
|
IPBypass []IPBypassConfig `json:"ip_bypass"`
|
|
TrustedProxies []string `json:"trusted_proxies"`
|
|
IPBypassUseForwardedIP bool `json:"ip_bypass_use_forwarded_ip,omitempty"`
|
|
}
|
|
|
|
type OIDCConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Issuer string `json:"issuer"`
|
|
Audience string `json:"audience"`
|
|
TenantClaim string `json:"tenant_claim"`
|
|
ApplicationClaim string `json:"application_claim"`
|
|
GroupsClaim string `json:"groups_claim"`
|
|
AdminGroups []string `json:"admin_groups"`
|
|
ClockSkew Duration `json:"clock_skew"`
|
|
JWKSRefreshMinInterval Duration `json:"jwks_refresh_min_interval"`
|
|
AllowedAlgorithms []string `json:"allowed_algorithms"`
|
|
}
|
|
|
|
type APIKeyConfig struct {
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
Tenant string `json:"tenant"`
|
|
Subject string `json:"subject"`
|
|
Application string `json:"application"`
|
|
Scopes []string `json:"scopes"`
|
|
AllowedModels []string `json:"allowed_models,omitempty"`
|
|
DeniedModels []string `json:"denied_models,omitempty"`
|
|
ServiceClass string `json:"service_class,omitempty"`
|
|
}
|
|
|
|
type IPBypassConfig struct {
|
|
CIDRs []string `json:"cidrs"`
|
|
Tenant string `json:"tenant"`
|
|
Subject string `json:"subject"`
|
|
Application string `json:"application"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
type SchedulerConfig struct {
|
|
GlobalConcurrency int `json:"global_concurrency"`
|
|
MaxQueue int `json:"max_queue"`
|
|
MaxQueuePerActor int `json:"max_queue_per_actor"`
|
|
QueueTimeout Duration `json:"queue_timeout"`
|
|
DefaultTenantWeight float64 `json:"default_tenant_weight"`
|
|
DefaultActorWeight float64 `json:"default_actor_weight"`
|
|
Policies map[string]TenantPolicy `json:"policies"`
|
|
ComputePaths []string `json:"compute_paths"`
|
|
}
|
|
|
|
type TenantPolicy struct {
|
|
TenantWeight float64 `json:"tenant_weight"`
|
|
ActorWeight float64 `json:"actor_weight"`
|
|
ActorCreditsPerMinute float64 `json:"actor_credits_per_minute"`
|
|
ActorBurstCredits float64 `json:"actor_burst_credits"`
|
|
TenantCreditsPerMinute float64 `json:"tenant_credits_per_minute"`
|
|
TenantBurstCredits float64 `json:"tenant_burst_credits"`
|
|
}
|
|
|
|
type QuotaConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type CostConfig struct {
|
|
Default ModelRate `json:"default"`
|
|
Models map[string]ModelRate `json:"models"`
|
|
DefaultMaxOutputTokens int `json:"default_max_output_tokens"`
|
|
}
|
|
|
|
type ModelRate struct {
|
|
InputCreditsPer1K float64 `json:"input_credits_per_1k"`
|
|
CachedInputFactor float64 `json:"cached_input_factor"`
|
|
OutputCreditsPer1K float64 `json:"output_credits_per_1k"`
|
|
ComputeCreditsPerSecond float64 `json:"compute_credits_per_second"`
|
|
ExpectedPromptTokensPerSecond float64 `json:"expected_prompt_tokens_per_second"`
|
|
ExpectedOutputTokensPerSecond float64 `json:"expected_output_tokens_per_second"`
|
|
}
|
|
|
|
type WorkerConfig struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
ModelConcurrency map[string]int `json:"model_concurrency,omitempty"`
|
|
ContextLimits map[string]int64 `json:"context_limits,omitempty"`
|
|
DefaultContextTokens int64 `json:"default_context_tokens,omitempty"`
|
|
ModelPlacement ModelPlacementRule `json:"model_placement,omitempty"`
|
|
HealthInterval Duration `json:"health_interval"`
|
|
MemoryCapacityBytes int64 `json:"memory_capacity_bytes,omitempty"`
|
|
VRAMCapacityBytes int64 `json:"vram_capacity_bytes,omitempty"`
|
|
LocalSystemStats bool `json:"local_system_stats,omitempty"`
|
|
TelemetryURL string `json:"telemetry_url,omitempty"`
|
|
NVIDIASMI bool `json:"nvidia_smi,omitempty"`
|
|
NVIDIAGPU string `json:"nvidia_gpu,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
// ModelPlacementRule is a hard routing constraint applied before adaptive
|
|
// worker scoring. "allow_all" permits every model unless a deny rule matches;
|
|
// "whitelist" permits only models matched by allowed_models. Exact matches
|
|
// are more specific than prefix rules ("gemma4:*") and therefore can be used
|
|
// as exceptions to broader rules. At equal specificity, deny wins.
|
|
type ModelPlacementRule struct {
|
|
Mode string `json:"mode,omitempty"` // allow_all | whitelist
|
|
AllowedModels []string `json:"allowed_models,omitempty"`
|
|
DeniedModels []string `json:"denied_models,omitempty"`
|
|
}
|
|
|
|
type UsageConfig struct {
|
|
JournalDir string `json:"journal_dir"`
|
|
Buffer int `json:"buffer"`
|
|
FlushInterval Duration `json:"flush_interval"`
|
|
Retention UsageRetentionConfig `json:"retention"`
|
|
}
|
|
|
|
type UsageRetentionConfig struct {
|
|
DetailDays int `json:"detail_days"`
|
|
DailyDays int `json:"daily_days"`
|
|
MonthlyMonths int `json:"monthly_months"`
|
|
CompactionInterval Duration `json:"compaction_interval"`
|
|
}
|
|
|
|
type NativeConfig struct {
|
|
ManagementRequiresAdmin bool `json:"management_requires_admin"`
|
|
ControlWorker string `json:"control_worker"`
|
|
}
|
|
|
|
type UIConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
RecentEvents int `json:"recent_events"`
|
|
SessionSecret string `json:"session_secret"`
|
|
SecureCookies bool `json:"secure_cookies"`
|
|
OIDC UIOIDCConfig `json:"oidc"`
|
|
}
|
|
|
|
type UIOIDCConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"`
|
|
Scopes []string `json:"scopes"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// PublicDashboardConfig controls the optional unauthenticated, strictly
|
|
// read-only status dashboard. The public API is deliberately sanitized and
|
|
// never exposes tenant/actor/application identities, worker URLs, labels,
|
|
// error strings, API keys, policies, quotas or persistent-state paths.
|
|
type PublicDashboardConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
RefreshInterval Duration `json:"refresh_interval"`
|
|
MaxLiveRequests int `json:"max_live_requests"`
|
|
ShowWorkerNames bool `json:"show_worker_names"`
|
|
ShowModelNames bool `json:"show_model_names"`
|
|
ShowResourceMetrics bool `json:"show_resource_metrics"`
|
|
WorkerDisplayNames map[string]string `json:"worker_display_names,omitempty"`
|
|
}
|
|
|
|
type InfrastructureConfig struct {
|
|
NodeID string `json:"node_id"`
|
|
NodeName string `json:"node_name"`
|
|
RefreshInterval Duration `json:"refresh_interval"`
|
|
MaxRequests int `json:"max_requests"`
|
|
}
|
|
|
|
// ModelCapabilitiesConfig controls model metadata discovery through Ollama
|
|
// /api/show and request preflight. Unknown capability metadata is allowed by
|
|
// default so a temporarily unavailable metadata call never takes inference
|
|
// offline.
|
|
type ModelCapabilitiesConfig struct {
|
|
Mode string `json:"mode"` // enforce | observe | off
|
|
CacheTTL Duration `json:"cache_ttl"`
|
|
ContextGuard string `json:"context_guard"` // reject | warn | off
|
|
Context ContextPolicyConfig `json:"context"`
|
|
}
|
|
|
|
// ContextPolicyConfig controls how the gateway turns a model's theoretical
|
|
// context window into a safe, routable effective context window. The defaults
|
|
// intentionally favor predictable memory use on mixed/local Ollama fleets.
|
|
// Set max_requested_tokens to -1 only when an operator explicitly wants to
|
|
// remove the gateway-side cap. Set default_worker_tokens to -1 only when the
|
|
// gateway should fall back to the model maximum for unloaded models without a
|
|
// Modelfile num_ctx.
|
|
type ContextPolicyConfig struct {
|
|
MaxRequestedTokens int64 `json:"max_requested_tokens"`
|
|
DefaultWorkerTokens int64 `json:"default_worker_tokens"`
|
|
EstimationMarginPercent float64 `json:"estimation_margin_percent"`
|
|
VisionReserveTokensPerImage int64 `json:"vision_reserve_tokens_per_image"`
|
|
}
|
|
|
|
// RoutingConfig tunes the local worker scoring function. Scores are relative;
|
|
// lower is better. Throughput is learned from completed requests in memory.
|
|
|
|
// ReliabilityConfig controls worker circuit breaking and safe pre-stream retries.
|
|
// Retries are only attempted when the upstream request failed before any
|
|
// response headers/body were committed to the client.
|
|
type ReliabilityConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
FailureThreshold int `json:"failure_threshold"`
|
|
OpenDuration Duration `json:"open_duration"`
|
|
RetryAttempts int `json:"retry_attempts"`
|
|
RetryBackoff Duration `json:"retry_backoff"`
|
|
}
|
|
|
|
// ModelAliasConfig exposes a stable virtual model name to clients and resolves
|
|
// it to the first currently routable real model in Models.
|
|
type ModelAliasConfig struct {
|
|
Models []string `json:"models"`
|
|
RequiredCapabilities []string `json:"required_capabilities,omitempty"`
|
|
Visible *bool `json:"visible,omitempty"`
|
|
}
|
|
|
|
// ModelAccessRule controls which models an identity may request. Exact names
|
|
// and one trailing '*' wildcard are supported. At equal specificity deny wins.
|
|
type ModelAccessRule struct {
|
|
Mode string `json:"mode,omitempty"` // allow_all | whitelist
|
|
AllowedModels []string `json:"allowed_models,omitempty"`
|
|
DeniedModels []string `json:"denied_models,omitempty"`
|
|
}
|
|
|
|
type ModelAccessConfig struct {
|
|
Default ModelAccessRule `json:"default"`
|
|
Tenants map[string]ModelAccessRule `json:"tenants,omitempty"`
|
|
}
|
|
|
|
// ServiceClassConfig provides a request-level QoS hint inside the tenant fairness boundary.
|
|
type ServiceClassConfig struct {
|
|
Weight float64 `json:"weight"`
|
|
MaxQueueWait Duration `json:"max_queue_wait"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
}
|
|
|
|
type ServiceClassesConfig struct {
|
|
Default string `json:"default"`
|
|
Header string `json:"header"`
|
|
OverrideScope string `json:"override_scope"`
|
|
Classes map[string]ServiceClassConfig `json:"classes"`
|
|
}
|
|
|
|
// AutoTuningConfig controls the explicit admin-triggered benchmark workflow.
|
|
// Auto tuning never changes production concurrency unless Apply is called by an admin.
|
|
type AutoTuningConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
MaxConcurrency int `json:"max_concurrency"`
|
|
SamplesPerLevel int `json:"samples_per_level"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
Timeout Duration `json:"timeout"`
|
|
Prompt string `json:"prompt"`
|
|
TTFTWeight float64 `json:"ttft_weight"`
|
|
ThroughputWeight float64 `json:"throughput_weight"`
|
|
}
|
|
|
|
type BatchJobsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Retention Duration `json:"retention"`
|
|
MaxJobs int `json:"max_jobs"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
MaxInputBytes int64 `json:"max_input_bytes"`
|
|
}
|
|
|
|
type ConversationsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
EncryptionKey string `json:"encryption_key,omitempty"`
|
|
Retention Duration `json:"retention"`
|
|
MaxEntries int `json:"max_entries"`
|
|
MaxContentBytes int64 `json:"max_content_bytes"`
|
|
}
|
|
|
|
type OpenTelemetryConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Endpoint string `json:"endpoint"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
ServiceName string `json:"service_name"`
|
|
ServiceVersion string `json:"service_version,omitempty"`
|
|
SampleRatio float64 `json:"sample_ratio"`
|
|
BatchSize int `json:"batch_size"`
|
|
FlushInterval Duration `json:"flush_interval"`
|
|
CaptureContent bool `json:"capture_content"`
|
|
}
|
|
|
|
// WarmModelsConfig controls proactive model residency and idle unloading.
|
|
// Policies use exact model names or a single trailing '*' prefix wildcard.
|
|
type WarmModelsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
ReconcileInterval Duration `json:"reconcile_interval"`
|
|
OperationTimeout Duration `json:"operation_timeout"`
|
|
Policies map[string]WarmModelPolicy `json:"policies"`
|
|
}
|
|
|
|
type WarmModelPolicy struct {
|
|
Class string `json:"class"` // hot | warm | cold
|
|
Workers []string `json:"workers,omitempty"`
|
|
Replicas int `json:"replicas,omitempty"`
|
|
Preload bool `json:"preload,omitempty"`
|
|
IdleTimeout Duration `json:"idle_timeout,omitempty"`
|
|
}
|
|
|
|
// AlertsConfig evaluates bounded operational conditions and can deliver a
|
|
// signed generic webhook. Payloads never include prompts or model output.
|
|
type AlertsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
EvaluationInterval Duration `json:"evaluation_interval"`
|
|
Cooldown Duration `json:"cooldown"`
|
|
HistoryLimit int `json:"history_limit"`
|
|
WebhookTimeout Duration `json:"webhook_timeout"`
|
|
WebhookMaxConcurrent int `json:"webhook_max_concurrent"`
|
|
WebhookQueue int `json:"webhook_queue"`
|
|
WebhookRetryAttempts int `json:"webhook_retry_attempts"`
|
|
WebhookRetryBackoff Duration `json:"webhook_retry_backoff"`
|
|
Webhooks []WebhookConfig `json:"webhooks"`
|
|
Thresholds AlertThresholds `json:"thresholds"`
|
|
}
|
|
|
|
type WebhookConfig struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Secret string `json:"secret,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type AlertThresholds struct {
|
|
WorkerDownFor Duration `json:"worker_down_for"`
|
|
CircuitOpen bool `json:"circuit_open"`
|
|
QueueDepth int `json:"queue_depth"`
|
|
QueueWait Duration `json:"queue_wait"`
|
|
VRAMPercent float64 `json:"vram_percent"`
|
|
StorageBytes int64 `json:"storage_bytes"`
|
|
QuotaRemainingPct float64 `json:"quota_remaining_percent"`
|
|
OOM bool `json:"oom"`
|
|
}
|
|
|
|
// StorageConfig defines the local durable state directory. The gateway keeps
|
|
// active scheduling state in memory; only restart-worthy state is persisted.
|
|
type StorageConfig struct {
|
|
DataDir string `json:"data_dir"`
|
|
ConfigFile string `json:"config_file"`
|
|
APIKeysFile string `json:"api_keys_file"`
|
|
PoliciesFile string `json:"policies_file"`
|
|
MetricsFile string `json:"metrics_file"`
|
|
QuotaFile string `json:"quota_file"`
|
|
WorkerPerformanceFile string `json:"worker_performance_file"`
|
|
ModelPlacementFile string `json:"model_placement_file"`
|
|
WorkerStateFile string `json:"worker_state_file"`
|
|
AutoTuneFile string `json:"auto_tune_file"`
|
|
WarmModelsFile string `json:"warm_models_file"`
|
|
AlertsFile string `json:"alerts_file"`
|
|
ConversationsFile string `json:"conversations_file"`
|
|
BatchJobsFile string `json:"batch_jobs_file"`
|
|
BatchJobsDir string `json:"batch_jobs_dir"`
|
|
FlushInterval Duration `json:"flush_interval"`
|
|
}
|
|
|
|
type RoutingConfig struct {
|
|
LoadedBonus float64 `json:"loaded_bonus"`
|
|
InstalledBonus float64 `json:"installed_bonus"`
|
|
ThroughputBonus float64 `json:"throughput_bonus"`
|
|
VRAMPressurePenalty float64 `json:"vram_pressure_penalty"`
|
|
GPUUtilizationPenalty float64 `json:"gpu_utilization_penalty"`
|
|
AvoidVRAMPercent float64 `json:"avoid_vram_percent"`
|
|
}
|
|
|
|
func Load(path string) (*Config, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseBytes(b)
|
|
}
|
|
|
|
// ParseBytes parses a complete gateway configuration using the same strict
|
|
// validation used at process startup. Environment variables are expanded so
|
|
// persisted configurations can continue to reference deployment secrets.
|
|
func ParseBytes(b []byte) (*Config, error) {
|
|
b = []byte(os.ExpandEnv(string(b)))
|
|
var c Config
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&c); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
c.defaults()
|
|
if err := c.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (c *Config) defaults() {
|
|
if c.Server.Listen == "" {
|
|
c.Server.Listen = ":8080"
|
|
}
|
|
if c.Server.ReadHeaderTimeout == 0 {
|
|
c.Server.ReadHeaderTimeout = Duration(10 * time.Second)
|
|
}
|
|
if c.Server.IdleTimeout == 0 {
|
|
c.Server.IdleTimeout = Duration(2 * time.Minute)
|
|
}
|
|
if c.Server.MaxRequestDuration == 0 {
|
|
c.Server.MaxRequestDuration = Duration(30 * time.Minute)
|
|
}
|
|
if c.Server.MaxBodyBytes == 0 {
|
|
c.Server.MaxBodyBytes = 64 << 20
|
|
}
|
|
if c.Scheduler.GlobalConcurrency <= 0 {
|
|
for _, w := range c.Workers {
|
|
c.Scheduler.GlobalConcurrency += max(1, w.MaxConcurrent)
|
|
}
|
|
if c.Scheduler.GlobalConcurrency == 0 {
|
|
c.Scheduler.GlobalConcurrency = 1
|
|
}
|
|
}
|
|
if c.Scheduler.MaxQueue <= 0 {
|
|
c.Scheduler.MaxQueue = 1024
|
|
}
|
|
if c.Scheduler.MaxQueuePerActor <= 0 {
|
|
c.Scheduler.MaxQueuePerActor = 64
|
|
}
|
|
if c.Scheduler.QueueTimeout == 0 {
|
|
c.Scheduler.QueueTimeout = Duration(10 * time.Minute)
|
|
}
|
|
if c.Scheduler.DefaultTenantWeight <= 0 {
|
|
c.Scheduler.DefaultTenantWeight = 1
|
|
}
|
|
if c.Scheduler.DefaultActorWeight <= 0 {
|
|
c.Scheduler.DefaultActorWeight = 1
|
|
}
|
|
if len(c.Scheduler.ComputePaths) == 0 {
|
|
c.Scheduler.ComputePaths = []string{"/api/generate", "/api/chat", "/api/embed", "/api/embeddings", "/v1/chat/completions", "/v1/completions", "/v1/embeddings", "/v1/responses", "/v1/messages"}
|
|
}
|
|
if c.Cost.Default.InputCreditsPer1K <= 0 {
|
|
c.Cost.Default.InputCreditsPer1K = 1
|
|
}
|
|
if c.Cost.Default.OutputCreditsPer1K <= 0 {
|
|
c.Cost.Default.OutputCreditsPer1K = 3
|
|
}
|
|
if c.Cost.Default.CachedInputFactor <= 0 {
|
|
c.Cost.Default.CachedInputFactor = 1
|
|
}
|
|
if c.Cost.DefaultMaxOutputTokens <= 0 {
|
|
c.Cost.DefaultMaxOutputTokens = 1024
|
|
}
|
|
if c.Storage.DataDir == "" {
|
|
c.Storage.DataDir = "./data"
|
|
}
|
|
if c.Storage.ConfigFile == "" {
|
|
c.Storage.ConfigFile = "gateway-config.json"
|
|
}
|
|
if c.Storage.APIKeysFile == "" {
|
|
c.Storage.APIKeysFile = "api-keys.json"
|
|
}
|
|
if c.Storage.PoliciesFile == "" {
|
|
c.Storage.PoliciesFile = "policies.json"
|
|
}
|
|
if c.Storage.MetricsFile == "" {
|
|
c.Storage.MetricsFile = "metrics.json"
|
|
}
|
|
if c.Storage.QuotaFile == "" {
|
|
c.Storage.QuotaFile = "quota.json"
|
|
}
|
|
if c.Storage.WorkerPerformanceFile == "" {
|
|
c.Storage.WorkerPerformanceFile = "worker-performance.json"
|
|
}
|
|
if c.Storage.ModelPlacementFile == "" {
|
|
c.Storage.ModelPlacementFile = "model-placement.json"
|
|
}
|
|
if c.Storage.WorkerStateFile == "" {
|
|
c.Storage.WorkerStateFile = "worker-state.json"
|
|
}
|
|
if c.Storage.AutoTuneFile == "" {
|
|
c.Storage.AutoTuneFile = "auto-tune.json"
|
|
}
|
|
if c.Storage.WarmModelsFile == "" {
|
|
c.Storage.WarmModelsFile = "warm-models.json"
|
|
}
|
|
if c.Storage.AlertsFile == "" {
|
|
c.Storage.AlertsFile = "alerts.json"
|
|
}
|
|
if c.Storage.ConversationsFile == "" {
|
|
c.Storage.ConversationsFile = "conversations.enc.json"
|
|
}
|
|
if c.Storage.BatchJobsFile == "" {
|
|
c.Storage.BatchJobsFile = "batch-jobs.json"
|
|
}
|
|
if c.Storage.BatchJobsDir == "" {
|
|
c.Storage.BatchJobsDir = "batch"
|
|
}
|
|
if c.Storage.FlushInterval == 0 {
|
|
c.Storage.FlushInterval = Duration(10 * time.Second)
|
|
}
|
|
if c.Usage.JournalDir == "" {
|
|
c.Usage.JournalDir = filepath.Join(c.Storage.DataDir, "usage")
|
|
}
|
|
if c.Usage.Buffer <= 0 {
|
|
c.Usage.Buffer = 8192
|
|
}
|
|
if c.Usage.FlushInterval == 0 {
|
|
c.Usage.FlushInterval = Duration(time.Second)
|
|
}
|
|
if c.Usage.Retention.DetailDays <= 0 {
|
|
c.Usage.Retention.DetailDays = 30
|
|
}
|
|
if c.Usage.Retention.DailyDays <= 0 {
|
|
c.Usage.Retention.DailyDays = 400
|
|
}
|
|
if c.Usage.Retention.CompactionInterval == 0 {
|
|
c.Usage.Retention.CompactionInterval = Duration(6 * time.Hour)
|
|
}
|
|
if c.Auth.OIDC.ClockSkew == 0 {
|
|
c.Auth.OIDC.ClockSkew = Duration(60 * time.Second)
|
|
}
|
|
if c.Auth.OIDC.JWKSRefreshMinInterval == 0 {
|
|
c.Auth.OIDC.JWKSRefreshMinInterval = Duration(10 * time.Second)
|
|
}
|
|
if c.UI.Path == "" {
|
|
c.UI.Path = "/admin"
|
|
}
|
|
if !strings.HasPrefix(c.UI.Path, "/") {
|
|
c.UI.Path = "/" + c.UI.Path
|
|
}
|
|
c.UI.Path = strings.TrimRight(c.UI.Path, "/")
|
|
if c.UI.Title == "" {
|
|
c.UI.Title = "Ollama Fair Gateway"
|
|
}
|
|
if c.UI.RecentEvents <= 0 {
|
|
c.UI.RecentEvents = 10000
|
|
}
|
|
if c.UI.OIDC.Enabled && len(c.UI.OIDC.Scopes) == 0 {
|
|
c.UI.OIDC.Scopes = []string{"openid", "profile", "email"}
|
|
}
|
|
if c.PublicDashboard.Path == "" {
|
|
c.PublicDashboard.Path = "/status"
|
|
}
|
|
if !strings.HasPrefix(c.PublicDashboard.Path, "/") {
|
|
c.PublicDashboard.Path = "/" + c.PublicDashboard.Path
|
|
}
|
|
c.PublicDashboard.Path = strings.TrimRight(c.PublicDashboard.Path, "/")
|
|
if c.PublicDashboard.Title == "" {
|
|
c.PublicDashboard.Title = c.UI.Title
|
|
if c.PublicDashboard.Title == "" {
|
|
c.PublicDashboard.Title = "Ollama Gateway Status"
|
|
}
|
|
}
|
|
if c.PublicDashboard.Subtitle == "" {
|
|
c.PublicDashboard.Subtitle = "Live-Auslastung und Infrastruktur"
|
|
}
|
|
if c.PublicDashboard.RefreshInterval == 0 {
|
|
c.PublicDashboard.RefreshInterval = Duration(2 * time.Second)
|
|
}
|
|
if c.PublicDashboard.MaxLiveRequests <= 0 {
|
|
c.PublicDashboard.MaxLiveRequests = 64
|
|
}
|
|
if c.Infrastructure.RefreshInterval == 0 {
|
|
c.Infrastructure.RefreshInterval = Duration(250 * time.Millisecond)
|
|
}
|
|
if c.Infrastructure.MaxRequests <= 0 {
|
|
c.Infrastructure.MaxRequests = 256
|
|
}
|
|
if len(c.Auth.OIDC.AllowedAlgorithms) == 0 {
|
|
c.Auth.OIDC.AllowedAlgorithms = []string{"RS256", "PS256", "ES256", "EdDSA"}
|
|
}
|
|
if c.ModelCapabilities.Mode == "" {
|
|
c.ModelCapabilities.Mode = "enforce"
|
|
}
|
|
if c.ModelCapabilities.CacheTTL == 0 {
|
|
c.ModelCapabilities.CacheTTL = Duration(10 * time.Minute)
|
|
}
|
|
if c.ModelCapabilities.ContextGuard == "" {
|
|
c.ModelCapabilities.ContextGuard = "reject"
|
|
}
|
|
if c.ModelCapabilities.Context.MaxRequestedTokens == 0 {
|
|
c.ModelCapabilities.Context.MaxRequestedTokens = 32768
|
|
}
|
|
if c.ModelCapabilities.Context.DefaultWorkerTokens == 0 {
|
|
c.ModelCapabilities.Context.DefaultWorkerTokens = 4096
|
|
}
|
|
if c.ModelCapabilities.Context.EstimationMarginPercent == 0 {
|
|
c.ModelCapabilities.Context.EstimationMarginPercent = 15
|
|
}
|
|
if c.ModelCapabilities.Context.VisionReserveTokensPerImage == 0 {
|
|
c.ModelCapabilities.Context.VisionReserveTokensPerImage = 2048
|
|
}
|
|
if c.Routing.LoadedBonus == 0 {
|
|
c.Routing.LoadedBonus = 60
|
|
}
|
|
if c.Routing.InstalledBonus == 0 {
|
|
c.Routing.InstalledBonus = 30
|
|
}
|
|
if c.Routing.ThroughputBonus == 0 {
|
|
c.Routing.ThroughputBonus = 20
|
|
}
|
|
if c.Routing.VRAMPressurePenalty == 0 {
|
|
c.Routing.VRAMPressurePenalty = 35
|
|
}
|
|
if c.Routing.GPUUtilizationPenalty == 0 {
|
|
c.Routing.GPUUtilizationPenalty = 10
|
|
}
|
|
if c.Routing.AvoidVRAMPercent == 0 {
|
|
c.Routing.AvoidVRAMPercent = 97
|
|
}
|
|
if c.Reliability.FailureThreshold <= 0 {
|
|
c.Reliability.FailureThreshold = 3
|
|
}
|
|
if c.Reliability.OpenDuration == 0 {
|
|
c.Reliability.OpenDuration = Duration(30 * time.Second)
|
|
}
|
|
if c.Reliability.RetryAttempts <= 0 {
|
|
c.Reliability.RetryAttempts = 2
|
|
}
|
|
if c.Reliability.RetryBackoff == 0 {
|
|
c.Reliability.RetryBackoff = Duration(50 * time.Millisecond)
|
|
}
|
|
if c.ServiceClasses.Default == "" {
|
|
c.ServiceClasses.Default = "interactive"
|
|
}
|
|
if c.ServiceClasses.Header == "" {
|
|
c.ServiceClasses.Header = "X-Gateway-Service-Class"
|
|
}
|
|
if c.ServiceClasses.OverrideScope == "" {
|
|
c.ServiceClasses.OverrideScope = "gateway:service-class"
|
|
}
|
|
if len(c.ServiceClasses.Classes) == 0 {
|
|
c.ServiceClasses.Classes = map[string]ServiceClassConfig{
|
|
"interactive": {Weight: 4, MaxQueueWait: Duration(30 * time.Second)},
|
|
"system": {Weight: 8, MaxQueueWait: Duration(30 * time.Second), MaxConcurrent: 1},
|
|
"background": {Weight: 1, MaxQueueWait: Duration(10 * time.Minute), MaxConcurrent: 1},
|
|
"batch": {Weight: .5, MaxQueueWait: Duration(30 * time.Minute), MaxConcurrent: 1},
|
|
}
|
|
}
|
|
for name, sc := range c.ServiceClasses.Classes {
|
|
if sc.Weight <= 0 {
|
|
sc.Weight = 1
|
|
}
|
|
if sc.MaxQueueWait == 0 {
|
|
sc.MaxQueueWait = c.Scheduler.QueueTimeout
|
|
}
|
|
c.ServiceClasses.Classes[name] = sc
|
|
}
|
|
if c.AutoTuning.MaxConcurrency <= 0 {
|
|
c.AutoTuning.MaxConcurrency = 4
|
|
}
|
|
if c.AutoTuning.SamplesPerLevel <= 0 {
|
|
c.AutoTuning.SamplesPerLevel = 2
|
|
}
|
|
if c.AutoTuning.MaxTokens <= 0 {
|
|
c.AutoTuning.MaxTokens = 96
|
|
}
|
|
if c.AutoTuning.Timeout == 0 {
|
|
c.AutoTuning.Timeout = Duration(10 * time.Minute)
|
|
}
|
|
if c.AutoTuning.Prompt == "" {
|
|
c.AutoTuning.Prompt = "Write a concise explanation of why deterministic benchmarking matters for local LLM serving."
|
|
}
|
|
if c.AutoTuning.TTFTWeight <= 0 {
|
|
c.AutoTuning.TTFTWeight = .25
|
|
}
|
|
if c.AutoTuning.ThroughputWeight <= 0 {
|
|
c.AutoTuning.ThroughputWeight = 1
|
|
}
|
|
if c.OpenTelemetry.ServiceName == "" {
|
|
c.OpenTelemetry.ServiceName = "ollama-fair-gateway"
|
|
}
|
|
if c.OpenTelemetry.SampleRatio <= 0 || c.OpenTelemetry.SampleRatio > 1 {
|
|
c.OpenTelemetry.SampleRatio = 1
|
|
}
|
|
if c.OpenTelemetry.BatchSize <= 0 {
|
|
c.OpenTelemetry.BatchSize = 128
|
|
}
|
|
if c.OpenTelemetry.FlushInterval == 0 {
|
|
c.OpenTelemetry.FlushInterval = Duration(2 * time.Second)
|
|
}
|
|
if c.WarmModels.ReconcileInterval == 0 {
|
|
c.WarmModels.ReconcileInterval = Duration(30 * time.Second)
|
|
}
|
|
if c.WarmModels.OperationTimeout == 0 {
|
|
c.WarmModels.OperationTimeout = Duration(2 * time.Minute)
|
|
}
|
|
for pattern, wp := range c.WarmModels.Policies {
|
|
if wp.Class == "" {
|
|
wp.Class = "warm"
|
|
}
|
|
if wp.Replicas <= 0 {
|
|
wp.Replicas = 1
|
|
}
|
|
if wp.IdleTimeout == 0 {
|
|
if wp.Class == "cold" {
|
|
wp.IdleTimeout = Duration(5 * time.Minute)
|
|
} else {
|
|
wp.IdleTimeout = Duration(30 * time.Minute)
|
|
}
|
|
}
|
|
c.WarmModels.Policies[pattern] = wp
|
|
}
|
|
if c.BatchJobs.Retention == 0 {
|
|
c.BatchJobs.Retention = Duration(7 * 24 * time.Hour)
|
|
}
|
|
if c.BatchJobs.MaxJobs <= 0 {
|
|
c.BatchJobs.MaxJobs = 1000
|
|
}
|
|
if c.BatchJobs.MaxConcurrent <= 0 {
|
|
c.BatchJobs.MaxConcurrent = 1
|
|
}
|
|
if c.BatchJobs.MaxInputBytes <= 0 {
|
|
c.BatchJobs.MaxInputBytes = 16 << 20
|
|
}
|
|
if c.Conversations.Retention == 0 {
|
|
c.Conversations.Retention = Duration(24 * time.Hour)
|
|
}
|
|
if c.Conversations.MaxEntries <= 0 {
|
|
c.Conversations.MaxEntries = 1000
|
|
}
|
|
if c.Conversations.MaxContentBytes <= 0 {
|
|
c.Conversations.MaxContentBytes = 2 << 20
|
|
}
|
|
if c.Alerts.EvaluationInterval == 0 {
|
|
c.Alerts.EvaluationInterval = Duration(15 * time.Second)
|
|
}
|
|
if c.Alerts.Cooldown == 0 {
|
|
c.Alerts.Cooldown = Duration(5 * time.Minute)
|
|
}
|
|
if c.Alerts.HistoryLimit <= 0 {
|
|
c.Alerts.HistoryLimit = 500
|
|
}
|
|
if c.Alerts.WebhookTimeout == 0 {
|
|
c.Alerts.WebhookTimeout = Duration(5 * time.Second)
|
|
}
|
|
if c.Alerts.WebhookMaxConcurrent <= 0 {
|
|
c.Alerts.WebhookMaxConcurrent = 4
|
|
}
|
|
if c.Alerts.WebhookQueue <= 0 {
|
|
c.Alerts.WebhookQueue = 1024
|
|
}
|
|
if c.Alerts.WebhookRetryAttempts <= 0 {
|
|
c.Alerts.WebhookRetryAttempts = 3
|
|
}
|
|
if c.Alerts.WebhookRetryBackoff == 0 {
|
|
c.Alerts.WebhookRetryBackoff = Duration(500 * time.Millisecond)
|
|
}
|
|
if c.Alerts.Thresholds.WorkerDownFor == 0 {
|
|
c.Alerts.Thresholds.WorkerDownFor = Duration(30 * time.Second)
|
|
}
|
|
if c.Alerts.Thresholds.VRAMPercent == 0 {
|
|
c.Alerts.Thresholds.VRAMPercent = 95
|
|
}
|
|
if c.Alerts.Thresholds.QuotaRemainingPct == 0 {
|
|
c.Alerts.Thresholds.QuotaRemainingPct = 10
|
|
}
|
|
if c.ModelAccess.Default.Mode == "" {
|
|
c.ModelAccess.Default.Mode = "allow_all"
|
|
}
|
|
for i := range c.Workers {
|
|
if c.Workers[i].Name == "" {
|
|
c.Workers[i].Name = fmt.Sprintf("worker-%d", i+1)
|
|
}
|
|
if c.Workers[i].MaxConcurrent <= 0 {
|
|
c.Workers[i].MaxConcurrent = 1
|
|
}
|
|
if c.Workers[i].HealthInterval == 0 {
|
|
c.Workers[i].HealthInterval = Duration(5 * time.Second)
|
|
}
|
|
if c.Workers[i].ModelPlacement.Mode == "" {
|
|
c.Workers[i].ModelPlacement.Mode = "allow_all"
|
|
}
|
|
}
|
|
}
|
|
|
|
func pathPrefixesOverlap(a, b string) bool {
|
|
a = strings.TrimRight(a, "/")
|
|
b = strings.TrimRight(b, "/")
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
|
}
|
|
|
|
func (c *Config) Validate() error {
|
|
if len(c.Workers) == 0 {
|
|
return errors.New("at least one worker is required")
|
|
}
|
|
names := map[string]bool{}
|
|
for _, w := range c.Workers {
|
|
if w.URL == "" {
|
|
return fmt.Errorf("worker %q has empty url", w.Name)
|
|
}
|
|
if names[w.Name] {
|
|
return fmt.Errorf("duplicate worker name %q", w.Name)
|
|
}
|
|
names[w.Name] = true
|
|
}
|
|
if c.Auth.OIDC.Enabled && (c.Auth.OIDC.Issuer == "" || c.Auth.OIDC.Audience == "") {
|
|
return errors.New("auth.oidc.issuer and auth.oidc.audience are required when OIDC is enabled")
|
|
}
|
|
if !c.Auth.OIDC.Enabled && len(c.Auth.APIKeys) == 0 && len(c.Auth.IPBypass) == 0 {
|
|
return errors.New("no authentication method configured")
|
|
}
|
|
for _, p := range c.Auth.TrustedProxies {
|
|
if _, _, err := net.ParseCIDR(p); err != nil {
|
|
return fmt.Errorf("invalid trusted proxy CIDR %q: %w", p, err)
|
|
}
|
|
}
|
|
for _, b := range c.Auth.IPBypass {
|
|
if b.Tenant == "" {
|
|
return errors.New("ip_bypass tenant must not be empty")
|
|
}
|
|
for _, s := range b.CIDRs {
|
|
if _, _, err := net.ParseCIDR(s); err != nil {
|
|
return fmt.Errorf("invalid bypass CIDR %q: %w", s, err)
|
|
}
|
|
}
|
|
}
|
|
if c.Infrastructure.RefreshInterval.Value() < 50*time.Millisecond {
|
|
return errors.New("infrastructure.refresh_interval must be at least 50ms")
|
|
}
|
|
if c.PublicDashboard.Path == "" || c.PublicDashboard.Path == "/" {
|
|
return errors.New("public_dashboard.path must be a non-root path")
|
|
}
|
|
if c.PublicDashboard.Enabled {
|
|
if pathPrefixesOverlap(c.PublicDashboard.Path, c.UI.Path) {
|
|
return errors.New("public_dashboard.path must not overlap ui.path")
|
|
}
|
|
for _, reserved := range []string{"/healthz", "/readyz", "/metrics", "/api", "/v1", "/gateway"} {
|
|
if pathPrefixesOverlap(c.PublicDashboard.Path, reserved) {
|
|
return errors.New("public_dashboard.path conflicts with a reserved gateway path")
|
|
}
|
|
}
|
|
if c.PublicDashboard.RefreshInterval.Value() < time.Second {
|
|
return errors.New("public_dashboard.refresh_interval must be at least 1s")
|
|
}
|
|
if c.PublicDashboard.MaxLiveRequests < 1 || c.PublicDashboard.MaxLiveRequests > 512 {
|
|
return errors.New("public_dashboard.max_live_requests must be between 1 and 512")
|
|
}
|
|
}
|
|
switch c.ModelCapabilities.Mode {
|
|
case "enforce", "observe", "off":
|
|
default:
|
|
return errors.New("model_capabilities.mode must be enforce, observe, or off")
|
|
}
|
|
switch c.ModelCapabilities.ContextGuard {
|
|
case "reject", "warn", "off":
|
|
default:
|
|
return errors.New("model_capabilities.context_guard must be reject, warn, or off")
|
|
}
|
|
if c.ModelCapabilities.CacheTTL.Value() < time.Second {
|
|
return errors.New("model_capabilities.cache_ttl must be at least 1s")
|
|
}
|
|
if c.ModelCapabilities.Context.MaxRequestedTokens < -1 {
|
|
return errors.New("model_capabilities.context.max_requested_tokens must be -1 or >= 1")
|
|
}
|
|
if c.ModelCapabilities.Context.DefaultWorkerTokens < -1 {
|
|
return errors.New("model_capabilities.context.default_worker_tokens must be -1 or >= 1")
|
|
}
|
|
if c.ModelCapabilities.Context.EstimationMarginPercent < 0 || c.ModelCapabilities.Context.EstimationMarginPercent > 100 {
|
|
return errors.New("model_capabilities.context.estimation_margin_percent must be between 0 and 100")
|
|
}
|
|
if c.ModelCapabilities.Context.VisionReserveTokensPerImage < 0 {
|
|
return errors.New("model_capabilities.context.vision_reserve_tokens_per_image must be >= 0")
|
|
}
|
|
for _, w := range c.Workers {
|
|
if w.DefaultContextTokens < 0 {
|
|
return fmt.Errorf("worker %s default_context_tokens must be >= 0", w.Name)
|
|
}
|
|
for pattern, limit := range w.ContextLimits {
|
|
if strings.TrimSpace(pattern) == "" || limit <= 0 {
|
|
return fmt.Errorf("worker %s context_limits entries require a non-empty pattern and positive token limit", w.Name)
|
|
}
|
|
}
|
|
}
|
|
if c.Routing.AvoidVRAMPercent < 0 || c.Routing.AvoidVRAMPercent > 100 {
|
|
return errors.New("routing.avoid_vram_percent must be between 0 and 100")
|
|
}
|
|
if strings.TrimSpace(c.Storage.DataDir) == "" {
|
|
return errors.New("storage.data_dir must not be empty")
|
|
}
|
|
for name, file := range map[string]string{
|
|
"config_file": c.Storage.ConfigFile, "api_keys_file": c.Storage.APIKeysFile,
|
|
"policies_file": c.Storage.PoliciesFile, "metrics_file": c.Storage.MetricsFile, "quota_file": c.Storage.QuotaFile,
|
|
"worker_performance_file": c.Storage.WorkerPerformanceFile, "model_placement_file": c.Storage.ModelPlacementFile, "worker_state_file": c.Storage.WorkerStateFile, "auto_tune_file": c.Storage.AutoTuneFile, "warm_models_file": c.Storage.WarmModelsFile, "alerts_file": c.Storage.AlertsFile, "conversations_file": c.Storage.ConversationsFile, "batch_jobs_file": c.Storage.BatchJobsFile, "batch_jobs_dir": c.Storage.BatchJobsDir,
|
|
} {
|
|
if file == "" || filepath.IsAbs(file) || filepath.Base(file) != file || file == "." || file == ".." {
|
|
return fmt.Errorf("storage.%s must be a simple relative filename", name)
|
|
}
|
|
}
|
|
if c.Storage.FlushInterval.Value() < time.Second {
|
|
return errors.New("storage.flush_interval must be at least 1s")
|
|
}
|
|
if c.Usage.Retention.DetailDays < 1 {
|
|
return errors.New("usage.retention.detail_days must be at least 1")
|
|
}
|
|
if c.Usage.Retention.DailyDays < c.Usage.Retention.DetailDays {
|
|
return errors.New("usage.retention.daily_days must be >= detail_days")
|
|
}
|
|
if c.Usage.Retention.MonthlyMonths < 0 {
|
|
return errors.New("usage.retention.monthly_months must be >= 0 (0 keeps monthly rollups forever)")
|
|
}
|
|
if c.Usage.Retention.CompactionInterval.Value() < time.Minute {
|
|
return errors.New("usage.retention.compaction_interval must be at least 1m")
|
|
}
|
|
if c.Reliability.FailureThreshold < 1 {
|
|
return errors.New("reliability.failure_threshold must be >= 1")
|
|
}
|
|
if c.Reliability.OpenDuration.Value() < time.Second {
|
|
return errors.New("reliability.open_duration must be at least 1s")
|
|
}
|
|
if c.Reliability.RetryAttempts < 1 || c.Reliability.RetryAttempts > 5 {
|
|
return errors.New("reliability.retry_attempts must be between 1 and 5")
|
|
}
|
|
if c.Reliability.RetryBackoff.Value() < 0 {
|
|
return errors.New("reliability.retry_backoff must be >= 0")
|
|
}
|
|
if err := ValidateModelAccessRule(c.ModelAccess.Default); err != nil {
|
|
return fmt.Errorf("model_access.default: %w", err)
|
|
}
|
|
for tenant, rule := range c.ModelAccess.Tenants {
|
|
if strings.TrimSpace(tenant) == "" {
|
|
return errors.New("model_access.tenants contains empty tenant")
|
|
}
|
|
if err := ValidateModelAccessRule(rule); err != nil {
|
|
return fmt.Errorf("model_access.tenants[%q]: %w", tenant, err)
|
|
}
|
|
}
|
|
if _, ok := c.ServiceClasses.Classes[c.ServiceClasses.Default]; !ok {
|
|
return fmt.Errorf("service_classes.default %q is not defined", c.ServiceClasses.Default)
|
|
}
|
|
for name, sc := range c.ServiceClasses.Classes {
|
|
if strings.TrimSpace(name) == "" || sc.Weight <= 0 || sc.MaxConcurrent < 0 || sc.MaxQueueWait.Value() < 0 {
|
|
return fmt.Errorf("invalid service class %q", name)
|
|
}
|
|
}
|
|
for _, k := range c.Auth.APIKeys {
|
|
if cls := strings.TrimSpace(k.ServiceClass); cls != "" {
|
|
if _, ok := c.ServiceClasses.Classes[cls]; !ok {
|
|
return fmt.Errorf("auth.api_keys[%q].service_class %q is not defined", k.Name, cls)
|
|
}
|
|
}
|
|
}
|
|
if c.AutoTuning.MaxConcurrency < 1 || c.AutoTuning.MaxConcurrency > 32 || c.AutoTuning.SamplesPerLevel < 1 || c.AutoTuning.SamplesPerLevel > 20 || c.AutoTuning.MaxTokens < 1 {
|
|
return errors.New("auto_tuning limits are invalid")
|
|
}
|
|
if c.BatchJobs.Enabled {
|
|
if c.BatchJobs.Retention.Value() < time.Minute {
|
|
return errors.New("batch_jobs.retention must be at least 1m")
|
|
}
|
|
if c.BatchJobs.MaxJobs < 1 || c.BatchJobs.MaxJobs > 1000000 {
|
|
return errors.New("batch_jobs.max_jobs must be between 1 and 1000000")
|
|
}
|
|
if c.BatchJobs.MaxConcurrent < 1 || c.BatchJobs.MaxConcurrent > 32 {
|
|
return errors.New("batch_jobs.max_concurrent must be between 1 and 32")
|
|
}
|
|
if c.BatchJobs.MaxInputBytes < 1024 || c.BatchJobs.MaxInputBytes > 64<<20 {
|
|
return errors.New("batch_jobs.max_input_bytes must be between 1KiB and 64MiB")
|
|
}
|
|
if c.Server.MaxBodyBytes > 0 && c.BatchJobs.MaxInputBytes > c.Server.MaxBodyBytes {
|
|
return errors.New("batch_jobs.max_input_bytes must be <= server.max_body_bytes")
|
|
}
|
|
if _, ok := c.ServiceClasses.Classes["batch"]; len(c.ServiceClasses.Classes) > 0 && !ok {
|
|
return errors.New("batch_jobs.enabled requires service_classes.classes.batch")
|
|
}
|
|
}
|
|
if c.Conversations.Enabled {
|
|
if len(c.Conversations.EncryptionKey) < 32 {
|
|
return errors.New("conversations.encryption_key must contain at least 32 characters when conversations are enabled")
|
|
}
|
|
if c.Conversations.Retention.Value() < time.Minute {
|
|
return errors.New("conversations.retention must be at least 1m")
|
|
}
|
|
if c.Conversations.MaxEntries < 1 || c.Conversations.MaxEntries > 1000000 {
|
|
return errors.New("conversations.max_entries must be between 1 and 1000000")
|
|
}
|
|
if c.Conversations.MaxContentBytes < 1024 || c.Conversations.MaxContentBytes > 64<<20 {
|
|
return errors.New("conversations.max_content_bytes must be between 1KiB and 64MiB")
|
|
}
|
|
}
|
|
if c.OpenTelemetry.Enabled && strings.TrimSpace(c.OpenTelemetry.Endpoint) == "" {
|
|
return errors.New("opentelemetry.endpoint is required when enabled")
|
|
}
|
|
if c.WarmModels.ReconcileInterval.Value() < time.Second {
|
|
return errors.New("warm_models.reconcile_interval must be at least 1s")
|
|
}
|
|
if c.WarmModels.OperationTimeout.Value() < time.Second {
|
|
return errors.New("warm_models.operation_timeout must be at least 1s")
|
|
}
|
|
for pattern, wp := range c.WarmModels.Policies {
|
|
if err := validateSimpleModelPattern(pattern); err != nil {
|
|
return fmt.Errorf("warm_models.policies[%q]: %w", pattern, err)
|
|
}
|
|
switch wp.Class {
|
|
case "hot", "warm", "cold":
|
|
default:
|
|
return fmt.Errorf("warm_models.policies[%q].class must be hot, warm, or cold", pattern)
|
|
}
|
|
if wp.Replicas < 1 {
|
|
return fmt.Errorf("warm_models.policies[%q].replicas must be >= 1", pattern)
|
|
}
|
|
if wp.IdleTimeout.Value() < 0 {
|
|
return fmt.Errorf("warm_models.policies[%q].idle_timeout must be >= 0", pattern)
|
|
}
|
|
for _, wn := range wp.Workers {
|
|
if !names[wn] {
|
|
return fmt.Errorf("warm_models.policies[%q] references unknown worker %q", pattern, wn)
|
|
}
|
|
}
|
|
}
|
|
if c.Alerts.EvaluationInterval.Value() < time.Second {
|
|
return errors.New("alerts.evaluation_interval must be at least 1s")
|
|
}
|
|
if c.Alerts.Cooldown.Value() < 0 {
|
|
return errors.New("alerts.cooldown must be >= 0")
|
|
}
|
|
if c.Alerts.HistoryLimit < 1 || c.Alerts.HistoryLimit > 10000 {
|
|
return errors.New("alerts.history_limit must be between 1 and 10000")
|
|
}
|
|
if c.Alerts.WebhookTimeout.Value() < time.Second || c.Alerts.WebhookTimeout.Value() > 2*time.Minute {
|
|
return errors.New("alerts.webhook_timeout must be between 1s and 2m")
|
|
}
|
|
if c.Alerts.WebhookMaxConcurrent < 1 || c.Alerts.WebhookMaxConcurrent > 64 {
|
|
return errors.New("alerts.webhook_max_concurrent must be between 1 and 64")
|
|
}
|
|
if c.Alerts.WebhookQueue < 1 || c.Alerts.WebhookQueue > 100000 {
|
|
return errors.New("alerts.webhook_queue must be between 1 and 100000")
|
|
}
|
|
if c.Alerts.WebhookRetryAttempts < 1 || c.Alerts.WebhookRetryAttempts > 10 {
|
|
return errors.New("alerts.webhook_retry_attempts must be between 1 and 10")
|
|
}
|
|
if c.Alerts.WebhookRetryBackoff.Value() < 0 || c.Alerts.WebhookRetryBackoff.Value() > time.Minute {
|
|
return errors.New("alerts.webhook_retry_backoff must be between 0 and 1m")
|
|
}
|
|
if c.Alerts.Thresholds.QueueWait.Value() < 0 {
|
|
return errors.New("alerts.thresholds.queue_wait must be >= 0")
|
|
}
|
|
if c.Alerts.Thresholds.VRAMPercent < 0 || c.Alerts.Thresholds.VRAMPercent > 100 {
|
|
return errors.New("alerts.thresholds.vram_percent must be between 0 and 100")
|
|
}
|
|
if c.Alerts.Thresholds.QuotaRemainingPct < 0 || c.Alerts.Thresholds.QuotaRemainingPct > 100 {
|
|
return errors.New("alerts.thresholds.quota_remaining_percent must be between 0 and 100")
|
|
}
|
|
for i, wh := range c.Alerts.Webhooks {
|
|
if !wh.Enabled {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(wh.URL) == "" {
|
|
return fmt.Errorf("alerts.webhooks[%d].url is required when enabled", i)
|
|
}
|
|
if !(strings.HasPrefix(wh.URL, "http://") || strings.HasPrefix(wh.URL, "https://")) {
|
|
return fmt.Errorf("alerts.webhooks[%d].url must be http(s)", i)
|
|
}
|
|
}
|
|
for alias, a := range c.ModelAliases {
|
|
if strings.TrimSpace(alias) == "" {
|
|
return errors.New("model_aliases contains empty alias")
|
|
}
|
|
if len(a.Models) == 0 {
|
|
return fmt.Errorf("model_aliases[%q] requires at least one model", alias)
|
|
}
|
|
for _, m := range a.Models {
|
|
if strings.TrimSpace(m) == "" {
|
|
return fmt.Errorf("model_aliases[%q] contains empty model", alias)
|
|
}
|
|
}
|
|
}
|
|
for _, k := range c.Auth.APIKeys {
|
|
if err := ValidateModelAccessRule(ModelAccessRule{Mode: "allow_all", AllowedModels: k.AllowedModels, DeniedModels: k.DeniedModels}); err != nil {
|
|
return fmt.Errorf("api key %q model ACL: %w", k.Name, err)
|
|
}
|
|
}
|
|
for _, w := range c.Workers {
|
|
for pattern, limit := range w.ModelConcurrency {
|
|
if strings.TrimSpace(pattern) == "" || limit <= 0 {
|
|
return fmt.Errorf("worker %q model_concurrency entries require a non-empty pattern and limit > 0", w.Name)
|
|
}
|
|
}
|
|
if err := ValidateModelPlacementRule(w.ModelPlacement); err != nil {
|
|
return fmt.Errorf("worker %q model_placement: %w", w.Name, err)
|
|
}
|
|
}
|
|
if c.UI.Enabled {
|
|
if c.UI.Path == "" || c.UI.Path == "/" {
|
|
return errors.New("ui.path must be a non-root path")
|
|
}
|
|
if c.UI.OIDC.Enabled {
|
|
if !c.Auth.OIDC.Enabled {
|
|
return errors.New("ui.oidc.enabled requires auth.oidc.enabled")
|
|
}
|
|
if c.UI.OIDC.ClientID == "" {
|
|
return errors.New("ui.oidc.client_id is required when browser OIDC login is enabled")
|
|
}
|
|
if len(c.UI.SessionSecret) < 32 {
|
|
return errors.New("ui.session_secret must contain at least 32 characters when browser OIDC login is enabled")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateWarmModelPolicies(policies map[string]WarmModelPolicy, workers map[string]bool) error {
|
|
for pattern, wp := range policies {
|
|
if err := validateSimpleModelPattern(pattern); err != nil {
|
|
return fmt.Errorf("policy %q: %w", pattern, err)
|
|
}
|
|
switch wp.Class {
|
|
case "hot", "warm", "cold":
|
|
default:
|
|
return fmt.Errorf("policy %q class must be hot, warm, or cold", pattern)
|
|
}
|
|
if wp.Replicas < 1 {
|
|
return fmt.Errorf("policy %q replicas must be >= 1", pattern)
|
|
}
|
|
if wp.IdleTimeout.Value() < 0 {
|
|
return fmt.Errorf("policy %q idle_timeout must be >= 0", pattern)
|
|
}
|
|
for _, wn := range wp.Workers {
|
|
if len(workers) > 0 && !workers[wn] {
|
|
return fmt.Errorf("policy %q references unknown worker %q", pattern, wn)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSimpleModelPattern(p string) error {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return errors.New("model pattern must not be empty")
|
|
}
|
|
if strings.Count(p, "*") > 1 || (strings.Contains(p, "*") && !strings.HasSuffix(p, "*")) {
|
|
return errors.New("model pattern supports only one trailing * wildcard")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateModelPlacementRule validates the compact pattern syntax used by
|
|
// model placement. Patterns may be exact model names, "*", or a single
|
|
// trailing wildcard such as "gemma4:*". This deliberately matches the model
|
|
// pattern semantics already used by cost and per-model concurrency rules.
|
|
func ValidateModelPlacementRule(r ModelPlacementRule) error {
|
|
mode := strings.TrimSpace(r.Mode)
|
|
if mode == "" {
|
|
mode = "allow_all"
|
|
}
|
|
if mode != "allow_all" && mode != "whitelist" {
|
|
return errors.New("mode must be allow_all or whitelist")
|
|
}
|
|
for _, group := range []struct {
|
|
name string
|
|
vals []string
|
|
}{{"allowed_models", r.AllowedModels}, {"denied_models", r.DeniedModels}} {
|
|
seen := map[string]bool{}
|
|
for _, raw := range group.vals {
|
|
p := strings.TrimSpace(raw)
|
|
if p == "" {
|
|
return fmt.Errorf("%s contains an empty pattern", group.name)
|
|
}
|
|
if strings.Count(p, "*") > 1 || (strings.Contains(p, "*") && !strings.HasSuffix(p, "*")) {
|
|
return fmt.Errorf("%s pattern %q must be exact, '*' or use one trailing '*'", group.name, p)
|
|
}
|
|
if seen[p] {
|
|
return fmt.Errorf("%s contains duplicate pattern %q", group.name, p)
|
|
}
|
|
seen[p] = true
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateModelAccessRule uses the same compact pattern syntax as placement.
|
|
func ValidateModelAccessRule(r ModelAccessRule) error {
|
|
return ValidateModelPlacementRule(ModelPlacementRule{Mode: r.Mode, AllowedModels: r.AllowedModels, DeniedModels: r.DeniedModels})
|
|
}
|
|
|
|
// ModelAccessAllowed evaluates exact and trailing-wildcard rules. More specific
|
|
// rules win; at equal specificity deny wins.
|
|
func ModelAccessAllowed(r ModelAccessRule, model string) bool {
|
|
mode := strings.TrimSpace(r.Mode)
|
|
if mode == "" {
|
|
mode = "allow_all"
|
|
}
|
|
model = strings.TrimSpace(model)
|
|
plain := strings.TrimSuffix(model, ":latest")
|
|
match := func(pattern string) (int, bool) {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == model || pattern == plain {
|
|
return 100000 + len(pattern), true
|
|
}
|
|
if pattern == "*" {
|
|
return 0, true
|
|
}
|
|
if strings.HasSuffix(pattern, "*") {
|
|
prefix := strings.TrimSuffix(pattern, "*")
|
|
if strings.HasPrefix(model, prefix) || strings.HasPrefix(plain, prefix) {
|
|
return len(prefix), true
|
|
}
|
|
}
|
|
return -1, false
|
|
}
|
|
bestAllow, bestDeny := -1, -1
|
|
for _, p := range r.AllowedModels {
|
|
if n, ok := match(p); ok && n > bestAllow {
|
|
bestAllow = n
|
|
}
|
|
}
|
|
for _, p := range r.DeniedModels {
|
|
if n, ok := match(p); ok && n > bestDeny {
|
|
bestDeny = n
|
|
}
|
|
}
|
|
if bestAllow >= 0 || bestDeny >= 0 {
|
|
return bestAllow > bestDeny
|
|
}
|
|
return mode != "whitelist"
|
|
}
|
|
|
|
func (c *Config) ModelAccessRuleForTenant(tenant string) ModelAccessRule {
|
|
if r, ok := c.ModelAccess.Tenants[tenant]; ok {
|
|
if r.Mode == "" {
|
|
r.Mode = "allow_all"
|
|
}
|
|
return r
|
|
}
|
|
r := c.ModelAccess.Default
|
|
if r.Mode == "" {
|
|
r.Mode = "allow_all"
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (c *Config) Policy(tenant string) TenantPolicy {
|
|
p, ok := c.Scheduler.Policies[tenant]
|
|
if !ok {
|
|
p = c.Scheduler.Policies["*"]
|
|
}
|
|
if p.TenantWeight <= 0 {
|
|
p.TenantWeight = c.Scheduler.DefaultTenantWeight
|
|
}
|
|
if p.ActorWeight <= 0 {
|
|
p.ActorWeight = c.Scheduler.DefaultActorWeight
|
|
}
|
|
return p
|
|
}
|