From bb996dc925760addbc8d9afc2622d722577f65a2 Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Thu, 21 May 2026 21:59:10 -0400 Subject: [PATCH 01/20] feat(instinct#12): add FC discovery type (petersimmons1972/instinct#12) (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds discovery.type: "fc" — Olla polls Flight Controller /registry every 15s and atomically reconciles its backend set, meeting the <30s convergence SLO. - internal/adapter/discovery/fc_repository.go: FCDiscoveryPoller with Poll() and RunLoop(). Converts FC RegistryEntry{Host,Models[]{Name,Port}} into EndpointConfig slices and calls LoadFromConfig (thread-safe atomic map swap already in StaticEndpointRepository). Fail-open: FC unreachable → warn and preserve existing endpoint set. - internal/adapter/discovery/fc_repository_test.go: 3 tests — convert, remove-stale (core acceptance criterion), fail-open on FC unavailability - internal/config/types.go: FCDiscoveryConfig{RegistryURL, PollInterval}; DiscoveryConfig.FC added - internal/app/services/discovery.go: "fc" switch case validates registry_url, defaults poll_interval to 15s, runs initial blocking poll at startup, launches background RunLoop goroutine; fcPollerCancel called on Stop() ADV.1 advisory recommendation: Path A chosen over Path C (Reconciler) because Olla has no hot-reload path — ConfigMap volume mount convergence is 30-60s+ making <30s SLO unreliable without an Olla fork change anyway. Co-authored-by: Peter Simmons Co-authored-by: Claude Sonnet 4.6 --- internal/adapter/discovery/fc_repository.go | 138 ++++++++++++ .../adapter/discovery/fc_repository_test.go | 202 ++++++++++++++++++ internal/app/services/discovery.go | 32 +++ internal/config/types.go | 14 +- 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 internal/adapter/discovery/fc_repository.go create mode 100644 internal/adapter/discovery/fc_repository_test.go diff --git a/internal/adapter/discovery/fc_repository.go b/internal/adapter/discovery/fc_repository.go new file mode 100644 index 00000000..4ca3d83d --- /dev/null +++ b/internal/adapter/discovery/fc_repository.go @@ -0,0 +1,138 @@ +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/logger" +) + +// fcModelSpec mirrors the Flight Controller ModelSpec fields we care about for +// building Olla endpoint configs. Only Name and Port are required for routing. +type fcModelSpec struct { + Name string `json:"name"` + Port int `json:"port"` + Framework string `json:"framework"` +} + +// fcRegistryEntry mirrors the Flight Controller RegistryEntry type returned by +// GET /registry. We only parse the fields Olla needs; extra fields are ignored. +type fcRegistryEntry struct { + Host string `json:"host"` + Models []fcModelSpec `json:"models"` +} + +// FCDiscoveryPoller polls the Flight Controller /registry endpoint and reconciles +// the StaticEndpointRepository on each poll. A full replace-on-poll strategy ensures +// that endpoints removed from the FC registry are promptly evicted from Olla's rotation, +// meeting the <30s convergence acceptance criterion for petersimmons1972/instinct#12. +type FCDiscoveryPoller struct { + repo *StaticEndpointRepository + registryURL string + logger logger.StyledLogger + client *http.Client +} + +// NewFCDiscoveryPoller creates a poller that syncs Olla endpoints from FC /registry. +// registryBaseURL is the FC service base URL, e.g. http://ai-fleet-controller.ai-fleet.svc.cluster.local. +func NewFCDiscoveryPoller(repo *StaticEndpointRepository, registryBaseURL string, log logger.StyledLogger) *FCDiscoveryPoller { + return &FCDiscoveryPoller{ + repo: repo, + registryURL: registryBaseURL + "/registry", + logger: log, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Poll fetches the FC registry and reconciles Olla's endpoint set. +// On FC unreachable: logs a warning, preserves the current endpoint set (fail-open). +// On success: fully replaces the endpoint set with the FC-derived list. +func (p *FCDiscoveryPoller) Poll(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.registryURL, nil) + if err != nil { + return fmt.Errorf("fc-discovery: build request: %w", err) + } + + resp, err := p.client.Do(req) + if err != nil { + // Fail-open: preserve the existing endpoint set when FC is unreachable. + p.logger.Warn("fc-discovery: FC registry unreachable, preserving existing endpoints", "url", p.registryURL, "error", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + p.logger.Warn("fc-discovery: FC registry returned non-200, preserving existing endpoints", + "url", p.registryURL, "status", resp.StatusCode) + return nil + } + + var entries []fcRegistryEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return fmt.Errorf("fc-discovery: decode registry response: %w", err) + } + + configs := fcEntriesToEndpointConfigs(entries) + if err := p.repo.LoadFromConfig(ctx, configs); err != nil { + return fmt.Errorf("fc-discovery: load endpoint configs: %w", err) + } + + p.logger.Info("fc-discovery: endpoint set reconciled from FC registry", + "endpoints", len(configs)) + return nil +} + +// RunLoop starts a background polling loop. It polls immediately on first call, then +// at the configured interval. The loop exits when ctx is cancelled. +func (p *FCDiscoveryPoller) RunLoop(ctx context.Context, interval time.Duration) { + // Immediate first poll so Olla is populated before the ticker fires. + if err := p.Poll(ctx); err != nil { + p.logger.Warn("fc-discovery: initial poll error", "error", err) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + p.logger.Info("fc-discovery: loop stopped") + return + case <-ticker.C: + if err := p.Poll(ctx); err != nil { + p.logger.Warn("fc-discovery: poll error", "error", err) + } + } + } +} + +// fcEntriesToEndpointConfigs converts FC registry entries into Olla EndpointConfig slices. +// Each (host, model) pair becomes one endpoint at http://:. +// Type is always "openai" because all FC-managed containers serve the OpenAI-compatible API. +// Priority defaults to 100 (standard Olla default). +func fcEntriesToEndpointConfigs(entries []fcRegistryEntry) []config.EndpointConfig { + defaultPriority := 100 + var configs []config.EndpointConfig + + for _, entry := range entries { + for _, model := range entry.Models { + if model.Port == 0 { + continue // skip models without a port (incomplete CRD) + } + cfg := config.EndpointConfig{ + URL: fmt.Sprintf("http://%s:%d", entry.Host, model.Port), + Name: fmt.Sprintf("%s-%s", entry.Host, model.Name), + Type: "openai", + Priority: &defaultPriority, + } + configs = append(configs, cfg) + } + } + return configs +} diff --git a/internal/adapter/discovery/fc_repository_test.go b/internal/adapter/discovery/fc_repository_test.go new file mode 100644 index 00000000..22a7a8f1 --- /dev/null +++ b/internal/adapter/discovery/fc_repository_test.go @@ -0,0 +1,202 @@ +package discovery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/thushan/olla/internal/adapter/discovery" + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/logger" +) + +// FCRegistryEntry mirrors the Flight Controller RegistryEntry type. +type FCRegistryEntry struct { + Host string `json:"host"` + Models []FCModelSpec `json:"models"` +} + +type FCModelSpec struct { + Name string `json:"name"` + Port int `json:"port"` +} + +func newTestLogger() logger.StyledLogger { + loggerCfg := &logger.Config{Level: "error", Theme: "default"} + log, cleanup, _ := logger.New(loggerCfg) + _ = cleanup // test logger; cleanup deferred but not critical here + return logger.NewPlainStyledLogger(log) +} + +// TestFCEndpointRepository_PollConvertsRegistryToEndpoints verifies that the FC +// discovery poller transforms Flight Controller /registry entries into Olla endpoints. +func TestFCEndpointRepository_PollConvertsRegistryToEndpoints(t *testing.T) { + entries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{ + {Name: "qwen3-32b-vllm", Port: 8000}, + {Name: "bge-m3-infinity", Port: 8003}, + }, + }, + { + Host: "precision.petersimmons.com", + Models: []FCModelSpec{ + {Name: "bge-m3-infinity", Port: 8005}, + }, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/registry" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) + })) + defer srv.Close() + + repo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := poller.Poll(ctx); err != nil { + t.Fatalf("Poll() returned error: %v", err) + } + + all, err := repo.GetAll(ctx) + if err != nil { + t.Fatalf("GetAll() returned error: %v", err) + } + + // 3 total endpoints: 2 from oblivion + 1 from precision + if len(all) != 3 { + t.Errorf("expected 3 endpoints, got %d", len(all)) + for _, e := range all { + t.Logf(" endpoint: name=%q url=%q", e.Name, e.URLString) + } + } + + // Verify a specific endpoint URL was generated correctly + wantURL := "http://oblivion.petersimmons.com:8000" + found := false + for _, e := range all { + if e.URLString == wantURL { + found = true + break + } + } + if !found { + t.Errorf("expected endpoint with URL %q but not found", wantURL) + } +} + +// TestFCEndpointRepository_PollRemovesStaleEndpoints verifies that when FC registry +// shrinks (host goes offline), polling removes the stale endpoints from Olla's rotation. +// This is the core acceptance criterion for instinct#12. +func TestFCEndpointRepository_PollRemovesStaleEndpoints(t *testing.T) { + initialEntries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{{Name: "qwen3-32b-vllm", Port: 8000}}, + }, + { + Host: "precision.petersimmons.com", + Models: []FCModelSpec{{Name: "bge-m3-infinity", Port: 8005}}, + }, + } + + // After first poll, precision goes offline + reducedEntries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{{Name: "qwen3-32b-vllm", Port: 8000}}, + }, + } + + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/registry" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(initialEntries) + } else { + json.NewEncoder(w).Encode(reducedEntries) + } + })) + defer srv.Close() + + repo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // First poll: 2 endpoints + if err := poller.Poll(ctx); err != nil { + t.Fatalf("first Poll() error: %v", err) + } + all, _ := repo.GetAll(ctx) + if len(all) != 2 { + t.Errorf("after first poll: expected 2 endpoints, got %d", len(all)) + } + + // Second poll: precision removed from registry → should be removed from Olla + if err := poller.Poll(ctx); err != nil { + t.Fatalf("second Poll() error: %v", err) + } + all, _ = repo.GetAll(ctx) + if len(all) != 1 { + t.Errorf("after second poll: expected 1 endpoint (stale precision removed), got %d", len(all)) + for _, e := range all { + t.Logf(" remaining: name=%q url=%q", e.Name, e.URLString) + } + } + + // Verify the remaining endpoint is oblivion only + if len(all) == 1 && all[0].URLString != "http://oblivion.petersimmons.com:8000" { + t.Errorf("expected oblivion endpoint, got %q", all[0].URLString) + } +} + +// TestFCEndpointRepository_PollFailOpenOnFCUnavailable verifies that when FC is +// unreachable, the existing endpoint set is preserved (fail-open for availability). +func TestFCEndpointRepository_PollFailOpenOnFCUnavailable(t *testing.T) { + // Seed the repo with one endpoint + repo := discovery.NewStaticEndpointRepository() + priority := 100 + seedConfigs := []config.EndpointConfig{ + {URL: "http://oblivion.petersimmons.com:8000", Name: "oblivion", Type: "openai", Priority: &priority}, + } + ctx := context.Background() + if err := repo.LoadFromConfig(ctx, seedConfigs); err != nil { + t.Fatalf("seed LoadFromConfig error: %v", err) + } + + // Point at a server that is already closed + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + // Poll to an unavailable FC — should not error fatally, should preserve existing endpoints + err := poller.Poll(ctx) + if err == nil { + t.Log("Poll returned nil (considered non-fatal by poller — acceptable)") + } + + all, _ := repo.GetAll(ctx) + if len(all) != 1 { + t.Errorf("fail-open: expected original 1 endpoint preserved, got %d", len(all)) + } +} diff --git a/internal/app/services/discovery.go b/internal/app/services/discovery.go index 627197f6..a6f57349 100644 --- a/internal/app/services/discovery.go +++ b/internal/app/services/discovery.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "sync/atomic" + "time" "github.com/thushan/olla/internal/adapter/discovery" "github.com/thushan/olla/internal/adapter/health" @@ -29,6 +30,11 @@ type DiscoveryService struct { statsService *StatsService logger logger.StyledLogger modelDiscovery *discovery.ModelDiscoveryService + // fcPoller drives dynamic endpoint reconciliation when discovery.type = "fc". + // Runs a background goroutine that polls Flight Controller /registry; cancelled + // on Stop() via fcPollerCancel (petersimmons1972/instinct#12). + fcPoller *discovery.FCDiscoveryPoller + fcPollerCancel context.CancelFunc registry domain.ModelRegistry endpointRepo domain.EndpointRepository // purgeDeadFn, when set, is called with the current routable endpoint list @@ -99,6 +105,27 @@ func (s *DiscoveryService) Start(ctx context.Context) error { return fmt.Errorf("failed to load endpoints from config: %w", err) } s.endpointRepo = staticRepo + case "fc": + // Flight Controller dynamic discovery: poll FC /registry on a fixed interval + // and atomically replace the endpoint set (petersimmons1972/instinct#12). + if s.config.FC.RegistryURL == "" { + return fmt.Errorf("discovery.fc.registry_url is required when discovery.type is \"fc\"") + } + pollInterval := s.config.FC.PollInterval + if pollInterval <= 0 { + pollInterval = 15 * time.Second + } + staticRepo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(staticRepo, s.config.FC.RegistryURL, s.logger) + // Run an initial blocking poll so endpoints are populated before health checks start. + if err := poller.Poll(ctx); err != nil { + return fmt.Errorf("fc-discovery: initial poll failed: %w", err) + } + pollerCtx, cancel := context.WithCancel(context.Background()) + s.fcPoller = poller + s.fcPollerCancel = cancel + go poller.RunLoop(pollerCtx, pollInterval) + s.endpointRepo = staticRepo default: return fmt.Errorf("unsupported discovery type: %s", s.config.Type) } @@ -215,6 +242,11 @@ func (s *DiscoveryService) Stop(ctx context.Context) error { s.logger.Warn(" Failed to stop model discovery", "error", err) } } + + if s.fcPollerCancel != nil { + s.fcPollerCancel() + } + return nil } diff --git a/internal/config/types.go b/internal/config/types.go index 69407931..edeb80dd 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -105,12 +105,24 @@ type ProxyConfig struct { // DiscoveryConfig holds service discovery configuration type DiscoveryConfig struct { - Type string `yaml:"type"` // Only "static" is implemented + Type string `yaml:"type"` // "static" or "fc" Static StaticDiscoveryConfig `yaml:"static"` + FC FCDiscoveryConfig `yaml:"fc"` RefreshInterval time.Duration `yaml:"refresh_interval"` ModelDiscovery ModelDiscoveryConfig `yaml:"model_discovery"` } +// FCDiscoveryConfig holds configuration for Flight Controller-based dynamic discovery. +// When discovery.type is "fc", Olla polls the FC /registry endpoint and reconciles +// its backend list on each tick (petersimmons1972/instinct#12). +type FCDiscoveryConfig struct { + // RegistryURL is the base URL of the Flight Controller service, e.g. + // http://ai-fleet-controller.ai-fleet.svc.cluster.local + RegistryURL string `yaml:"registry_url"` + // PollInterval is how often Olla polls FC /registry. Default: 15s. + PollInterval time.Duration `yaml:"poll_interval"` +} + // ModelDiscoveryConfig holds model discvery specific settings type ModelDiscoveryConfig struct { Enabled bool `yaml:"enabled"` From 85c1cd4d17a1a24fde940de374453df3ee3c2772 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:26:35 -0400 Subject: [PATCH 02/20] fix(olla): honor endpoint circuit breaker config --- internal/adapter/discovery/repository.go | 36 ++++++------ internal/adapter/discovery/repository_test.go | 33 +++++++++++ internal/adapter/health/checker_test.go | 56 ++++++++++++++++++ internal/adapter/health/circuit_breaker.go | 33 ++++++++++- internal/adapter/health/client.go | 4 ++ internal/adapter/proxy/olla/service.go | 35 ++++++++--- .../olla/service_circuit_breaker_test.go | 58 +++++++++++++++++++ internal/config/types.go | 24 +++++--- internal/core/domain/endpoint.go | 42 +++++++------- 9 files changed, 266 insertions(+), 55 deletions(-) create mode 100644 internal/adapter/proxy/olla/service_circuit_breaker_test.go diff --git a/internal/adapter/discovery/repository.go b/internal/adapter/discovery/repository.go index 0774ef53..36af56e5 100644 --- a/internal/adapter/discovery/repository.go +++ b/internal/adapter/discovery/repository.go @@ -171,23 +171,25 @@ func (r *StaticEndpointRepository) LoadFromConfig(ctx context.Context, configs [ } newEndpoint := &domain.Endpoint{ - Name: cfg.Name, - URL: endpointURL, - Type: cfg.Type, - Priority: *cfg.Priority, - HealthCheckURL: healthCheckURL, - ModelUrl: modelURL, - ModelFilter: cfg.ModelFilter, - CheckInterval: cfg.CheckInterval, - CheckTimeout: cfg.CheckTimeout, - Status: domain.StatusUnknown, - URLString: urlString, - HealthCheckPathString: healthCheckPath, - HealthCheckURLString: healthCheckURLString, - ModelURLString: modelURLString, - BackoffMultiplier: 1, - NextCheckTime: now, - PreservePath: cfg.PreservePath, + Name: cfg.Name, + URL: endpointURL, + Type: cfg.Type, + Priority: *cfg.Priority, + HealthCheckURL: healthCheckURL, + ModelUrl: modelURL, + ModelFilter: cfg.ModelFilter, + CheckInterval: cfg.CheckInterval, + CheckTimeout: cfg.CheckTimeout, + CircuitBreakerTimeout: cfg.CircuitBreakerTimeout, + CircuitBreakerThreshold: cfg.CircuitBreakerThreshold, + Status: domain.StatusUnknown, + URLString: urlString, + HealthCheckPathString: healthCheckPath, + HealthCheckURLString: healthCheckURLString, + ModelURLString: modelURLString, + BackoffMultiplier: 1, + NextCheckTime: now, + PreservePath: cfg.PreservePath, } newEndpoints[urlString] = newEndpoint diff --git a/internal/adapter/discovery/repository_test.go b/internal/adapter/discovery/repository_test.go index e75152bd..ac29a5f3 100644 --- a/internal/adapter/discovery/repository_test.go +++ b/internal/adapter/discovery/repository_test.go @@ -425,6 +425,39 @@ func TestStaticEndpointRepository_LoadFromConfig(t *testing.T) { } } +func TestStaticEndpointRepository_LoadFromConfig_CircuitBreakerOverrides(t *testing.T) { + repo := NewStaticEndpointRepository() + cfg := config.EndpointConfig{ + Name: "slow-vllm", + URL: "http://localhost:8000", + Type: "ollama", + Priority: ptrInt(100), + CheckInterval: 5 * time.Second, + CheckTimeout: 2 * time.Second, + CircuitBreakerTimeout: 2000 * time.Second, + CircuitBreakerThreshold: 7, + } + + err := repo.LoadFromConfig(context.Background(), []config.EndpointConfig{cfg}) + if err != nil { + t.Fatalf("LoadFromConfig failed: %v", err) + } + + endpoints, err := repo.GetAll(context.Background()) + if err != nil { + t.Fatalf("GetAll failed: %v", err) + } + if len(endpoints) != 1 { + t.Fatalf("expected 1 endpoint, got %d", len(endpoints)) + } + if endpoints[0].CircuitBreakerTimeout != 2000*time.Second { + t.Fatalf("CircuitBreakerTimeout = %v, want 2000s", endpoints[0].CircuitBreakerTimeout) + } + if endpoints[0].CircuitBreakerThreshold != 7 { + t.Fatalf("CircuitBreakerThreshold = %d, want 7", endpoints[0].CircuitBreakerThreshold) + } +} + func TestStaticEndpointRepository_EmptyConfig(t *testing.T) { repo := NewStaticEndpointRepository() ctx := context.Background() diff --git a/internal/adapter/health/checker_test.go b/internal/adapter/health/checker_test.go index ed9f5c9b..299262d9 100644 --- a/internal/adapter/health/checker_test.go +++ b/internal/adapter/health/checker_test.go @@ -251,6 +251,62 @@ func TestCircuitBreaker_BasicOperation(t *testing.T) { } } +func TestCircuitBreaker_PerEndpointOverrides(t *testing.T) { + tests := []struct { + name string + config CircuitBreakerConfig + failures int + expectOpen bool + sleepBefore bool + }{ + { + name: "custom threshold delays open", + config: CircuitBreakerConfig{ + Threshold: DefaultCircuitBreakerThreshold + 2, + }, + failures: DefaultCircuitBreakerThreshold, + expectOpen: false, + }, + { + name: "custom threshold opens at override", + config: CircuitBreakerConfig{ + Threshold: DefaultCircuitBreakerThreshold + 2, + }, + failures: DefaultCircuitBreakerThreshold + 2, + expectOpen: true, + }, + { + name: "custom timeout controls half open cooldown", + config: CircuitBreakerConfig{ + Threshold: 1, + Timeout: 10 * time.Millisecond, + }, + failures: 1, + expectOpen: false, + sleepBefore: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cb := NewCircuitBreaker() + url := "http://localhost:11434/" + tt.name + cb.ConfigureEndpoint(url, tt.config) + + for range tt.failures { + cb.RecordFailure(url) + } + if tt.sleepBefore { + time.Sleep(20 * time.Millisecond) + } + + if got := cb.IsOpen(url); got != tt.expectOpen { + t.Fatalf("IsOpen() = %v, want %v", got, tt.expectOpen) + } + }) + } +} + func TestCircuitBreaker_Cleanup(t *testing.T) { cb := NewCircuitBreaker() url1 := "http://localhost:11434" diff --git a/internal/adapter/health/circuit_breaker.go b/internal/adapter/health/circuit_breaker.go index 5d534ffd..10107284 100644 --- a/internal/adapter/health/circuit_breaker.go +++ b/internal/adapter/health/circuit_breaker.go @@ -25,6 +25,7 @@ type CircuitBreaker struct { endpoints *xsync.Map[string, *circuitState] failureThreshold int timeout time.Duration + endpointConfigs *xsync.Map[string, CircuitBreakerConfig] } type circuitState struct { @@ -34,12 +35,26 @@ type circuitState struct { isOpen int32 } +type CircuitBreakerConfig struct { + Timeout time.Duration + Threshold int +} + func NewCircuitBreaker() *CircuitBreaker { return &CircuitBreaker{ endpoints: xsync.NewMap[string, *circuitState](), failureThreshold: DefaultCircuitBreakerThreshold, timeout: DefaultCircuitBreakerTimeout, + endpointConfigs: xsync.NewMap[string, CircuitBreakerConfig](), + } +} + +func (cb *CircuitBreaker) ConfigureEndpoint(endpointURL string, cfg CircuitBreakerConfig) { + if cfg.Timeout <= 0 && cfg.Threshold <= 0 { + cb.endpointConfigs.Delete(endpointURL) + return } + cb.endpointConfigs.Store(endpointURL, cfg) } func (cb *CircuitBreaker) IsOpen(endpointURL string) bool { @@ -53,7 +68,7 @@ func (cb *CircuitBreaker) IsOpen(endpointURL string) bool { // Check if circuit should auto-recover if atomic.LoadInt32(&state.isOpen) == 1 { lastFailure := atomic.LoadInt64(&state.lastFailure) - if time.Unix(0, lastFailure).Add(cb.timeout).Before(time.Now()) { + if time.Unix(0, lastFailure).Add(cb.timeoutForEndpoint(endpointURL)).Before(time.Now()) { // Allow one request through (half-open state) if atomic.CompareAndSwapInt64(&state.lastAttempt, 0, now) { return false @@ -89,7 +104,7 @@ func (cb *CircuitBreaker) RecordFailure(endpointURL string) { atomic.StoreInt64(&state.lastFailure, time.Now().UnixNano()) atomic.StoreInt64(&state.lastAttempt, 0) - if failures >= int64(cb.failureThreshold) { + if failures >= int64(cb.thresholdForEndpoint(endpointURL)) { atomic.StoreInt32(&state.isOpen, 1) } } @@ -113,3 +128,17 @@ func (cb *CircuitBreaker) loadOrCreateState(endpointURL string) *circuitState { }) return state } + +func (cb *CircuitBreaker) timeoutForEndpoint(endpointURL string) time.Duration { + if cfg, ok := cb.endpointConfigs.Load(endpointURL); ok && cfg.Timeout > 0 { + return cfg.Timeout + } + return cb.timeout +} + +func (cb *CircuitBreaker) thresholdForEndpoint(endpointURL string) int { + if cfg, ok := cb.endpointConfigs.Load(endpointURL); ok && cfg.Threshold > 0 { + return cfg.Threshold + } + return cb.failureThreshold +} diff --git a/internal/adapter/health/client.go b/internal/adapter/health/client.go index 28b5c25b..8f84eb2d 100644 --- a/internal/adapter/health/client.go +++ b/internal/adapter/health/client.go @@ -50,6 +50,10 @@ func (hc *HealthClient) Check(ctx context.Context, endpoint *domain.Endpoint) (r }() healthCheckURL := endpoint.GetHealthCheckURLString() + hc.circuitBreaker.ConfigureEndpoint(healthCheckURL, CircuitBreakerConfig{ + Timeout: endpoint.CircuitBreakerTimeout, + Threshold: endpoint.CircuitBreakerThreshold, + }) // Check circuit breaker first if hc.circuitBreaker.IsOpen(healthCheckURL) { diff --git a/internal/adapter/proxy/olla/service.go b/internal/adapter/proxy/olla/service.go index eecda19b..90936cad 100644 --- a/internal/adapter/proxy/olla/service.go +++ b/internal/adapter/proxy/olla/service.go @@ -58,8 +58,8 @@ const ( ClientDisconnectionBytesThreshold = 1024 ClientDisconnectionTimeThreshold = 5 * time.Second - // Circuit breaker threshold higher than health checker for tolerance - circuitBreakerThreshold = 5 // vs health.DefaultCircuitBreakerThreshold (3) + // Circuit breaker threshold higher than health checker for tolerance. + proxyCircuitBreakerThreshold = 5 // vs health.DefaultCircuitBreakerThreshold (3) ) // Service implements the Olla proxy - optimised for high performance and resilience @@ -99,6 +99,7 @@ type circuitBreaker struct { lastFailure int64 // atomic state int64 // atomic: 0=closed, 1=open, 2=half-open threshold int64 + timeout time.Duration } // requestContext contains per-request data from our object pool @@ -258,14 +259,34 @@ func (s *Service) getOrCreateEndpointPool(endpoint string) *connectionPool { return actual } -// GetCircuitBreaker returns the circuit breaker for an endpoint (exported for testing) +// GetCircuitBreaker returns the circuit breaker for an endpoint (exported for testing). func (s *Service) GetCircuitBreaker(endpoint string) *circuitBreaker { + return s.getCircuitBreaker(endpoint, 0, 0) +} + +func (s *Service) getCircuitBreakerForEndpoint(endpoint *domain.Endpoint) *circuitBreaker { + return s.getCircuitBreaker( + endpoint.Name, + endpoint.CircuitBreakerTimeout, + endpoint.CircuitBreakerThreshold, + ) +} + +func (s *Service) getCircuitBreaker(endpoint string, timeout time.Duration, threshold int) *circuitBreaker { if cb, ok := s.circuitBreakers.Load(endpoint); ok { return cb } + if threshold <= 0 { + threshold = proxyCircuitBreakerThreshold + } + if timeout <= 0 { + timeout = health.DefaultCircuitBreakerTimeout + } + newCB := &circuitBreaker{ - threshold: circuitBreakerThreshold, + threshold: int64(threshold), + timeout: timeout, state: 0, // closed } @@ -282,7 +303,7 @@ func (cb *circuitBreaker) IsOpen() bool { // Check if timeout has passed lastFailure := atomic.LoadInt64(&cb.lastFailure) - if time.Since(time.Unix(0, lastFailure)) > health.DefaultCircuitBreakerTimeout { + if time.Since(time.Unix(0, lastFailure)) > cb.timeout { // Try half-open state if atomic.CompareAndSwapInt64(&cb.state, 1, 2) { // State transition: Open -> Half-open @@ -341,7 +362,7 @@ func (s *Service) handlePanic(ctx context.Context, w http.ResponseWriter, r *htt // selectEndpointWithCircuitBreaker selects an endpoint that has a healthy circuit breaker func (s *Service) selectEndpointWithCircuitBreaker(endpoints []*domain.Endpoint, rlog logger.StyledLogger) (*domain.Endpoint, *circuitBreaker) { for _, ep := range endpoints { - cb := s.GetCircuitBreaker(ep.Name) + cb := s.getCircuitBreakerForEndpoint(ep) stateBefore := atomic.LoadInt64(&cb.state) if !cb.IsOpen() { stateAfter := atomic.LoadInt64(&cb.state) @@ -349,7 +370,7 @@ func (s *Service) selectEndpointWithCircuitBreaker(endpoints []*domain.Endpoint, if stateBefore == 1 && stateAfter == 2 { rlog.Info("Circuit breaker entering half-open state", "endpoint", ep.Name, - "timeout", health.DefaultCircuitBreakerTimeout) + "timeout", cb.timeout) } return ep, cb } diff --git a/internal/adapter/proxy/olla/service_circuit_breaker_test.go b/internal/adapter/proxy/olla/service_circuit_breaker_test.go new file mode 100644 index 00000000..2c9e3bd9 --- /dev/null +++ b/internal/adapter/proxy/olla/service_circuit_breaker_test.go @@ -0,0 +1,58 @@ +package olla + +import ( + "testing" + "time" + + "github.com/puzpuzpuz/xsync/v4" + "github.com/thushan/olla/internal/adapter/health" + "github.com/thushan/olla/internal/core/domain" +) + +func TestServiceCircuitBreaker_PerEndpointOverrides(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + endpoint := &domain.Endpoint{ + Name: "slow-vllm", + CircuitBreakerTimeout: 10 * time.Millisecond, + CircuitBreakerThreshold: 2, + } + + cb := s.getCircuitBreakerForEndpoint(endpoint) + if cb.threshold != 2 { + t.Fatalf("threshold = %d, want 2", cb.threshold) + } + if cb.timeout != 10*time.Millisecond { + t.Fatalf("timeout = %v, want 10ms", cb.timeout) + } + + cb.RecordFailure() + if cb.IsOpen() { + t.Fatal("breaker opened before endpoint threshold") + } + + cb.RecordFailure() + if !cb.IsOpen() { + t.Fatal("breaker did not open at endpoint threshold") + } + + time.Sleep(20 * time.Millisecond) + if cb.IsOpen() { + t.Fatal("breaker did not use endpoint timeout for half-open transition") + } +} + +func TestServiceCircuitBreaker_DefaultsRemainUnchanged(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + + cb := s.GetCircuitBreaker("default-endpoint") + if cb.threshold != proxyCircuitBreakerThreshold { + t.Fatalf("threshold = %d, want %d", cb.threshold, proxyCircuitBreakerThreshold) + } + if cb.timeout != health.DefaultCircuitBreakerTimeout { + t.Fatalf("timeout = %v, want %v", cb.timeout, health.DefaultCircuitBreakerTimeout) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index edeb80dd..51566319 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -144,15 +144,21 @@ type EndpointConfig struct { // Priority uses a pointer so nil means "omitted in config" rather than explicitly zero. // This lets applyEndpointDefaults distinguish "user set 0" from "user said nothing", // since 0 is a valid, lower-than-default priority value. - Priority *int `yaml:"priority"` - URL string `yaml:"url"` - Name string `yaml:"name"` - Type string `yaml:"type"` - HealthCheckURL string `yaml:"health_check_url"` - ModelURL string `yaml:"model_url"` - CheckInterval time.Duration `yaml:"check_interval"` - CheckTimeout time.Duration `yaml:"check_timeout"` - PreservePath bool `yaml:"preserve_path"` + Priority *int `yaml:"priority"` + // CircuitBreakerTimeout overrides the circuit breaker cooldown for this endpoint. + // Zero means use the global default. + CircuitBreakerTimeout time.Duration `yaml:"circuit_breaker_timeout,omitempty"` + // CircuitBreakerThreshold overrides the consecutive failure threshold for this endpoint. + // Zero means use the global default. + CircuitBreakerThreshold int `yaml:"circuit_breaker_threshold,omitempty"` + URL string `yaml:"url"` + Name string `yaml:"name"` + Type string `yaml:"type"` + HealthCheckURL string `yaml:"health_check_url"` + ModelURL string `yaml:"model_url"` + CheckInterval time.Duration `yaml:"check_interval"` + CheckTimeout time.Duration `yaml:"check_timeout"` + PreservePath bool `yaml:"preserve_path"` } // LoggingConfig holds logging configuration diff --git a/internal/core/domain/endpoint.go b/internal/core/domain/endpoint.go index afb4ad83..e7106998 100644 --- a/internal/core/domain/endpoint.go +++ b/internal/core/domain/endpoint.go @@ -17,26 +17,28 @@ const ( ) type Endpoint struct { - LastChecked time.Time - NextCheckTime time.Time - URL *url.URL - HealthCheckURL *url.URL - ModelUrl *url.URL - ModelFilter *FilterConfig - Name string - Type string `json:"type,omitempty"` - Status EndpointStatus - URLString string - HealthCheckPathString string - HealthCheckURLString string - ModelURLString string - LastLatency time.Duration - CheckInterval time.Duration - CheckTimeout time.Duration - Priority int - ConsecutiveFailures int - BackoffMultiplier int - PreservePath bool + LastChecked time.Time + NextCheckTime time.Time + URL *url.URL + HealthCheckURL *url.URL + ModelUrl *url.URL + ModelFilter *FilterConfig + Name string + Type string `json:"type,omitempty"` + Status EndpointStatus + URLString string + HealthCheckPathString string + HealthCheckURLString string + ModelURLString string + LastLatency time.Duration + CheckInterval time.Duration + CheckTimeout time.Duration + CircuitBreakerTimeout time.Duration + Priority int + CircuitBreakerThreshold int + ConsecutiveFailures int + BackoffMultiplier int + PreservePath bool } func (e *Endpoint) GetURLString() string { From 012e5e35e9de2d7dbe476e1f136bf753c01fb12a Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 19:23:02 -0400 Subject: [PATCH 03/20] test(olla): cover breaker config override wiring --- config/config.yaml | 3 ++- internal/config/config_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/config/config.yaml b/config/config.yaml index 7a4f767f..b14c4bc6 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -106,13 +106,14 @@ discovery: check_interval: 2s check_timeout: 1s - url: "http://localhost:8000" - name: "local-vllm" + name: "oblivion-gb10-vllm" type: "vllm" priority: 100 model_url: "/v1/models" health_check_url: "/health" check_interval: 5s check_timeout: 2s + circuit_breaker_timeout: 2000s model_discovery: enabled: true interval: 5m diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0aa0b6fa..9673a655 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,8 +3,11 @@ package config import ( "os" "runtime" + "strings" "testing" "time" + + "gopkg.in/yaml.v3" ) func TestDefaultConfig(t *testing.T) { @@ -216,6 +219,45 @@ func TestConfigTypes(t *testing.T) { } } +func TestConfigYAML_EndpointCircuitBreakerOverrides(t *testing.T) { + var cfg Config + data := ` +discovery: + type: "static" + static: + endpoints: + - url: "http://localhost:8000" + name: "oblivion-gb10-vllm" + type: "vllm" + priority: 100 + health_check_url: "/health" + model_url: "/v1/models" + check_interval: 5s + check_timeout: 2s + circuit_breaker_timeout: 2000s + circuit_breaker_threshold: 7 +` + + if err := yaml.Unmarshal([]byte(strings.TrimSpace(data)), &cfg); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + if len(cfg.Discovery.Static.Endpoints) != 1 { + t.Fatalf("expected 1 endpoint, got %d", len(cfg.Discovery.Static.Endpoints)) + } + + endpoint := cfg.Discovery.Static.Endpoints[0] + if endpoint.Name != "oblivion-gb10-vllm" { + t.Fatalf("endpoint.Name = %q, want oblivion-gb10-vllm", endpoint.Name) + } + if endpoint.CircuitBreakerTimeout != 2000*time.Second { + t.Fatalf("endpoint.CircuitBreakerTimeout = %v, want 2000s", endpoint.CircuitBreakerTimeout) + } + if endpoint.CircuitBreakerThreshold != 7 { + t.Fatalf("endpoint.CircuitBreakerThreshold = %d, want 7", endpoint.CircuitBreakerThreshold) + } +} + func TestEnvironmentVariableParsing(t *testing.T) { testCases := []struct { envVar string From 801b0a80bb077aaa402e23ea59cf28219f070791 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:29:28 -0400 Subject: [PATCH 04/20] feat(olla): expose breaker state in endpoint status --- internal/adapter/proxy/olla/service.go | 41 +++++++++++ .../olla/service_circuit_breaker_test.go | 29 ++++++++ .../app/handlers/handler_status_endpoints.go | 39 +++++++--- .../handlers/handler_status_endpoints_test.go | 72 +++++++++++++++++++ internal/core/domain/circuit_breaker.go | 10 +++ 5 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 internal/core/domain/circuit_breaker.go diff --git a/internal/adapter/proxy/olla/service.go b/internal/adapter/proxy/olla/service.go index 90936cad..451bfb13 100644 --- a/internal/adapter/proxy/olla/service.go +++ b/internal/adapter/proxy/olla/service.go @@ -264,6 +264,10 @@ func (s *Service) GetCircuitBreaker(endpoint string) *circuitBreaker { return s.getCircuitBreaker(endpoint, 0, 0) } +func (s *Service) GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState { + return s.getCircuitBreakerForEndpoint(endpoint).GetState() +} + func (s *Service) getCircuitBreakerForEndpoint(endpoint *domain.Endpoint) *circuitBreaker { return s.getCircuitBreaker( endpoint.Name, @@ -328,6 +332,43 @@ func (cb *circuitBreaker) RecordFailure() { } } +func (cb *circuitBreaker) GetState() domain.CircuitBreakerState { + state := cb.stateName() + lastFailureNs := atomic.LoadInt64(&cb.lastFailure) + var lastTrip *time.Time + cooldownRemainingSec := 0 + if lastFailureNs > 0 { + lastFailure := time.Unix(0, lastFailureNs) + lastTrip = &lastFailure + if state == "open" { + remaining := time.Until(lastFailure.Add(cb.timeout)) + if remaining > 0 { + cooldownRemainingSec = int(remaining.Seconds()) + } + } + } + + return domain.CircuitBreakerState{ + State: state, + LastTripTimestamp: lastTrip, + ConsecutiveFailures: atomic.LoadInt64(&cb.failures), + CooldownRemainingSec: cooldownRemainingSec, + } +} + +func (cb *circuitBreaker) stateName() string { + switch atomic.LoadInt64(&cb.state) { + case 0: + return "closed" + case 1: + return "open" + case 2: + return "half-open" + default: + return "unknown" + } +} + // ProxyRequest handles incoming HTTP requests func (s *Service) ProxyRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, stats *ports.RequestStats, rlog logger.StyledLogger) error { endpoints, err := s.DiscoveryService.GetHealthyEndpoints(ctx) diff --git a/internal/adapter/proxy/olla/service_circuit_breaker_test.go b/internal/adapter/proxy/olla/service_circuit_breaker_test.go index 2c9e3bd9..7434598e 100644 --- a/internal/adapter/proxy/olla/service_circuit_breaker_test.go +++ b/internal/adapter/proxy/olla/service_circuit_breaker_test.go @@ -56,3 +56,32 @@ func TestServiceCircuitBreaker_DefaultsRemainUnchanged(t *testing.T) { t.Fatalf("timeout = %v, want %v", cb.timeout, health.DefaultCircuitBreakerTimeout) } } + +func TestServiceCircuitBreaker_StateSnapshot(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + endpoint := &domain.Endpoint{ + Name: "breaker-open", + CircuitBreakerTimeout: 30 * time.Second, + CircuitBreakerThreshold: 2, + } + + cb := s.getCircuitBreakerForEndpoint(endpoint) + cb.RecordFailure() + cb.RecordFailure() + + state := s.GetCircuitBreakerState(endpoint) + if state.State != "open" { + t.Fatalf("State = %q, want open", state.State) + } + if state.ConsecutiveFailures != 2 { + t.Fatalf("ConsecutiveFailures = %d, want 2", state.ConsecutiveFailures) + } + if state.LastTripTimestamp == nil { + t.Fatal("LastTripTimestamp is nil") + } + if state.CooldownRemainingSec <= 0 { + t.Fatalf("CooldownRemainingSec = %d, want positive", state.CooldownRemainingSec) + } +} diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index 619e06b9..b759f92b 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -15,17 +15,18 @@ import ( ) type EndpointSummary struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - LastModelSync string `json:"last_model_sync,omitempty"` - HealthCheck string `json:"health_check"` - ResponseTime string `json:"response_time,omitempty"` - SuccessRate string `json:"success_rate"` - Issues string `json:"issues,omitempty"` - Priority int `json:"priority"` - ModelCount int `json:"model_count"` - RequestCount int64 `json:"request_count"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LastModelSync string `json:"last_model_sync,omitempty"` + HealthCheck string `json:"health_check"` + ResponseTime string `json:"response_time,omitempty"` + SuccessRate string `json:"success_rate"` + Issues string `json:"issues,omitempty"` + Priority int `json:"priority"` + ModelCount int `json:"model_count"` + RequestCount int64 `json:"request_count"` + CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` } type EndpointStatusResponse struct { @@ -119,10 +120,26 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s } summary.Issues = a.getEndpointIssuesSummaryOptimised(endpoint, stats, hasStats) + summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) return summary } +func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain.CircuitBreakerState { + if a.proxyService == nil { + return nil + } + breakerStateProvider, ok := a.proxyService.(interface { + GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState + }) + if !ok { + return nil + } + + state := breakerStateProvider.GetCircuitBreakerState(endpoint) + return &state +} + func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { return "" diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index 9efff05a..97e90110 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/thushan/olla/internal/core/domain" "github.com/thushan/olla/internal/core/ports" + "github.com/thushan/olla/internal/logger" ) // mockStatusEndpointRepository for status endpoint testing @@ -103,6 +104,35 @@ func (m *mockStatusStatsCollector) GetEndpointStats() map[string]ports.EndpointS return m.endpointStats } +type mockStatusProxyService struct { + states map[string]domain.CircuitBreakerState +} + +func (m *mockStatusProxyService) ProxyRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, stats *ports.RequestStats, rlog logger.StyledLogger) error { + return nil +} + +func (m *mockStatusProxyService) ProxyRequestToEndpoints( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + endpoints []*domain.Endpoint, + stats *ports.RequestStats, + rlog logger.StyledLogger, +) error { + return nil +} + +func (m *mockStatusProxyService) GetStats(ctx context.Context) (ports.ProxyStats, error) { + return ports.ProxyStats{}, nil +} + +func (m *mockStatusProxyService) UpdateConfig(configuration ports.ProxyConfiguration) {} + +func (m *mockStatusProxyService) GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState { + return m.states[endpoint.Name] +} + // mockStatusModelRegistry for status endpoint testing type mockStatusModelRegistry struct { endpointModels map[string]*domain.EndpointModels @@ -205,6 +235,48 @@ func TestEndpointsStatusHandler_BasicFunctionality(t *testing.T) { assert.Len(t, response.Endpoints, 2) } +func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { + lastTrip := time.Now().Add(-5 * time.Second).UTC().Truncate(time.Second) + endpoints := []*domain.Endpoint{ + { + Name: "breaker-open", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + }, + } + app := createTestStatusApplication(endpoints) + app.proxyService = &mockStatusProxyService{ + states: map[string]domain.CircuitBreakerState{ + "breaker-open": { + State: "open", + LastTripTimestamp: &lastTrip, + ConsecutiveFailures: 3, + CooldownRemainingSec: 25, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + require.NotNil(t, response.Endpoints[0].CircuitBreaker) + assert.Equal(t, "open", response.Endpoints[0].CircuitBreaker.State) + assert.Equal(t, int64(3), response.Endpoints[0].CircuitBreaker.ConsecutiveFailures) + assert.Equal(t, 25, response.Endpoints[0].CircuitBreaker.CooldownRemainingSec) + require.NotNil(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp) + assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) +} + func TestEndpointsStatusHandler_Concurrent(t *testing.T) { // Create test endpoints endpoints := []*domain.Endpoint{ diff --git a/internal/core/domain/circuit_breaker.go b/internal/core/domain/circuit_breaker.go new file mode 100644 index 00000000..97c33a4e --- /dev/null +++ b/internal/core/domain/circuit_breaker.go @@ -0,0 +1,10 @@ +package domain + +import "time" + +type CircuitBreakerState struct { + State string `json:"state"` + LastTripTimestamp *time.Time `json:"last_trip_ts,omitempty"` + ConsecutiveFailures int64 `json:"consecutive_failures"` + CooldownRemainingSec int `json:"cooldown_remaining_s"` +} From db9a9637f8e9bb1617c421596ba97fca38812cc7 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:32:53 -0400 Subject: [PATCH 05/20] feat(olla): enforce allowed model aliases --- config/config.yaml | 8 +++ internal/app/handlers/application.go | 6 ++ internal/app/handlers/handler_proxy.go | 35 ++++++++++++ .../handlers/handler_proxy_allowlist_test.go | 56 +++++++++++++++++++ internal/app/handlers/handler_translation.go | 6 ++ internal/config/config.go | 13 +++++ internal/config/config_test.go | 47 ++++++++++++++++ internal/config/types.go | 23 ++++++++ 8 files changed, 194 insertions(+) create mode 100644 internal/app/handlers/handler_proxy_allowlist_test.go diff --git a/config/config.yaml b/config/config.yaml index 7a4f767f..91ef5fb2 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,4 +1,12 @@ # Olla Configuration (default) +allowed_models: + - qwen3-coder-30b + - qwen3-14b-awq + - inference + - leviathan-7900xt-vllm + - embed + - oblivion-gb10-infinity + server: host: "localhost" # Use 0.0.0.0 to bind to all interfaces (so it's accessible externally) port: 40114 # default port for Olla - 4-OLLA diff --git a/internal/app/handlers/application.go b/internal/app/handlers/application.go index 3e8b31d9..b54a3779 100644 --- a/internal/app/handlers/application.go +++ b/internal/app/handlers/application.go @@ -89,6 +89,7 @@ type Application struct { // closure so the handler layer does not need to import the balancer package. stickyStatsFn func() *balancer.StickyStats aliasResolver *registry.AliasResolver + allowedModels map[string]struct{} server *http.Server errCh chan error StartTime time.Time @@ -171,6 +172,10 @@ func NewApplication( if aliasResolver != nil { logger.Info("Model aliases configured", "alias_count", len(cfg.ModelAliases)) } + allowedModels := makeAllowedModelSet(cfg.AllowedModels) + if len(allowedModels) > 0 { + logger.Info("Model allowlist configured", "model_count", len(allowedModels)) + } // Use profile factory directly as it implements the ProfileLookup interface // The Factory.GetAnthropicSupport method provides the required functionality @@ -192,6 +197,7 @@ func NewApplication( converterFactory: converter.NewConverterFactory(), translatorRegistry: translatorRegistry, aliasResolver: aliasResolver, + allowedModels: allowedModels, server: server, errCh: make(chan error, 1), StartTime: time.Now(), diff --git a/internal/app/handlers/handler_proxy.go b/internal/app/handlers/handler_proxy.go index 43e9ab0e..6cde0e02 100644 --- a/internal/app/handlers/handler_proxy.go +++ b/internal/app/handlers/handler_proxy.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -42,6 +43,11 @@ func (a *Application) proxyHandler(w http.ResponseWriter, r *http.Request) { a.analyzeRequest(ctx, r, pr) + if !a.isModelAllowed(pr.model) { + a.writeModelAllowlistError(w, pr.model) + return + } + // Sticky session key must be computed after analyzeRequest so the model // name is available; inject into context before endpoint selection. // The outcome pointer is stored in context; the proxy engine reads it before WriteHeader. @@ -392,6 +398,35 @@ func (a *Application) handleProxyError(w http.ResponseWriter, err error) { } } +func makeAllowedModelSet(models []string) map[string]struct{} { + if len(models) == 0 { + return nil + } + allowed := make(map[string]struct{}, len(models)) + for _, model := range models { + allowed[model] = struct{}{} + } + return allowed +} + +func (a *Application) isModelAllowed(model string) bool { + if model == "" || len(a.allowedModels) == 0 { + return true + } + _, ok := a.allowedModels[model] + return ok +} + +func (a *Application) writeModelAllowlistError(w http.ResponseWriter, model string) { + w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": fmt.Sprintf("model %q is not allowed by allowed_models", model), + "model": model, + "allowed_models": a.Config.AllowedModels, + }) +} + func (a *Application) stripRoutePrefix(ctx context.Context, path string) string { return util.StripRoutePrefix(ctx, path, constants.ContextRoutePrefixKey) } diff --git a/internal/app/handlers/handler_proxy_allowlist_test.go b/internal/app/handlers/handler_proxy_allowlist_test.go new file mode 100644 index 00000000..c9e07921 --- /dev/null +++ b/internal/app/handlers/handler_proxy_allowlist_test.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/adapter/inspector" + "github.com/thushan/olla/internal/adapter/registry/profile" + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/core/constants" +) + +func TestProxyHandler_RejectsModelOutsideAllowlist(t *testing.T) { + styledLog := &mockStyledLogger{} + profileFactory, err := profile.NewFactoryWithDefaults() + require.NoError(t, err) + + inspectorFactory := inspector.NewFactory(profileFactory, styledLog) + chain := inspectorFactory.CreateChain() + chain.AddInspector(inspectorFactory.CreatePathInspector()) + bodyInspector, err := inspectorFactory.CreateBodyInspector() + require.NoError(t, err) + chain.AddInspector(bodyInspector) + + cfg := config.DefaultConfig() + cfg.AllowedModels = []string{"qwen3-coder-30b"} + app := &Application{ + Config: cfg, + logger: styledLog, + inspectorChain: chain, + allowedModels: makeAllowedModelSet(cfg.AllowedModels), + } + + req := httptest.NewRequest( + http.MethodPost, + "/olla/proxy/v1/chat/completions", + bytes.NewBufferString(`{"model":"unknown-model","messages":[{"role":"user","content":"hi"}]}`), + ) + req.Header.Set(constants.HeaderContentType, constants.ContentTypeJSON) + w := httptest.NewRecorder() + + app.proxyHandler(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Header().Get(constants.HeaderContentType), constants.ContentTypeJSON) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + assert.Equal(t, "unknown-model", body["model"]) + assert.Contains(t, body["error"], "not allowed") +} diff --git a/internal/app/handlers/handler_translation.go b/internal/app/handlers/handler_translation.go index b54fef10..45e19afd 100644 --- a/internal/app/handlers/handler_translation.go +++ b/internal/app/handlers/handler_translation.go @@ -257,6 +257,12 @@ func (a *Application) translationHandler(trans translator.RequestTranslator) htt // Run through proxy pipeline (inspector, security, routing) a.analyzeRequest(ctx, r, pr) + if !a.isModelAllowed(pr.model) { + a.writeModelAllowlistError(w, pr.model) + a.recordTranslatorMetrics(trans, pr, constants.TranslatorModeTranslation, constants.FallbackReasonNone) + return + } + // Inject sticky session key. bodyBytes is already buffered from the model-name // extraction above, so pass it directly to avoid a second read/restore cycle. // The outcome pointer is stored in context; sub-handlers read it before WriteHeader. diff --git a/internal/config/config.go b/internal/config/config.go index 3d867552..c57322b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,8 +40,18 @@ var DefaultLocalNetworkTrustedCIDRs = []string{ "192.168.0.0/16", } +var DefaultAllowedModels = []string{ + "qwen3-coder-30b", + "qwen3-14b-awq", + "inference", + "leviathan-7900xt-vllm", + "embed", + "oblivion-gb10-infinity", +} + func DefaultConfig() *Config { return &Config{ + AllowedModels: append([]string(nil), DefaultAllowedModels...), Server: ServerConfig{ Host: DefaultHost, Port: DefaultPort, @@ -194,6 +204,9 @@ func (c *Config) Validate() error { if err := c.Translators.Validate(); err != nil { return fmt.Errorf("translators config invalid: %w", err) } + if err := c.ValidateAllowedModels(); err != nil { + return fmt.Errorf("allowed_models invalid: %w", err) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0aa0b6fa..a3344019 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -947,6 +947,53 @@ func TestDefaultConfig_NoModelAliases(t *testing.T) { } } +func TestDefaultConfig_AllowedModels(t *testing.T) { + cfg := DefaultConfig() + if len(cfg.AllowedModels) != len(DefaultAllowedModels) { + t.Fatalf("expected %d default allowed models, got %d", len(DefaultAllowedModels), len(cfg.AllowedModels)) + } + + for i, expected := range DefaultAllowedModels { + if cfg.AllowedModels[i] != expected { + t.Fatalf("AllowedModels[%d] = %q, want %q", i, cfg.AllowedModels[i], expected) + } + } +} + +func TestValidateAllowedModels_RejectsBadEntries(t *testing.T) { + tests := []struct { + name string + allowedModels []string + wantErr string + }{ + { + name: "empty model", + allowedModels: []string{"qwen3-coder-30b", ""}, + wantErr: "empty model name", + }, + { + name: "leading whitespace", + allowedModels: []string{" qwen3-coder-30b"}, + wantErr: "leading/trailing whitespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowedModels = tt.allowedModels + + err := cfg.ValidateAllowedModels() + if err == nil { + t.Fatal("expected error") + } + if !stringContains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestValidateModelAliases_NoAliases(t *testing.T) { cfg := DefaultConfig() if err := cfg.ValidateModelAliases(); err != nil { diff --git a/internal/config/types.go b/internal/config/types.go index 51566319..ba9b4bb4 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -27,6 +27,7 @@ type Config struct { // - gpt-oss-120b-MLX // - gguf_gpt_oss_120b.gguf ModelAliases map[string][]string `yaml:"model_aliases,omitempty"` + AllowedModels []string `yaml:"allowed_models,omitempty"` Logging LoggingConfig `yaml:"logging"` Filename string `yaml:"-"` Translators TranslatorsConfig `yaml:"translators"` @@ -438,3 +439,25 @@ func (c *Config) ValidateModelAliases() error { return nil } + +func (c *Config) ValidateAllowedModels() error { + if len(c.AllowedModels) == 0 { + return nil + } + + seen := make(map[string]bool, len(c.AllowedModels)) + for i, modelName := range c.AllowedModels { + if modelName == "" { + return fmt.Errorf("allowed_models has empty model name at position %d", i) + } + if strings.TrimSpace(modelName) != modelName { + return fmt.Errorf("allowed model %q contains leading/trailing whitespace", modelName) + } + if seen[modelName] { + slog.Warn("Duplicate model name in allowed_models", "model", modelName) + } + seen[modelName] = true + } + + return nil +} From 52c0a2b3a00325347ad2aaa1a1106a410b3a7a2a Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 11:39:22 -0400 Subject: [PATCH 06/20] fix(unifier): infer BGE embedding capability (#14) Co-authored-by: Peter Simmons --- config/models.yaml | 8 +++++++- internal/adapter/unifier/metadata_extractor.go | 7 +++++++ internal/adapter/unifier/metadata_extractor_test.go | 8 +++++++- internal/adapter/unifier/model_config.go | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/config/models.yaml b/config/models.yaml index 4ac16a91..53ae9ff8 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -195,6 +195,12 @@ capabilities: - multimodal - image-understanding + - pattern: "(^|[/_-])(bge|e5|gte|minilm|jina-embeddings?|nomic-embed|mxbai-embed|text-embedding)" + capabilities: + - embeddings + - similarity + - vector-search + # Context length categories (in tokens) context_thresholds: extended_context: 32000 @@ -213,4 +219,4 @@ special_rules: - unknown - test - temp - - default \ No newline at end of file + - default diff --git a/internal/adapter/unifier/metadata_extractor.go b/internal/adapter/unifier/metadata_extractor.go index 610dec14..6cae5a05 100644 --- a/internal/adapter/unifier/metadata_extractor.go +++ b/internal/adapter/unifier/metadata_extractor.go @@ -136,13 +136,20 @@ func inferCapabilitiesFromMetadata(modelType, modelName string, contextLength in // Name-based inference using patterns from configuration nameLower := strings.ToLower(modelName) + embeddingByName := false for _, pattern := range config.Capabilities.NamePatterns { if pattern.regex != nil && pattern.regex.MatchString(nameLower) { for _, cap := range pattern.Capabilities { capabilities[cap] = true + if cap == "embeddings" { + embeddingByName = true + } } } } + if embeddingByName { + delete(capabilities, "text-generation") + } // Context window size determines long-form processing capabilities if contextLength > 0 { diff --git a/internal/adapter/unifier/metadata_extractor_test.go b/internal/adapter/unifier/metadata_extractor_test.go index 9ccfe180..86595c9c 100644 --- a/internal/adapter/unifier/metadata_extractor_test.go +++ b/internal/adapter/unifier/metadata_extractor_test.go @@ -180,6 +180,12 @@ func TestInferCapabilitiesFromMetadata(t *testing.T) { modelName: "bakllava-7b", expectedCapabilities: []string{"text-generation", "vision", "multimodal", "image-understanding"}, }, + { + name: "llama.cpp BGE alias by name", + modelType: "", + modelName: "BAAI/bge-m3", + expectedCapabilities: []string{"embeddings", "similarity", "vector-search"}, + }, { name: "Multiple capabilities", modelType: "vlm", @@ -212,7 +218,7 @@ func TestInferCapabilitiesFromMetadata(t *testing.T) { } // For embedding models, ensure text-generation is not present - if tt.modelType == "embeddings" || tt.modelType == "embedding" { + if tt.modelType == "embeddings" || tt.modelType == "embedding" || tt.modelName == "BAAI/bge-m3" { assert.NotContains(t, caps, "text-generation") } }) diff --git a/internal/adapter/unifier/model_config.go b/internal/adapter/unifier/model_config.go index ff453891..7e1e5f97 100644 --- a/internal/adapter/unifier/model_config.go +++ b/internal/adapter/unifier/model_config.go @@ -273,6 +273,10 @@ func getDefaultConfig() *ModelUnificationConfig { Pattern: "(vision|vlm|llava|bakllava)", Capabilities: []string{"vision", "multimodal", "image-understanding"}, }, + { + Pattern: "(^|[/_-])(bge|e5|gte|minilm|jina-embeddings?|nomic-embed|mxbai-embed|text-embedding)", + Capabilities: []string{"embeddings", "similarity", "vector-search"}, + }, } config.Capabilities.ContextThresholds = map[string]int64{ From 1f58b147d2fe118e12c1fe38d20c96b21c4f3e2f Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 11:55:38 -0400 Subject: [PATCH 07/20] fix(status): flag degraded endpoints by success rate (#15) Co-authored-by: Peter Simmons --- config/config.yaml | 2 + docs/content/configuration/reference.md | 6 +- .../app/handlers/handler_status_endpoints.go | 88 ++++++++++---- .../handlers/handler_status_endpoints_test.go | 111 +++++++++++++++++- internal/config/config.go | 4 +- internal/config/types.go | 4 +- 6 files changed, 188 insertions(+), 27 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 56f9099f..ddbe1172 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -177,6 +177,8 @@ logging: engineering: show_nerdstats: false + endpoint_degraded_success_rate_threshold: 80.0 + endpoint_degraded_minimum_requests: 10 # Model aliases allow mapping a single virtual model name to multiple actual model names # across different backends. When a request arrives with an alias, olla finds endpoints diff --git a/docs/content/configuration/reference.md b/docs/content/configuration/reference.md index 1739fa0e..9ee7e2d3 100644 --- a/docs/content/configuration/reference.md +++ b/docs/content/configuration/reference.md @@ -714,12 +714,16 @@ Debug and development features. | Field | Type | Default | Description | |-------|------|---------|-------------| | `show_nerdstats` | bool | `false` | Show memory stats on shutdown | +| `endpoint_degraded_success_rate_threshold` | float | `80.0` | Mark endpoints degraded when request success rate drops below this percentage | +| `endpoint_degraded_minimum_requests` | integer | `10` | Minimum endpoint request count before low success rate degradation is reported | Example: ```yaml engineering: show_nerdstats: true + endpoint_degraded_success_rate_threshold: 80.0 + endpoint_degraded_minimum_requests: 10 ``` When enabled, displays: @@ -885,4 +889,4 @@ Additionally, Olla's `Validate()` method catches dangerous zero or empty configu - [Configuration Examples](examples.md) - Common configurations - [Best Practices](practices/overview.md) - Production recommendations -- [Environment Variables](#environment-variables) - Override configuration \ No newline at end of file +- [Environment Variables](#environment-variables) - Override configuration diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index b759f92b..3b160b0a 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -15,18 +15,21 @@ import ( ) type EndpointSummary struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - LastModelSync string `json:"last_model_sync,omitempty"` - HealthCheck string `json:"health_check"` - ResponseTime string `json:"response_time,omitempty"` - SuccessRate string `json:"success_rate"` - Issues string `json:"issues,omitempty"` - Priority int `json:"priority"` - ModelCount int `json:"model_count"` - RequestCount int64 `json:"request_count"` - CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LastModelSync string `json:"last_model_sync,omitempty"` + HealthCheck string `json:"health_check"` + ResponseTime string `json:"response_time,omitempty"` + SuccessRate string `json:"success_rate"` + DegradationReason string `json:"degradation_reason,omitempty"` + Issues string `json:"issues,omitempty"` + Priority int `json:"priority"` + ModelCount int `json:"model_count"` + RequestCount int64 `json:"request_count"` + SuccessRatePercent float64 `json:"success_rate_percent,omitempty"` + Degraded bool `json:"degraded"` + CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` } type EndpointStatusResponse struct { @@ -38,7 +41,9 @@ type EndpointStatusResponse struct { } const ( - healthyStatus = "healthy" + healthyStatus = "healthy" + degradedSuccessRateThresholdPercent = 80.0 + degradedSuccessRateMinimumRequestCount = int64(10) ) func (a *Application) endpointsStatusHandler(w http.ResponseWriter, r *http.Request) { @@ -109,8 +114,8 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s if hasStats { summary.RequestCount = stats.TotalRequests - if stats.TotalRequests > 0 { - successRate := (float64(stats.SuccessfulRequests) * 100.0) / float64(stats.TotalRequests) + if successRate, ok := endpointSuccessRatePercent(stats, hasStats); ok { + summary.SuccessRatePercent = successRate summary.SuccessRate = format.Percentage(successRate) } else { summary.SuccessRate = "N/A" @@ -120,6 +125,10 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s } summary.Issues = a.getEndpointIssuesSummaryOptimised(endpoint, stats, hasStats) + if a.endpointSuccessRateDegraded(stats, hasStats) { + summary.Degraded = true + summary.DegradationReason = "low success rate" + } summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) return summary @@ -141,10 +150,6 @@ func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain. } func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { - if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { - return "" - } - if endpoint.Status == domain.StatusOffline || endpoint.Status == domain.StatusUnhealthy { return "unavailable" } @@ -153,11 +158,48 @@ func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoin return "unstable" } - if hasStats && stats.TotalRequests > 10 { - if stats.SuccessfulRequests*100 < stats.TotalRequests*90 { - return "low success rate" - } + if a.endpointSuccessRateDegraded(stats, hasStats) { + return "low success rate" + } + + if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { + return "" } return "" } + +func endpointSuccessRatePercent(stats ports.EndpointStats, hasStats bool) (float64, bool) { + if !hasStats || stats.TotalRequests == 0 { + return 0, false + } + + return float64(stats.SuccessfulRequests) * 100.0 / float64(stats.TotalRequests), true +} + +func (a *Application) endpointSuccessRateDegraded(stats ports.EndpointStats, hasStats bool) bool { + successRate, ok := endpointSuccessRatePercent(stats, hasStats) + threshold, minRequests := a.endpointSuccessRateDegradationConfig() + if !ok || stats.TotalRequests < minRequests { + return false + } + + return successRate < threshold +} + +func (a *Application) endpointSuccessRateDegradationConfig() (float64, int64) { + threshold := degradedSuccessRateThresholdPercent + minRequests := degradedSuccessRateMinimumRequestCount + if a.Config == nil { + return threshold, minRequests + } + + if configured := a.Config.Engineering.EndpointDegradedSuccessRateThreshold; configured > 0 { + threshold = configured + } + if configured := a.Config.Engineering.EndpointDegradedMinimumRequests; configured > 0 { + minRequests = configured + } + + return threshold, minRequests +} diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index 97e90110..e754f56b 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/config" "github.com/thushan/olla/internal/core/domain" "github.com/thushan/olla/internal/core/ports" "github.com/thushan/olla/internal/logger" @@ -277,6 +278,88 @@ func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) } +func TestEndpointsStatusHandler_MarksHealthyEndpointWithLowSuccessRateDegraded(t *testing.T) { + endpoint := &domain.Endpoint{ + Name: "flaky-endpoint", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + } + app := createTestStatusApplication([]*domain.Endpoint{endpoint}) + app.statsCollector = &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 20, + SuccessfulRequests: 12, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + + summary := response.Endpoints[0] + assert.Equal(t, "healthy", summary.Status) + assert.True(t, summary.Degraded) + assert.Equal(t, "low success rate", summary.DegradationReason) + assert.Equal(t, "low success rate", summary.Issues) + assert.Equal(t, "60.0%", summary.SuccessRate) + assert.Equal(t, 60.0, summary.SuccessRatePercent) +} + +func TestEndpointsStatusHandler_UsesConfiguredDegradedSuccessRateThreshold(t *testing.T) { + endpoint := &domain.Endpoint{ + Name: "borderline-endpoint", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + } + app := createTestStatusApplication([]*domain.Endpoint{endpoint}) + app.Config = &config.Config{ + Engineering: config.EngineeringConfig{ + EndpointDegradedSuccessRateThreshold: 95, + EndpointDegradedMinimumRequests: 5, + }, + } + app.statsCollector = &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 10, + SuccessfulRequests: 9, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + + summary := response.Endpoints[0] + assert.True(t, summary.Degraded) + assert.Equal(t, "low success rate", summary.Issues) + assert.Equal(t, "90.0%", summary.SuccessRate) + assert.Equal(t, 90.0, summary.SuccessRatePercent) +} + func TestEndpointsStatusHandler_Concurrent(t *testing.T) { // Create test endpoints endpoints := []*domain.Endpoint{ @@ -558,11 +641,37 @@ func TestGetEndpointIssuesSummaryOptimised(t *testing.T) { }, stats: ports.EndpointStats{ TotalRequests: 100, - SuccessfulRequests: 80, // 80% success rate + SuccessfulRequests: 79, // below the default 80% success rate threshold }, hasStats: true, expectedIssues: "low success rate", }, + { + name: "healthy endpoint with low success rate", + endpoint: &domain.Endpoint{ + Status: domain.StatusHealthy, + ConsecutiveFailures: 0, + }, + stats: ports.EndpointStats{ + TotalRequests: 20, + SuccessfulRequests: 12, + }, + hasStats: true, + expectedIssues: "low success rate", + }, + { + name: "low success rate below minimum request count", + endpoint: &domain.Endpoint{ + Status: domain.StatusHealthy, + ConsecutiveFailures: 0, + }, + stats: ports.EndpointStats{ + TotalRequests: 9, + SuccessfulRequests: 6, + }, + hasStats: true, + expectedIssues: "", + }, } for _, tt := range tests { diff --git a/internal/config/config.go b/internal/config/config.go index c57322b2..a1d10de4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,7 +148,9 @@ func DefaultConfig() *Config { Output: "stdout", }, Engineering: EngineeringConfig{ - ShowNerdStats: false, + ShowNerdStats: false, + EndpointDegradedSuccessRateThreshold: 80.0, + EndpointDegradedMinimumRequests: 10, }, Translators: TranslatorsConfig{ Anthropic: AnthropicTranslatorConfig{ diff --git a/internal/config/types.go b/internal/config/types.go index ba9b4bb4..423796a1 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -171,7 +171,9 @@ type LoggingConfig struct { // EngineeringConfig holds development/debugging configuration type EngineeringConfig struct { - ShowNerdStats bool `yaml:"show_nerdstats"` + ShowNerdStats bool `yaml:"show_nerdstats"` + EndpointDegradedSuccessRateThreshold float64 `yaml:"endpoint_degraded_success_rate_threshold"` + EndpointDegradedMinimumRequests int64 `yaml:"endpoint_degraded_minimum_requests"` } // ModelRegistryConfig holds model registry configuration From c2a0f27c7f4a18a8e5c054ab753c6c3b885720cb Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 13:15:09 -0400 Subject: [PATCH 08/20] Expose circuit breaker and discovery status fixes (#16) Co-authored-by: Peter Simmons --- config/config.yaml | 2 +- .../app/handlers/handler_discovery_refresh.go | 33 ++++++++ .../handler_discovery_refresh_test.go | 80 +++++++++++++++++++ .../app/handlers/handler_status_endpoints.go | 26 +++++- .../handlers/handler_status_endpoints_test.go | 18 +++-- internal/app/handlers/server_routes.go | 1 + internal/config/config.go | 2 +- internal/config/config_test.go | 3 + 8 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 internal/app/handlers/handler_discovery_refresh.go create mode 100644 internal/app/handlers/handler_discovery_refresh_test.go diff --git a/config/config.yaml b/config/config.yaml index ddbe1172..6a708c2c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -149,7 +149,7 @@ model_registry: # Discovery mode settings discovery_timeout: 2s # Timeout for discovery refresh - discovery_refresh_on_miss: false # Refresh discovery when model not found + discovery_refresh_on_miss: true # Refresh discovery when model not found translators: ##### diff --git a/internal/app/handlers/handler_discovery_refresh.go b/internal/app/handlers/handler_discovery_refresh.go new file mode 100644 index 00000000..2821e421 --- /dev/null +++ b/internal/app/handlers/handler_discovery_refresh.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/thushan/olla/internal/core/constants" +) + +type DiscoveryRefreshResponse struct { + Timestamp time.Time `json:"timestamp"` + Refreshed bool `json:"refreshed"` +} + +func (a *Application) discoveryRefreshHandler(w http.ResponseWriter, r *http.Request) { + if a.discoveryService == nil { + http.Error(w, "Discovery service not initialized", http.StatusServiceUnavailable) + return + } + + if err := a.discoveryService.RefreshEndpoints(r.Context()); err != nil { + http.Error(w, "Discovery refresh failed", http.StatusInternalServerError) + return + } + + w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(DiscoveryRefreshResponse{ + Timestamp: time.Now(), + Refreshed: true, + }) +} diff --git a/internal/app/handlers/handler_discovery_refresh_test.go b/internal/app/handlers/handler_discovery_refresh_test.go new file mode 100644 index 00000000..04620d67 --- /dev/null +++ b/internal/app/handlers/handler_discovery_refresh_test.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/core/domain" +) + +type manualRefreshDiscoveryService struct { + err error + refreshCalls int +} + +func (m *manualRefreshDiscoveryService) GetEndpoints(ctx context.Context) ([]*domain.Endpoint, error) { + return nil, nil +} + +func (m *manualRefreshDiscoveryService) GetHealthyEndpoints(ctx context.Context) ([]*domain.Endpoint, error) { + return nil, nil +} + +func (m *manualRefreshDiscoveryService) RefreshEndpoints(ctx context.Context) error { + m.refreshCalls++ + return m.err +} + +func (m *manualRefreshDiscoveryService) UpdateEndpointStatus(ctx context.Context, endpoint *domain.Endpoint) error { + return nil +} + +func TestDiscoveryRefreshHandler_RefreshesDiscovery(t *testing.T) { + discovery := &manualRefreshDiscoveryService{} + app := &Application{discoveryService: discovery} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + assert.Equal(t, 1, discovery.refreshCalls) + + var response DiscoveryRefreshResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + assert.True(t, response.Refreshed) + assert.False(t, response.Timestamp.IsZero()) +} + +func TestDiscoveryRefreshHandler_ReturnsServiceUnavailableWithoutDiscovery(t *testing.T) { + app := &Application{} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestDiscoveryRefreshHandler_ReturnsInternalServerErrorOnRefreshFailure(t *testing.T) { + discovery := &manualRefreshDiscoveryService{err: errors.New("refresh failed")} + app := &Application{discoveryService: discovery} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, 1, discovery.refreshCalls) +} diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index 3b160b0a..e5d8376d 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -42,6 +42,8 @@ type EndpointStatusResponse struct { const ( healthyStatus = "healthy" + circuitOpenStatus = "circuit_open" + circuitOpenDegradationReason = "circuit breaker open" degradedSuccessRateThresholdPercent = 80.0 degradedSuccessRateMinimumRequestCount = int64(10) ) @@ -129,7 +131,15 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s summary.Degraded = true summary.DegradationReason = "low success rate" } - summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) + if circuitBreaker := a.getCircuitBreakerState(endpoint); circuitBreaker != nil { + summary.CircuitBreaker = circuitBreaker + if isCircuitBreakerOpen(circuitBreaker) { + summary.Status = circuitOpenStatus + summary.Degraded = true + summary.DegradationReason = circuitOpenDegradationReason + summary.Issues = appendEndpointIssue(summary.Issues, circuitOpenDegradationReason) + } + } return summary } @@ -149,6 +159,20 @@ func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain. return &state } +func isCircuitBreakerOpen(state *domain.CircuitBreakerState) bool { + return state != nil && state.State == "open" +} + +func appendEndpointIssue(existing, issue string) string { + if existing == "" { + return issue + } + if existing == issue { + return existing + } + return existing + "; " + issue +} + func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { if endpoint.Status == domain.StatusOffline || endpoint.Status == domain.StatusUnhealthy { return "unavailable" diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index e754f56b..074bb3e0 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -270,12 +270,18 @@ func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) require.Len(t, response.Endpoints, 1) - require.NotNil(t, response.Endpoints[0].CircuitBreaker) - assert.Equal(t, "open", response.Endpoints[0].CircuitBreaker.State) - assert.Equal(t, int64(3), response.Endpoints[0].CircuitBreaker.ConsecutiveFailures) - assert.Equal(t, 25, response.Endpoints[0].CircuitBreaker.CooldownRemainingSec) - require.NotNil(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp) - assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) + + summary := response.Endpoints[0] + assert.Equal(t, "circuit_open", summary.Status) + assert.True(t, summary.Degraded) + assert.Equal(t, "circuit breaker open", summary.DegradationReason) + assert.Equal(t, "circuit breaker open", summary.Issues) + require.NotNil(t, summary.CircuitBreaker) + assert.Equal(t, "open", summary.CircuitBreaker.State) + assert.Equal(t, int64(3), summary.CircuitBreaker.ConsecutiveFailures) + assert.Equal(t, 25, summary.CircuitBreaker.CooldownRemainingSec) + require.NotNil(t, summary.CircuitBreaker.LastTripTimestamp) + assert.True(t, summary.CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) } func TestEndpointsStatusHandler_MarksHealthyEndpointWithLowSuccessRateDegraded(t *testing.T) { diff --git a/internal/app/handlers/server_routes.go b/internal/app/handlers/server_routes.go index 8e5cd052..9a986aff 100644 --- a/internal/app/handlers/server_routes.go +++ b/internal/app/handlers/server_routes.go @@ -33,6 +33,7 @@ func (a *Application) registerRoutes() { a.routeRegistry.RegisterWithMethod("/internal/status", a.statusHandler, "Endpoint status", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/endpoints", a.endpointsStatusHandler, "Endpoints status", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/models", a.modelsStatusHandler, "Models status", "GET") + a.routeRegistry.RegisterWithMethod("/internal/discovery/refresh", a.discoveryRefreshHandler, "Refresh endpoint and model discovery", "POST") a.routeRegistry.RegisterWithMethod("/internal/stats/models", a.modelStatsHandler, "Model statistics", "GET") a.routeRegistry.RegisterWithMethod("/internal/stats/translators", a.translatorStatsHandler, "Translator statistics", "GET") a.routeRegistry.RegisterWithMethod("/internal/stats/sticky", a.stickyStatsHandler, "Sticky session statistics", "GET") diff --git a/internal/config/config.go b/internal/config/config.go index a1d10de4..e0c6981b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,7 +124,7 @@ func DefaultConfig() *Config { RoutingStrategy: ModelRoutingStrategy{ Type: "strict", // default to strict for predictable behavior Options: ModelRoutingStrategyOptions{ - DiscoveryRefreshOnMiss: false, + DiscoveryRefreshOnMiss: true, DiscoveryTimeout: 2 * time.Second, FallbackBehavior: constants.FallbackBehaviorCompatibleOnly, }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d10fd3c0..7beaa9a1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -52,6 +52,9 @@ func TestDefaultConfig(t *testing.T) { if cfg.Proxy.MaxRetries != 3 { t.Errorf("Expected max retries 3, got %d", cfg.Proxy.MaxRetries) } + if !cfg.ModelRegistry.RoutingStrategy.Options.DiscoveryRefreshOnMiss { + t.Error("Expected discovery refresh on miss to be enabled by default") + } // Test engineering defaults if cfg.Engineering.ShowNerdStats != false { From 9f406ae13bdb75bccc9139966e544d8cff5b1b1d Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Thu, 21 May 2026 21:59:10 -0400 Subject: [PATCH 09/20] feat(instinct#12): add FC discovery type (petersimmons1972/instinct#12) (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds discovery.type: "fc" — Olla polls Flight Controller /registry every 15s and atomically reconciles its backend set, meeting the <30s convergence SLO. - internal/adapter/discovery/fc_repository.go: FCDiscoveryPoller with Poll() and RunLoop(). Converts FC RegistryEntry{Host,Models[]{Name,Port}} into EndpointConfig slices and calls LoadFromConfig (thread-safe atomic map swap already in StaticEndpointRepository). Fail-open: FC unreachable → warn and preserve existing endpoint set. - internal/adapter/discovery/fc_repository_test.go: 3 tests — convert, remove-stale (core acceptance criterion), fail-open on FC unavailability - internal/config/types.go: FCDiscoveryConfig{RegistryURL, PollInterval}; DiscoveryConfig.FC added - internal/app/services/discovery.go: "fc" switch case validates registry_url, defaults poll_interval to 15s, runs initial blocking poll at startup, launches background RunLoop goroutine; fcPollerCancel called on Stop() ADV.1 advisory recommendation: Path A chosen over Path C (Reconciler) because Olla has no hot-reload path — ConfigMap volume mount convergence is 30-60s+ making <30s SLO unreliable without an Olla fork change anyway. Co-authored-by: Peter Simmons Co-authored-by: Claude Sonnet 4.6 --- internal/adapter/discovery/fc_repository.go | 138 ++++++++++++ .../adapter/discovery/fc_repository_test.go | 202 ++++++++++++++++++ internal/app/services/discovery.go | 32 +++ internal/config/types.go | 14 +- 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 internal/adapter/discovery/fc_repository.go create mode 100644 internal/adapter/discovery/fc_repository_test.go diff --git a/internal/adapter/discovery/fc_repository.go b/internal/adapter/discovery/fc_repository.go new file mode 100644 index 00000000..4ca3d83d --- /dev/null +++ b/internal/adapter/discovery/fc_repository.go @@ -0,0 +1,138 @@ +package discovery + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/logger" +) + +// fcModelSpec mirrors the Flight Controller ModelSpec fields we care about for +// building Olla endpoint configs. Only Name and Port are required for routing. +type fcModelSpec struct { + Name string `json:"name"` + Port int `json:"port"` + Framework string `json:"framework"` +} + +// fcRegistryEntry mirrors the Flight Controller RegistryEntry type returned by +// GET /registry. We only parse the fields Olla needs; extra fields are ignored. +type fcRegistryEntry struct { + Host string `json:"host"` + Models []fcModelSpec `json:"models"` +} + +// FCDiscoveryPoller polls the Flight Controller /registry endpoint and reconciles +// the StaticEndpointRepository on each poll. A full replace-on-poll strategy ensures +// that endpoints removed from the FC registry are promptly evicted from Olla's rotation, +// meeting the <30s convergence acceptance criterion for petersimmons1972/instinct#12. +type FCDiscoveryPoller struct { + repo *StaticEndpointRepository + registryURL string + logger logger.StyledLogger + client *http.Client +} + +// NewFCDiscoveryPoller creates a poller that syncs Olla endpoints from FC /registry. +// registryBaseURL is the FC service base URL, e.g. http://ai-fleet-controller.ai-fleet.svc.cluster.local. +func NewFCDiscoveryPoller(repo *StaticEndpointRepository, registryBaseURL string, log logger.StyledLogger) *FCDiscoveryPoller { + return &FCDiscoveryPoller{ + repo: repo, + registryURL: registryBaseURL + "/registry", + logger: log, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Poll fetches the FC registry and reconciles Olla's endpoint set. +// On FC unreachable: logs a warning, preserves the current endpoint set (fail-open). +// On success: fully replaces the endpoint set with the FC-derived list. +func (p *FCDiscoveryPoller) Poll(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.registryURL, nil) + if err != nil { + return fmt.Errorf("fc-discovery: build request: %w", err) + } + + resp, err := p.client.Do(req) + if err != nil { + // Fail-open: preserve the existing endpoint set when FC is unreachable. + p.logger.Warn("fc-discovery: FC registry unreachable, preserving existing endpoints", "url", p.registryURL, "error", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + p.logger.Warn("fc-discovery: FC registry returned non-200, preserving existing endpoints", + "url", p.registryURL, "status", resp.StatusCode) + return nil + } + + var entries []fcRegistryEntry + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return fmt.Errorf("fc-discovery: decode registry response: %w", err) + } + + configs := fcEntriesToEndpointConfigs(entries) + if err := p.repo.LoadFromConfig(ctx, configs); err != nil { + return fmt.Errorf("fc-discovery: load endpoint configs: %w", err) + } + + p.logger.Info("fc-discovery: endpoint set reconciled from FC registry", + "endpoints", len(configs)) + return nil +} + +// RunLoop starts a background polling loop. It polls immediately on first call, then +// at the configured interval. The loop exits when ctx is cancelled. +func (p *FCDiscoveryPoller) RunLoop(ctx context.Context, interval time.Duration) { + // Immediate first poll so Olla is populated before the ticker fires. + if err := p.Poll(ctx); err != nil { + p.logger.Warn("fc-discovery: initial poll error", "error", err) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + p.logger.Info("fc-discovery: loop stopped") + return + case <-ticker.C: + if err := p.Poll(ctx); err != nil { + p.logger.Warn("fc-discovery: poll error", "error", err) + } + } + } +} + +// fcEntriesToEndpointConfigs converts FC registry entries into Olla EndpointConfig slices. +// Each (host, model) pair becomes one endpoint at http://:. +// Type is always "openai" because all FC-managed containers serve the OpenAI-compatible API. +// Priority defaults to 100 (standard Olla default). +func fcEntriesToEndpointConfigs(entries []fcRegistryEntry) []config.EndpointConfig { + defaultPriority := 100 + var configs []config.EndpointConfig + + for _, entry := range entries { + for _, model := range entry.Models { + if model.Port == 0 { + continue // skip models without a port (incomplete CRD) + } + cfg := config.EndpointConfig{ + URL: fmt.Sprintf("http://%s:%d", entry.Host, model.Port), + Name: fmt.Sprintf("%s-%s", entry.Host, model.Name), + Type: "openai", + Priority: &defaultPriority, + } + configs = append(configs, cfg) + } + } + return configs +} diff --git a/internal/adapter/discovery/fc_repository_test.go b/internal/adapter/discovery/fc_repository_test.go new file mode 100644 index 00000000..22a7a8f1 --- /dev/null +++ b/internal/adapter/discovery/fc_repository_test.go @@ -0,0 +1,202 @@ +package discovery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/thushan/olla/internal/adapter/discovery" + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/logger" +) + +// FCRegistryEntry mirrors the Flight Controller RegistryEntry type. +type FCRegistryEntry struct { + Host string `json:"host"` + Models []FCModelSpec `json:"models"` +} + +type FCModelSpec struct { + Name string `json:"name"` + Port int `json:"port"` +} + +func newTestLogger() logger.StyledLogger { + loggerCfg := &logger.Config{Level: "error", Theme: "default"} + log, cleanup, _ := logger.New(loggerCfg) + _ = cleanup // test logger; cleanup deferred but not critical here + return logger.NewPlainStyledLogger(log) +} + +// TestFCEndpointRepository_PollConvertsRegistryToEndpoints verifies that the FC +// discovery poller transforms Flight Controller /registry entries into Olla endpoints. +func TestFCEndpointRepository_PollConvertsRegistryToEndpoints(t *testing.T) { + entries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{ + {Name: "qwen3-32b-vllm", Port: 8000}, + {Name: "bge-m3-infinity", Port: 8003}, + }, + }, + { + Host: "precision.petersimmons.com", + Models: []FCModelSpec{ + {Name: "bge-m3-infinity", Port: 8005}, + }, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/registry" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) + })) + defer srv.Close() + + repo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := poller.Poll(ctx); err != nil { + t.Fatalf("Poll() returned error: %v", err) + } + + all, err := repo.GetAll(ctx) + if err != nil { + t.Fatalf("GetAll() returned error: %v", err) + } + + // 3 total endpoints: 2 from oblivion + 1 from precision + if len(all) != 3 { + t.Errorf("expected 3 endpoints, got %d", len(all)) + for _, e := range all { + t.Logf(" endpoint: name=%q url=%q", e.Name, e.URLString) + } + } + + // Verify a specific endpoint URL was generated correctly + wantURL := "http://oblivion.petersimmons.com:8000" + found := false + for _, e := range all { + if e.URLString == wantURL { + found = true + break + } + } + if !found { + t.Errorf("expected endpoint with URL %q but not found", wantURL) + } +} + +// TestFCEndpointRepository_PollRemovesStaleEndpoints verifies that when FC registry +// shrinks (host goes offline), polling removes the stale endpoints from Olla's rotation. +// This is the core acceptance criterion for instinct#12. +func TestFCEndpointRepository_PollRemovesStaleEndpoints(t *testing.T) { + initialEntries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{{Name: "qwen3-32b-vllm", Port: 8000}}, + }, + { + Host: "precision.petersimmons.com", + Models: []FCModelSpec{{Name: "bge-m3-infinity", Port: 8005}}, + }, + } + + // After first poll, precision goes offline + reducedEntries := []FCRegistryEntry{ + { + Host: "oblivion.petersimmons.com", + Models: []FCModelSpec{{Name: "qwen3-32b-vllm", Port: 8000}}, + }, + } + + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/registry" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(initialEntries) + } else { + json.NewEncoder(w).Encode(reducedEntries) + } + })) + defer srv.Close() + + repo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // First poll: 2 endpoints + if err := poller.Poll(ctx); err != nil { + t.Fatalf("first Poll() error: %v", err) + } + all, _ := repo.GetAll(ctx) + if len(all) != 2 { + t.Errorf("after first poll: expected 2 endpoints, got %d", len(all)) + } + + // Second poll: precision removed from registry → should be removed from Olla + if err := poller.Poll(ctx); err != nil { + t.Fatalf("second Poll() error: %v", err) + } + all, _ = repo.GetAll(ctx) + if len(all) != 1 { + t.Errorf("after second poll: expected 1 endpoint (stale precision removed), got %d", len(all)) + for _, e := range all { + t.Logf(" remaining: name=%q url=%q", e.Name, e.URLString) + } + } + + // Verify the remaining endpoint is oblivion only + if len(all) == 1 && all[0].URLString != "http://oblivion.petersimmons.com:8000" { + t.Errorf("expected oblivion endpoint, got %q", all[0].URLString) + } +} + +// TestFCEndpointRepository_PollFailOpenOnFCUnavailable verifies that when FC is +// unreachable, the existing endpoint set is preserved (fail-open for availability). +func TestFCEndpointRepository_PollFailOpenOnFCUnavailable(t *testing.T) { + // Seed the repo with one endpoint + repo := discovery.NewStaticEndpointRepository() + priority := 100 + seedConfigs := []config.EndpointConfig{ + {URL: "http://oblivion.petersimmons.com:8000", Name: "oblivion", Type: "openai", Priority: &priority}, + } + ctx := context.Background() + if err := repo.LoadFromConfig(ctx, seedConfigs); err != nil { + t.Fatalf("seed LoadFromConfig error: %v", err) + } + + // Point at a server that is already closed + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + poller := discovery.NewFCDiscoveryPoller(repo, srv.URL, newTestLogger()) + + // Poll to an unavailable FC — should not error fatally, should preserve existing endpoints + err := poller.Poll(ctx) + if err == nil { + t.Log("Poll returned nil (considered non-fatal by poller — acceptable)") + } + + all, _ := repo.GetAll(ctx) + if len(all) != 1 { + t.Errorf("fail-open: expected original 1 endpoint preserved, got %d", len(all)) + } +} diff --git a/internal/app/services/discovery.go b/internal/app/services/discovery.go index 627197f6..a6f57349 100644 --- a/internal/app/services/discovery.go +++ b/internal/app/services/discovery.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "sync/atomic" + "time" "github.com/thushan/olla/internal/adapter/discovery" "github.com/thushan/olla/internal/adapter/health" @@ -29,6 +30,11 @@ type DiscoveryService struct { statsService *StatsService logger logger.StyledLogger modelDiscovery *discovery.ModelDiscoveryService + // fcPoller drives dynamic endpoint reconciliation when discovery.type = "fc". + // Runs a background goroutine that polls Flight Controller /registry; cancelled + // on Stop() via fcPollerCancel (petersimmons1972/instinct#12). + fcPoller *discovery.FCDiscoveryPoller + fcPollerCancel context.CancelFunc registry domain.ModelRegistry endpointRepo domain.EndpointRepository // purgeDeadFn, when set, is called with the current routable endpoint list @@ -99,6 +105,27 @@ func (s *DiscoveryService) Start(ctx context.Context) error { return fmt.Errorf("failed to load endpoints from config: %w", err) } s.endpointRepo = staticRepo + case "fc": + // Flight Controller dynamic discovery: poll FC /registry on a fixed interval + // and atomically replace the endpoint set (petersimmons1972/instinct#12). + if s.config.FC.RegistryURL == "" { + return fmt.Errorf("discovery.fc.registry_url is required when discovery.type is \"fc\"") + } + pollInterval := s.config.FC.PollInterval + if pollInterval <= 0 { + pollInterval = 15 * time.Second + } + staticRepo := discovery.NewStaticEndpointRepository() + poller := discovery.NewFCDiscoveryPoller(staticRepo, s.config.FC.RegistryURL, s.logger) + // Run an initial blocking poll so endpoints are populated before health checks start. + if err := poller.Poll(ctx); err != nil { + return fmt.Errorf("fc-discovery: initial poll failed: %w", err) + } + pollerCtx, cancel := context.WithCancel(context.Background()) + s.fcPoller = poller + s.fcPollerCancel = cancel + go poller.RunLoop(pollerCtx, pollInterval) + s.endpointRepo = staticRepo default: return fmt.Errorf("unsupported discovery type: %s", s.config.Type) } @@ -215,6 +242,11 @@ func (s *DiscoveryService) Stop(ctx context.Context) error { s.logger.Warn(" Failed to stop model discovery", "error", err) } } + + if s.fcPollerCancel != nil { + s.fcPollerCancel() + } + return nil } diff --git a/internal/config/types.go b/internal/config/types.go index 69407931..edeb80dd 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -105,12 +105,24 @@ type ProxyConfig struct { // DiscoveryConfig holds service discovery configuration type DiscoveryConfig struct { - Type string `yaml:"type"` // Only "static" is implemented + Type string `yaml:"type"` // "static" or "fc" Static StaticDiscoveryConfig `yaml:"static"` + FC FCDiscoveryConfig `yaml:"fc"` RefreshInterval time.Duration `yaml:"refresh_interval"` ModelDiscovery ModelDiscoveryConfig `yaml:"model_discovery"` } +// FCDiscoveryConfig holds configuration for Flight Controller-based dynamic discovery. +// When discovery.type is "fc", Olla polls the FC /registry endpoint and reconciles +// its backend list on each tick (petersimmons1972/instinct#12). +type FCDiscoveryConfig struct { + // RegistryURL is the base URL of the Flight Controller service, e.g. + // http://ai-fleet-controller.ai-fleet.svc.cluster.local + RegistryURL string `yaml:"registry_url"` + // PollInterval is how often Olla polls FC /registry. Default: 15s. + PollInterval time.Duration `yaml:"poll_interval"` +} + // ModelDiscoveryConfig holds model discvery specific settings type ModelDiscoveryConfig struct { Enabled bool `yaml:"enabled"` From 74b7efa26ebeb2043ada90c44a043d8f1f4fed25 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:26:35 -0400 Subject: [PATCH 10/20] fix(olla): honor endpoint circuit breaker config --- internal/adapter/discovery/repository.go | 36 ++++++------ internal/adapter/discovery/repository_test.go | 33 +++++++++++ internal/adapter/health/checker_test.go | 56 ++++++++++++++++++ internal/adapter/health/circuit_breaker.go | 33 ++++++++++- internal/adapter/health/client.go | 4 ++ internal/adapter/proxy/olla/service.go | 35 ++++++++--- .../olla/service_circuit_breaker_test.go | 58 +++++++++++++++++++ internal/config/types.go | 24 +++++--- internal/core/domain/endpoint.go | 42 +++++++------- 9 files changed, 266 insertions(+), 55 deletions(-) create mode 100644 internal/adapter/proxy/olla/service_circuit_breaker_test.go diff --git a/internal/adapter/discovery/repository.go b/internal/adapter/discovery/repository.go index 0774ef53..36af56e5 100644 --- a/internal/adapter/discovery/repository.go +++ b/internal/adapter/discovery/repository.go @@ -171,23 +171,25 @@ func (r *StaticEndpointRepository) LoadFromConfig(ctx context.Context, configs [ } newEndpoint := &domain.Endpoint{ - Name: cfg.Name, - URL: endpointURL, - Type: cfg.Type, - Priority: *cfg.Priority, - HealthCheckURL: healthCheckURL, - ModelUrl: modelURL, - ModelFilter: cfg.ModelFilter, - CheckInterval: cfg.CheckInterval, - CheckTimeout: cfg.CheckTimeout, - Status: domain.StatusUnknown, - URLString: urlString, - HealthCheckPathString: healthCheckPath, - HealthCheckURLString: healthCheckURLString, - ModelURLString: modelURLString, - BackoffMultiplier: 1, - NextCheckTime: now, - PreservePath: cfg.PreservePath, + Name: cfg.Name, + URL: endpointURL, + Type: cfg.Type, + Priority: *cfg.Priority, + HealthCheckURL: healthCheckURL, + ModelUrl: modelURL, + ModelFilter: cfg.ModelFilter, + CheckInterval: cfg.CheckInterval, + CheckTimeout: cfg.CheckTimeout, + CircuitBreakerTimeout: cfg.CircuitBreakerTimeout, + CircuitBreakerThreshold: cfg.CircuitBreakerThreshold, + Status: domain.StatusUnknown, + URLString: urlString, + HealthCheckPathString: healthCheckPath, + HealthCheckURLString: healthCheckURLString, + ModelURLString: modelURLString, + BackoffMultiplier: 1, + NextCheckTime: now, + PreservePath: cfg.PreservePath, } newEndpoints[urlString] = newEndpoint diff --git a/internal/adapter/discovery/repository_test.go b/internal/adapter/discovery/repository_test.go index e75152bd..ac29a5f3 100644 --- a/internal/adapter/discovery/repository_test.go +++ b/internal/adapter/discovery/repository_test.go @@ -425,6 +425,39 @@ func TestStaticEndpointRepository_LoadFromConfig(t *testing.T) { } } +func TestStaticEndpointRepository_LoadFromConfig_CircuitBreakerOverrides(t *testing.T) { + repo := NewStaticEndpointRepository() + cfg := config.EndpointConfig{ + Name: "slow-vllm", + URL: "http://localhost:8000", + Type: "ollama", + Priority: ptrInt(100), + CheckInterval: 5 * time.Second, + CheckTimeout: 2 * time.Second, + CircuitBreakerTimeout: 2000 * time.Second, + CircuitBreakerThreshold: 7, + } + + err := repo.LoadFromConfig(context.Background(), []config.EndpointConfig{cfg}) + if err != nil { + t.Fatalf("LoadFromConfig failed: %v", err) + } + + endpoints, err := repo.GetAll(context.Background()) + if err != nil { + t.Fatalf("GetAll failed: %v", err) + } + if len(endpoints) != 1 { + t.Fatalf("expected 1 endpoint, got %d", len(endpoints)) + } + if endpoints[0].CircuitBreakerTimeout != 2000*time.Second { + t.Fatalf("CircuitBreakerTimeout = %v, want 2000s", endpoints[0].CircuitBreakerTimeout) + } + if endpoints[0].CircuitBreakerThreshold != 7 { + t.Fatalf("CircuitBreakerThreshold = %d, want 7", endpoints[0].CircuitBreakerThreshold) + } +} + func TestStaticEndpointRepository_EmptyConfig(t *testing.T) { repo := NewStaticEndpointRepository() ctx := context.Background() diff --git a/internal/adapter/health/checker_test.go b/internal/adapter/health/checker_test.go index ed9f5c9b..299262d9 100644 --- a/internal/adapter/health/checker_test.go +++ b/internal/adapter/health/checker_test.go @@ -251,6 +251,62 @@ func TestCircuitBreaker_BasicOperation(t *testing.T) { } } +func TestCircuitBreaker_PerEndpointOverrides(t *testing.T) { + tests := []struct { + name string + config CircuitBreakerConfig + failures int + expectOpen bool + sleepBefore bool + }{ + { + name: "custom threshold delays open", + config: CircuitBreakerConfig{ + Threshold: DefaultCircuitBreakerThreshold + 2, + }, + failures: DefaultCircuitBreakerThreshold, + expectOpen: false, + }, + { + name: "custom threshold opens at override", + config: CircuitBreakerConfig{ + Threshold: DefaultCircuitBreakerThreshold + 2, + }, + failures: DefaultCircuitBreakerThreshold + 2, + expectOpen: true, + }, + { + name: "custom timeout controls half open cooldown", + config: CircuitBreakerConfig{ + Threshold: 1, + Timeout: 10 * time.Millisecond, + }, + failures: 1, + expectOpen: false, + sleepBefore: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cb := NewCircuitBreaker() + url := "http://localhost:11434/" + tt.name + cb.ConfigureEndpoint(url, tt.config) + + for range tt.failures { + cb.RecordFailure(url) + } + if tt.sleepBefore { + time.Sleep(20 * time.Millisecond) + } + + if got := cb.IsOpen(url); got != tt.expectOpen { + t.Fatalf("IsOpen() = %v, want %v", got, tt.expectOpen) + } + }) + } +} + func TestCircuitBreaker_Cleanup(t *testing.T) { cb := NewCircuitBreaker() url1 := "http://localhost:11434" diff --git a/internal/adapter/health/circuit_breaker.go b/internal/adapter/health/circuit_breaker.go index 5d534ffd..10107284 100644 --- a/internal/adapter/health/circuit_breaker.go +++ b/internal/adapter/health/circuit_breaker.go @@ -25,6 +25,7 @@ type CircuitBreaker struct { endpoints *xsync.Map[string, *circuitState] failureThreshold int timeout time.Duration + endpointConfigs *xsync.Map[string, CircuitBreakerConfig] } type circuitState struct { @@ -34,12 +35,26 @@ type circuitState struct { isOpen int32 } +type CircuitBreakerConfig struct { + Timeout time.Duration + Threshold int +} + func NewCircuitBreaker() *CircuitBreaker { return &CircuitBreaker{ endpoints: xsync.NewMap[string, *circuitState](), failureThreshold: DefaultCircuitBreakerThreshold, timeout: DefaultCircuitBreakerTimeout, + endpointConfigs: xsync.NewMap[string, CircuitBreakerConfig](), + } +} + +func (cb *CircuitBreaker) ConfigureEndpoint(endpointURL string, cfg CircuitBreakerConfig) { + if cfg.Timeout <= 0 && cfg.Threshold <= 0 { + cb.endpointConfigs.Delete(endpointURL) + return } + cb.endpointConfigs.Store(endpointURL, cfg) } func (cb *CircuitBreaker) IsOpen(endpointURL string) bool { @@ -53,7 +68,7 @@ func (cb *CircuitBreaker) IsOpen(endpointURL string) bool { // Check if circuit should auto-recover if atomic.LoadInt32(&state.isOpen) == 1 { lastFailure := atomic.LoadInt64(&state.lastFailure) - if time.Unix(0, lastFailure).Add(cb.timeout).Before(time.Now()) { + if time.Unix(0, lastFailure).Add(cb.timeoutForEndpoint(endpointURL)).Before(time.Now()) { // Allow one request through (half-open state) if atomic.CompareAndSwapInt64(&state.lastAttempt, 0, now) { return false @@ -89,7 +104,7 @@ func (cb *CircuitBreaker) RecordFailure(endpointURL string) { atomic.StoreInt64(&state.lastFailure, time.Now().UnixNano()) atomic.StoreInt64(&state.lastAttempt, 0) - if failures >= int64(cb.failureThreshold) { + if failures >= int64(cb.thresholdForEndpoint(endpointURL)) { atomic.StoreInt32(&state.isOpen, 1) } } @@ -113,3 +128,17 @@ func (cb *CircuitBreaker) loadOrCreateState(endpointURL string) *circuitState { }) return state } + +func (cb *CircuitBreaker) timeoutForEndpoint(endpointURL string) time.Duration { + if cfg, ok := cb.endpointConfigs.Load(endpointURL); ok && cfg.Timeout > 0 { + return cfg.Timeout + } + return cb.timeout +} + +func (cb *CircuitBreaker) thresholdForEndpoint(endpointURL string) int { + if cfg, ok := cb.endpointConfigs.Load(endpointURL); ok && cfg.Threshold > 0 { + return cfg.Threshold + } + return cb.failureThreshold +} diff --git a/internal/adapter/health/client.go b/internal/adapter/health/client.go index 28b5c25b..8f84eb2d 100644 --- a/internal/adapter/health/client.go +++ b/internal/adapter/health/client.go @@ -50,6 +50,10 @@ func (hc *HealthClient) Check(ctx context.Context, endpoint *domain.Endpoint) (r }() healthCheckURL := endpoint.GetHealthCheckURLString() + hc.circuitBreaker.ConfigureEndpoint(healthCheckURL, CircuitBreakerConfig{ + Timeout: endpoint.CircuitBreakerTimeout, + Threshold: endpoint.CircuitBreakerThreshold, + }) // Check circuit breaker first if hc.circuitBreaker.IsOpen(healthCheckURL) { diff --git a/internal/adapter/proxy/olla/service.go b/internal/adapter/proxy/olla/service.go index eecda19b..90936cad 100644 --- a/internal/adapter/proxy/olla/service.go +++ b/internal/adapter/proxy/olla/service.go @@ -58,8 +58,8 @@ const ( ClientDisconnectionBytesThreshold = 1024 ClientDisconnectionTimeThreshold = 5 * time.Second - // Circuit breaker threshold higher than health checker for tolerance - circuitBreakerThreshold = 5 // vs health.DefaultCircuitBreakerThreshold (3) + // Circuit breaker threshold higher than health checker for tolerance. + proxyCircuitBreakerThreshold = 5 // vs health.DefaultCircuitBreakerThreshold (3) ) // Service implements the Olla proxy - optimised for high performance and resilience @@ -99,6 +99,7 @@ type circuitBreaker struct { lastFailure int64 // atomic state int64 // atomic: 0=closed, 1=open, 2=half-open threshold int64 + timeout time.Duration } // requestContext contains per-request data from our object pool @@ -258,14 +259,34 @@ func (s *Service) getOrCreateEndpointPool(endpoint string) *connectionPool { return actual } -// GetCircuitBreaker returns the circuit breaker for an endpoint (exported for testing) +// GetCircuitBreaker returns the circuit breaker for an endpoint (exported for testing). func (s *Service) GetCircuitBreaker(endpoint string) *circuitBreaker { + return s.getCircuitBreaker(endpoint, 0, 0) +} + +func (s *Service) getCircuitBreakerForEndpoint(endpoint *domain.Endpoint) *circuitBreaker { + return s.getCircuitBreaker( + endpoint.Name, + endpoint.CircuitBreakerTimeout, + endpoint.CircuitBreakerThreshold, + ) +} + +func (s *Service) getCircuitBreaker(endpoint string, timeout time.Duration, threshold int) *circuitBreaker { if cb, ok := s.circuitBreakers.Load(endpoint); ok { return cb } + if threshold <= 0 { + threshold = proxyCircuitBreakerThreshold + } + if timeout <= 0 { + timeout = health.DefaultCircuitBreakerTimeout + } + newCB := &circuitBreaker{ - threshold: circuitBreakerThreshold, + threshold: int64(threshold), + timeout: timeout, state: 0, // closed } @@ -282,7 +303,7 @@ func (cb *circuitBreaker) IsOpen() bool { // Check if timeout has passed lastFailure := atomic.LoadInt64(&cb.lastFailure) - if time.Since(time.Unix(0, lastFailure)) > health.DefaultCircuitBreakerTimeout { + if time.Since(time.Unix(0, lastFailure)) > cb.timeout { // Try half-open state if atomic.CompareAndSwapInt64(&cb.state, 1, 2) { // State transition: Open -> Half-open @@ -341,7 +362,7 @@ func (s *Service) handlePanic(ctx context.Context, w http.ResponseWriter, r *htt // selectEndpointWithCircuitBreaker selects an endpoint that has a healthy circuit breaker func (s *Service) selectEndpointWithCircuitBreaker(endpoints []*domain.Endpoint, rlog logger.StyledLogger) (*domain.Endpoint, *circuitBreaker) { for _, ep := range endpoints { - cb := s.GetCircuitBreaker(ep.Name) + cb := s.getCircuitBreakerForEndpoint(ep) stateBefore := atomic.LoadInt64(&cb.state) if !cb.IsOpen() { stateAfter := atomic.LoadInt64(&cb.state) @@ -349,7 +370,7 @@ func (s *Service) selectEndpointWithCircuitBreaker(endpoints []*domain.Endpoint, if stateBefore == 1 && stateAfter == 2 { rlog.Info("Circuit breaker entering half-open state", "endpoint", ep.Name, - "timeout", health.DefaultCircuitBreakerTimeout) + "timeout", cb.timeout) } return ep, cb } diff --git a/internal/adapter/proxy/olla/service_circuit_breaker_test.go b/internal/adapter/proxy/olla/service_circuit_breaker_test.go new file mode 100644 index 00000000..2c9e3bd9 --- /dev/null +++ b/internal/adapter/proxy/olla/service_circuit_breaker_test.go @@ -0,0 +1,58 @@ +package olla + +import ( + "testing" + "time" + + "github.com/puzpuzpuz/xsync/v4" + "github.com/thushan/olla/internal/adapter/health" + "github.com/thushan/olla/internal/core/domain" +) + +func TestServiceCircuitBreaker_PerEndpointOverrides(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + endpoint := &domain.Endpoint{ + Name: "slow-vllm", + CircuitBreakerTimeout: 10 * time.Millisecond, + CircuitBreakerThreshold: 2, + } + + cb := s.getCircuitBreakerForEndpoint(endpoint) + if cb.threshold != 2 { + t.Fatalf("threshold = %d, want 2", cb.threshold) + } + if cb.timeout != 10*time.Millisecond { + t.Fatalf("timeout = %v, want 10ms", cb.timeout) + } + + cb.RecordFailure() + if cb.IsOpen() { + t.Fatal("breaker opened before endpoint threshold") + } + + cb.RecordFailure() + if !cb.IsOpen() { + t.Fatal("breaker did not open at endpoint threshold") + } + + time.Sleep(20 * time.Millisecond) + if cb.IsOpen() { + t.Fatal("breaker did not use endpoint timeout for half-open transition") + } +} + +func TestServiceCircuitBreaker_DefaultsRemainUnchanged(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + + cb := s.GetCircuitBreaker("default-endpoint") + if cb.threshold != proxyCircuitBreakerThreshold { + t.Fatalf("threshold = %d, want %d", cb.threshold, proxyCircuitBreakerThreshold) + } + if cb.timeout != health.DefaultCircuitBreakerTimeout { + t.Fatalf("timeout = %v, want %v", cb.timeout, health.DefaultCircuitBreakerTimeout) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index edeb80dd..51566319 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -144,15 +144,21 @@ type EndpointConfig struct { // Priority uses a pointer so nil means "omitted in config" rather than explicitly zero. // This lets applyEndpointDefaults distinguish "user set 0" from "user said nothing", // since 0 is a valid, lower-than-default priority value. - Priority *int `yaml:"priority"` - URL string `yaml:"url"` - Name string `yaml:"name"` - Type string `yaml:"type"` - HealthCheckURL string `yaml:"health_check_url"` - ModelURL string `yaml:"model_url"` - CheckInterval time.Duration `yaml:"check_interval"` - CheckTimeout time.Duration `yaml:"check_timeout"` - PreservePath bool `yaml:"preserve_path"` + Priority *int `yaml:"priority"` + // CircuitBreakerTimeout overrides the circuit breaker cooldown for this endpoint. + // Zero means use the global default. + CircuitBreakerTimeout time.Duration `yaml:"circuit_breaker_timeout,omitempty"` + // CircuitBreakerThreshold overrides the consecutive failure threshold for this endpoint. + // Zero means use the global default. + CircuitBreakerThreshold int `yaml:"circuit_breaker_threshold,omitempty"` + URL string `yaml:"url"` + Name string `yaml:"name"` + Type string `yaml:"type"` + HealthCheckURL string `yaml:"health_check_url"` + ModelURL string `yaml:"model_url"` + CheckInterval time.Duration `yaml:"check_interval"` + CheckTimeout time.Duration `yaml:"check_timeout"` + PreservePath bool `yaml:"preserve_path"` } // LoggingConfig holds logging configuration diff --git a/internal/core/domain/endpoint.go b/internal/core/domain/endpoint.go index afb4ad83..e7106998 100644 --- a/internal/core/domain/endpoint.go +++ b/internal/core/domain/endpoint.go @@ -17,26 +17,28 @@ const ( ) type Endpoint struct { - LastChecked time.Time - NextCheckTime time.Time - URL *url.URL - HealthCheckURL *url.URL - ModelUrl *url.URL - ModelFilter *FilterConfig - Name string - Type string `json:"type,omitempty"` - Status EndpointStatus - URLString string - HealthCheckPathString string - HealthCheckURLString string - ModelURLString string - LastLatency time.Duration - CheckInterval time.Duration - CheckTimeout time.Duration - Priority int - ConsecutiveFailures int - BackoffMultiplier int - PreservePath bool + LastChecked time.Time + NextCheckTime time.Time + URL *url.URL + HealthCheckURL *url.URL + ModelUrl *url.URL + ModelFilter *FilterConfig + Name string + Type string `json:"type,omitempty"` + Status EndpointStatus + URLString string + HealthCheckPathString string + HealthCheckURLString string + ModelURLString string + LastLatency time.Duration + CheckInterval time.Duration + CheckTimeout time.Duration + CircuitBreakerTimeout time.Duration + Priority int + CircuitBreakerThreshold int + ConsecutiveFailures int + BackoffMultiplier int + PreservePath bool } func (e *Endpoint) GetURLString() string { From 6002a4964dd02d9cbc233a6274333a461105d741 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 19:23:02 -0400 Subject: [PATCH 11/20] test(olla): cover breaker config override wiring --- config/config.yaml | 3 ++- internal/config/config_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/config/config.yaml b/config/config.yaml index 7a4f767f..b14c4bc6 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -106,13 +106,14 @@ discovery: check_interval: 2s check_timeout: 1s - url: "http://localhost:8000" - name: "local-vllm" + name: "oblivion-gb10-vllm" type: "vllm" priority: 100 model_url: "/v1/models" health_check_url: "/health" check_interval: 5s check_timeout: 2s + circuit_breaker_timeout: 2000s model_discovery: enabled: true interval: 5m diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0aa0b6fa..9673a655 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,8 +3,11 @@ package config import ( "os" "runtime" + "strings" "testing" "time" + + "gopkg.in/yaml.v3" ) func TestDefaultConfig(t *testing.T) { @@ -216,6 +219,45 @@ func TestConfigTypes(t *testing.T) { } } +func TestConfigYAML_EndpointCircuitBreakerOverrides(t *testing.T) { + var cfg Config + data := ` +discovery: + type: "static" + static: + endpoints: + - url: "http://localhost:8000" + name: "oblivion-gb10-vllm" + type: "vllm" + priority: 100 + health_check_url: "/health" + model_url: "/v1/models" + check_interval: 5s + check_timeout: 2s + circuit_breaker_timeout: 2000s + circuit_breaker_threshold: 7 +` + + if err := yaml.Unmarshal([]byte(strings.TrimSpace(data)), &cfg); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + if len(cfg.Discovery.Static.Endpoints) != 1 { + t.Fatalf("expected 1 endpoint, got %d", len(cfg.Discovery.Static.Endpoints)) + } + + endpoint := cfg.Discovery.Static.Endpoints[0] + if endpoint.Name != "oblivion-gb10-vllm" { + t.Fatalf("endpoint.Name = %q, want oblivion-gb10-vllm", endpoint.Name) + } + if endpoint.CircuitBreakerTimeout != 2000*time.Second { + t.Fatalf("endpoint.CircuitBreakerTimeout = %v, want 2000s", endpoint.CircuitBreakerTimeout) + } + if endpoint.CircuitBreakerThreshold != 7 { + t.Fatalf("endpoint.CircuitBreakerThreshold = %d, want 7", endpoint.CircuitBreakerThreshold) + } +} + func TestEnvironmentVariableParsing(t *testing.T) { testCases := []struct { envVar string From d1c23fe042100125117b4cb6002068992c777f77 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:29:28 -0400 Subject: [PATCH 12/20] feat(olla): expose breaker state in endpoint status --- internal/adapter/proxy/olla/service.go | 41 +++++++++++ .../olla/service_circuit_breaker_test.go | 29 ++++++++ .../app/handlers/handler_status_endpoints.go | 39 +++++++--- .../handlers/handler_status_endpoints_test.go | 72 +++++++++++++++++++ internal/core/domain/circuit_breaker.go | 10 +++ 5 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 internal/core/domain/circuit_breaker.go diff --git a/internal/adapter/proxy/olla/service.go b/internal/adapter/proxy/olla/service.go index 90936cad..451bfb13 100644 --- a/internal/adapter/proxy/olla/service.go +++ b/internal/adapter/proxy/olla/service.go @@ -264,6 +264,10 @@ func (s *Service) GetCircuitBreaker(endpoint string) *circuitBreaker { return s.getCircuitBreaker(endpoint, 0, 0) } +func (s *Service) GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState { + return s.getCircuitBreakerForEndpoint(endpoint).GetState() +} + func (s *Service) getCircuitBreakerForEndpoint(endpoint *domain.Endpoint) *circuitBreaker { return s.getCircuitBreaker( endpoint.Name, @@ -328,6 +332,43 @@ func (cb *circuitBreaker) RecordFailure() { } } +func (cb *circuitBreaker) GetState() domain.CircuitBreakerState { + state := cb.stateName() + lastFailureNs := atomic.LoadInt64(&cb.lastFailure) + var lastTrip *time.Time + cooldownRemainingSec := 0 + if lastFailureNs > 0 { + lastFailure := time.Unix(0, lastFailureNs) + lastTrip = &lastFailure + if state == "open" { + remaining := time.Until(lastFailure.Add(cb.timeout)) + if remaining > 0 { + cooldownRemainingSec = int(remaining.Seconds()) + } + } + } + + return domain.CircuitBreakerState{ + State: state, + LastTripTimestamp: lastTrip, + ConsecutiveFailures: atomic.LoadInt64(&cb.failures), + CooldownRemainingSec: cooldownRemainingSec, + } +} + +func (cb *circuitBreaker) stateName() string { + switch atomic.LoadInt64(&cb.state) { + case 0: + return "closed" + case 1: + return "open" + case 2: + return "half-open" + default: + return "unknown" + } +} + // ProxyRequest handles incoming HTTP requests func (s *Service) ProxyRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, stats *ports.RequestStats, rlog logger.StyledLogger) error { endpoints, err := s.DiscoveryService.GetHealthyEndpoints(ctx) diff --git a/internal/adapter/proxy/olla/service_circuit_breaker_test.go b/internal/adapter/proxy/olla/service_circuit_breaker_test.go index 2c9e3bd9..7434598e 100644 --- a/internal/adapter/proxy/olla/service_circuit_breaker_test.go +++ b/internal/adapter/proxy/olla/service_circuit_breaker_test.go @@ -56,3 +56,32 @@ func TestServiceCircuitBreaker_DefaultsRemainUnchanged(t *testing.T) { t.Fatalf("timeout = %v, want %v", cb.timeout, health.DefaultCircuitBreakerTimeout) } } + +func TestServiceCircuitBreaker_StateSnapshot(t *testing.T) { + s := &Service{ + circuitBreakers: *xsync.NewMap[string, *circuitBreaker](), + } + endpoint := &domain.Endpoint{ + Name: "breaker-open", + CircuitBreakerTimeout: 30 * time.Second, + CircuitBreakerThreshold: 2, + } + + cb := s.getCircuitBreakerForEndpoint(endpoint) + cb.RecordFailure() + cb.RecordFailure() + + state := s.GetCircuitBreakerState(endpoint) + if state.State != "open" { + t.Fatalf("State = %q, want open", state.State) + } + if state.ConsecutiveFailures != 2 { + t.Fatalf("ConsecutiveFailures = %d, want 2", state.ConsecutiveFailures) + } + if state.LastTripTimestamp == nil { + t.Fatal("LastTripTimestamp is nil") + } + if state.CooldownRemainingSec <= 0 { + t.Fatalf("CooldownRemainingSec = %d, want positive", state.CooldownRemainingSec) + } +} diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index 619e06b9..b759f92b 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -15,17 +15,18 @@ import ( ) type EndpointSummary struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - LastModelSync string `json:"last_model_sync,omitempty"` - HealthCheck string `json:"health_check"` - ResponseTime string `json:"response_time,omitempty"` - SuccessRate string `json:"success_rate"` - Issues string `json:"issues,omitempty"` - Priority int `json:"priority"` - ModelCount int `json:"model_count"` - RequestCount int64 `json:"request_count"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LastModelSync string `json:"last_model_sync,omitempty"` + HealthCheck string `json:"health_check"` + ResponseTime string `json:"response_time,omitempty"` + SuccessRate string `json:"success_rate"` + Issues string `json:"issues,omitempty"` + Priority int `json:"priority"` + ModelCount int `json:"model_count"` + RequestCount int64 `json:"request_count"` + CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` } type EndpointStatusResponse struct { @@ -119,10 +120,26 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s } summary.Issues = a.getEndpointIssuesSummaryOptimised(endpoint, stats, hasStats) + summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) return summary } +func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain.CircuitBreakerState { + if a.proxyService == nil { + return nil + } + breakerStateProvider, ok := a.proxyService.(interface { + GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState + }) + if !ok { + return nil + } + + state := breakerStateProvider.GetCircuitBreakerState(endpoint) + return &state +} + func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { return "" diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index 9efff05a..97e90110 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/thushan/olla/internal/core/domain" "github.com/thushan/olla/internal/core/ports" + "github.com/thushan/olla/internal/logger" ) // mockStatusEndpointRepository for status endpoint testing @@ -103,6 +104,35 @@ func (m *mockStatusStatsCollector) GetEndpointStats() map[string]ports.EndpointS return m.endpointStats } +type mockStatusProxyService struct { + states map[string]domain.CircuitBreakerState +} + +func (m *mockStatusProxyService) ProxyRequest(ctx context.Context, w http.ResponseWriter, r *http.Request, stats *ports.RequestStats, rlog logger.StyledLogger) error { + return nil +} + +func (m *mockStatusProxyService) ProxyRequestToEndpoints( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + endpoints []*domain.Endpoint, + stats *ports.RequestStats, + rlog logger.StyledLogger, +) error { + return nil +} + +func (m *mockStatusProxyService) GetStats(ctx context.Context) (ports.ProxyStats, error) { + return ports.ProxyStats{}, nil +} + +func (m *mockStatusProxyService) UpdateConfig(configuration ports.ProxyConfiguration) {} + +func (m *mockStatusProxyService) GetCircuitBreakerState(endpoint *domain.Endpoint) domain.CircuitBreakerState { + return m.states[endpoint.Name] +} + // mockStatusModelRegistry for status endpoint testing type mockStatusModelRegistry struct { endpointModels map[string]*domain.EndpointModels @@ -205,6 +235,48 @@ func TestEndpointsStatusHandler_BasicFunctionality(t *testing.T) { assert.Len(t, response.Endpoints, 2) } +func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { + lastTrip := time.Now().Add(-5 * time.Second).UTC().Truncate(time.Second) + endpoints := []*domain.Endpoint{ + { + Name: "breaker-open", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + }, + } + app := createTestStatusApplication(endpoints) + app.proxyService = &mockStatusProxyService{ + states: map[string]domain.CircuitBreakerState{ + "breaker-open": { + State: "open", + LastTripTimestamp: &lastTrip, + ConsecutiveFailures: 3, + CooldownRemainingSec: 25, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + require.NotNil(t, response.Endpoints[0].CircuitBreaker) + assert.Equal(t, "open", response.Endpoints[0].CircuitBreaker.State) + assert.Equal(t, int64(3), response.Endpoints[0].CircuitBreaker.ConsecutiveFailures) + assert.Equal(t, 25, response.Endpoints[0].CircuitBreaker.CooldownRemainingSec) + require.NotNil(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp) + assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) +} + func TestEndpointsStatusHandler_Concurrent(t *testing.T) { // Create test endpoints endpoints := []*domain.Endpoint{ diff --git a/internal/core/domain/circuit_breaker.go b/internal/core/domain/circuit_breaker.go new file mode 100644 index 00000000..97c33a4e --- /dev/null +++ b/internal/core/domain/circuit_breaker.go @@ -0,0 +1,10 @@ +package domain + +import "time" + +type CircuitBreakerState struct { + State string `json:"state"` + LastTripTimestamp *time.Time `json:"last_trip_ts,omitempty"` + ConsecutiveFailures int64 `json:"consecutive_failures"` + CooldownRemainingSec int `json:"cooldown_remaining_s"` +} From 6b4ec48bddc283741b74d0a1ef624fba46b52d4f Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Sat, 23 May 2026 10:32:53 -0400 Subject: [PATCH 13/20] feat(olla): enforce allowed model aliases --- config/config.yaml | 8 +++ internal/app/handlers/application.go | 6 ++ internal/app/handlers/handler_proxy.go | 35 ++++++++++++ .../handlers/handler_proxy_allowlist_test.go | 56 +++++++++++++++++++ internal/app/handlers/handler_translation.go | 6 ++ internal/config/config.go | 13 +++++ internal/config/config_test.go | 47 ++++++++++++++++ internal/config/types.go | 23 ++++++++ 8 files changed, 194 insertions(+) create mode 100644 internal/app/handlers/handler_proxy_allowlist_test.go diff --git a/config/config.yaml b/config/config.yaml index b14c4bc6..56f9099f 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,4 +1,12 @@ # Olla Configuration (default) +allowed_models: + - qwen3-coder-30b + - qwen3-14b-awq + - inference + - leviathan-7900xt-vllm + - embed + - oblivion-gb10-infinity + server: host: "localhost" # Use 0.0.0.0 to bind to all interfaces (so it's accessible externally) port: 40114 # default port for Olla - 4-OLLA diff --git a/internal/app/handlers/application.go b/internal/app/handlers/application.go index 3e8b31d9..b54a3779 100644 --- a/internal/app/handlers/application.go +++ b/internal/app/handlers/application.go @@ -89,6 +89,7 @@ type Application struct { // closure so the handler layer does not need to import the balancer package. stickyStatsFn func() *balancer.StickyStats aliasResolver *registry.AliasResolver + allowedModels map[string]struct{} server *http.Server errCh chan error StartTime time.Time @@ -171,6 +172,10 @@ func NewApplication( if aliasResolver != nil { logger.Info("Model aliases configured", "alias_count", len(cfg.ModelAliases)) } + allowedModels := makeAllowedModelSet(cfg.AllowedModels) + if len(allowedModels) > 0 { + logger.Info("Model allowlist configured", "model_count", len(allowedModels)) + } // Use profile factory directly as it implements the ProfileLookup interface // The Factory.GetAnthropicSupport method provides the required functionality @@ -192,6 +197,7 @@ func NewApplication( converterFactory: converter.NewConverterFactory(), translatorRegistry: translatorRegistry, aliasResolver: aliasResolver, + allowedModels: allowedModels, server: server, errCh: make(chan error, 1), StartTime: time.Now(), diff --git a/internal/app/handlers/handler_proxy.go b/internal/app/handlers/handler_proxy.go index 43e9ab0e..6cde0e02 100644 --- a/internal/app/handlers/handler_proxy.go +++ b/internal/app/handlers/handler_proxy.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -42,6 +43,11 @@ func (a *Application) proxyHandler(w http.ResponseWriter, r *http.Request) { a.analyzeRequest(ctx, r, pr) + if !a.isModelAllowed(pr.model) { + a.writeModelAllowlistError(w, pr.model) + return + } + // Sticky session key must be computed after analyzeRequest so the model // name is available; inject into context before endpoint selection. // The outcome pointer is stored in context; the proxy engine reads it before WriteHeader. @@ -392,6 +398,35 @@ func (a *Application) handleProxyError(w http.ResponseWriter, err error) { } } +func makeAllowedModelSet(models []string) map[string]struct{} { + if len(models) == 0 { + return nil + } + allowed := make(map[string]struct{}, len(models)) + for _, model := range models { + allowed[model] = struct{}{} + } + return allowed +} + +func (a *Application) isModelAllowed(model string) bool { + if model == "" || len(a.allowedModels) == 0 { + return true + } + _, ok := a.allowedModels[model] + return ok +} + +func (a *Application) writeModelAllowlistError(w http.ResponseWriter, model string) { + w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": fmt.Sprintf("model %q is not allowed by allowed_models", model), + "model": model, + "allowed_models": a.Config.AllowedModels, + }) +} + func (a *Application) stripRoutePrefix(ctx context.Context, path string) string { return util.StripRoutePrefix(ctx, path, constants.ContextRoutePrefixKey) } diff --git a/internal/app/handlers/handler_proxy_allowlist_test.go b/internal/app/handlers/handler_proxy_allowlist_test.go new file mode 100644 index 00000000..c9e07921 --- /dev/null +++ b/internal/app/handlers/handler_proxy_allowlist_test.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/adapter/inspector" + "github.com/thushan/olla/internal/adapter/registry/profile" + "github.com/thushan/olla/internal/config" + "github.com/thushan/olla/internal/core/constants" +) + +func TestProxyHandler_RejectsModelOutsideAllowlist(t *testing.T) { + styledLog := &mockStyledLogger{} + profileFactory, err := profile.NewFactoryWithDefaults() + require.NoError(t, err) + + inspectorFactory := inspector.NewFactory(profileFactory, styledLog) + chain := inspectorFactory.CreateChain() + chain.AddInspector(inspectorFactory.CreatePathInspector()) + bodyInspector, err := inspectorFactory.CreateBodyInspector() + require.NoError(t, err) + chain.AddInspector(bodyInspector) + + cfg := config.DefaultConfig() + cfg.AllowedModels = []string{"qwen3-coder-30b"} + app := &Application{ + Config: cfg, + logger: styledLog, + inspectorChain: chain, + allowedModels: makeAllowedModelSet(cfg.AllowedModels), + } + + req := httptest.NewRequest( + http.MethodPost, + "/olla/proxy/v1/chat/completions", + bytes.NewBufferString(`{"model":"unknown-model","messages":[{"role":"user","content":"hi"}]}`), + ) + req.Header.Set(constants.HeaderContentType, constants.ContentTypeJSON) + w := httptest.NewRecorder() + + app.proxyHandler(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Header().Get(constants.HeaderContentType), constants.ContentTypeJSON) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + assert.Equal(t, "unknown-model", body["model"]) + assert.Contains(t, body["error"], "not allowed") +} diff --git a/internal/app/handlers/handler_translation.go b/internal/app/handlers/handler_translation.go index b54fef10..45e19afd 100644 --- a/internal/app/handlers/handler_translation.go +++ b/internal/app/handlers/handler_translation.go @@ -257,6 +257,12 @@ func (a *Application) translationHandler(trans translator.RequestTranslator) htt // Run through proxy pipeline (inspector, security, routing) a.analyzeRequest(ctx, r, pr) + if !a.isModelAllowed(pr.model) { + a.writeModelAllowlistError(w, pr.model) + a.recordTranslatorMetrics(trans, pr, constants.TranslatorModeTranslation, constants.FallbackReasonNone) + return + } + // Inject sticky session key. bodyBytes is already buffered from the model-name // extraction above, so pass it directly to avoid a second read/restore cycle. // The outcome pointer is stored in context; sub-handlers read it before WriteHeader. diff --git a/internal/config/config.go b/internal/config/config.go index 3d867552..c57322b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,8 +40,18 @@ var DefaultLocalNetworkTrustedCIDRs = []string{ "192.168.0.0/16", } +var DefaultAllowedModels = []string{ + "qwen3-coder-30b", + "qwen3-14b-awq", + "inference", + "leviathan-7900xt-vllm", + "embed", + "oblivion-gb10-infinity", +} + func DefaultConfig() *Config { return &Config{ + AllowedModels: append([]string(nil), DefaultAllowedModels...), Server: ServerConfig{ Host: DefaultHost, Port: DefaultPort, @@ -194,6 +204,9 @@ func (c *Config) Validate() error { if err := c.Translators.Validate(); err != nil { return fmt.Errorf("translators config invalid: %w", err) } + if err := c.ValidateAllowedModels(); err != nil { + return fmt.Errorf("allowed_models invalid: %w", err) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9673a655..d10fd3c0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -989,6 +989,53 @@ func TestDefaultConfig_NoModelAliases(t *testing.T) { } } +func TestDefaultConfig_AllowedModels(t *testing.T) { + cfg := DefaultConfig() + if len(cfg.AllowedModels) != len(DefaultAllowedModels) { + t.Fatalf("expected %d default allowed models, got %d", len(DefaultAllowedModels), len(cfg.AllowedModels)) + } + + for i, expected := range DefaultAllowedModels { + if cfg.AllowedModels[i] != expected { + t.Fatalf("AllowedModels[%d] = %q, want %q", i, cfg.AllowedModels[i], expected) + } + } +} + +func TestValidateAllowedModels_RejectsBadEntries(t *testing.T) { + tests := []struct { + name string + allowedModels []string + wantErr string + }{ + { + name: "empty model", + allowedModels: []string{"qwen3-coder-30b", ""}, + wantErr: "empty model name", + }, + { + name: "leading whitespace", + allowedModels: []string{" qwen3-coder-30b"}, + wantErr: "leading/trailing whitespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.AllowedModels = tt.allowedModels + + err := cfg.ValidateAllowedModels() + if err == nil { + t.Fatal("expected error") + } + if !stringContains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestValidateModelAliases_NoAliases(t *testing.T) { cfg := DefaultConfig() if err := cfg.ValidateModelAliases(); err != nil { diff --git a/internal/config/types.go b/internal/config/types.go index 51566319..ba9b4bb4 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -27,6 +27,7 @@ type Config struct { // - gpt-oss-120b-MLX // - gguf_gpt_oss_120b.gguf ModelAliases map[string][]string `yaml:"model_aliases,omitempty"` + AllowedModels []string `yaml:"allowed_models,omitempty"` Logging LoggingConfig `yaml:"logging"` Filename string `yaml:"-"` Translators TranslatorsConfig `yaml:"translators"` @@ -438,3 +439,25 @@ func (c *Config) ValidateModelAliases() error { return nil } + +func (c *Config) ValidateAllowedModels() error { + if len(c.AllowedModels) == 0 { + return nil + } + + seen := make(map[string]bool, len(c.AllowedModels)) + for i, modelName := range c.AllowedModels { + if modelName == "" { + return fmt.Errorf("allowed_models has empty model name at position %d", i) + } + if strings.TrimSpace(modelName) != modelName { + return fmt.Errorf("allowed model %q contains leading/trailing whitespace", modelName) + } + if seen[modelName] { + slog.Warn("Duplicate model name in allowed_models", "model", modelName) + } + seen[modelName] = true + } + + return nil +} From 1e306f6efaa50d22677e8569e01c85dd241a7490 Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 11:39:22 -0400 Subject: [PATCH 14/20] fix(unifier): infer BGE embedding capability (#14) Co-authored-by: Peter Simmons --- config/models.yaml | 8 +++++++- internal/adapter/unifier/metadata_extractor.go | 7 +++++++ internal/adapter/unifier/metadata_extractor_test.go | 8 +++++++- internal/adapter/unifier/model_config.go | 4 ++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/config/models.yaml b/config/models.yaml index 4ac16a91..53ae9ff8 100644 --- a/config/models.yaml +++ b/config/models.yaml @@ -195,6 +195,12 @@ capabilities: - multimodal - image-understanding + - pattern: "(^|[/_-])(bge|e5|gte|minilm|jina-embeddings?|nomic-embed|mxbai-embed|text-embedding)" + capabilities: + - embeddings + - similarity + - vector-search + # Context length categories (in tokens) context_thresholds: extended_context: 32000 @@ -213,4 +219,4 @@ special_rules: - unknown - test - temp - - default \ No newline at end of file + - default diff --git a/internal/adapter/unifier/metadata_extractor.go b/internal/adapter/unifier/metadata_extractor.go index 610dec14..6cae5a05 100644 --- a/internal/adapter/unifier/metadata_extractor.go +++ b/internal/adapter/unifier/metadata_extractor.go @@ -136,13 +136,20 @@ func inferCapabilitiesFromMetadata(modelType, modelName string, contextLength in // Name-based inference using patterns from configuration nameLower := strings.ToLower(modelName) + embeddingByName := false for _, pattern := range config.Capabilities.NamePatterns { if pattern.regex != nil && pattern.regex.MatchString(nameLower) { for _, cap := range pattern.Capabilities { capabilities[cap] = true + if cap == "embeddings" { + embeddingByName = true + } } } } + if embeddingByName { + delete(capabilities, "text-generation") + } // Context window size determines long-form processing capabilities if contextLength > 0 { diff --git a/internal/adapter/unifier/metadata_extractor_test.go b/internal/adapter/unifier/metadata_extractor_test.go index 9ccfe180..86595c9c 100644 --- a/internal/adapter/unifier/metadata_extractor_test.go +++ b/internal/adapter/unifier/metadata_extractor_test.go @@ -180,6 +180,12 @@ func TestInferCapabilitiesFromMetadata(t *testing.T) { modelName: "bakllava-7b", expectedCapabilities: []string{"text-generation", "vision", "multimodal", "image-understanding"}, }, + { + name: "llama.cpp BGE alias by name", + modelType: "", + modelName: "BAAI/bge-m3", + expectedCapabilities: []string{"embeddings", "similarity", "vector-search"}, + }, { name: "Multiple capabilities", modelType: "vlm", @@ -212,7 +218,7 @@ func TestInferCapabilitiesFromMetadata(t *testing.T) { } // For embedding models, ensure text-generation is not present - if tt.modelType == "embeddings" || tt.modelType == "embedding" { + if tt.modelType == "embeddings" || tt.modelType == "embedding" || tt.modelName == "BAAI/bge-m3" { assert.NotContains(t, caps, "text-generation") } }) diff --git a/internal/adapter/unifier/model_config.go b/internal/adapter/unifier/model_config.go index ff453891..7e1e5f97 100644 --- a/internal/adapter/unifier/model_config.go +++ b/internal/adapter/unifier/model_config.go @@ -273,6 +273,10 @@ func getDefaultConfig() *ModelUnificationConfig { Pattern: "(vision|vlm|llava|bakllava)", Capabilities: []string{"vision", "multimodal", "image-understanding"}, }, + { + Pattern: "(^|[/_-])(bge|e5|gte|minilm|jina-embeddings?|nomic-embed|mxbai-embed|text-embedding)", + Capabilities: []string{"embeddings", "similarity", "vector-search"}, + }, } config.Capabilities.ContextThresholds = map[string]int64{ From 7000ce5298f32d7cdc7413ac117f59bbc84a2905 Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 11:55:38 -0400 Subject: [PATCH 15/20] fix(status): flag degraded endpoints by success rate (#15) Co-authored-by: Peter Simmons --- config/config.yaml | 2 + docs/content/configuration/reference.md | 6 +- .../app/handlers/handler_status_endpoints.go | 88 ++++++++++---- .../handlers/handler_status_endpoints_test.go | 111 +++++++++++++++++- internal/config/config.go | 4 +- internal/config/types.go | 4 +- 6 files changed, 188 insertions(+), 27 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 56f9099f..ddbe1172 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -177,6 +177,8 @@ logging: engineering: show_nerdstats: false + endpoint_degraded_success_rate_threshold: 80.0 + endpoint_degraded_minimum_requests: 10 # Model aliases allow mapping a single virtual model name to multiple actual model names # across different backends. When a request arrives with an alias, olla finds endpoints diff --git a/docs/content/configuration/reference.md b/docs/content/configuration/reference.md index 2ac225aa..b49bfd8e 100644 --- a/docs/content/configuration/reference.md +++ b/docs/content/configuration/reference.md @@ -714,12 +714,16 @@ Debug and development features. | Field | Type | Default | Description | |-------|------|---------|-------------| | `show_nerdstats` | bool | `false` | Show memory stats on shutdown | +| `endpoint_degraded_success_rate_threshold` | float | `80.0` | Mark endpoints degraded when request success rate drops below this percentage | +| `endpoint_degraded_minimum_requests` | integer | `10` | Minimum endpoint request count before low success rate degradation is reported | Example: ```yaml engineering: show_nerdstats: true + endpoint_degraded_success_rate_threshold: 80.0 + endpoint_degraded_minimum_requests: 10 ``` When enabled, displays: @@ -885,4 +889,4 @@ Additionally, Olla's `Validate()` method catches dangerous zero or empty configu - [Configuration Examples](examples.md) - Common configurations - [Best Practices](practices/overview.md) - Production recommendations -- [Environment Variables](#environment-variables) - Override configuration \ No newline at end of file +- [Environment Variables](#environment-variables) - Override configuration diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index b759f92b..3b160b0a 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -15,18 +15,21 @@ import ( ) type EndpointSummary struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - LastModelSync string `json:"last_model_sync,omitempty"` - HealthCheck string `json:"health_check"` - ResponseTime string `json:"response_time,omitempty"` - SuccessRate string `json:"success_rate"` - Issues string `json:"issues,omitempty"` - Priority int `json:"priority"` - ModelCount int `json:"model_count"` - RequestCount int64 `json:"request_count"` - CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + LastModelSync string `json:"last_model_sync,omitempty"` + HealthCheck string `json:"health_check"` + ResponseTime string `json:"response_time,omitempty"` + SuccessRate string `json:"success_rate"` + DegradationReason string `json:"degradation_reason,omitempty"` + Issues string `json:"issues,omitempty"` + Priority int `json:"priority"` + ModelCount int `json:"model_count"` + RequestCount int64 `json:"request_count"` + SuccessRatePercent float64 `json:"success_rate_percent,omitempty"` + Degraded bool `json:"degraded"` + CircuitBreaker *domain.CircuitBreakerState `json:"circuit_breaker,omitempty"` } type EndpointStatusResponse struct { @@ -38,7 +41,9 @@ type EndpointStatusResponse struct { } const ( - healthyStatus = "healthy" + healthyStatus = "healthy" + degradedSuccessRateThresholdPercent = 80.0 + degradedSuccessRateMinimumRequestCount = int64(10) ) func (a *Application) endpointsStatusHandler(w http.ResponseWriter, r *http.Request) { @@ -109,8 +114,8 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s if hasStats { summary.RequestCount = stats.TotalRequests - if stats.TotalRequests > 0 { - successRate := (float64(stats.SuccessfulRequests) * 100.0) / float64(stats.TotalRequests) + if successRate, ok := endpointSuccessRatePercent(stats, hasStats); ok { + summary.SuccessRatePercent = successRate summary.SuccessRate = format.Percentage(successRate) } else { summary.SuccessRate = "N/A" @@ -120,6 +125,10 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s } summary.Issues = a.getEndpointIssuesSummaryOptimised(endpoint, stats, hasStats) + if a.endpointSuccessRateDegraded(stats, hasStats) { + summary.Degraded = true + summary.DegradationReason = "low success rate" + } summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) return summary @@ -141,10 +150,6 @@ func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain. } func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { - if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { - return "" - } - if endpoint.Status == domain.StatusOffline || endpoint.Status == domain.StatusUnhealthy { return "unavailable" } @@ -153,11 +158,48 @@ func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoin return "unstable" } - if hasStats && stats.TotalRequests > 10 { - if stats.SuccessfulRequests*100 < stats.TotalRequests*90 { - return "low success rate" - } + if a.endpointSuccessRateDegraded(stats, hasStats) { + return "low success rate" + } + + if endpoint.Status == domain.StatusHealthy && endpoint.ConsecutiveFailures == 0 { + return "" } return "" } + +func endpointSuccessRatePercent(stats ports.EndpointStats, hasStats bool) (float64, bool) { + if !hasStats || stats.TotalRequests == 0 { + return 0, false + } + + return float64(stats.SuccessfulRequests) * 100.0 / float64(stats.TotalRequests), true +} + +func (a *Application) endpointSuccessRateDegraded(stats ports.EndpointStats, hasStats bool) bool { + successRate, ok := endpointSuccessRatePercent(stats, hasStats) + threshold, minRequests := a.endpointSuccessRateDegradationConfig() + if !ok || stats.TotalRequests < minRequests { + return false + } + + return successRate < threshold +} + +func (a *Application) endpointSuccessRateDegradationConfig() (float64, int64) { + threshold := degradedSuccessRateThresholdPercent + minRequests := degradedSuccessRateMinimumRequestCount + if a.Config == nil { + return threshold, minRequests + } + + if configured := a.Config.Engineering.EndpointDegradedSuccessRateThreshold; configured > 0 { + threshold = configured + } + if configured := a.Config.Engineering.EndpointDegradedMinimumRequests; configured > 0 { + minRequests = configured + } + + return threshold, minRequests +} diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index 97e90110..e754f56b 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/config" "github.com/thushan/olla/internal/core/domain" "github.com/thushan/olla/internal/core/ports" "github.com/thushan/olla/internal/logger" @@ -277,6 +278,88 @@ func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) } +func TestEndpointsStatusHandler_MarksHealthyEndpointWithLowSuccessRateDegraded(t *testing.T) { + endpoint := &domain.Endpoint{ + Name: "flaky-endpoint", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + } + app := createTestStatusApplication([]*domain.Endpoint{endpoint}) + app.statsCollector = &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 20, + SuccessfulRequests: 12, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + + summary := response.Endpoints[0] + assert.Equal(t, "healthy", summary.Status) + assert.True(t, summary.Degraded) + assert.Equal(t, "low success rate", summary.DegradationReason) + assert.Equal(t, "low success rate", summary.Issues) + assert.Equal(t, "60.0%", summary.SuccessRate) + assert.Equal(t, 60.0, summary.SuccessRatePercent) +} + +func TestEndpointsStatusHandler_UsesConfiguredDegradedSuccessRateThreshold(t *testing.T) { + endpoint := &domain.Endpoint{ + Name: "borderline-endpoint", + Type: "ollama", + URLString: "http://localhost:11434", + Status: domain.StatusHealthy, + Priority: 1, + } + app := createTestStatusApplication([]*domain.Endpoint{endpoint}) + app.Config = &config.Config{ + Engineering: config.EngineeringConfig{ + EndpointDegradedSuccessRateThreshold: 95, + EndpointDegradedMinimumRequests: 5, + }, + } + app.statsCollector = &mockStatusStatsCollector{ + endpointStats: map[string]ports.EndpointStats{ + "http://localhost:11434": { + TotalRequests: 10, + SuccessfulRequests: 9, + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/internal/status/endpoints", nil) + w := httptest.NewRecorder() + + app.endpointsStatusHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response EndpointStatusResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + require.Len(t, response.Endpoints, 1) + + summary := response.Endpoints[0] + assert.True(t, summary.Degraded) + assert.Equal(t, "low success rate", summary.Issues) + assert.Equal(t, "90.0%", summary.SuccessRate) + assert.Equal(t, 90.0, summary.SuccessRatePercent) +} + func TestEndpointsStatusHandler_Concurrent(t *testing.T) { // Create test endpoints endpoints := []*domain.Endpoint{ @@ -558,11 +641,37 @@ func TestGetEndpointIssuesSummaryOptimised(t *testing.T) { }, stats: ports.EndpointStats{ TotalRequests: 100, - SuccessfulRequests: 80, // 80% success rate + SuccessfulRequests: 79, // below the default 80% success rate threshold }, hasStats: true, expectedIssues: "low success rate", }, + { + name: "healthy endpoint with low success rate", + endpoint: &domain.Endpoint{ + Status: domain.StatusHealthy, + ConsecutiveFailures: 0, + }, + stats: ports.EndpointStats{ + TotalRequests: 20, + SuccessfulRequests: 12, + }, + hasStats: true, + expectedIssues: "low success rate", + }, + { + name: "low success rate below minimum request count", + endpoint: &domain.Endpoint{ + Status: domain.StatusHealthy, + ConsecutiveFailures: 0, + }, + stats: ports.EndpointStats{ + TotalRequests: 9, + SuccessfulRequests: 6, + }, + hasStats: true, + expectedIssues: "", + }, } for _, tt := range tests { diff --git a/internal/config/config.go b/internal/config/config.go index c57322b2..a1d10de4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,7 +148,9 @@ func DefaultConfig() *Config { Output: "stdout", }, Engineering: EngineeringConfig{ - ShowNerdStats: false, + ShowNerdStats: false, + EndpointDegradedSuccessRateThreshold: 80.0, + EndpointDegradedMinimumRequests: 10, }, Translators: TranslatorsConfig{ Anthropic: AnthropicTranslatorConfig{ diff --git a/internal/config/types.go b/internal/config/types.go index ba9b4bb4..423796a1 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -171,7 +171,9 @@ type LoggingConfig struct { // EngineeringConfig holds development/debugging configuration type EngineeringConfig struct { - ShowNerdStats bool `yaml:"show_nerdstats"` + ShowNerdStats bool `yaml:"show_nerdstats"` + EndpointDegradedSuccessRateThreshold float64 `yaml:"endpoint_degraded_success_rate_threshold"` + EndpointDegradedMinimumRequests int64 `yaml:"endpoint_degraded_minimum_requests"` } // ModelRegistryConfig holds model registry configuration From 91edb9c8cecaa5b7cc368ec62d8128a330786897 Mon Sep 17 00:00:00 2001 From: Peter Simmons <106694408+petersimmons1972@users.noreply.github.com> Date: Tue, 26 May 2026 13:15:09 -0400 Subject: [PATCH 16/20] Expose circuit breaker and discovery status fixes (#16) Co-authored-by: Peter Simmons --- config/config.yaml | 2 +- .../app/handlers/handler_discovery_refresh.go | 33 ++++++++ .../handler_discovery_refresh_test.go | 80 +++++++++++++++++++ .../app/handlers/handler_status_endpoints.go | 26 +++++- .../handlers/handler_status_endpoints_test.go | 18 +++-- internal/app/handlers/server_routes.go | 1 + internal/config/config.go | 2 +- internal/config/config_test.go | 3 + 8 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 internal/app/handlers/handler_discovery_refresh.go create mode 100644 internal/app/handlers/handler_discovery_refresh_test.go diff --git a/config/config.yaml b/config/config.yaml index ddbe1172..6a708c2c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -149,7 +149,7 @@ model_registry: # Discovery mode settings discovery_timeout: 2s # Timeout for discovery refresh - discovery_refresh_on_miss: false # Refresh discovery when model not found + discovery_refresh_on_miss: true # Refresh discovery when model not found translators: ##### diff --git a/internal/app/handlers/handler_discovery_refresh.go b/internal/app/handlers/handler_discovery_refresh.go new file mode 100644 index 00000000..2821e421 --- /dev/null +++ b/internal/app/handlers/handler_discovery_refresh.go @@ -0,0 +1,33 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/thushan/olla/internal/core/constants" +) + +type DiscoveryRefreshResponse struct { + Timestamp time.Time `json:"timestamp"` + Refreshed bool `json:"refreshed"` +} + +func (a *Application) discoveryRefreshHandler(w http.ResponseWriter, r *http.Request) { + if a.discoveryService == nil { + http.Error(w, "Discovery service not initialized", http.StatusServiceUnavailable) + return + } + + if err := a.discoveryService.RefreshEndpoints(r.Context()); err != nil { + http.Error(w, "Discovery refresh failed", http.StatusInternalServerError) + return + } + + w.Header().Set(constants.HeaderContentType, constants.ContentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(DiscoveryRefreshResponse{ + Timestamp: time.Now(), + Refreshed: true, + }) +} diff --git a/internal/app/handlers/handler_discovery_refresh_test.go b/internal/app/handlers/handler_discovery_refresh_test.go new file mode 100644 index 00000000..04620d67 --- /dev/null +++ b/internal/app/handlers/handler_discovery_refresh_test.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/core/domain" +) + +type manualRefreshDiscoveryService struct { + err error + refreshCalls int +} + +func (m *manualRefreshDiscoveryService) GetEndpoints(ctx context.Context) ([]*domain.Endpoint, error) { + return nil, nil +} + +func (m *manualRefreshDiscoveryService) GetHealthyEndpoints(ctx context.Context) ([]*domain.Endpoint, error) { + return nil, nil +} + +func (m *manualRefreshDiscoveryService) RefreshEndpoints(ctx context.Context) error { + m.refreshCalls++ + return m.err +} + +func (m *manualRefreshDiscoveryService) UpdateEndpointStatus(ctx context.Context, endpoint *domain.Endpoint) error { + return nil +} + +func TestDiscoveryRefreshHandler_RefreshesDiscovery(t *testing.T) { + discovery := &manualRefreshDiscoveryService{} + app := &Application{discoveryService: discovery} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + assert.Equal(t, 1, discovery.refreshCalls) + + var response DiscoveryRefreshResponse + err := json.NewDecoder(w.Body).Decode(&response) + require.NoError(t, err) + assert.True(t, response.Refreshed) + assert.False(t, response.Timestamp.IsZero()) +} + +func TestDiscoveryRefreshHandler_ReturnsServiceUnavailableWithoutDiscovery(t *testing.T) { + app := &Application{} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestDiscoveryRefreshHandler_ReturnsInternalServerErrorOnRefreshFailure(t *testing.T) { + discovery := &manualRefreshDiscoveryService{err: errors.New("refresh failed")} + app := &Application{discoveryService: discovery} + + req := httptest.NewRequest(http.MethodPost, "/internal/discovery/refresh", nil) + w := httptest.NewRecorder() + + app.discoveryRefreshHandler(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, 1, discovery.refreshCalls) +} diff --git a/internal/app/handlers/handler_status_endpoints.go b/internal/app/handlers/handler_status_endpoints.go index 3b160b0a..e5d8376d 100644 --- a/internal/app/handlers/handler_status_endpoints.go +++ b/internal/app/handlers/handler_status_endpoints.go @@ -42,6 +42,8 @@ type EndpointStatusResponse struct { const ( healthyStatus = "healthy" + circuitOpenStatus = "circuit_open" + circuitOpenDegradationReason = "circuit breaker open" degradedSuccessRateThresholdPercent = 80.0 degradedSuccessRateMinimumRequestCount = int64(10) ) @@ -129,7 +131,15 @@ func (a *Application) buildEndpointSummaryOptimised(endpoint *domain.Endpoint, s summary.Degraded = true summary.DegradationReason = "low success rate" } - summary.CircuitBreaker = a.getCircuitBreakerState(endpoint) + if circuitBreaker := a.getCircuitBreakerState(endpoint); circuitBreaker != nil { + summary.CircuitBreaker = circuitBreaker + if isCircuitBreakerOpen(circuitBreaker) { + summary.Status = circuitOpenStatus + summary.Degraded = true + summary.DegradationReason = circuitOpenDegradationReason + summary.Issues = appendEndpointIssue(summary.Issues, circuitOpenDegradationReason) + } + } return summary } @@ -149,6 +159,20 @@ func (a *Application) getCircuitBreakerState(endpoint *domain.Endpoint) *domain. return &state } +func isCircuitBreakerOpen(state *domain.CircuitBreakerState) bool { + return state != nil && state.State == "open" +} + +func appendEndpointIssue(existing, issue string) string { + if existing == "" { + return issue + } + if existing == issue { + return existing + } + return existing + "; " + issue +} + func (a *Application) getEndpointIssuesSummaryOptimised(endpoint *domain.Endpoint, stats ports.EndpointStats, hasStats bool) string { if endpoint.Status == domain.StatusOffline || endpoint.Status == domain.StatusUnhealthy { return "unavailable" diff --git a/internal/app/handlers/handler_status_endpoints_test.go b/internal/app/handlers/handler_status_endpoints_test.go index e754f56b..074bb3e0 100644 --- a/internal/app/handlers/handler_status_endpoints_test.go +++ b/internal/app/handlers/handler_status_endpoints_test.go @@ -270,12 +270,18 @@ func TestEndpointsStatusHandler_IncludesCircuitBreakerState(t *testing.T) { err := json.NewDecoder(w.Body).Decode(&response) require.NoError(t, err) require.Len(t, response.Endpoints, 1) - require.NotNil(t, response.Endpoints[0].CircuitBreaker) - assert.Equal(t, "open", response.Endpoints[0].CircuitBreaker.State) - assert.Equal(t, int64(3), response.Endpoints[0].CircuitBreaker.ConsecutiveFailures) - assert.Equal(t, 25, response.Endpoints[0].CircuitBreaker.CooldownRemainingSec) - require.NotNil(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp) - assert.True(t, response.Endpoints[0].CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) + + summary := response.Endpoints[0] + assert.Equal(t, "circuit_open", summary.Status) + assert.True(t, summary.Degraded) + assert.Equal(t, "circuit breaker open", summary.DegradationReason) + assert.Equal(t, "circuit breaker open", summary.Issues) + require.NotNil(t, summary.CircuitBreaker) + assert.Equal(t, "open", summary.CircuitBreaker.State) + assert.Equal(t, int64(3), summary.CircuitBreaker.ConsecutiveFailures) + assert.Equal(t, 25, summary.CircuitBreaker.CooldownRemainingSec) + require.NotNil(t, summary.CircuitBreaker.LastTripTimestamp) + assert.True(t, summary.CircuitBreaker.LastTripTimestamp.Equal(lastTrip)) } func TestEndpointsStatusHandler_MarksHealthyEndpointWithLowSuccessRateDegraded(t *testing.T) { diff --git a/internal/app/handlers/server_routes.go b/internal/app/handlers/server_routes.go index 9e90d977..7df8ce07 100644 --- a/internal/app/handlers/server_routes.go +++ b/internal/app/handlers/server_routes.go @@ -33,6 +33,7 @@ func (a *Application) registerRoutes() { a.routeRegistry.RegisterWithMethod("/internal/status", a.statusHandler, "Endpoint status", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/endpoints", a.endpointsStatusHandler, "Endpoints status", "GET") a.routeRegistry.RegisterWithMethod("/internal/status/models", a.modelsStatusHandler, "Models status", "GET") + a.routeRegistry.RegisterWithMethod("/internal/discovery/refresh", a.discoveryRefreshHandler, "Refresh endpoint and model discovery", "POST") a.routeRegistry.RegisterWithMethod("/internal/stats/models", a.modelStatsHandler, "Model statistics", "GET") a.routeRegistry.RegisterWithMethod("/internal/stats/translators", a.translatorStatsHandler, "Translator statistics", "GET") a.routeRegistry.RegisterWithMethod("/internal/stats/sticky", a.stickyStatsHandler, "Sticky session statistics", "GET") diff --git a/internal/config/config.go b/internal/config/config.go index a1d10de4..e0c6981b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,7 +124,7 @@ func DefaultConfig() *Config { RoutingStrategy: ModelRoutingStrategy{ Type: "strict", // default to strict for predictable behavior Options: ModelRoutingStrategyOptions{ - DiscoveryRefreshOnMiss: false, + DiscoveryRefreshOnMiss: true, DiscoveryTimeout: 2 * time.Second, FallbackBehavior: constants.FallbackBehaviorCompatibleOnly, }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d10fd3c0..7beaa9a1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -52,6 +52,9 @@ func TestDefaultConfig(t *testing.T) { if cfg.Proxy.MaxRetries != 3 { t.Errorf("Expected max retries 3, got %d", cfg.Proxy.MaxRetries) } + if !cfg.ModelRegistry.RoutingStrategy.Options.DiscoveryRefreshOnMiss { + t.Error("Expected discovery refresh on miss to be enabled by default") + } // Test engineering defaults if cfg.Engineering.ShowNerdStats != false { From a8ee1f0974e727fc68fc555804e583215134f994 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Tue, 26 May 2026 19:44:05 -0400 Subject: [PATCH 17/20] Fix model config and discovery defaults --- .../registry/profile/openai_compatible.go | 1 + .../profile/openai_compatible_parser_test.go | 29 +++++ internal/adapter/registry/profile/parsers.go | 9 +- internal/config/config.go | 37 ++++-- internal/config/config_test.go | 117 +++++++++++++++++- 5 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 internal/adapter/registry/profile/openai_compatible_parser_test.go diff --git a/internal/adapter/registry/profile/openai_compatible.go b/internal/adapter/registry/profile/openai_compatible.go index 0885a83f..679eba1a 100644 --- a/internal/adapter/registry/profile/openai_compatible.go +++ b/internal/adapter/registry/profile/openai_compatible.go @@ -4,6 +4,7 @@ package profile type OpenAICompatibleResponse struct { Object string `json:"object"` Data []OpenAICompatibleModel `json:"data"` + Models []OpenAICompatibleModel `json:"models"` } // OpenAICompatibleModel represents a model in OpenAI-compatible response diff --git a/internal/adapter/registry/profile/openai_compatible_parser_test.go b/internal/adapter/registry/profile/openai_compatible_parser_test.go new file mode 100644 index 00000000..10c41efa --- /dev/null +++ b/internal/adapter/registry/profile/openai_compatible_parser_test.go @@ -0,0 +1,29 @@ +package profile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIParser_ParseInfinityTopLevelModels(t *testing.T) { + parser := &openAIParser{} + + response := `{ + "models": [ + { + "id": "BAAI/bge-m3", + "object": "model", + "created": 1734000007, + "owned_by": "infinity" + } + ] + }` + + models, err := parser.Parse([]byte(response)) + require.NoError(t, err) + require.Len(t, models, 1) + assert.Equal(t, "BAAI/bge-m3", models[0].Name) + assert.Equal(t, "model", models[0].Type) +} diff --git a/internal/adapter/registry/profile/parsers.go b/internal/adapter/registry/profile/parsers.go index ea91e53d..1ac5adda 100644 --- a/internal/adapter/registry/profile/parsers.go +++ b/internal/adapter/registry/profile/parsers.go @@ -213,10 +213,15 @@ func (p *openAIParser) Parse(data []byte) ([]*domain.ModelInfo, error) { return nil, fmt.Errorf("failed to parse OpenAI-compatible response: %w", err) } - models := make([]*domain.ModelInfo, 0, len(response.Data)) + responseModels := response.Data + if len(responseModels) == 0 && len(response.Models) > 0 { + responseModels = response.Models + } + + models := make([]*domain.ModelInfo, 0, len(responseModels)) now := time.Now() - for _, model := range response.Data { + for _, model := range responseModels { if model.ID == "" { continue } diff --git a/internal/config/config.go b/internal/config/config.go index e0c6981b..7a8e849a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,14 +40,7 @@ var DefaultLocalNetworkTrustedCIDRs = []string{ "192.168.0.0/16", } -var DefaultAllowedModels = []string{ - "qwen3-coder-30b", - "qwen3-14b-awq", - "inference", - "leviathan-7900xt-vllm", - "embed", - "oblivion-gb10-infinity", -} +var DefaultAllowedModels = []string{} func DefaultConfig() *Config { return &Config{ @@ -231,6 +224,9 @@ func Load(flagConfigFile ...string) (*Config, error) { for _, path := range configPaths { if data, err := os.ReadFile(path); err == nil { + if err := validateConfigFileSchema(data); err != nil { + return nil, fmt.Errorf("failed to validate %s: %w", path, err) + } if err := yaml.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("failed to parse %s: %w", path, err) } @@ -261,6 +257,31 @@ func Load(flagConfigFile ...string) (*Config, error) { return config, nil } +func validateConfigFileSchema(data []byte) error { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil + } + + var problems []string + root := doc.Content[0] + for i := 0; i < len(root.Content); i += 2 { + switch root.Content[i].Value { + case "model_discovery": + problems = append(problems, "model_discovery must be configured as discovery.model_discovery") + case "routing_strategy": + problems = append(problems, "routing_strategy must be configured as model_registry.routing_strategy") + } + } + if len(problems) > 0 { + return errors.New(strings.Join(problems, "; ")) + } + return nil +} + func ApplyConfigCaches(config *Config) { if val := config.Server.RateLimits.TrustedProxyCIDRs; len(val) > 0 { if trustedCIDRs, err := util.ParseTrustedCIDRs(val); err == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7beaa9a1..56b07090 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -994,17 +994,124 @@ func TestDefaultConfig_NoModelAliases(t *testing.T) { func TestDefaultConfig_AllowedModels(t *testing.T) { cfg := DefaultConfig() - if len(cfg.AllowedModels) != len(DefaultAllowedModels) { - t.Fatalf("expected %d default allowed models, got %d", len(DefaultAllowedModels), len(cfg.AllowedModels)) + if len(cfg.AllowedModels) != 0 { + t.Fatalf("expected default allowed_models to allow all models, got %v", cfg.AllowedModels) } +} - for i, expected := range DefaultAllowedModels { - if cfg.AllowedModels[i] != expected { - t.Fatalf("AllowedModels[%d] = %q, want %q", i, cfg.AllowedModels[i], expected) +func TestLoadConfig_ExplicitAllowedModels(t *testing.T) { + tmpFile, err := os.CreateTemp("", "olla-allowed-models-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + configData := ` +allowed_models: + - qwen3-coder:30b + - BAAI/bge-m3 +` + if _, err := tmpFile.WriteString(strings.TrimSpace(configData)); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + cfg, err := Load(tmpFile.Name()) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + want := []string{"qwen3-coder:30b", "BAAI/bge-m3"} + if len(cfg.AllowedModels) != len(want) { + t.Fatalf("expected %d allowed models, got %d", len(want), len(cfg.AllowedModels)) + } + for i := range want { + if cfg.AllowedModels[i] != want[i] { + t.Fatalf("AllowedModels[%d] = %q, want %q", i, cfg.AllowedModels[i], want[i]) } } } +func TestLoadConfig_RejectsMisplacedTopLevelModelSections(t *testing.T) { + tmpFile, err := os.CreateTemp("", "olla-misplaced-model-sections-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + configData := ` +model_discovery: + enabled: true +routing_strategy: + type: optimistic +` + if _, err := tmpFile.WriteString(strings.TrimSpace(configData)); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + _, err = Load(tmpFile.Name()) + if err == nil { + t.Fatal("expected misplaced top-level model sections to fail validation") + } + if !stringContains(err.Error(), "model_discovery must be configured as discovery.model_discovery") { + t.Fatalf("error = %q, want model_discovery schema guidance", err.Error()) + } + if !stringContains(err.Error(), "routing_strategy must be configured as model_registry.routing_strategy") { + t.Fatalf("error = %q, want routing_strategy schema guidance", err.Error()) + } +} + +func TestLoadConfig_NestedModelSections(t *testing.T) { + tmpFile, err := os.CreateTemp("", "olla-nested-model-sections-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + configData := ` +discovery: + model_discovery: + enabled: true + interval: 5m + timeout: 30s + concurrent_workers: 5 +model_registry: + routing_strategy: + type: optimistic + options: + fallback_behavior: compatible_only +` + if _, err := tmpFile.WriteString(strings.TrimSpace(configData)); err != nil { + t.Fatal(err) + } + if err := tmpFile.Close(); err != nil { + t.Fatal(err) + } + + cfg, err := Load(tmpFile.Name()) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if !cfg.Discovery.ModelDiscovery.Enabled { + t.Fatal("expected deployed config to enable discovery.model_discovery") + } + if cfg.Discovery.ModelDiscovery.Interval != 5*time.Minute { + t.Fatalf("discovery.model_discovery.interval = %v, want 5m", cfg.Discovery.ModelDiscovery.Interval) + } + if cfg.ModelRegistry.RoutingStrategy.Type != "optimistic" { + t.Fatalf("model_registry.routing_strategy.type = %q, want optimistic", cfg.ModelRegistry.RoutingStrategy.Type) + } + if cfg.ModelRegistry.RoutingStrategy.Options.FallbackBehavior != "compatible_only" { + t.Fatalf("model_registry.routing_strategy.options.fallback_behavior = %q, want compatible_only", cfg.ModelRegistry.RoutingStrategy.Options.FallbackBehavior) + } +} + func TestValidateAllowedModels_RejectsBadEntries(t *testing.T) { tests := []struct { name string From 3e00838ac76cbf46c10cee899cbe620c615471fe Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Tue, 26 May 2026 19:53:00 -0400 Subject: [PATCH 18/20] fix: harden Olla model routing defaults --- config/config.yaml | 7 --- internal/adapter/proxy/common/errors.go | 8 +-- internal/adapter/proxy/core/retry.go | 4 ++ internal/adapter/proxy/core/retry_test.go | 63 +++++++++++++++++++ internal/adapter/proxy/error.go | 8 +-- .../adapter/registry/profile/profile_test.go | 53 ++++++++++++++++ .../handlers/handler_proxy_allowlist_test.go | 12 ++++ 7 files changed, 140 insertions(+), 15 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 6a708c2c..10479e7e 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,11 +1,4 @@ # Olla Configuration (default) -allowed_models: - - qwen3-coder-30b - - qwen3-14b-awq - - inference - - leviathan-7900xt-vllm - - embed - - oblivion-gb10-infinity server: host: "localhost" # Use 0.0.0.0 to bind to all interfaces (so it's accessible externally) diff --git a/internal/adapter/proxy/common/errors.go b/internal/adapter/proxy/common/errors.go index d575a159..e311d4e7 100644 --- a/internal/adapter/proxy/common/errors.go +++ b/internal/adapter/proxy/common/errors.go @@ -44,10 +44,10 @@ func MakeUserFriendlyError(err error, duration time.Duration, errorContext strin case errors.Is(err, context.DeadlineExceeded): if responseTimeout > 0 { - return fmt.Errorf("request timeout after %.1fs - server timeout of %.1fs exceeded (LLM model taking longer than expected)", - duration.Seconds(), responseTimeout.Seconds()) + return fmt.Errorf("request timeout after %.1fs - server timeout of %.1fs exceeded (LLM model taking longer than expected): %w", + duration.Seconds(), responseTimeout.Seconds(), err) } - return fmt.Errorf("request timeout after %.1fs - server timeout exceeded (LLM response took too long)", duration.Seconds()) + return fmt.Errorf("request timeout after %.1fs - server timeout exceeded (LLM response took too long): %w", duration.Seconds(), err) case errors.Is(err, io.EOF): if errorContext == "streaming" { @@ -63,7 +63,7 @@ func MakeUserFriendlyError(err error, duration time.Duration, errorContext strin var netErr net.Error if errors.As(err, &netErr) { if netErr.Timeout() { - return fmt.Errorf("network timeout after %.1fs - unable to connect to LLM backend (check backend availability)", duration.Seconds()) + return fmt.Errorf("network timeout after %.1fs - unable to connect to LLM backend (check backend availability): %w", duration.Seconds(), netErr) } return fmt.Errorf("network error after %.1fs - %w (check network connectivity to LLM backend)", duration.Seconds(), netErr) } diff --git a/internal/adapter/proxy/core/retry.go b/internal/adapter/proxy/core/retry.go index 9ad01a2d..6e8454c9 100644 --- a/internal/adapter/proxy/core/retry.go +++ b/internal/adapter/proxy/core/retry.go @@ -192,6 +192,10 @@ func IsConnectionError(err error) bool { return false } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error if errors.As(err, &netErr) { return true diff --git a/internal/adapter/proxy/core/retry_test.go b/internal/adapter/proxy/core/retry_test.go index 100bdda6..3fd2039d 100644 --- a/internal/adapter/proxy/core/retry_test.go +++ b/internal/adapter/proxy/core/retry_test.go @@ -2,11 +2,17 @@ package core import ( "context" + "errors" + "net/http" + "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thushan/olla/internal/adapter/proxy/common" "github.com/thushan/olla/internal/core/domain" + "github.com/thushan/olla/internal/core/ports" "github.com/thushan/olla/internal/logger" ) @@ -34,6 +40,24 @@ func (t *testDiscoveryService) UpdateEndpointStatus(ctx context.Context, endpoin return nil } +type retryTestSelector struct { + selectCalls int +} + +func (s *retryTestSelector) Select(ctx context.Context, endpoints []*domain.Endpoint) (*domain.Endpoint, error) { + if len(endpoints) == 0 { + return nil, errors.New("no endpoints") + } + s.selectCalls++ + return endpoints[0], nil +} + +func (s *retryTestSelector) Name() string { return "retry-test" } + +func (s *retryTestSelector) IncrementConnections(endpoint *domain.Endpoint) {} + +func (s *retryTestSelector) DecrementConnections(endpoint *domain.Endpoint) {} + func TestMarkEndpointUnhealthyBackoffProgression(t *testing.T) { tests := []struct { name string @@ -140,3 +164,42 @@ func TestMarkEndpointUnhealthyNilEndpoint(t *testing.T) { // Verify no update was made assert.Nil(t, testDiscovery.updatedEndpoint, "Should not update nil endpoint") } + +func TestExecuteWithRetry_RetriesFriendlyTimeoutError(t *testing.T) { + testDiscovery := &testDiscoveryService{} + + logConfig := &logger.Config{Level: "error"} + log, _, err := logger.New(logConfig) + require.NoError(t, err) + testLogger := logger.NewPlainStyledLogger(log) + + handler := NewRetryHandler(testDiscovery, testLogger) + selector := &retryTestSelector{} + + endpoints := []*domain.Endpoint{ + {Name: "endpoint-a", CheckInterval: 5 * time.Second}, + {Name: "endpoint-b", CheckInterval: 5 * time.Second}, + } + + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", http.NoBody) + w := httptest.NewRecorder() + stats := &ports.RequestStats{StartTime: time.Now()} + + attempts := 0 + proxyFunc := func(ctx context.Context, w http.ResponseWriter, r *http.Request, endpoint *domain.Endpoint, stats *ports.RequestStats) error { + attempts++ + if attempts == 1 { + return common.MakeUserFriendlyError(context.DeadlineExceeded, 30*time.Second, "backend", 15*time.Second) + } + w.WriteHeader(http.StatusOK) + return nil + } + + err = handler.ExecuteWithRetry(context.Background(), w, req, endpoints, selector, stats, proxyFunc) + + require.NoError(t, err) + assert.Equal(t, 2, attempts) + assert.Equal(t, http.StatusOK, w.Code) + assert.NotNil(t, testDiscovery.updatedEndpoint) + assert.Equal(t, "endpoint-a", testDiscovery.updatedEndpoint.Name) +} diff --git a/internal/adapter/proxy/error.go b/internal/adapter/proxy/error.go index 6ee83be5..cf98cd11 100644 --- a/internal/adapter/proxy/error.go +++ b/internal/adapter/proxy/error.go @@ -49,10 +49,10 @@ func makeUserFriendlyError(err error, duration time.Duration, errorContext strin case errors.Is(err, context.DeadlineExceeded): if responseTimeout > 0 { - return fmt.Errorf("request timeout after %.1fs - server timeout of %.1fs exceeded (LLM model taking longer than expected)", - duration.Seconds(), responseTimeout.Seconds()) + return fmt.Errorf("request timeout after %.1fs - server timeout of %.1fs exceeded (LLM model taking longer than expected): %w", + duration.Seconds(), responseTimeout.Seconds(), err) } - return fmt.Errorf("request timeout after %.1fs - server timeout exceeded (LLM response took too long)", duration.Seconds()) + return fmt.Errorf("request timeout after %.1fs - server timeout exceeded (LLM response took too long): %w", duration.Seconds(), err) case errors.Is(err, io.EOF): if errorContext == "streaming" { @@ -68,7 +68,7 @@ func makeUserFriendlyError(err error, duration time.Duration, errorContext strin var netErr net.Error if errors.As(err, &netErr) { if netErr.Timeout() { - return fmt.Errorf("network timeout after %.1fs - unable to connect to LLM backend (check backend availability)", duration.Seconds()) + return fmt.Errorf("network timeout after %.1fs - unable to connect to LLM backend (check backend availability): %w", duration.Seconds(), netErr) } return fmt.Errorf("network error after %.1fs - %w (check network connectivity to LLM backend)", duration.Seconds(), netErr) } diff --git a/internal/adapter/registry/profile/profile_test.go b/internal/adapter/registry/profile/profile_test.go index 9d21795d..e3e0c0cb 100644 --- a/internal/adapter/registry/profile/profile_test.go +++ b/internal/adapter/registry/profile/profile_test.go @@ -171,6 +171,59 @@ func TestDetectionHints(t *testing.T) { } } +func TestOpenAICompatibleProfile_ParseModelsResponse(t *testing.T) { + profile := getTestProfile(t, domain.ProfileOpenAICompatible) + + tests := []struct { + name string + responseBody []byte + wantName string + }{ + { + name: "OpenAI data array", + responseBody: []byte(`{ + "object": "list", + "data": [ + { + "id": "gpt-4o-mini", + "object": "model" + } + ] + }`), + wantName: "gpt-4o-mini", + }, + { + name: "Infinity top-level models array", + responseBody: []byte(`{ + "models": [ + { + "id": "BAAI/bge-m3", + "object": "model", + "created": 1734000007, + "owned_by": "infinity" + } + ] + }`), + wantName: "BAAI/bge-m3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + models, err := profile.ParseModelsResponse(tt.responseBody) + if err != nil { + t.Fatalf("ParseModelsResponse failed: %v", err) + } + if len(models) != 1 { + t.Fatalf("expected 1 model, got %d", len(models)) + } + if models[0].Name != tt.wantName { + t.Fatalf("model name = %q, want %q", models[0].Name, tt.wantName) + } + }) + } +} + // Migrated and enhanced tests from parser_test.go func TestParseModelsResponse(t *testing.T) { tests := []struct { diff --git a/internal/app/handlers/handler_proxy_allowlist_test.go b/internal/app/handlers/handler_proxy_allowlist_test.go index c9e07921..2fd44e7f 100644 --- a/internal/app/handlers/handler_proxy_allowlist_test.go +++ b/internal/app/handlers/handler_proxy_allowlist_test.go @@ -15,6 +15,18 @@ import ( "github.com/thushan/olla/internal/core/constants" ) +func TestDefaultConfigFile_AllowsFleetModelNames(t *testing.T) { + cfg, err := config.Load("../../../config/config.yaml") + require.NoError(t, err) + + app := &Application{ + Config: cfg, + allowedModels: makeAllowedModelSet(cfg.AllowedModels), + } + + assert.True(t, app.isModelAllowed("qwen3-coder:30b")) +} + func TestProxyHandler_RejectsModelOutsideAllowlist(t *testing.T) { styledLog := &mockStyledLogger{} profileFactory, err := profile.NewFactoryWithDefaults() From 2307e347b1b64a22d98f3b4892739269d62accdf Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Fri, 5 Jun 2026 06:35:08 -0400 Subject: [PATCH 19/20] docs: align OLLA env overrides documentation and add unsupported env var regression Closes #79 --- docs/content/configuration/reference.md | 35 ++++++++++++++++++++----- internal/config/config_test.go | 30 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/content/configuration/reference.md b/docs/content/configuration/reference.md index b49bfd8e..8ee19a23 100644 --- a/docs/content/configuration/reference.md +++ b/docs/content/configuration/reference.md @@ -735,9 +735,7 @@ When enabled, displays: ## Environment Variables -All configuration can be overridden via environment variables. - -Pattern: `OLLA_
_` in uppercase with underscores. +Supported environment variables are listed below. Examples: @@ -745,21 +743,46 @@ Examples: # Server settings OLLA_SERVER_HOST=0.0.0.0 OLLA_SERVER_PORT=8080 -OLLA_SERVER_REQUEST_LOGGING=true +OLLA_SERVER_READ_TIMEOUT=45s +OLLA_SERVER_WRITE_TIMEOUT=30s +OLLA_SERVER_MAX_BODY_SIZE=25MB +OLLA_SERVER_MAX_HEADER_SIZE=2MB +OLLA_SERVER_GLOBAL_RATE_LIMIT=1000 +OLLA_SERVER_PER_IP_RATE_LIMIT=100 +OLLA_SERVER_RATE_BURST_SIZE=50 +OLLA_SERVER_HEALTH_RATE_LIMIT=1000 +OLLA_SERVER_RATE_CLEANUP_INTERVAL=5m +OLLA_SERVER_TRUST_PROXY_HEADERS=true +OLLA_SERVER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,10.0.0.0/8 # Proxy settings OLLA_PROXY_ENGINE=olla OLLA_PROXY_LOAD_BALANCER=round-robin OLLA_PROXY_PROFILE=auto +OLLA_PROXY_RESPONSE_TIMEOUT=600s +OLLA_PROXY_READ_TIMEOUT=120s +OLLA_PROXY_STICKY_SESSIONS_ENABLED=true # Logging OLLA_LOGGING_LEVEL=debug OLLA_LOGGING_FORMAT=text -# Rate limits -OLLA_SERVER_RATE_LIMITS_GLOBAL_REQUESTS_PER_MINUTE=1000 +# Model registry / translators / engine +OLLA_MODEL_REGISTRY_TYPE=memory +OLLA_MODEL_UNIFIER_ENABLED=true +OLLA_MODEL_UNIFIER_CACHE_TTL=10m +OLLA_TRANSLATORS_ANTHROPIC_ENABLED=true +OLLA_TRANSLATORS_ANTHROPIC_MAX_MESSAGE_SIZE=10485760 +OLLA_TRANSLATORS_ANTHROPIC_PASSTHROUGH_ENABLED=true + +# Operational +OLLA_SHOW_NERD_STATS=true +OLLA_CONFIG_FILE=config/config.yaml ``` +Deprecated or unsupported variables shown in older docs (for example, `OLLA_LOG_LEVEL`, `OLLA_SERVER_REQUEST_LOGGING`, +and `OLLA_SERVER_RATE_LIMITS_*`) are intentionally unsupported by the current env override path and must not be used. + ## Duration Format Duration values use Go duration syntax: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 56b07090..9d9bf596 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -306,6 +306,36 @@ func TestEnvironmentVariableParsing(t *testing.T) { } } +func TestLoadConfig_IgnoresUnsupportedEnvironmentVariables(t *testing.T) { + base := DefaultConfig() + + oldEnvVars := map[string]string{ + "OLLA_SERVER_REQUEST_LOGGING": "false", + "OLLA_SERVER_RATE_LIMITS_GLOBAL_REQUESTS_PER_MINUTE": "9999", + "OLLA_LOG_LEVEL": "error", + } + + for key, value := range oldEnvVars { + os.Setenv(key, value) + defer os.Unsetenv(key) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.Server.RequestLogging != base.Server.RequestLogging { + t.Errorf("RequestLogging changed from %t to %t via unsupported env var", base.Server.RequestLogging, loaded.Server.RequestLogging) + } + if loaded.Server.RateLimits.GlobalRequestsPerMinute != base.Server.RateLimits.GlobalRequestsPerMinute { + t.Errorf("GlobalRequestsPerMinute changed from %d to %d via unsupported env var", base.Server.RateLimits.GlobalRequestsPerMinute, loaded.Server.RateLimits.GlobalRequestsPerMinute) + } + if loaded.Logging.Level != base.Logging.Level { + t.Errorf("Logging level changed from %q to %q via unsupported env var", base.Logging.Level, loaded.Logging.Level) + } +} + func TestParseByteSize(t *testing.T) { testCases := []struct { input string From babe871b2fcf69eedb3a6b01379fb90a8e71e300 Mon Sep 17 00:00:00 2001 From: Peter Simmons Date: Fri, 5 Jun 2026 06:38:07 -0400 Subject: [PATCH 20/20] fix(handlers): fail closed provider scoped routes (Closes #84) --- .../app/handlers/handler_provider_common.go | 37 ++++++++++++++++++- .../app/handlers/handler_provider_test.go | 31 ++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/app/handlers/handler_provider_common.go b/internal/app/handlers/handler_provider_common.go index 94a92095..bd19732d 100644 --- a/internal/app/handlers/handler_provider_common.go +++ b/internal/app/handlers/handler_provider_common.go @@ -10,6 +10,7 @@ import ( "github.com/thushan/olla/internal/core/constants" "github.com/thushan/olla/internal/core/domain" "github.com/thushan/olla/internal/core/ports" + "github.com/thushan/olla/internal/logger" ) // createProviderProfile builds routing constraints for provider-specific requests. @@ -133,7 +134,7 @@ func (a *Application) getProviderEndpoints(ctx context.Context, providerType str providerProfile := a.createProviderProfile(providerType) providerProfile.Path = pr.targetPath - providerEndpoints := a.filterEndpointsByProfile(endpoints, providerProfile, pr.requestLogger) + providerEndpoints := a.filterProviderScopedEndpoints(endpoints, providerProfile, pr.requestLogger) // If the request has specific requirements (e.g., needs vision support), // apply those filters on top of the provider constraint @@ -192,6 +193,40 @@ func (a *Application) filterModelsByProvider(ctx context.Context, models []*doma return providerModels, nil } +// filterProviderScopedEndpoints applies provider-type filtering without falling back to +// unrelated endpoint types. Provider routes must fail closed when no matching provider +// endpoints are available. +func (a *Application) filterProviderScopedEndpoints(endpoints []*domain.Endpoint, profile *domain.RequestProfile, logger logger.StyledLogger) []*domain.Endpoint { + if profile == nil || len(profile.SupportedBy) == 0 { + logger.Debug("No provider profile filtering applied", "total_endpoints", len(endpoints)) + return endpoints + } + + compatible := make([]*domain.Endpoint, 0, len(endpoints)) + for _, endpoint := range endpoints { + normalizedType := NormaliseProviderType(endpoint.Type) + if profile.IsCompatibleWith(normalizedType) { + compatible = append(compatible, endpoint) + } + } + + if len(compatible) == 0 { + logger.Warn("No compatible endpoints found for provider-scoped path", + "path", profile.Path, + "supported_by", profile.SupportedBy, + "total_endpoints", len(endpoints)) + return []*domain.Endpoint{} + } + + logger.Debug("Filtered provider endpoints by compatibility", + "path", profile.Path, + "compatible_count", len(compatible), + "total_count", len(endpoints), + "supported_by", profile.SupportedBy) + + return compatible +} + // getProviderModels handles the complete flow of fetching models for a provider-specific // endpoint (e.g., /olla/ollama/models) func (a *Application) getProviderModels(ctx context.Context, providerType string) ([]*domain.UnifiedModel, error) { diff --git a/internal/app/handlers/handler_provider_test.go b/internal/app/handlers/handler_provider_test.go index 8eaef2e9..6226d72d 100644 --- a/internal/app/handlers/handler_provider_test.go +++ b/internal/app/handlers/handler_provider_test.go @@ -147,6 +147,37 @@ func TestProviderRouting(t *testing.T) { } } +func TestProviderRouting_FailsClosedWhenNoProviderMatches(t *testing.T) { + app := createTestApplication(t) + + openAIURL, _ := url.Parse("http://localhost:8080") + app.discoveryService = &mockDiscoveryServiceWithHealthy{ + endpoints: []*domain.Endpoint{{ + Name: "openai-1", + URL: openAIURL, + URLString: openAIURL.String(), + Type: "openai", + Status: domain.StatusHealthy, + }}, + } + + req := httptest.NewRequest(http.MethodPost, "/olla/ollama/api/generate", strings.NewReader(`{"model":"llama3"}`)) + req.Header.Set(constants.HeaderContentType, constants.ContentTypeJSON) + w := httptest.NewRecorder() + + app.providerProxyHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected status %d, got %d: %s", http.StatusNotFound, resp.StatusCode, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, "No ollama endpoints available") { + t.Fatalf("expected 'No ollama endpoints available', got %q", body) + } +} + // mockDiscoveryServiceWithHealthy returns a single healthy endpoint matching the // requested provider type so provider-scoped routing can reach the proxy stage. type mockDiscoveryServiceWithHealthy struct {