Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ models:
# - source: regular # a plain alias
# target: anthropic/claude-sonnet-4-6
# - source: smart # weighted round-robin load balancer
# strategy: round_robin # round_robin (default) | cost
# strategy: round_robin # round_robin (default) | cost | adaptive (uses a routing extension when registered; otherwise falls back to round_robin)
# targets:
# - { model: openai/gpt-4o, weight: 2 }
# - { model: anthropic/claude-sonnet-4-6 }
Expand Down
4 changes: 3 additions & 1 deletion config/virtualmodels.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ type VirtualModelConfig struct {
Source string `yaml:"source" json:"source"`

// Strategy selects load balancing across multiple targets: "round_robin"
// (default) or "cost". Ignored for single-target aliases and access policies.
// (default), "cost", or "adaptive" (delegates to a registered routing
// extension and falls back to round_robin without one). Ignored for
// single-target aliases and access policies.
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`

// SessionAffinity keeps requests of one detected client session on the
Expand Down
8 changes: 4 additions & 4 deletions ext/ext.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Package ext is the public extension API for building custom gateway
// binaries on top of GoModel. External modules register request rewriters,
// HTTP middleware, and extra routes on a Registry (usually ext.Default)
// before starting the gateway; core consumes an immutable snapshot of the
// registry at server construction. An empty registry adds zero request
// overhead.
// HTTP middleware, extra routes, and a route selector on a Registry (usually
// ext.Default) before starting the gateway; core consumes an immutable
// snapshot of the registry at server construction. An empty registry adds
// zero request overhead.
package ext

import (
Expand Down
30 changes: 25 additions & 5 deletions ext/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import (
// Register everything before the server is constructed (before run.Run or
// app.New); core snapshots the registry once and never consults it again.
type Registry struct {
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
mu sync.Mutex
rewriters []RequestRewriter
middleware []echo.MiddlewareFunc
routes []func(*echo.Echo)
publicPaths []string
routeSelector RouteSelector
}

// RegisterRewriter adds a request rewriter. Rewriters run in registration
Expand Down Expand Up @@ -51,6 +52,15 @@ func (r *Registry) AddPublicPaths(paths ...string) {
r.publicPaths = append(r.publicPaths, paths...)
}

// RegisterRouteSelector installs the route selector consulted by virtual
// models using the "adaptive" load-balancing strategy. Only one selector can
// be active; a later registration replaces an earlier one.
func (r *Registry) RegisterRouteSelector(sel RouteSelector) {
r.mu.Lock()
defer r.mu.Unlock()
r.routeSelector = sel
}

// Rewriters returns a defensive copy of the registered rewriters.
func (r *Registry) Rewriters() []RequestRewriter {
r.mu.Lock()
Expand Down Expand Up @@ -79,6 +89,13 @@ func (r *Registry) PublicPaths() []string {
return slices.Clone(r.publicPaths)
}

// RouteSelector returns the registered route selector, or nil.
func (r *Registry) RouteSelector() RouteSelector {
r.mu.Lock()
defer r.mu.Unlock()
return r.routeSelector
}

// Default is the process-wide registry used by package-level helpers and, by
// default, by run.Run.
var Default = &Registry{}
Expand All @@ -94,3 +111,6 @@ func RegisterRoutes(fn func(e *echo.Echo)) { Default.RegisterRoutes(fn) }

// AddPublicPaths registers auth-skip paths on the Default registry.
func AddPublicPaths(paths ...string) { Default.AddPublicPaths(paths...) }

// RegisterRouteSelector installs a route selector on the Default registry.
func RegisterRouteSelector(sel RouteSelector) { Default.RegisterRouteSelector(sel) }
51 changes: 51 additions & 0 deletions ext/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,57 @@ func TestRegistryConcurrentRegistration(t *testing.T) {
assert.Len(t, reg.PublicPaths(), workers)
}

type namedSelector struct{ name string }

func (s *namedSelector) Name() string { return s.name }
func (s *namedSelector) Select(RouteRequest) (string, bool) { return "", false }
func (s *namedSelector) OnAttemptStart(RouteTarget) {}
func (s *namedSelector) OnAttemptEnd(RouteOutcome) {}

func TestRegistryRouteSelectorSingleSlot(t *testing.T) {
tests := []struct {
name string
register []RouteSelector
want string // Name() of the expected selector; "" means nil
}{
{name: "unset", register: nil, want: ""},
{name: "single registration", register: []RouteSelector{&namedSelector{name: "only"}}, want: "only"},
{name: "later registration replaces earlier", register: []RouteSelector{&namedSelector{name: "first"}, &namedSelector{name: "second"}}, want: "second"},
{name: "nil registration resets the slot", register: []RouteSelector{&namedSelector{name: "first"}, nil}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &Registry{}
for _, sel := range tt.register {
reg.RegisterRouteSelector(sel)
}
got := reg.RouteSelector()
if tt.want == "" {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.want, got.Name())
})
}
}

func TestRouteTargetQualified(t *testing.T) {
tests := []struct {
name string
target RouteTarget
want string
}{
{name: "provider and model", target: RouteTarget{Provider: "openai", Model: "gpt-4o"}, want: "openai/gpt-4o"},
{name: "empty provider keeps the separator", target: RouteTarget{Model: "gpt-4o"}, want: "/gpt-4o"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.target.Qualified())
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestRejectionErrorMessage(t *testing.T) {
err := &RejectionError{Status: 422, Code: "policy_violation", Message: "blocked by policy"}
assert.Equal(t, "request rejected (422 policy_violation): blocked by policy", err.Error())
Expand Down
89 changes: 89 additions & 0 deletions ext/route.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package ext

import "time"

// RouteCandidate is one currently viable target of a load-balanced virtual
// model, offered to a RouteSelector. Pricing comes from the model registry
// and is per million tokens; nil means the registry has no price for the
// target.
type RouteCandidate struct {
// Provider is the configured provider name (e.g. "openai", "azure-eu").
Provider string
// Model is the provider-native model ID (e.g. "gpt-4o").
Model string
// Qualified is "provider/model", the stable key selection answers with.
Qualified string
// Weight is the operator-configured target weight; 0 means unset (treat
// as 1).
Weight float64
InputPerMtok *float64
OutputPerMtok *float64
}

// RouteRequest asks a RouteSelector to pick one target for a request routed
// through a load-balanced virtual model. Candidates are the targets that are
// catalog-supported and have rate-limit capacity right now, in declared
// order; there are always at least two (single-candidate picks bypass the
// selector so an alias behaves identically with and without one).
type RouteRequest struct {
// Source is the virtual model name the request addressed.
Source string
// SessionID is the detected client session, when present. Session
// affinity is enforced by core before the selector runs; the ID is
// provided for observability only.
SessionID string
Candidates []RouteCandidate
}

// RouteTarget identifies a provider/model pair as seen by the upstream
// client layer.
type RouteTarget struct {
Provider string
Model string
}

// Qualified returns the "provider/model" key matching RouteCandidate.Qualified.
func (t RouteTarget) Qualified() string { return t.Provider + "/" + t.Model }

// RouteOutcome describes one completed upstream call. Every call is
// reported — primaries and failover attempts alike — so selectors learn from
// traffic they did not steer. Transport-level retries inside the provider
// client are aggregated into their call's single outcome: StatusCode and Err
// reflect the final result, and Duration spans the whole call including
// retry backoff, so a target that only succeeds after internal retries still
// scores slower than a target that succeeds at once.
type RouteOutcome struct {
RouteTarget
// Endpoint is the upstream API endpoint (e.g. "/chat/completions").
Endpoint string
// StatusCode is the final upstream HTTP status; 0 on a network error.
StatusCode int
// Duration is the call duration, including any transport-level retries.
// For streaming requests it measures time to stream establishment, not
// the full stream lifetime.
Duration time.Duration
Stream bool
// Err is the client-layer error, nil on success.
Err error
}

// RouteSelector steers load balancing for virtual models using the
// "adaptive" strategy. Core consults the selector only to pick among
// currently viable targets; session affinity, rate-limit capacity, failover
// chains, and retries all remain core's responsibility.
//
// Select must be fast and must not block: it runs on the request path before
// the upstream call. Implementations must be safe for concurrent use. A
// (_, false) answer — and any answer naming a model outside Candidates —
// falls back to weighted round robin, so selectors fail open by declining.
//
// OnAttemptStart and OnAttemptEnd observe the upstream client lifecycle,
// once per upstream call (transport-level retries within a call are
// aggregated — see RouteOutcome). For streaming requests OnAttemptEnd fires
// when the stream is established, not when it closes.
type RouteSelector interface {
Name() string
Select(req RouteRequest) (qualified string, ok bool)
OnAttemptStart(target RouteTarget)
OnAttemptEnd(outcome RouteOutcome)
}

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 21 additions & 14 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const (
DashboardConfigPricingRecalculation = "USAGE_PRICING_RECALCULATION_ENABLED"
DashboardConfigLiveLogsEnabled = "DASHBOARD_LIVE_LOGS_ENABLED"
DashboardConfigMCPEnabled = "MCP_ENABLED"
DashboardConfigVMStrategies = "VIRTUAL_MODEL_STRATEGIES"
)

// statusClientClosedRequest is the de facto status used by proxies for client-aborted requests.
Expand All @@ -98,6 +99,11 @@ type DashboardConfigResponse struct {
PricingRecalculation string `json:"USAGE_PRICING_RECALCULATION_ENABLED,omitempty"`
LiveLogsEnabled string `json:"DASHBOARD_LIVE_LOGS_ENABLED,omitempty"`
MCPEnabled string `json:"MCP_ENABLED,omitempty"`
// VirtualModelStrategies is the comma-separated list of load-balancing
// strategies this deployment supports. "adaptive" appears only when a
// route-selector extension is registered, so the dashboard never offers
// a strategy that would silently fall back to round robin.
VirtualModelStrategies string `json:"VIRTUAL_MODEL_STRATEGIES,omitempty"`
}

type providerStatusSummaryResponse struct {
Expand Down Expand Up @@ -367,20 +373,21 @@ func NewHandler(reader usage.UsageReader, registry *providers.ModelRegistry, opt

func normalizeDashboardRuntimeConfig(values DashboardConfigResponse) DashboardConfigResponse {
return DashboardConfigResponse{
DemoMode: strings.TrimSpace(values.DemoMode),
FailoverEnabled: strings.TrimSpace(values.FailoverEnabled),
LoggingEnabled: strings.TrimSpace(values.LoggingEnabled),
LoggingRetentionDays: strings.TrimSpace(values.LoggingRetentionDays),
UsageEnabled: strings.TrimSpace(values.UsageEnabled),
BudgetsEnabled: strings.TrimSpace(values.BudgetsEnabled),
RateLimitsEnabled: strings.TrimSpace(values.RateLimitsEnabled),
GuardrailsEnabled: strings.TrimSpace(values.GuardrailsEnabled),
CacheEnabled: strings.TrimSpace(values.CacheEnabled),
RedisURL: strings.TrimSpace(values.RedisURL),
SemanticCacheEnabled: strings.TrimSpace(values.SemanticCacheEnabled),
PricingRecalculation: strings.TrimSpace(values.PricingRecalculation),
LiveLogsEnabled: strings.TrimSpace(values.LiveLogsEnabled),
MCPEnabled: strings.TrimSpace(values.MCPEnabled),
DemoMode: strings.TrimSpace(values.DemoMode),
FailoverEnabled: strings.TrimSpace(values.FailoverEnabled),
LoggingEnabled: strings.TrimSpace(values.LoggingEnabled),
LoggingRetentionDays: strings.TrimSpace(values.LoggingRetentionDays),
UsageEnabled: strings.TrimSpace(values.UsageEnabled),
BudgetsEnabled: strings.TrimSpace(values.BudgetsEnabled),
RateLimitsEnabled: strings.TrimSpace(values.RateLimitsEnabled),
GuardrailsEnabled: strings.TrimSpace(values.GuardrailsEnabled),
CacheEnabled: strings.TrimSpace(values.CacheEnabled),
RedisURL: strings.TrimSpace(values.RedisURL),
SemanticCacheEnabled: strings.TrimSpace(values.SemanticCacheEnabled),
PricingRecalculation: strings.TrimSpace(values.PricingRecalculation),
LiveLogsEnabled: strings.TrimSpace(values.LiveLogsEnabled),
MCPEnabled: strings.TrimSpace(values.MCPEnabled),
VirtualModelStrategies: strings.TrimSpace(values.VirtualModelStrategies),
}
}

Expand Down
32 changes: 18 additions & 14 deletions internal/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2264,20 +2264,21 @@ func TestBuildProviderStatusItem_ClassifyAndDisplayFallbacks(t *testing.T) {

func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) {
h := NewHandler(nil, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{
DemoMode: "on",
FailoverEnabled: "on",
LoggingEnabled: "on",
LoggingRetentionDays: "14",
UsageEnabled: "off",
BudgetsEnabled: "on",
RateLimitsEnabled: "off",
GuardrailsEnabled: "on",
CacheEnabled: "on",
RedisURL: "on",
SemanticCacheEnabled: "off",
PricingRecalculation: "on",
LiveLogsEnabled: "on",
MCPEnabled: "off",
DemoMode: "on",
FailoverEnabled: "on",
LoggingEnabled: "on",
LoggingRetentionDays: "14",
UsageEnabled: "off",
BudgetsEnabled: "on",
RateLimitsEnabled: "off",
GuardrailsEnabled: "on",
CacheEnabled: "on",
RedisURL: "on",
SemanticCacheEnabled: "off",
PricingRecalculation: "on",
LiveLogsEnabled: "on",
MCPEnabled: "off",
VirtualModelStrategies: "round_robin,cost,adaptive",
}))
c, rec := newHandlerContext("/admin/runtime/config")

Expand Down Expand Up @@ -2334,6 +2335,9 @@ func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) {
if got := body.MCPEnabled; got != "off" {
t.Fatalf("MCP_ENABLED = %q, want off", got)
}
if got := body.VirtualModelStrategies; got != "round_robin,cost,adaptive" {
t.Fatalf("VIRTUAL_MODEL_STRATEGIES = %q, want round_robin,cost,adaptive", got)
}
if rec.Body.String() == "" || strings.Contains(rec.Body.String(), "UNRELATED_FLAG") {
t.Fatal("UNRELATED_FLAG should not be exposed")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/admin/handler_virtualmodels.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
// upsertVirtualModelRequest is the unified admin upsert contract. Presence of
// target_model or targets makes the row a redirect; absence makes it an access
// policy. A single target_model is a plain alias; multiple targets are load
// balanced across by strategy ("round_robin" or "cost").
// balanced across by strategy ("round_robin", "cost", or "adaptive").
type upsertVirtualModelRequest struct {
Source string `json:"source"`
OldSource string `json:"old_source,omitempty"`
Expand Down
Loading