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
7 changes: 6 additions & 1 deletion docs/content/concepts/model-aliases.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ In this case:

When a model name matches both a configured alias **and** a real model known to the registry, the alias takes priority. This ensures consistent cross-backend routing.

If the alias resolves to zero endpoints (none of the actual model names are available), Olla falls back to standard model routing using the alias name as a regular model name.
If the alias resolves to zero endpoints (none of the actual model names are available), Olla falls back to standard model routing using the alias name as a regular model name. That fallback honours the configured [routing strategy](model-routing.md), so an alias whose targets are all unavailable behaves exactly like any other unroutable model:

- **`strict`** (default), or **`optimistic`** with `none` / `compatible_only`: the request is rejected. `404` when the alias name matches no model anywhere, `503` when it only resolves to unhealthy endpoints.
- **`optimistic`** with `all`: the request is routed to any healthy endpoint.

Olla does not silently proxy an unroutable alias to a compatible-but-wrong backend.

## Interaction with Other Features

Expand Down
2 changes: 1 addition & 1 deletion docs/content/configuration/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ model_aliases:
```

!!! note
Alias names take priority over standard model routing. If no endpoints are found for the alias, Olla falls back to standard routing using the alias name as a regular model name. See [Model Aliases](../concepts/model-aliases.md) for details.
Alias names take priority over standard model routing. If no endpoints are found for the alias, Olla falls back to standard routing using the alias name as a regular model name, honouring the configured routing strategy. Under `strict` (the default) an alias whose targets are all unavailable is rejected (`404`/`503`), not proxied to a compatible-but-wrong backend. See [Model Aliases](../concepts/model-aliases.md) for details.

## Routing Response Headers

Expand Down
63 changes: 63 additions & 0 deletions internal/adapter/registry/routing/strict_strategy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package routing

import (
"context"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thushan/olla/internal/core/constants"
"github.com/thushan/olla/internal/core/domain"
"github.com/thushan/olla/internal/core/ports"
)

// TestStrictStrategy_GetRoutableEndpoints covers the three outcomes strict routing
// can produce (#191): a healthy match routes, a match confined to unhealthy endpoints
// rejects with 503, and no match anywhere rejects with 404. Handlers rely on these
// exact status codes to fail fast instead of falling through to the proxy engine.
func TestStrictStrategy_GetRoutableEndpoints(t *testing.T) {
ctx := context.Background()
testLogger := createTestLogger()
strategy := NewStrictStrategy(testLogger)

healthyEndpoints := []*domain.Endpoint{
{Name: "ep1", URLString: "http://ep1", Status: domain.StatusHealthy},
{Name: "ep2", URLString: "http://ep2", Status: domain.StatusHealthy},
}

t.Run("model found on healthy endpoint routes", func(t *testing.T) {
modelEndpoints := []string{"http://ep1"}

result, decision, err := strategy.GetRoutableEndpoints(ctx, "test-model", healthyEndpoints, modelEndpoints)

require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "ep1", result[0].Name)
assert.Equal(t, ports.RoutingActionRouted, decision.Action)
assert.Equal(t, constants.RoutingReasonModelFound, decision.Reason)
assert.Equal(t, http.StatusOK, decision.StatusCode)
})

t.Run("model only on unhealthy endpoint rejects with 503", func(t *testing.T) {
modelEndpoints := []string{"http://ep3"} // not in healthyEndpoints

result, decision, err := strategy.GetRoutableEndpoints(ctx, "test-model", healthyEndpoints, modelEndpoints)

require.Error(t, err)
assert.Empty(t, result)
assert.Equal(t, ports.RoutingActionRejected, decision.Action)
assert.Equal(t, constants.RoutingReasonModelUnavailable, decision.Reason)
assert.Equal(t, http.StatusServiceUnavailable, decision.StatusCode)
})

t.Run("model nowhere in the fleet rejects with 404", func(t *testing.T) {
result, decision, err := strategy.GetRoutableEndpoints(ctx, "test-model", healthyEndpoints, nil)

require.Error(t, err)
assert.Empty(t, result)
assert.Equal(t, ports.RoutingActionRejected, decision.Action)
assert.Equal(t, constants.RoutingReasonModelNotFound, decision.Reason)
assert.Equal(t, http.StatusNotFound, decision.StatusCode)
})
}
21 changes: 3 additions & 18 deletions internal/app/handlers/handler_provider_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func (a *Application) createProviderProfile(providerType string) *domain.Request
}
}
} else {
// Test/static fallback must track the openai-compatible types in
// Test/static fallback: must track the openai-compatible types in
// isProviderSupported (handler_common.go) and getStaticProviders (server_routes.go)
profile.AddSupportedProfile(constants.ProviderTypeOpenAI)
profile.AddSupportedProfile(constants.ProviderTypeVLLM)
Expand Down Expand Up @@ -81,7 +81,7 @@ func (a *Application) providerProxyHandler(w http.ResponseWriter, r *http.Reques

// The proxy needs to know which prefix to strip before forwarding.
// We must use the RAW (pre-normalisation) path segment as the strip prefix so
// that alias spellings like /olla/lmstudio/ strip correctly — using the
// that alias spellings like /olla/lmstudio/ strip correctly. Using the
// normalised name (lm-studio) would produce a non-matching prefix and forward
// the full /olla/lmstudio/... path to the backend, causing a 404.
providerPrefix := getRawProviderPrefix(r.URL.Path)
Expand All @@ -104,22 +104,7 @@ func (a *Application) providerProxyHandler(w http.ResponseWriter, r *http.Reques
return
}

if len(endpoints) == 0 {
http.Error(w, fmt.Sprintf("No %s endpoints available", providerType), http.StatusNotFound)
return
}

// Update request path to the target path (strip provider prefix)
r.URL.Path = pr.targetPath

a.logRequestStart(pr, len(endpoints))
err = a.executeProxyRequest(ctx, w, r, endpoints, pr)
pr.captureStickyOutcome(ctx, r)
a.logRequestResult(pr, err)

if err != nil {
a.handleProxyError(w, err)
}
a.dispatchToEndpoints(ctx, w, r, pr, endpoints, providerType)
}

// getProviderEndpoints returns only endpoints matching the requested provider type.
Expand Down
169 changes: 157 additions & 12 deletions internal/app/handlers/handler_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,29 @@ func (a *Application) proxyHandler(w http.ResponseWriter, r *http.Request) {
return
}

a.logRequestStart(pr, len(endpoints))
a.dispatchToEndpoints(ctx, w, r, pr, endpoints, "")
}

// dispatchToEndpoints is the shared tail of proxyHandler and providerProxyHandler:
// forward to the proxy engine when endpoints were selected, or fail fast with the
// correct status when selection produced none. Consolidating this here means a
// routing rejection (#191) is honoured identically regardless of which route the
// request came in on, instead of each handler drifting its own empty-endpoint handling.
func (a *Application) dispatchToEndpoints(ctx context.Context, w http.ResponseWriter, r *http.Request, pr *proxyRequest, endpoints []*domain.Endpoint, providerType string) {
if len(endpoints) == 0 {
a.writeNoRoutableEndpoints(w, r, pr, providerType)
return
}

// Strip the route prefix before forwarding to the backend.
// Without this, BuildTargetURL receives the full /olla/proxy/... path and
// GetProxyPrefix() returns "route_prefix" (a context key name, not a URL path),
// so its StripPrefix is a no-op. This mirrors providerProxyHandler (line 100).
// so its StripPrefix is a no-op.
r.URL.Path = pr.targetPath

err = a.executeProxyRequest(ctx, w, r, endpoints, pr)
a.logRequestStart(pr, len(endpoints))

err := a.executeProxyRequest(ctx, w, r, endpoints, pr)
pr.captureStickyOutcome(ctx, r)
a.logRequestResult(pr, err)

Expand All @@ -79,6 +93,66 @@ func (a *Application) proxyHandler(w http.ResponseWriter, r *http.Request) {
}
}

// writeNoRoutableEndpoints short-circuits a request when endpoint selection produced
// zero candidates, instead of letting it fall through to the proxy engine (which would
// either proxy to a compatible-but-wrong backend, or return a generic 502/404 that hides
// the actual routing verdict). A rejection routing decision takes priority: it carries
// the precise status code and reason (strict model_not_found gives 404). Without a
// decision we keep the historical per-route defaults.
func (a *Application) writeNoRoutableEndpoints(w http.ResponseWriter, r *http.Request, pr *proxyRequest, providerType string) {
var decision *domain.ModelRoutingDecision
if pr.profile != nil {
decision = pr.profile.RoutingDecision
}

var status int
var reason string

switch {
case decision != nil && decision.StatusCode >= http.StatusBadRequest:
status = decision.StatusCode
reason = decision.Reason
pr.stats.RoutingDecision = decision
case providerType != "":
// no decision was recorded (e.g. modelRegistry unset), so preserve the
// precise provider-route message rather than a vague generic one.
status = http.StatusNotFound
reason = fmt.Sprintf("No %s endpoints available", providerType)
default:
status = http.StatusServiceUnavailable
reason = "no healthy endpoints available"
}

// Preserve normal request telemetry (client_ip, model, duration, routing fields)
// even though we're short-circuiting before the proxy engine ever runs. Logged as
// an explicit rejection rather than logRequestResult's "completed", so a fail-fast
// 404/503 is never mistaken for a successful proxy in the logs (#191).
a.logRequestStart(pr, 0)
pr.captureStickyOutcome(r.Context(), r)
a.logRequestRejected(pr, status)

// Headers must be set before http.Error, which calls WriteHeader.
a.setStickyResponseHeadersFromRequest(w, r)
a.setRoutingDecisionHeaders(w, decision)

http.Error(w, reason, status)
}

// setRoutingDecisionHeaders writes the X-Olla-Routing-* observability headers from
// a routing decision. Shared by writeNoRoutableEndpoints and the translation route's
// zero-endpoint rejection so a decision-aware rejection carries the same headers
// regardless of which route produced it (#191).
func (a *Application) setRoutingDecisionHeaders(w http.ResponseWriter, decision *domain.ModelRoutingDecision) {
if decision == nil {
return
}
w.Header().Set(constants.HeaderXOllaRoutingStrategy, decision.Strategy)
w.Header().Set(constants.HeaderXOllaRoutingDecision, decision.Action)
if decision.Reason != "" {
w.Header().Set(constants.HeaderXOllaRoutingReason, decision.Reason)
}
}

func (a *Application) initializeProxyRequest(r *http.Request) *proxyRequest {
// get the requestID from the middleware context first
requestID := ""
Expand Down Expand Up @@ -288,6 +362,40 @@ func (a *Application) logRequestStart(pr *proxyRequest, endpointCount int) {
pr.requestLogger.Debug("Request details", debugFields...)
}

// logRequestRejected records a request that never reached the proxy engine because
// endpoint selection produced no routable target. Kept distinct from logRequestResult's
// "completed"/"failed" outcomes so a fail-fast rejection (e.g. strict model_not_found)
// is surfaced as a rejection rather than a successful completion (#191).
func (a *Application) logRequestRejected(pr *proxyRequest, status int) {
duration := time.Since(pr.stats.StartTime)

logFields := []any{
"client_ip", pr.clientIP,
"path", pr.path,
"status", status,
"duration_ms", duration.Milliseconds(),
}

if pr.model != "" {
logFields = append(logFields, "model", pr.model)
}

// routing fields explain why nothing was routable (strategy/action/reason)
if rd := pr.stats.RoutingDecision; rd != nil {
if rd.Strategy != "" {
logFields = append(logFields, "routing_strategy", rd.Strategy)
}
if rd.Action != "" {
logFields = append(logFields, "routing_action", rd.Action)
}
if rd.Reason != "" {
logFields = append(logFields, "routing_reason", rd.Reason)
}
}

pr.requestLogger.Warn("Request rejected", logFields...)
}

func (a *Application) logRequestResult(pr *proxyRequest, err error) {
duration := time.Since(pr.stats.StartTime)

Expand Down Expand Up @@ -572,14 +680,7 @@ func (a *Application) resolveAliasEndpoints(ctx context.Context, profile *domain
logFields...)

// fall through to standard routing in case the alias name itself is a known model
routableEndpoints, decision, routeErr := a.modelRegistry.GetRoutableEndpointsForModel(ctx, aliasName, candidates)
if decision != nil {
profile.RoutingDecision = decision
}
if routeErr != nil || len(routableEndpoints) == 0 {
return candidates
}
return routableEndpoints
return a.routeByAliasName(ctx, aliasName, profile, candidates)
}

// filter candidates to only those that have one of the aliased models
Expand All @@ -594,7 +695,18 @@ func (a *Application) resolveAliasEndpoints(ctx context.Context, profile *domain
logger.Warn("No healthy endpoints found for model alias",
"alias", aliasName,
"resolved_endpoints", len(endpointToModel))
return []*domain.Endpoint{}

// The alias resolved to real target models, but none of them are on a healthy/
// compatible candidate. Rather than synthesising a rejection here, consult the
// routing strategy for the alias name itself, exactly like the "resolved to no
// endpoints at all" branch above - this is what lets optimistic routing with
// fallback_behavior: all substitute a different endpoint instead of the request
// being unconditionally rejected (#191 follow-up). Trade-off accepted: the
// resulting rejection reason/status is whatever the registry reports for an
// unknown model (typically model_not_found/404) rather than the alias-specific
// model_unavailable/503 this branch used to synthesise; consistency with the
// policy engine wins over status-code precision.
return a.routeByAliasName(ctx, aliasName, profile, candidates)
}

// store the rewrite map in the profile for use during request proxying
Expand Down Expand Up @@ -624,6 +736,39 @@ func (a *Application) resolveAliasEndpoints(ctx context.Context, profile *domain
return aliasEndpoints
}

// routeByAliasName is the shared tail for both resolveAliasEndpoints fallback paths:
// alias resolution producing no endpoints at all, and alias resolution producing
// endpoints that don't intersect the healthy/compatible candidate set. Both cases treat
// the alias name as if it were a plain model name and hand the decision to the configured
// routing strategy, rather than the handler synthesising its own rejection - this is what
// lets fallback_behavior: all under optimistic routing substitute a different endpoint
// instead of always rejecting (#191 follow-up). It does NOT set the alias rewrite map:
// any endpoints returned here were not confirmed to serve one of the alias's actual
// target models, so the proxy must forward the original request body unchanged.
func (a *Application) routeByAliasName(ctx context.Context, aliasName string, profile *domain.RequestProfile, candidates []*domain.Endpoint) []*domain.Endpoint {
routableEndpoints, decision, routeErr := a.modelRegistry.GetRoutableEndpointsForModel(ctx, aliasName, candidates)
if decision != nil {
profile.RoutingDecision = decision
}

// A rejection must fail fast exactly like the non-alias path in filterEndpointsByProfile.
// Returning candidates here would silently proxy to a compatible-but-wrong backend and
// ignore the routing verdict (#191). Keyed on status code rather than the "rejected"
// action string because writeNoRoutableEndpoints uses the same status-code contract,
// and not every registry implementation reports rejections as "rejected" -
// MemoryModelRegistry's base GetRoutableEndpointsForModel uses "no_model"/"no_healthy"
// with 404/503. Keying on the action string would silently miss those and reintroduce
// the bug this fix closes.
if decision != nil && decision.StatusCode >= http.StatusBadRequest {
return []*domain.Endpoint{}
}

if routeErr != nil || len(routableEndpoints) == 0 {
return candidates
}
return routableEndpoints
}

func (a *Application) filterEndpointsByCapabilities(endpoints []*domain.Endpoint, profile *domain.RequestProfile, logger logger.StyledLogger) []*domain.Endpoint {
if profile.ModelCapabilities == nil {
return endpoints
Expand Down
Loading
Loading