Architecture Guide¶
This document covers Continuum Router's architecture, design decisions, and extension points.
Table of Contents¶
- Overview
- 4-Layer Architecture
- Library Entry Point (src/server/)
- Core Components
- Data Flow
- Dependency Injection
- Error Handling Strategy
- Extension Points
- Design Decisions
- Performance Considerations
- Rate Limiting → Configuration
- Model Fallback System → Error Handling
- Circuit Breaker → Error Handling
- File Storage → Architecture Details
- Realtime WebSocket Proxy → Configuration
- Dynamo Worker Discovery → Architecture Details
- Agent Communication Protocol (ACP)
- Backend Passthrough Contract
- Model ID Suffix Normalization → Configuration
Overview¶
Continuum Router is a high-performance, production-ready LLM API router using a 4-layer architecture that provides separation of concerns, testability, and maintainability. The architecture follows Domain-Driven Design principles and dependency inversion to create an extensible system.
Architecture Goals¶
- Separation of Concerns: Each layer has a single, well-defined responsibility
- Dependency Inversion: Higher layers depend on abstractions, not concrete implementations
- Testability: Each component can be unit tested in isolation
- Extensibility: New features can be added without modifying existing code
- Performance: Minimal overhead while maintaining clean architecture
- Reliability: Fail-fast design with full error handling
4-Layer Architecture¶
Layer Descriptions¶
1. HTTP Layer (src/http/)¶
Responsibility: Handle HTTP requests, responses, and web-specific concerns
Components¶
- Routes (
routes.rs): Define HTTP endpoints and route handling - Middleware (
middleware/): Cross-cutting concerns (auth, logging, metrics, rate limiting) - DTOs (
dto/): Data Transfer Objects for HTTP serialization/deserialization - Streaming (
streaming/): Server-Sent Events (SSE) handling
Key Files¶
src/http/
├── mod.rs # HTTP layer exports
├── routes.rs # Route definitions and handlers
├── dto.rs # Request/Response DTOs
├── handlers/ # Request handlers
│ ├── mod.rs
│ └── responses.rs # Responses API handlers
├── middleware/ # HTTP middleware components
│ ├── mod.rs
│ ├── auth.rs # API key authentication middleware
│ ├── admin_auth.rs # Admin API authentication middleware
│ ├── files_auth.rs # Files API authentication middleware
│ ├── admin_audit.rs # Admin operations audit logging
│ ├── cors.rs # CORS (Cross-Origin Resource Sharing) middleware
│ ├── logging.rs # Request/response logging
│ ├── metrics.rs # Metrics collection
│ ├── metrics_auth.rs # Metrics endpoint authentication
│ ├── model_extractor.rs # Model extraction from requests
│ ├── prometheus.rs # Prometheus metrics integration
│ ├── rate_limit.rs # Rate limiting middleware (legacy)
│ └── rate_limit_v2/ # Enhanced rate limiting (modular)
│ ├── mod.rs # Module exports
│ ├── middleware.rs # Rate limiting middleware
│ ├── store.rs # Rate limit storage and tracking
│ └── token_bucket.rs # Token bucket algorithm
└── streaming/ # SSE streaming handlers
├── mod.rs
└── handler.rs # Streaming response handling
Middleware Components¶
The HTTP layer includes several middleware components that provide cross-cutting concerns:
-
auth.rs: API key authentication for main endpoints (
/v1/chat/completions,/v1/models,/anthropic/*, etc.)- Validates API keys presented as
Authorization: Bearer <key>or the Anthropic-nativex-api-key: <key>header;Authorization: Bearertakes precedence when both are present - Supports multiple API keys configured in
config.yaml - Returns 401 Unauthorized for invalid/missing keys
- Validates API keys presented as
-
admin_auth.rs: Separate authentication for admin endpoints (
/admin/*)- Supports explicit
none, bearer token, HTTP Basic, IP-whitelist, and shared-store API-key modes - Protects sensitive operations (config reload, circuit-breaker control, health management)
- Configurable through
admin.auth; a missing block fails closed and a block withoutmethoddefaults toapi_key
- Supports explicit
-
files_auth.rs: Authentication middleware for Files API (
/v1/files/*)- Validates API keys specifically for file upload/download/deletion operations
- Prevents unauthorized file access and manipulation
- Integrates with file storage service for permission checks
-
admin_audit.rs: Audit logging middleware for admin operations
- Records all admin API calls with timestamps and caller identification
- Logs parameters and outcomes of sensitive operations
- Provides audit trail for compliance and security monitoring
- Configurable log levels and retention policies
-
cors.rs: CORS (Cross-Origin Resource Sharing) middleware
- Enables embedding the router in web applications, Tauri apps, and Electron apps
- Supports wildcard origins (
*), exact origins, and port wildcards (http://localhost:*) - Custom scheme support for desktop apps (e.g.,
tauri://localhost) - Configurable methods, headers, credentials, and preflight cache duration
- Applied early in the middleware stack for proper preflight handling
-
rate_limit_v2/: Enhanced rate limiting system (see Rate Limiting section)
- Token bucket algorithm with per-client tracking
- Separate limits for sustained rate and burst protection
- Automatic cleanup of expired client entries
- Detailed metrics for monitoring
2. Services Layer (src/services/)¶
Responsibility: Orchestrate business logic and coordinate between infrastructure components
Components¶
- Backend Service (
backend_service.rs): Manage backend pool, load balancing, health checks - Model Service (
model_service.rs): Aggregate models from backends, handle caching, enrich with metadata - Proxy Service (
proxy_service.rs): Route requests, handle retries, manage streaming - Health Service (
health_service.rs): Monitor service health, track status - Web Search Service (
search/): Router-managedweb_searchtool for self-hosted backends. Pluggable provider trait with Serper, Exa, Brave, and SearXNG implementations, bounded tool-execution loop with iteration cap / wall-clock / byte-budget guards (see Web Search) - System One Client (
systemone/): Shared client for System One decision models across thegeneric,typesafe,foundry, andllama_cppprovider profiles (hosted TypeSafe Jev, Microsoft-Decision-1 on Foundry, and self-hosted Laya / Kev / Nimble / VLLM Decision 2/D3). Config block:SystemOneTargetConfiginsrc/core/config/models/systemone.rs. See System One Decision Models for wire details.- Typed queries and resilience: Executes
noul,choice,score, andboxquestions over astate, with one bounded retry on 429 / 529 / connect failure, capped error bodies, andbackend:references resolved under the pinned-backend contract. - Cache invalidation:
cache_target_identity()includes a monotonicCacheTopologyGenerationcounter that increments whenever the liveConfigsnapshot reloads, invalidating downstream caches without hashing credentials. - Client caching and consumers:
ClientCache(systemone/cache.rs) maintains one built client per consumer and router (or per consumer, router, and target name for/v1/decisionsviaget_or_build_keyed). Consumed by the guardrail provider, smart-routing classifier, action-safety evaluator, answer-quality fallback check, and public/v1/decisionsnative transport.
- Typed queries and resilience: Executes
- Answer Quality (
answer_quality/): Consumer-neutral answer-quality battery asking threenoulquestions (answered,refusal,truncated, or any subset viaquestions), evaluated byAnswerJudgment::worst_problemover a head-and-tail-capped{user_message, ai_response}state built from the final user message and reply. Driven byfallback.quality_check(src/proxy/quality_fallback.rs) after the guardrail output gate on non-streaming chat replies, advancing the served chain viaexecute_quality_hop(src/proxy/request.rs) withinmax_extra_hops,max_fallback_attempts, and the non-streaming chain budget. - Usage Quality Judgment (
src/control_plane/usage_quality/,control-planefeature): Extends the answer-quality battery withgroundedandlanguage_matchto evaluate served chat replies (buffered or streamed) after the guardrail output gate. For route-by-route wire extraction rules, see System One Decision Models.- Non-blocking evaluation and call sharing:
UsageQualityPlansamples the request and captures capped text before dispatch, attaching aQualityTicketto theUsageEvent. Once the reply is finalized,finish_text_withspawns the decision call in the background (bounded by a per-router in-flight cap) or reuses the judgment fromfallback.quality_checkwhen both consumers share a call. - Usage batch sealing: The usage loop maps, sequences, charges, and buffers records immediately, sealing each
QualityTicketwhen forming a push batch. Judgments ready before batch formation attach to the record (deduplicated by the Hub onidempotency_key); later arrivals count aslatewithout blocking accounting or batch pushes. - Streaming accumulation: Streamed requests pass a shared, take-once
StreamQualityHandleonStreamObservabilityContext::usage_quality(excluding cache replays). Wire relays feed a bounded head-and-tailStreamReplyAccumulator(src/services/answer_quality/stream.rs, equivalent tocap_middleof the full reply) and settle once after the output gate'sfinalize: judged on a clean finish, orskippedif the gate cut/rewrote the stream, a mid-stream hop spliced generations, an error occurred, the client disconnected, or no relay claimed the handle. Every outcome carries astreamlabel. - Multi-surface ingress support: Implements
QualityRequest(src/services/answer_quality/state.rs) across OpenAI Chat Completions (including theresponses_onlybridge insrc/proxy/responses_only/bridge_usage.rs), Anthropic Messages (src/services/answer_quality/anthropic.rs,src/http/handlers/anthropic/user_turn.rs, andstream_output.rs), and/v1/responses(src/services/answer_quality/responses.rsandsrc/http/handlers/responses_quality.rs), using request-scoped plan slots (with_request_plan,claim_current_ticket,take_request_planinsrc/control_plane/usage_quality/scope.rs) for buffered fallback chains andCompletedResponsePersister/ passthrough relays for streams. Model-experiment usage records also carryexperiment_idandexperiment_variant_id(ExperimentArm).
- Non-blocking evaluation and call sharing:
- System One Classifier (
smart_routing/system_one_classifier.rs): Smart-routingmethod: system_one/hybrid_system_oneclassifier.- Single-call scoring: Queries
score(complexity levels, rounded to nearest) andchoice(domains) in one decision call via the shared client, settingClassificationResultconfidence to the minimum of both answers. - Gating and shadow mode:
SmartRoutergates results onmin_confidence(falling back to rules, or escalating to LLM underhybrid_system_one), copies capabilities and language from the rule classifier, and inshadowmode runs concurrently with the active classifier to record agreement without altering routing. - Caching and candidate pre-filtering: Cache entries are namespaced by
cache_namespace({version, target, questions}),SystemOneClient::cache_target_identity(), and bounded post-guardrail state. Before classification and policy selection,collect_smart_routing_candidates_for_request(src/proxy/smart_routing_candidates.rs) intersects the caller API key'sallowed_modelsandallowed_backends, backend health, circuit-breaker admission, and chat output eligibility (excluding decision-only models asexcluded_incompatible).
- Single-call scoring: Queries
Key Files¶
src/services/
├── mod.rs # Module exports
├── model_service.rs # Model aggregation service
├── proxy_service.rs # Request proxying and routing
├── health_service.rs # Health monitoring service
├── deduplication.rs # Request deduplication service
├── responses/ # Responses API support
│ ├── mod.rs
│ ├── converter.rs # Response format conversion
│ ├── router.rs # Routing strategy determination (pass-through vs conversion)
│ ├── passthrough.rs # Direct pass-through for OpenAI/Azure and `responses_api: native` backends
│ ├── passthrough_lifecycle.rs # GET/DELETE/cancel forwarded to the backend that served a response
│ ├── passthrough_routes.rs # Bounded per-router (owner, response id) -> backend bindings
│ ├── session.rs # Session management
│ ├── stream_service.rs # Streaming service orchestration
│ └── streaming.rs # Streaming response handling
├── answer_quality/ # Answer-quality battery and decision state
│ ├── battery.rs # QualityQuestion, AnswerJudgment, judge()
│ └── state.rs # {user_message, ai_response[, tool_results]} state builder and caps
├── search/ # Router-managed web search service
│ ├── mod.rs # SearchProvider trait, SearchError, SearchResult, build_provider
│ ├── executor.rs # WebSearchExecutor: tool injection, tool-execution loop
│ └── providers.rs # Concrete provider implementations (Serper, Exa, Brave, SearXNG)
├── streaming/ # Streaming utilities
│ ├── mod.rs
│ ├── parser.rs # Stream parsing logic
│ └── transformer.rs # Stream transformation (OpenAI/Anthropic)
├── tokenize/ # Engine tokenizer access, always compiled
│ ├── wire.rs # Per-backend-type capability, paths, payloads; shared with count_tokens
│ ├── response.rs # Engine response readers over the capped bytes, no JSON tree
│ ├── lenient.rs # Visitors that read a wrong-typed field as absent
│ ├── token_strs.rs # Compact token-string list: one buffer plus u32 offsets
│ ├── upstream.rs # Time budget and capped body read, shared with count_tokens
│ ├── validate.rs # Client contract of /tokenize and /detokenize
│ ├── dispatch.rs # Exact-model resolution (no retry, no fallback)
│ ├── call.rs # The upstream call, or the /apply-template then /tokenize pair
│ ├── chat_render.rs # messages options each llama-server engine honors, and the pair's bodies
│ └── handlers.rs # The two handlers mounted by build_api_routes
└── systemone/ # Shared System One decision-model client
├── mod.rs # Module docs and re-exports
├── cache.rs # ClientCache: one client per consumer, router, and target key
├── client.rs # SystemOneClient: target resolution, bounded retry, decoding
├── metering.rs # Attempt-level usage recording per SystemOneConsumer
└── types.rs # Question / Answer / Usage wire types and question validation
3. Infrastructure Layer (src/infrastructure/)¶
Responsibility: Provide concrete implementations of external systems and technical capabilities
Components¶
- Backends (
backends/): Specific backend implementations (OpenAI, Anthropic, Gemini, vLLM, SGLang, Ollama, llama.cpp, MLxcel, LM Studio, Continuum Router) - Auth (
auth/): OAuth 2.0 Device Authorization Grant (RFC 8628) subsystem. TheAuthStrategyRegistryis populated at startup and updated during hot-reload; every proxy hot-path handler (chat completions, responses, image generation) consults it to sign outbound requests and force-refresh on401. The streaming request builders consult the same registry (initial dispatch, per-hop mid-stream fallback, auto backend selection, and the Unix socket transport). Seesrc/proxy/oauth_helper.rsfor the shared proxy auth-helper. - Cache (
cache/): Caching implementations (LRU/TTL-basedTtlLruCache,CacheStoretrait for pluggable backends,InMemoryCacheStore, SHA256-keyedResponseCacheStore, retry-aware cache) - KV Cache Index (
kv_index/): Shared index that maps token prefix hashes to backends for KV cache-aware routing. ProvidesInMemoryKvIndex(DashMap + LRU eviction, single-router) andRedisKvIndex(Redis sorted sets, multi-router). IncludesKvOverlapScorerwhich implementsBackendScorerto score backends by cache overlap, load, and health. The event consumer ingests real-time cache events from vLLM SSE streams to keep the index current. - Configuration (
config/): Configuration loading, watching, validation - HTTP Client (
common/http_client.rs):HttpClientFactorypresets for every backend client (the single client factory) - Backend HTTP Protocol (
backend_http/): Per-backendhttp_versionselection (autouses the shared proxy client;http1andhttp2use sibling clients built fromProxyClientTuningand cached process-wide).AppState::backend_clientresolves the client from the routed configuration on each dispatch, andBackendClientscarries that choice into mid-stream relay tasks. It also emits dispatch observability (continuum_backend_response_wait_secondsviaResponseWaitand rate-limited warnings at 100 in-flight HTTP/2 requests).
Key Files¶
src/infrastructure/
├── mod.rs # Infrastructure exports and utilities
├── auth/ # OAuth 2.0 Device Authorization Grant (RFC 8628)
│ ├── mod.rs # Public re-exports
│ ├── device_flow.rs # RFC 8628 state machine + ReqwestDeviceFlowClient
│ ├── token_store.rs # Atomic write (tempfile+rename), 0600 perms, ~ expansion
│ ├── strategy.rs # AsyncAuthStrategy trait + OAuthAuthStrategy (proactive refresh)
│ ├── registry.rs # AuthStrategyRegistry: hot-reloadable map of backend → strategy
│ └── error.rs # AuthError type
├── backends/ # Backend implementations
│ ├── mod.rs
│ ├── anthropic/ # Native Anthropic Claude backend
│ │ ├── mod.rs # Backend implementation & request transformation
│ │ └── stream.rs # SSE stream transformer (Anthropic → OpenAI)
│ ├── gemini/ # Native Google Gemini backend
│ │ ├── mod.rs # Backend implementation with TTFB optimization
│ │ └── stream.rs # SSE stream transformer (Gemini → OpenAI)
│ ├── openai/ # OpenAI-compatible backend
│ │ ├── mod.rs
│ │ ├── backend.rs # OpenAI backend implementation
│ │ └── models/ # OpenAI-specific model definitions
│ ├── factory/ # Backend factory pattern
│ │ ├── mod.rs
│ │ └── backend_factory.rs # Creates backends from config
│ ├── pool/ # Backend pooling and management
│ │ ├── mod.rs
│ │ ├── backend_pool.rs # Connection pool management
│ │ └── backend_manager.rs # Backend lifecycle management
│ ├── generic/ # Generic backend implementations
│ │ └── mod.rs
│ ├── llamacpp/ # llama.cpp / llama-server backend
│ │ ├── mod.rs
│ │ ├── backend.rs # LlamaCppBackend implementation
│ │ ├── model_parser.rs # Hybrid response format parsing
│ │ └── props.rs # /props endpoint for tool calling detection
│ ├── continuum_router/ # Continuum Router / Backend.AI GO backend
│ │ ├── mod.rs
│ │ └── backend.rs # ContinuumRouterBackend implementation
│ └── vllm.rs # vLLM/SGLang/Ollama/LM Studio backend implementation
├── common/ # Shared infrastructure utilities
│ ├── mod.rs
│ ├── executor.rs # Request execution with retry/metrics
│ ├── headers.rs # HTTP header utilities
│ ├── http_client.rs # HTTP client factory with pooling
│ ├── statistics.rs # Backend statistics collection
│ └── url_validator.rs # URL validation and security
├── transport/ # Transport abstraction layer
│ ├── mod.rs # Transport enum (HTTP, UnixSocket)
│ ├── unix_socket.rs # Unix Domain Socket client (Linux/macOS)
│ └── unix_socket_windows.rs # Windows AF_UNIX client via socket2
├── cache/ # Caching implementations
│ ├── mod.rs
│ ├── store.rs # CacheStore trait (async, byte-oriented cache backend abstraction)
│ ├── memory_store.rs # InMemoryCacheStore: TtlLruCache-backed CacheStore implementation
│ ├── lru_cache.rs # LRU cache with TTL and eviction
│ ├── response_cache.rs # SHA256-keyed LLM response cache (uses Arc<dyn CacheStore>)
│ └── retry_cache.rs # Retry-aware cache
├── kv_index/ # KV cache prefix-hash index and overlap scoring
│ ├── mod.rs # Re-exports KvCacheIndex, InMemoryKvIndex, RedisKvIndex, KvOverlapScorer
│ ├── index.rs # KvCacheIndex trait + InMemoryKvIndex (DashMap/LRU) + RedisKvIndex (sorted sets)
│ ├── event_consumer.rs # KvEventConsumerManager: subscribes to vLLM SSE KV cache event streams
│ ├── scorer.rs # KvOverlapScorer: BackendScorer impl using KV cache affinity + load + health
│ └── types.rs # KvCacheEvent, KvCacheEventType, ConsumerStatus, KvEventConsumerConfig
├── config/ # Configuration management
│ ├── mod.rs
│ ├── loader.rs # Configuration loading
│ ├── validator.rs # Configuration validation
│ ├── timeout_validator.rs # Timeout configuration validation
│ ├── watcher.rs # File watching for hot-reload
│ ├── migrator.rs # Configuration migration orchestrator
│ ├── migration.rs # Migration types and traits
│ ├── migrations.rs # Specific migration implementations
│ ├── fixer.rs # Auto-correction logic
│ ├── backup.rs # Backup management
│ └── secrets.rs # Secret/API key management
└── lock_optimization.rs # Lock and concurrency optimization
4. Core Layer (src/core/)¶
Responsibility: Define domain models, business rules, and fundamental abstractions
Components¶
- Models (
models/): Core domain entities (Backend, Model, Request, Response) - Traits (
traits.rs): Core interfaces and contracts - Errors (
errors.rs): Domain-specific error types and handling - Retry (
retry/): Retry policies and strategies - Scoring (
scoring.rs):BackendScorertrait for composable backend scoring pipeline; each scorer assigns a signal-specific score andBackendPoolsums them to select the best backend
Key Files¶
src/core/
├── mod.rs # Core exports and utilities
├── models/ # Domain models
│ ├── mod.rs
│ ├── backend.rs # Backend domain model
│ ├── model.rs # LLM model representation
│ ├── request.rs # Request models
│ └── responses.rs # Response models (Responses API)
├── traits.rs # Core traits and interfaces
├── errors.rs # Error types and handling
├── container.rs # Unwired legacy (not used at runtime)
├── scoring.rs # BackendScorer trait: composable async scoring pipeline for backend selection
├── hashing.rs # SHA256 prefix key computation (compute_hash)
├── prefix_key.rs # Prefix key extraction from request bodies
├── async_utils.rs # Async utility functions
├── duration_utils.rs # Duration parsing utilities
├── streaming/ # Streaming models
│ ├── mod.rs
│ └── models.rs # Streaming-specific models
├── retry/ # Retry mechanisms
│ ├── mod.rs
│ ├── policy.rs # Retry policies
│ └── strategy.rs # Retry strategies
├── circuit_breaker/ # Circuit breaker pattern
│ ├── mod.rs # Module exports
│ ├── config.rs # Configuration models
│ ├── state.rs # State machine and breaker logic
│ ├── error.rs # Circuit breaker errors
│ ├── metrics.rs # Prometheus metrics
│ └── tests.rs # Unit tests
├── files/ # File processing utilities
│ ├── mod.rs # Module exports
│ ├── resolver.rs # File reference resolution in chat requests
│ ├── transformer.rs # Message transformation with file content
│ └── transformer_utils.rs # Transformation utility functions
├── tool_calling/ # Tool calling transformation
│ ├── mod.rs # Module exports
│ ├── definitions.rs # Tool/function type definitions
│ └── transform.rs # Backend-specific transformations
└── config/ # Configuration models
├── mod.rs
├── models/ # Configuration data models (modular structure)
│ ├── mod.rs # Public module exports
│ ├── config.rs # Main Config struct, ServerConfig, BackendConfig
│ ├── backend_type.rs # BackendType enum definitions
│ ├── model_metadata.rs # ModelMetadata, PricingInfo, CapabilityInfo
│ ├── global_prompts.rs # GlobalPrompts configuration
│ ├── samples.rs # Sample generation configurations
│ ├── validation.rs # Configuration validation logic
│ └── error.rs # Configuration-specific errors
├── timeout_models.rs # Timeout configuration models
├── cached_timeout.rs # Cached timeout resolution
├── optimized_retry.rs # Optimized retry configuration
├── metrics.rs # Metrics configuration
└── rate_limit.rs # Rate limit configuration
Library Entry Point (src/server/)¶
Responsibility: Expose Continuum Router as an embeddable Rust library crate via a ContinuumRouter builder API
The src/server/ module decouples all initialization logic from the binary entry point (main.rs), enabling downstream Rust projects to embed the router directly without spawning a subprocess. The binary main.rs becomes a thin CLI wrapper (~220 lines) that delegates to this module.
Key Files:
src/server/
├── mod.rs # ContinuumRouter struct, ContinuumRouterBuilder with fluent API
├── init.rs # Individual service initialization functions (HTTP client, health checker,
│ # circuit breaker, file service, API key store, rate limiting)
├── state.rs # initialize_services() - assembles all services into AppState
├── routes.rs # Route builders for API, admin, files, WebUI, metrics, and CORS
└── serve.rs # Server lifecycle: bind listeners, hot-reload, graceful shutdown
Builder API:
use continuum_router::{ContinuumRouter, Config};
// From a config file (enables hot-reload when called)
let router = ContinuumRouter::from_config_file("config.yaml")
.await?
.enable_hot_reload(true)
.build()
.await?;
router.serve("0.0.0.0:8080").await?;
// From a programmatic Config struct (no file dependency)
let router = ContinuumRouter::from_config(Config::default())
.enable_health_checks(false)
.with_config_dir("/etc/continuum")
.build()
.await?;
// Embed into an existing Axum application
let app = axum::Router::new()
.nest("/llm", router.into_router());
Builder Override Semantics:
Each enable_* method registers an explicit override. When None (not called), the builder respects whatever the config file or Config::default() says. When Some(value), the override wins regardless of the config source.
| Method | Override field | Effect |
|---|---|---|
enable_hot_reload(bool) | overrides.hot_reload | Start/suppress the config file watcher |
enable_health_checks(bool) | overrides.health_checks | Start/suppress background health monitoring |
enable_circuit_breaker(bool) | overrides.circuit_breaker | Enable/disable circuit breaker state machine |
enable_metrics(bool) | overrides.metrics | Enable/disable Prometheus metrics collection |
enable_files_api(bool) | overrides.files_api | Enable/disable the /v1/files API |
without_control_plane_agent() | overrides.no_control_plane_agent | Declare that serve() will not run: no control-plane usage channel or usage write-ahead log, and a startup warning when control_plane.enabled is on |
with_http_client(client) | custom_http_client | Inject a pre-configured reqwest::Client |
with_probe_http_client(client) | custom_probe_http_client | Inject the client health checks and model catalog fetches use (own pool by default) |
with_config_dir(path) | config_dir | Set base directory for prompt file resolution |
with_config_manager(mgr) | config_manager | Attach a pre-created ConfigManager (CLI use-case) |
ContinuumRouter struct (returned by build()):
| Method | Description |
|---|---|
serve(addr) | Run as a standalone server; blocks until shutdown signal |
into_router() | Return the Axum Router for embedding into another app |
state() | Access the shared Arc<AppState> |
config_handle() | Access the ConfigManager (if loaded from a file) |
shutdown() | Consume and drop the router, releasing all resources |
Design Decisions:
initialize_prompt_file_cacheacceptsOption<&Path>instead of&Args, removing the CLI dependency from library code.perform_container_health_check(DockerHEALTHCHECK) remains inmain.rsbecause it is binary-only and creates no TCP listening sockets.load_configuration(CLI fallback logic) also remains inmain.rsto keep the library free of CLI concerns.- All
init::*andstate::*functions arepub(crate)orpub(super); onlyContinuumRouter,ContinuumRouterBuilder, and theinit/routessub-modules are re-exported fromlib.rs.
Core Components¶
Backend Pool¶
Location: src/infrastructure/backends/pool/ (backend_pool.rs + lifecycle.rs)
Purpose: The single source of truth for backend membership, selection, and lifecycle. It holds Arc<dyn Backend> execution objects, runs the health-aware selection (round-robin / weighted / least-latency / consistent-hash / prefix-aware, plus the composable scorer pipeline with in-flight counting and per-backend stats), and owns the graceful-drain and hot-reload lifecycle.
Every live HTTP request path (chat completions, streaming chat, Responses, Anthropic Messages and count_tokens, image generation) dispatches its final backend pick through this pool:
- Candidate resolution and selection: Each path resolves and pre-filters its own candidate set (model lookup, internal-backend filter, per-key allow-list, retry-state exclusion) and hands the names to the shared selection seam (
proxy::selection::select_pooled_backend). The seam health-filters the candidates and callsBackendPool::select_from_candidatesso the configuredselection_strategyand any registered scorers (such asKvOverlapScorerwhenprefix_routing.enabledand a KV index are configured) govern traffic distribution instead of candidate-list order. - Consistent-hash ring cache: The consistent-hash ring cache is a small bounded LRU keyed by the participating backend-name vector (including order), so alternating per-model candidate subsets reuse their own rings without unbounded growth. Each selection call clones an
Arcring snapshot before walking it, preventing a ring built for one candidate set from being interpreted against another and keeping the cache lock out of the ring walk. - Compile-time visibility enforcement (
UserRoutableCandidates): Candidate names travel asproxy::selection::UserRoutableCandidatesrather than a bareVec<String>. Because the user-routing visibility filter must run before the per-key allow-list (so a model served only byinternal: trueorenabled: falsebackends reports model-not-found rather than a misleading403 Forbidden),UserRoutableCandidatesencodes that proof at the type level:- Its only ordinary constructors are
filter_user_routing_candidates(names) andfilter_user_routing_backends(typedBackendConfigentries). All other operations are narrowings that cannot admit a hidden backend, so any dispatch path that omits the filter fails to compile. UserRoutableCandidates::bypass_for_managementis the sole explicit bypass and has no callers; router-internal consumers that legitimately address hidden backends (management probes, health monitoring, the admin catalog, hub inventory, model discovery) resolve backends without going through the user-routing seam.
- Its only ordinary constructors are
pub struct BackendPool {
backends: Arc<RwLock<Vec<Arc<dyn Backend>>>>,
counter: Arc<AtomicUsize>,
selection_strategy: SelectionStrategy,
stats: Arc<RwLock<HashMap<String, BackendStats>>>,
scorers: Arc<RwLock<Vec<Arc<dyn BackendScorer>>>>,
// Lifecycle side tables (keyed by backend name), see lifecycle.rs:
backend_states: Arc<RwLock<HashMap<String, BackendState>>>, // Active/Draining/Removed
draining: Arc<RwLock<Vec<DrainingEntry>>>, // + drain timestamps
// ... hash-ring cache, epsilon, scorer threshold ...
}
impl BackendPool {
// Health/scorer-aware selection over the live backends.
pub async fn select_backend(&self, model: Option<&str>) -> CoreResult<Arc<dyn Backend>> { /* ... */ }
// Candidate-restricted selection used by every live HTTP path:
// intersects the caller's pre-filtered candidate names with pool membership
// (by name only, preserving order), then runs the scorer pipeline and the
// configured strategy over exactly that subset.
pub async fn select_from_candidates(
&self,
candidates: &[String],
model: Option<&str>,
context: &ScoringContext,
) -> CoreResult<Arc<dyn Backend>> { /* ... */ }
// Graceful drain (out of rotation, in-flight requests finish) + cleanup.
pub async fn drain_backend(&self, name: &str) -> Option<Arc<dyn Backend>> { /* ... */ }
pub async fn cleanup_draining(&self) -> usize { /* ... */ }
// In-place hot reload: add/drain/recreate backends from new config.
pub async fn update_from_config(&self, configs: &[BackendConfig], factory: &BackendFactory) { /* ... */ }
}
The mutable lifecycle state lives in side tables keyed by backend name (backend_states, draining), mirroring the pool's existing name-keyed stats map, so the hot selection path keeps its plain Vec<Arc<dyn Backend>> layout. AppState holds exactly one backend_pool; the proxy/passthrough layers project the chosen Arc<dyn Backend> to a lightweight PooledBackend { name, url } handle at the selection boundary.
The pool also owns the per-backend engine statistics store (src/infrastructure/backends/engine_stats/), whose background poller reads each self-hosted engine's load or metrics endpoint (vLLM /metrics, SGLang /v1/loads, llama.cpp /slots and /metrics, Ollama /api/ps, LM Studio /api/v0/models) and normalizes it into a TTL-bounded EngineStatsSnapshot. Prometheus scrape refresh (backend_engine_*{backend}), GET /admin/backends/{name}/engine-stats, WebUI dashboard cards, Backend::get_current_load(), the KV overlap scorer, and selection saturation admission hints (routing.engine_load) all read this store (see Metrics).
Health Checker¶
Location: src/health.rs → src/services/health_service.rs
Purpose: Monitor backend health with configurable thresholds, automatic recovery, and accelerated warmup detection
pub struct HealthChecker {
backends: Arc<RwLock<Vec<Backend>>>,
config: HealthConfig,
status_map: Arc<RwLock<HashMap<String, HealthStatus>>>,
}
pub struct HealthConfig {
pub interval: Duration,
pub timeout: Duration,
pub unhealthy_threshold: u32, // Failures before marking unhealthy
pub healthy_threshold: u32, // Successes before marking healthy
pub warmup_check_interval: Duration, // Accelerated interval during warmup (default: 1s)
pub max_warmup_duration: Duration, // Max time in warmup mode (default: 300s)
}
pub enum HealthStatus {
Healthy, // Backend responding with HTTP 200
Unhealthy, // Connection failure or error
WarmingUp, // HTTP 503 - backend loading (accelerated checks)
Unknown, // Initial state
}
Accelerated Warmup Health Checks¶
When a backend returns HTTP 503 (Service Unavailable), it enters the WarmingUp state. During this state:
- Health checks run at
warmup_check_interval(default: 1 second) instead of the normal interval - This reduces model availability detection from ~30 seconds to ~1 second
- After
max_warmup_duration, the backend is marked asUnhealthy - Particularly useful for llama.cpp backends that return HTTP 503 during model loading
Transport Layer¶
Location: src/infrastructure/transport/
Purpose: Provide a unified transport abstraction for backend communication over HTTP/HTTPS or Unix Domain Sockets
The transport layer enables secure local LLM communication via Unix sockets, eliminating the need for TCP port exposure.
URL Schemes¶
| Scheme | Transport | Example |
|---|---|---|
http:// | TCP/HTTP | http://localhost:8080/v1 |
https:// | TCP/HTTPS | https://api.openai.com/v1 |
unix:// | Unix Socket | unix:///var/run/llama.sock |
Transport Enum¶
pub enum Transport {
Http { url: String },
UnixSocket { socket_path: PathBuf },
}
impl Transport {
pub fn from_url(url: &str) -> Result<Self, TransportError>;
pub fn is_unix_socket(&self) -> bool;
pub fn is_http(&self) -> bool;
}
Unix Socket Client¶
The UnixSocketClient provides HTTP-over-Unix-socket communication:
pub struct UnixSocketClient {
socket_path: PathBuf,
config: UnixSocketClientConfig,
}
impl UnixSocketClient {
pub async fn get(&self, endpoint: &str, headers: Option<Vec<(String, String)>>)
-> Result<UnixSocketResponse, UnixSocketError>;
pub async fn post(&self, endpoint: &str, headers: Option<Vec<(String, String)>>, body: Bytes)
-> Result<UnixSocketResponse, UnixSocketError>;
pub async fn health_check(&self) -> Result<bool, UnixSocketError>;
}
Security Features¶
- Path Traversal Protection: Validates socket paths to prevent directory traversal attacks
- CRLF Injection Protection: Validates endpoints and headers for HTTP header injection
- Response Size Limits: Configurable max response size (default: 100MB)
Platform Support¶
| Platform | Support |
|---|---|
| Linux | Full support via native AF_UNIX (tokio::net::UnixStream) |
| macOS | Full support via native AF_UNIX (tokio::net::UnixStream) |
| Windows | Full support via socket2 crate (Windows 10 1809+ / Build 17063+) |
| Other | Returns PlatformNotSupported error |
Windows AF_UNIX support uses the socket2 crate for socket creation and raw Winsock handle conversion to tokio-compatible types. The implementation is isolated in unix_socket_windows.rs and activated via #[cfg(windows)] gates.
Model Aggregation Service¶
Location: src/models/ (modular structure)
Purpose: Aggregate and cache model information from all backends, enrich with metadata, with cache stampede prevention
Module Structure:
src/models/
├── mod.rs # Public module exports
├── types.rs # Model, AggregatedModel, ModelList, SingleModelResponse types
├── metrics.rs # ModelMetrics tracking (includes stampede metrics)
├── cache.rs # ModelCache with stale-while-revalidate & singleflight
├── config.rs # ModelAggregationConfig
├── fetcher.rs # Model fetching from backends
├── handlers.rs # HTTP handlers for /v1/models and /v1/models/{model} endpoints
├── background_refresh.rs # Background periodic cache refresh service
├── pattern_matching.rs # 6-phase model ID lookup pipeline (date / format suffix normalization, HuggingFace repo-prefix stripping, wildcard)
├── utils.rs # Utility functions (normalize_model_id, etc.)
└── aggregation/ # Core aggregation logic
├── mod.rs # ModelAggregationService implementation
└── tests.rs # Unit tests
For the model ID resolution pipeline used during metadata enrichment, see Automatic Quantization and Format Suffix Handling in the configuration guide. For policy guidance on when a new mapping should be added as an explicit YAML alias versus an extension to the peel allowlist or the HuggingFace prefix-strip layer, see Aliases vs. suffix normalization: when to use which. The prefix-strip layer composes with the suffix peel: a single lookup can strip a vendor/repo prefix and then peel an allowlisted suffix token, so unsloth/Qwen3.6-35B-A3B-GGUF routes to qwen3.6-35b-a3b metadata without a hand-registered alias.
Cache Stampede Prevention¶
The model aggregation service implements four strategies to prevent cache stampede (thundering herd problem):
-
Singleflight Pattern: Only one aggregation request runs at a time. Concurrent requests wait for the ongoing aggregation to complete, then share the result.
-
Stale-While-Revalidate: When cache is stale (between soft and hard TTL), return stale data immediately while triggering a background refresh. Clients get fast responses with potentially slightly stale data.
-
Background Periodic Refresh: A background task proactively refreshes the cache before expiration. Requests never block on cache refresh (except cold start).
-
Supersession Stamps: Every aggregation records the pool's
membership_generationand the cache'sinvalidation_epochimmediately before snapshotting the pool, and compares both at store time:- Immediate expiration on stamp drift: If either stamp moved during the fan-out (a backend added, removed, or drained by hot reload, the Admin API, or AppProxy, or a
clear_cache()call from a configuration change that left membership identical), the result is stored as an already-expired entry instead ofFresh. Readers within the stale-serve bound still receive the previous list while a follow-up refresh starts immediately (in-place bounded at three passes for background revalidation, or as one spawned background task on the request path). - Race-free ordering: Stamps are read before taking the pool snapshot. Because the membership comparison is not fused with the insert lock like the epoch comparison, the store re-reads
membership_generationafter insertion and expires the entry in place if it moved. Hot reload bumps both stamps back-to-back before its health-check round trip, so a single reload reaches an in-flight fan-out as one event. - Bounded in-place retry: Between passes, the in-place retry releases the aggregation lock and backs off with jitter so a
POST /v1/models/refreshqueued behind a pass does not wait for the entire chain. If every pass in a chain is superseded, the entry remains expired with a 2 s re-arm hold before the next revalidation (lifted immediately by anyFreshpublish).
- Immediate expiration on stamp drift: If either stamp moved during the fan-out (a backend added, removed, or drained by hot reload, the Admin API, or AppProxy, or a
pub struct ModelAggregationService {
cache: ModelCache, // With singleflight lock and background refresh tracking
config: ModelAggregationConfig,
fetcher: ModelFetcher,
}
impl ModelAggregationService {
// Aggregate models with singleflight protection
pub async fn aggregate_models_with_singleflight(&self, state: &Arc<AppState>)
-> Result<AggregatedModelsResponse, StatusCode> { /* ... */ }
// Find backends with stale-while-revalidate support
pub async fn find_backends_for_model(&self, state: &Arc<AppState>, model_id: &str)
-> Vec<String> { /* ... */ }
// Clear cache (used during hot reload)
pub fn clear_cache(&self) { /* ... */ }
}
Cache TTL Configuration¶
The cache uses a dual-TTL approach:
| TTL Type | Duration | Behavior |
|---|---|---|
| Soft TTL | 80% of hard TTL | Triggers background refresh, returns stale data |
| Hard TTL | Configured value | Requires blocking refresh |
| Empty Response TTL | 5 seconds | Short TTL for empty responses to prevent DoS |
Metrics¶
New metrics for cache stampede monitoring:
stale_while_revalidate: Requests that returned stale data during refreshcoalesced_requests: Requests that waited for ongoing aggregationbackground_refreshes: Background refresh operations initiatedbackground_refresh_successes/failures: Background refresh outcomessingleflight_lock_acquired: Times the aggregation lock was acquired
Proxy Module¶
Location: src/proxy/ (modular structure)
Purpose: Handle request proxying, backend selection, file resolution, and image generation/editing
Module Structure:
src/proxy/
├── mod.rs # Public module exports
├── backend.rs # Backend selection and routing logic
├── request.rs # Request execution with retry logic
├── files.rs # File reference resolution in requests
├── image_gen.rs # Image generation handling (DALL-E, Gemini, GPT Image)
├── image_edit.rs # Image editing support (/v1/images/edits)
├── image_utils.rs # Image processing utilities (multipart, validation)
├── handlers.rs # HTTP handlers for proxy endpoints
├── utils.rs # Utility functions (error responses, etc.)
└── tests.rs # Unit tests
Key Responsibilities¶
- Backend Selection: Intelligent routing to available backends
- File Resolution: Resolve file references in chat requests
- Image Generation: Support for OpenAI (DALL-E, GPT Image) and Gemini (Nano Banana) image models
- Image Editing: Image editing and variations endpoints
- Request Retry: Automatic retry with exponential backoff
- Error Handling: Standardized error responses in OpenAI format
- Internal Streaming: Stream eligible buffered chat and completions requests from the backend so a client cancel is metered (
proxy.internal_streaming)
Internal streaming of buffered requests¶
proxy.internal_streaming (src/proxy/internal_streaming.rs and internal_streaming_read.rs) transforms eligible buffered requests on the wire inside make_http_request: after all payload transforms run, the dispatch is sent with stream: true and stream_options shaped by the shared apply_router_stream_options writer (include_usage: true, plus continuous_usage_stats: true on vllm backends; any client stream_options is discarded first).
- Scope and route eligibility: The chat completions and completions handlers run their post-policy work inside a task-local scope (
client_route), so their retries, fallback hops, deduplication, web-search turns, quality hops, and disaggregated or shim dispatch qualify. The/v1/decisionsemulation hop does not qualify, and the Anthropic Messages and Responses ingresses, embeddings, rerank, images, files, synthetic probes (which useBackendtrait executors), and router-initiated calls never reach this arm. - Backend and request conditions: Within
client_route, eligibility requires the backend's effective internal-streaming flag, an OpenAI-wire backend type (excludingazure), HTTP(S) transport, and a compatible request shape (n: 1, no logprobs,response_formatoftextorjson_object, and none offunctions,function_call,audio, audiomodalities, orweb_search_options, whose output the assembler does not rebuild).
A non-2xx reply is read and classified, and a 2xx application/json reply is passed through unchanged. A 2xx stream is read by one relay loop under the dispatch's own timeout, parsed with SseParser in standard mode, fed to a StreamUsageFinalizer, and assembled by services::streaming::assemble (shared with streaming cache replay) into a standard buffered response body. The read settles in a single tail (enforced by tests/stream_early_exit_audit_test.rs):
- Failure before any output: If no data event arrived, an error event arrived first, a transport error or timeout occurred after a role-only chunk, or the body ended with neither
[DONE]nor afinish_reason, nothing is recorded by the internal-streaming reader and the failure retries like a normal buffered body read (covered by the retry seam's per-attempt record). Output is defined as a non-zero router delta count, a reported engine completion count, or any chat delta field besiderolewith a non-empty value (so a refusal still makes the failure final). - Completed stream: Leaves its finalized usage on the request ledger keyed by the attempt. The row recording that attempt (the served row, a superseded web-search turn, or a quality hop) consumes it as a buffered row: token counts, outcome (
usage_missingwithout engine usage), and token source come from the stream, while TTFT remains the response-header observation andstreamstaysfalse. - Failure after output: Including a body that ends without
[DONE]and without anyfinish_reason, returns aBackendRequestFailurecarrying the finalized partial usage (partial). The retry seam records the attempt (upstream_errorortimeout_mid_stream, with the partial count) and treats it as final likereplay: disabled(TriggerReason::PartialOutput), preventing retries, fallback hops, or disaggregated unified fallback from re-sending the request. - Client disconnect: A read dropped after the first event (because the client disconnected and the handler future was dropped) synchronously records one
client_cancelledattempt fromDrop(when inside a Tokio runtime) and drops the upstream body unread. The circuit breaker recordssettle_successon an assembled body andsettle_erroron a failure.
Retry Handler¶
Location: src/services/deduplication.rs
Purpose: Implement exponential backoff with jitter and request deduplication
EnhancedRetryHandler composes the core retry engine with a DeduplicationManager; configuration uses the public string-duration RetryConfig (max_attempts, initial_delay, max_delay, backoff_multiplier, jitter, retryable status/error lists, and total timeout). The retry state machine itself lives in src/core/retry/strategy.rs.
Per-Backend Replay Policy and Attempt Tracking¶
- Replay policy (
backends[].replay): Enforced atexecute_single_retry_attempt_observed(src/proxy/request.rs), the single seam every retry attempt passes through. A failed dispatch to areplay: disabledbackend that is not provably undelivered (BackendRequestFailure::not_sent, set only on a failed connect or a router-side refusal before sending) terminates the retry loop and raises a per-request barrier that sets the fallback chain's trigger toTriggerReason::ReplayDisabled(which never hops). The streaming connection phase and mid-stream relay, provider-shaped ingress chains (src/http/ingress_fallback.rs::seal_replay_barrier), the quality-hop driver, and disaggregated unified fallback apply the same rule. Requests that areplay: disabledbackend may serve also skip deduplication. (RetryState::backend_attemptedis deprecated and unused by selection.) - Attempt tracking: The same seam begins an attempt on the request ledger (
begin_attemptinsrc/services/stats/request_ledger_attempts.rs) after selection, the hop dial bound, and circuit admission succeed, immediately before the request leaves, so a refusal prior to dispatch is never recorded as an attempt.- Each attempt claims the next
attempt_seqof its client request and names the previous attempt asparent_attempt. - On error, the seam immediately records the failed attempt via
record_attempt_usage(src/services/stats/attempts.rs) before any retry sleep or chain hop, capturingBackendRequestFailure::upstream_statusand any token usage reported in the error body. - The terminal attempt is recorded by the request-level recorder, which stamps the ledger's current attempt (a request-level failure row records no attempt). The pre-stream dials of
connect_with_pre_stream_fallback, the Anthropic web-search invoker, and the web-search loop's superseded-turn hook use the same two calls.
- Each attempt claims the next
Request Deduplication (In-Flight Coalescing)¶
Non-streaming POST requests under 10 KB that no replay: disabled backend may serve and that pass may_coalesce (src/services/deduplication/eligibility.rs) are coalesced in flight by execute_with_deduplication_attributed:
- Endpoint and temperature eligibility:
/v1/chat/completionsand/v1/completionsare admitted only with an explicit, numerically zerotemperature(an omitted temperature is not zero because OpenAI defaults to 1, whereas the response cache treats a missing value as 0). Eligibility inspects the request body before dispatch, so abackends[].request_extensions.body.overrides.temperatureapplied inside the attempt is not visible./v1/embeddings,/v1/rerank,/embed_sparse, and/v1/modelsare always eligible, as is the/v1/decisionsemulation hop (which sendstemperature: 0)./v1/images/generationsand all other endpoints are never eligible. The web-search tool loop and answer-quality fallback run their turns and hops insidewithout_coalescingso each turn or hop is recorded as its own attempt with its own tokens.
- Leader election and in-flight waiter lifecycle:
- The first request admitted under a key becomes the leader and owns an in-flight marker in the
DashMapvia a single atomicDashMap::entryoperation (DeduplicationManager::admit), guaranteeing two identical requests never both lead. - Subsequent identical requests join while the marker is in flight and wait on its
tokio::sync::watchchannel (src/services/deduplication/waiters.rs) for the leader's outcome (success or error, so waiters see the leader's503or429withRetry-Afterrather than a408). - When the leader settles, it removes its marker before publishing the outcome to joined waiters; publishing first would hand a settled result to a new arrival between publish and removal. Reusing completed answers is handled by the response cache.
- The wait budget is computed from the leader's maximum allowed execution time: one attempt's total timeout (
standard.totalor image-generation total, scaled for fallback hops and clamped bytimeouts.limits), plusretry.timeout(clamped bymax_retry_timeout), plustimeouts.connectionon a fallback hop (for the hop dial permit). Expiry fails the waiter with408 timeout. - If a leader disconnects before finishing, its drop guard removes the marker and closes the channel; waiting requests re-join once with one promoted to leader, and a waiter that loses a second leader executes its own retry loop without deduplication.
- The first request admitted under a key becomes the leader and owns an in-flight marker in the
- Waiter accounting (
src/services/stats/coalesced.rs):- A waiter receiving the leader's result is accounted like a local response-cache replay (
note_coalesced_result): recorded under no backend (not_dispatched) andattempt_seq: 0(even if an earlier attempt failed; Hub mapping floors the sequence at 1 and drops any parent), with the leader's prompt tokens and zero completion tokens. - It emits a usage export record with
cache_hit_type: exactand, oncontrol-planebuilds, a metadata-onlyrecord_local_cache_hitevent that charges no token window or budget. Request-count rate limiting in middleware still counts every caller.
- A waiter receiving the leader's result is accounted like a local response-cache replay (
- Deduplication key composition (
src/services/deduplication/key_headers.rs):- Keys combine the configuration snapshot, endpoint, dispatched body (after smart routing, Hub substitution, and alias or experiment dispatch rewrite
model), effective backend allow-list, and a closed allow-list of inbound headers matched case-insensitively in canonical order:- Credential headers (
is_credential_header:authorization,x-api-key,x-goog-api-key, etc.). - Provider headers affecting upstream behavior (
anthropic-version,anthropic-beta,openai-organization,openai-project,openai-beta,x-vllm-priority). - Router control headers (
x-backend,x-continuum-router-hops,x-smart-route-bypass, and Hub opt-outsx-continuum-substitution,-arbitrage,-budget-fallback,-compression,-batch). - Dynamo shim worker-picker pins (
x-dynamo-prefill-instance-id,x-dynamo-prefill-dp-rank,x-dynamo-worker-instance-id,x-dynamo-dp-rank; client-suppliedx-dynamo-*headers are stripped before the handler). - Headers referenced by the active configuration snapshot (
smart_routing.routing_policies[].when.headerandmodel_experiments[].sticky_key: header:<name>).
- Credential headers (
- All other headers (
X-Trace-ID,traceparent,x-request-id,user-agent,x-stainless-*) are excluded. Because callers sharing an API key (or unauthenticated callers) share one leader generation, callers that must not share results should use separate API keys.
- Keys combine the configuration snapshot, endpoint, dispatched body (after smart routing, Hub substitution, and alias or experiment dispatch rewrite
pub struct EnhancedRetryHandler {
pub retry_handler: RetryHandler,
deduplication: DeduplicationManager,
enable_deduplication: bool,
}
Circuit Breaker¶
Location: src/core/circuit_breaker/
Purpose: Maintain an independent per-backend three-state circuit-breaker state machine
pub struct CircuitBreaker {
states: Arc<DashMap<String, BackendCircuitState>>,
config: CircuitBreakerConfig,
metrics: Option<CircuitBreakerMetrics>,
}
pub struct CircuitBreakerConfig {
pub enabled: bool,
pub failure_threshold: u32, // Failures before opening (default: 5)
pub failure_rate_threshold: f64, // Failure rate threshold (default: 0.5)
pub minimum_requests: u32, // Min requests before rate calculation
pub timeout: Duration, // Open-state cooldown (default: 60s)
pub half_open_max_requests: u32, // Max requests in half-open state
pub half_open_success_threshold: u32, // Successes needed to close
}
pub enum CircuitState {
Closed, // Normal operation - requests pass through
Open, // Failing fast - requests rejected immediately
HalfOpen, // Testing recovery - limited requests allowed
}
Key Features¶
- Per-backend circuit breakers with independent state
- Atomic operations for lock-free state checking in hot path
- Automatic state transitions based on success/failure patterns
- Sliding window for failure rate calculation
- A metrics collector that can be attached by an embedding application
- Admin endpoints for inspection and manual control
The standard LLM proxy path records backend outcomes and consults the breaker during candidate selection and immediately before dispatch. Open circuits are removed from ordinary inference traffic, half-open probes are capacity-limited, and the normal metrics registry exposes the live breaker counters. Active health filtering, retry, and fallback remain complementary layers.
Container (Unwired Legacy)¶
Location: src/core/container.rs
Container (src/core/container.rs) is a leftover from an earlier design that was never wired up at runtime. It is constructed only inside its own unit tests and registers no service.
The router does not use a runtime DI container. Services are ordinary constructor arguments; see Dependency Injection.
Data Flow¶
Request Processing Flow¶
sequenceDiagram
participant Client
participant HTTPLayer as HTTP Layer
participant ProxyService as Proxy Service
participant BackendService as Backend Service
participant ModelService as Model Service
participant Backend as LLM Backend
Client->>HTTPLayer: POST /v1/chat/completions
HTTPLayer->>HTTPLayer: Apply Middleware (auth, logging, metrics)
HTTPLayer->>ProxyService: Forward Request
ProxyService->>ModelService: Get Model Info
ModelService->>ModelService: Check Cache
alt Cache Miss
ModelService->>BackendService: Get Backends for Model
BackendService->>Backend: Query Models
Backend-->>BackendService: Model List
BackendService-->>ModelService: Filtered Backends
ModelService->>ModelService: Update Cache
end
ModelService-->>ProxyService: Model Available on Backends
ProxyService->>BackendService: Select Healthy Backend
BackendService->>BackendService: Apply Load Balancing
BackendService-->>ProxyService: Selected Backend
ProxyService->>Backend: Forward Request
Backend-->>ProxyService: Response (streaming or non-streaming)
ProxyService->>ProxyService: Apply Response Processing
ProxyService-->>HTTPLayer: Processed Response
HTTPLayer-->>Client: HTTP Response Model Name Resolution and Model Experiments¶
Before backend selection, every model-routed ingress resolves the name it dispatches through one seam, dispatch_model_for_request in src/proxy/alias_dispatch.rs:
- Resolution pipeline: Runs after
model_aliases, smart routing, the request-parameter policy snapshot, and the per-key model gate (all of which evaluate the client-supplied model name):- Resolves any model experiment (
src/proxy/model_experiments/): assigns the client-facing name to a weighted variant via rendezvous hashing over a SHA-256 sticky key, continues under the variant's upstream model, and narrows the request's backend allow-list if the variant pins a backend. - Applies metadata alias dispatch to the resulting name.
- Resolves any model experiment (
- Return contract (
DispatchModel): Carries the client-facing name, the dispatched name, and the experiment assignment so selection errors and fallback attribution keep reporting what the client sent, and afallback: within_variantexperiment can suppress cross-model fallback chains. - Request-scoped attribution: Uses a
tokio::task_local!cell installed bymodel_experiment_middlewarearound the API route group (the same mechanism as the upstream TTFT scope). The dispatch seam records the assignment, the response-cache route identity folds it into the exact, prefix, and semantic cache namespaces, and the middleware recordsmodel_experiment_*metrics, emits opt-in attribution headers, and appliesresponse_model: clientonce the handler returns.
Guardrail Layer¶
When guardrails are configured (guardrails.enabled: true), a content-safety layer (GuardrailService in src/services/guardrail/) wraps backend calls across OpenAI chat completions (including the responses_only bridge to upstream /v1/responses), /v1/responses, legacy /v1/completions, and native Anthropic Messages. Stage coverage by route is tabulated in Guardrails: Coverage by route. When guardrails are unconfigured, handlers bypass this seam with zero overhead.
- Unified gating seam and wire views: Driven through
src/services/guardrail/gate.rsso every dispatch path shares the same policy and block renderers. Each wire defines its own block shape (Wire::OpenAi,Wire::Responses,Wire::Completions,Wire::Anthropic) and adjacent request/reply views (responses_input_view.rs,responses_view.rs,completions_view.rs,anthropic_input_view.rs). - Wire-neutral input masking (
masking.rs): Each wire enumerates inspectable strings with their JSON locations. Masking verdicts are re-derived per string by the providers that returned them, written back in place (token by token for tool-callargumentsinmasking_args.rs), and re-checked before dispatch within a deadline, failing closed if masking cannot be applied or a provider fails afterward. Scoped passes run throughGuardrailService::run_input_pass(service_pass.rs). - Terminal block responses: Every block on every wire is rendered by
response::block_into_response, which attaches a process-localGuardrailBlockedmarker. Both the ingress fallback driver and the/v1/decisionschain (TriggerReason::GuardrailBlocked) treat marked responses as committed so a guardrail block never hops to a fallback model. - Dynamic service slot (
GuardrailServiceHandle): Stored inAppStatebehind a lock-free, late-settablearc-swapslot (src/services/guardrail/handle.rs) rather than a startup-frozenOption. Request paths load the handle once per request with a single atomic load and reuse it across gates. A router started with guardrails disabled can materialize a service later viaguardrail_service_factory(src/server/state.rs, includingbackend:provider resolution) on a localenabled: truehot-reload or an enforceable Hub guardrail policy. If a Hub policy demands enforcement the router cannot execute, it is rejected whole withguardrails_unavailableon the control-plane policy store while local configuration continues serving.
The layer hooks the request lifecycle at two points:
- Input pre-call: Before backend dispatch, once per request, awaiting all input providers. A
Blockverdict short-circuits the request without calling the backend; aTransformrewrites the prompt before dispatch. On OpenAI chat completions, it runs once for both streaming and non-streaming arms (src/proxy/chat_input_gate.rs) before response-cache lookup, global prompt injection, and disaggregated dispatch, so a blocked streaming request receives a standard non-streamed block response before SSE starts. (GuardrailService::run_input_during_callremains available for library callers.) - Output post-call: After the backend returns, over the generated text, before the response is returned or cached. For streaming responses, this runs as a rolling-window check (
buffer_full,chunked, orpassthrough).
GuardrailService owns all policy (monitor vs. enforce mode, bypass allowlist, per-route provider subsetting, per-category thresholds, allow/deny lists, per-provider timeouts, and fail-open/fail-closed disposition), aggregates verdicts from configured providers (OpenAI Moderation, self-hosted classifier, PII, AWS Bedrock, Azure Content Safety) using a most-severe-wins rule, and emits Prometheus metrics and an audit-log entry for every decision.
sequenceDiagram
participant Client
participant Gate as Guardrail Gate
participant Service as GuardrailService
participant Backend as LLM Backend
Client->>Gate: request (prompt / messages)
Gate->>Service: input pre-call
alt Block (enforce)
Service-->>Client: block response (content_filter / refusal / error)
else Allow or Transform
Service-->>Gate: verdict
Gate->>Backend: forward (possibly transformed) request
Backend-->>Gate: response
Gate->>Service: output post-call
alt Block (enforce)
Service-->>Client: block response
else Allow or Transform
Service-->>Client: response (possibly sanitized)
end
end For the operator-facing guide (concepts, provider setup, configuration, threshold tuning, admin controls, and metrics), see Guardrails.
Claude Code Safeguards Layer¶
When safeguards.action_safety.enabled: true is configured, ActionSafetyEvaluator (src/services/action_safety.rs) evaluates tool actions proposed by conversion-backend responses before the client acts on them. When the section is absent, safeguard-bearing requests pass through unchanged on native paths and synthesize nothing on conversion paths.
- Action extraction and evaluation: Driven from the Anthropic Messages handlers and the
/v1/responsesconversion arm:- Extracts proposed
tool_useblocks (or/v1/responsestool_calls) in wire order and dispatches once per action to the pinned evaluator backend. - Attaches verdicts as
safeguard_resultsviaaction_safety::wire: adangerous_tool_usecheck keyed by tool-use id (not_flagged/flagged) plus anunavailablecheck when any action resolvesindeterminate. On streaming/v1/responses, the results event is emitted before[DONE]; on streaming Anthropic Messages, the terminalmessage_deltaandmessage_stopare held back within the per-request action budget until pending evaluations complete, and the verdicts ride on thatmessage_deltaasdelta.safeguard_results(#2682). A reply that proposes no tool use carries anavailablecheck with an emptytool_usesmap. - System One evaluator (
safeguards.action_safety.evaluator: systemone): UsesSystemOneEvaluator(src/services/action_safety/systemone.rs) over the sharedSystemOneClient(cached per router) to issue one decision call per action over{tool, arguments, user_request, recent_tool_results}. Inadvisorymode, it runs concurrently with the chat-model evaluator and feeds diagnostics andsafeguards_action_safety_advisory_total; inenforcemode, its fail-closed mapping produces the emitted verdict. - Fail-closed guarantee: Evaluator unavailability, timeouts, malformed/unknown verdicts, refusals, and budget exhaustion all resolve to non-allow outcomes, never a synthesized allow. Evaluator usage is attributed separately from generation, and diagnostics log only evaluation outcomes (never payloads, tool arguments, or credentials).
- Extracts proposed
- Client-facing
POST /v1/decisionsendpoint (alias/v1/systemone): Exposes the System One decision protocol outward (src/proxy/decisions/):- Validates the request body with shared question rules before dispatch, applies the per-key model gate, and walks
fallback.fallback_chainsviaFallbackService::execute_with_fallback_snapshot. - Target passthrough vs. chat emulation: Each hop is either a passthrough to
decisions.targets[]viaSystemOneClientcached inClientCacheby router and target name (src/services/decisions/targets.rs), or an emulation on a chat model (src/services/decisions/emulation.rs). Emulation converts the question map into a forcedemit_decisiontool call, applies chat's composedrequest_paramspolicy and input guardrail pre-call gate (src/proxy/decisions/emulation_gate.rs), dispatches through the chat retry loop (proxy::request::execute_decision_chat_hop), and maps the resulting tool arguments back to one-hot answers markedemulated: true. - Routing and access control: Resolves emulated model names via
dispatch_model_for_request(metadata aliases and model experiments), rejects inline-endpoint targets for API keys with a non-emptyallowed_backends(treated as an unavailable backend on chain hops so the chain skips it without sending), and records usage on the caller's key under the serving model via the non-streaming stats tap.
- Validates the request body with shared question rules before dispatch, applies the per-key model gate, and walks
For the operator-facing guide (native passthrough, adapter setup, client-side workarounds, and the protocol record), see Integrating with Claude Code.
Responses-API Routing for responses_only Models¶
Some OpenAI Pro models (currently gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro, gpt-5.5-pro) are exposed exclusively via /v1/responses and return 404 not_found from /v1/chat/completions. The responses_only: true capability flag in model-metadata.yaml (and the built-in OpenAI registry) marks these models so the router transparently dispatches them to the Responses API surface, regardless of which client surface the request arrived on.
The flag is consulted at the proxy layer right after model lookup, before backend dispatch. The same model metadata pipeline that resolves capability information also resolves responses_only, so the lookup composes with backend-specific overrides, the external metadata file, and built-in defaults in the standard priority order (see Metadata Priority and Alias Resolution).
sequenceDiagram
participant Client
participant ChatHandler as POST /v1/chat/completions
participant AnthropicHandler as POST /anthropic/v1/messages
participant Lookup as is_responses_only_model()
participant Converter as Request Converter
participant Backend as OpenAI / Azure OpenAI
alt OpenAI surface
Client->>ChatHandler: chat.completion request
ChatHandler->>Lookup: (backend, model)
else Anthropic surface
Client->>AnthropicHandler: messages request
AnthropicHandler->>Lookup: (backend, model)
end
alt responses_only = true
alt Backend is OpenAI / Azure OpenAI
Lookup-->>Converter: Convert to /v1/responses payload
Converter->>Backend: POST /v1/responses
Backend-->>Converter: Responses API response (or SSE)
Converter-->>Client: Translated to original surface shape
else Backend is not OpenAI / Azure OpenAI
Lookup-->>Client: 400 invalid_request_error
end
else responses_only = false
Lookup-->>Backend: Standard /v1/chat/completions dispatch
Backend-->>Client: chat.completion / Anthropic Messages
end Key properties:
- The dispatch is transparent on both surfaces: clients keep using
/v1/chat/completionsor/anthropic/v1/messagesand receive responses in the matching shape. - Only OpenAI and Azure OpenAI backends serve
/v1/responses; other backend types are rejected with400 invalid_request_errorbefore any upstream call. - The first dispatch per
(backend, model)pair logs atinfolevel so operators can confirm Responses-API routing without enabling debug logs. - Streaming requests are bridged event-by-event: Responses API SSE events are translated into
chat.completion.chunk(OpenAI surface) or Anthropic Messages SSE events (Anthropic surface) without buffering the whole response.
For configuration syntax, the current model list, and guidance on marking new models, see Responses-API-only Models in the configuration guide.
Health Check Flow¶
sequenceDiagram
participant HealthService as Health Service
participant BackendPool as Backend Pool
participant Backend as LLM Backend
participant Cache as Health Cache
loop Every Interval
HealthService->>BackendPool: Get All Backends
BackendPool-->>HealthService: Backend List
par For Each Backend
HealthService->>Backend: GET /v1/models (or /health)
alt Success
Backend-->>HealthService: 200 OK + Model List
HealthService->>Cache: Update: consecutive_successes++
HealthService->>HealthService: Mark Healthy if threshold met
else Failure
Backend-->>HealthService: Error/Timeout
HealthService->>Cache: Update: consecutive_failures++
HealthService->>HealthService: Mark Unhealthy if threshold met
end
end
HealthService->>BackendPool: Update Backend Health Status
end Hot Reload Service¶
Location: src/infrastructure/config/hot_reload.rs, src/infrastructure/config/hot_reload_service.rs
Purpose: Provide runtime configuration updates without server restart
The hot reload system enables zero-downtime configuration changes through automatic file watching and selective component updates.
Key Architecture Components¶
- ConfigManager: File system watching using
notifycrate, publishes updates viatokio::sync::watchchannel - HotReloadService: Computes configuration differences, classifies changes (immediate/gradual/restart)
- Component Updates: Interior mutability patterns (RwLock) for atomic updates to HealthChecker, CircuitBreaker, RateLimitStore, BackendPool
Backend Pool Hot Reload¶
The BackendPool uses interior mutability (Arc<RwLock<Vec<Arc<Backend>>>>) to support runtime backend additions and removals without service interruption.
Backend States:
- Active: Normal operation, accepts new requests
- Draining: Removed from config, existing requests continue, no new requests
- Removed: Fully cleaned up after all references released
Graceful Draining Process:
- When a backend is removed from config, it's marked as
Draining - New requests skip draining backends
- Existing in-flight requests/streams continue uninterrupted (Arc reference counting)
- Background cleanup task runs every 10 seconds
- Backends are removed when: no references remain OR 5-minute timeout exceeded
This ensures zero impact on ongoing connections during configuration changes.
Change Classification¶
- Immediate update: active rate-limit and circuit-breaker policies, global prompts, request parameters, streaming/smart-routing snapshots, selection strategy, prefix routing, the retry policy, per-request timeout budgets, and documented dynamic fields
- Gradual update: backends and health checks
- Requires restart: server/CORS, logging and tracing, the HTTP client's connection and overall timeouts, cache construction, and other startup-built services
Admin API: /admin/config/hot-reload-status for inspecting hot reload capabilities
For detailed hot reload configuration, process flow, and usage examples, see the Hot Reload section in the configuration guide.
Configuration Migration System¶
Location: src/infrastructure/config/{migrator,migration,migrations,fixer,backup}.rs
Purpose: Automatically detect and fix configuration issues, migrate schemas, and ensure configuration validity
The configuration migration system handles configuration evolution and maintenance. It automatically: - Detects and migrates outdated schema versions - Fixes common syntax errors in YAML/TOML files - Validates and corrects configuration values - Creates backups before making changes - Provides dry-run capability for previewing changes
Architecture Components¶
1. Migration Orchestrator (migrator.rs) - Main entry point for migration operations - Coordinates the entire migration workflow - Manages backup creation and restoration - Implements security validations (path traversal, file size limits)
2. Migration Framework (migration.rs) - Defines core types and traits for migrations - Migration trait for implementing version upgrades - ConfigIssue enum for categorizing problems - MigrationResult for tracking changes
3. Schema Migrations (migrations.rs) - Concrete migration implementations (e.g., V1ToV2Migration) - Transforms configuration structure between versions - Example: Converting backend_url to backends array
4. Auto-Correction Engine (fixer.rs) - Detects and fixes common configuration errors - Duration format correction (e.g., "10 seconds" → "10s") - URL validation and protocol addition - Field deprecation handling
5. Backup Manager (backup.rs) - Creates timestamped backups before modifications - Implements resource limits (10MB per file, 100MB total, max 50 backups) - Automatic cleanup of old backups - Preserves file permissions
Migration Workflow¶
graph TD
A[Read Config File] --> B[Validate Path & Size]
B --> C[Create Backup]
C --> D[Parse Configuration]
D --> E{Parse Success?}
E -->|No| F[Fix Syntax Errors]
F --> D
E -->|Yes| G[Detect Schema Version]
G --> H{Needs Migration?}
H -->|Yes| I[Apply Migrations]
H -->|No| J[Validate Values]
I --> J
J --> K{Issues Found?}
K -->|Yes| L[Apply Auto-Fixes]
K -->|No| M[Return Config]
L --> N[Write Updated Config]
N --> M Security Features¶
- Path Traversal Protection: Validates paths to prevent directory traversal attacks
- File Size Limits: Maximum 10MB configuration files to prevent DoS
- Format Validation: Only processes .yaml, .yml, and .toml files
- System Directory Protection: Blocks access to sensitive system paths
- Test Mode Relaxation: Uses conditional compilation for test-friendly validation
Example Migration: v1.0 to v2.0¶
// V1ToV2Migration implementation
fn migrate(&self, config: &mut Value) -> Result<(), MigrationError> {
// Convert single backend_url to backends array
if let Some(backend_url) = config.get("backend_url") {
let mut backends = Vec::new();
let mut backend = Map::new();
backend.insert("url".to_string(), backend_url.clone());
// Move models to backend
if let Some(model) = config.get("model") {
backend.insert("models".to_string(),
Value::Sequence(vec![model.clone()]));
}
backends.push(Value::Mapping(backend));
config["backends"] = Value::Sequence(backends);
// Remove old fields
config.remove("backend_url");
config.remove("model");
}
Ok(())
}
Configuration Loading Flow¶
graph TD
A[Application Start] --> B[Config Manager Init]
B --> C{Config File Specified?}
C -->|Yes| D[Load Specified File]
C -->|No| E[Search Standard Locations]
E --> F{Config File Found?}
F -->|Yes| G[Load Config File]
F -->|No| H[Use CLI Args + Env Vars + Defaults]
D --> I[Parse YAML]
G --> I
H --> J[Create Config from Args]
I --> K[Apply Environment Variable Overrides]
J --> K
K --> L[Apply CLI Argument Overrides]
L --> M[Validate Configuration]
M --> N{Valid?}
N -->|Yes| O[Return Config]
N -->|No| P[Exit with Error]
O --> Q[Start File Watcher for Hot Reload]
Q --> R[Application Running]
Q --> S[Config File Changed]
S --> T[Reload and Validate]
T --> U{Valid?}
U -->|Yes| V[Apply New Config]
U -->|No| W[Log Error, Keep Old Config]
V --> R
W --> R Dependency Injection¶
Nothing is resolved from a service container at runtime. Services are ordinary constructor arguments: src/server/state.rs builds them during startup, AppState (src/lib.rs) holds them, and each component receives the handles it needs. Where a consumer must not be bound to one concrete implementation, that argument is a trait object rather than a concrete type.
BackendService (src/core/traits.rs) is the seam where this matters. It exposes get_backend, list_backends, get_healthy_backends, and get_stats over the router's backends, and BackendManager (src/infrastructure/backends/pool/backend_manager.rs) is its only production implementation, delegating to a BackendPool and an optional Arc<dyn HealthChecker>. BackendPool is Arc-backed, so BackendManager::from_pool wraps the running pool instead of copying it.
src/core/container.rs still carries a Container from an earlier design that was never wired up: it is constructed only inside its own unit tests and registers no service.
Service Construction¶
Two sites in src/server/state.rs wrap the shared pool as a BackendService and hand it to a consumer.
// src/server/state.rs: the guardrail service factory.
pub(crate) fn guardrail_service_factory(
client: reqwest::Client,
backend_pool: BackendPool,
topology: LiveBackendTopology,
) -> GuardrailServiceFactory {
Arc::new(move |g_cfg| {
// BackendManager is the only production BackendService. from_pool
// shares the router's live pool rather than rebuilding it.
let backend_resolver: Arc<dyn BackendService> =
Arc::new(BackendManager::from_pool(backend_pool.clone()));
// The resolver travels bundled with a live view of the configured
// backend topology, so a `backend:` guard-model reference is checked
// against the visibility contract on every guard call.
let guardrail_service = GuardrailService::from_config(
g_cfg,
client.clone(),
Some(GuardBackendAccess::new(backend_resolver, topology.clone())),
);
Arc::new(guardrail_service)
})
}
// src/server/state.rs: the smart-routing LLM classifier receives the same
// kind of resolver, so classification dispatches through production backends,
// plus a live view of each backend's `request_extensions`, so a classification
// call carries the same extension headers user traffic does.
if let Some(llm_classifier) = LlmClassifier::from_runtime_config(
config,
classifier_config,
Arc::new(BackendManager::from_pool(backend_pool.clone())),
backend_topology.extensions_source(),
) {
router = router.with_llm_classifier(llm_classifier, classifier_config.clone());
}
Service Dependencies¶
A consumer keeps the trait object it was handed and calls the trait methods on it. Nothing is looked up.
// src/services/smart_routing/llm_classifier.rs
pub struct LlmClassifier {
backend_resolver: Arc<dyn BackendService>,
extensions_source: BackendExtensionsSource,
backend_name: String,
// ... prompt, schema, cache, and config fields
}
impl LlmClassifier {
async fn send_request(&self, request_payload: &Value) -> Result<Value, String> {
// The backend's current extension headers, then the recursion guard,
// which replaces any configured spelling of it.
let headers = self.dispatch_headers();
let backend = self
.backend_resolver
.get_backend(&self.backend_name)
.await
.map_err(|e| {
format!(
"classifier backend '{}' is not available in the live backend pool: {e}",
self.backend_name
)
})?;
// Dispatching through the resolved backend reuses the same request
// transformation, authentication, and resilience as user traffic.
let response = backend
.execute_chat_completion(request_payload, Some(headers))
.await;
// ...
}
}
Benefits¶
- Testability: a consumer takes
Arc<dyn BackendService>, so a test can hand it aBackendManagerover a pool it built itself instead of starting the router. - Shared live state:
from_poolshares the running pool's backends, stats, and lifecycle state, so a subsystem observes what production traffic observes rather than a second copy. - Substitutable resolution: consumers depend on the trait, not on
BackendManager, so a different resolver can be swapped in at the construction site without touching them.
Error Handling Strategy¶
The router implements an error handling strategy with typed errors, automatic recovery, and OpenAI-compatible error responses.
Error Type Hierarchy¶
- CoreError: Domain-level errors (validation, service failures, timeouts, configuration)
- RouterError: Application-level errors combining Core, HTTP, Backend, and Model errors
- HttpError: HTTP-specific errors (400 BadRequest, 401 Unauthorized, 404 NotFound, 500 InternalServerError, etc.)
Error Handling Principles¶
- Fail Fast: Validate inputs early with clear error messages
- Error Context: Include relevant context (field names, operation details)
- Retryable Classification: Distinguish between retryable (timeout, 503) and non-retryable (400, 401) errors
- User-Friendly Responses: Convert internal errors to OpenAI-compatible error format
- Structured Logging: Log errors with appropriate severity and context
Error Recovery Mechanisms¶
- Retry with Exponential Backoff: Automatically retry transient failures
- Model Fallback: Route to alternative models when primary unavailable (see Model Fallback System)
- Graceful Degradation: Continue with reduced functionality when components fail
For detailed error handling, recovery strategies, monitoring, and troubleshooting, see error-handling.md.
Extension Points¶
Backend Type Architecture¶
The router supports multiple backend types with different API formats. Each backend type handles request/response transformation automatically.
Supported Backend Types¶
| Backend Type | API Format | Authentication | Use Case |
|---|---|---|---|
openai | OpenAI Chat Completions | Authorization: Bearer | OpenAI API |
azure | OpenAI Chat Completions | Authorization: Bearer | Azure OpenAI Service |
anthropic | Anthropic Messages API | x-api-key header | Claude models via native API |
gemini | OpenAI-compatible | Authorization: Bearer | Google Gemini via OpenAI compatibility layer |
vllm | OpenAI-compatible | Authorization: Bearer | vLLM inference server |
sglang | OpenAI-compatible | Authorization: Bearer | SGLang inference server (sglang.srt) |
ollama | OpenAI-compatible | None (local) | Ollama local inference |
llamacpp | OpenAI-compatible | None (local) | llama.cpp / llama-server |
lmstudio | OpenAI-compatible | None (local) | LM Studio local inference |
continuum-router | OpenAI-compatible | Authorization: Bearer | Continuum Router / Backend.AI GO federation |
generic | OpenAI-compatible | Configurable | Any OpenAI-compatible API |
Anthropic Backend Architecture¶
The Anthropic backend provides native support for Claude models with automatic format translation:
Key Transformations¶
Request Format Differences:
| Aspect | OpenAI Format | Anthropic Format |
|---|---|---|
| System prompt | messages[0].role="system" | Separate system parameter |
| Auth header | Authorization: Bearer | x-api-key |
| Max tokens | Optional | Required (max_tokens) |
| Images | image_url.url | source.type + source.data |
Extended Thinking Support:
// OpenAI reasoning_effort → Anthropic thinking
{
"reasoning_effort": "high" // OpenAI format
}
// Transforms to:
{
"thinking": {
"type": "enabled",
"budget_tokens": 32768 // Mapped from effort level
}
}
Tool Calling Transformation¶
The router provides automatic transformation of OpenAI-format tool definitions to backend-native formats, enabling cross-provider tool calling support.
Location: src/core/tool_calling/
Module Structure:
src/core/tool_calling/
├── mod.rs # Module exports
├── definitions.rs # Type definitions (ToolDefinition, FunctionDefinition, etc.)
├── transform.rs # Transformation functions for each backend
├── tool_choice.rs # Tool choice transformation for each backend
├── response.rs # Tool call response extraction from backends
├── streaming.rs # Streaming tool call transformation
└── messages.rs # Multi-turn conversation message transformation
Supported Backends¶
| Backend | Input Format | Output Format | Notes |
|---|---|---|---|
| OpenAI | Native | Pass-through | Validates tool names |
| Anthropic | OpenAI | input_schema format | parameters → input_schema |
| Gemini | OpenAI | functionDeclarations | Nested structure |
| llama.cpp | OpenAI | Pass-through | Validates with --jinja flag |
OpenAI Tool Format (Input)¶
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
}
]
}
Anthropic Transformation¶
// transform_tools_to_anthropic()
// Input: OpenAI format with `function.parameters`
// Output: Anthropic format with `input_schema`
{
"tools": [
{
"name": "get_weather",
"description": "Get current weather for a location",
"input_schema": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
]
}
Gemini Transformation¶
// transform_tools_to_gemini()
// Input: OpenAI format
// Output: Gemini nested functionDeclarations format
{
"tools": [
{
"functionDeclarations": [
{
"name": "get_weather",
"description": "Get current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
]
}
]
}
Validation Rules¶
- Tool Name: Must match pattern
^[a-zA-Z0-9_-]+$(alphanumeric, underscore, hyphen only) - Empty Name: Rejected with validation error
- Missing Fields: Logged as warnings, tool skipped (no silent data loss)
- Type Field: Must be
"function"for standard tool definitions
Integration with Fallback System¶
The tool calling transformation integrates with the model fallback system via ParameterTranslator:
// In src/core/fallback/translation.rs
impl ParameterTranslator {
pub fn translate_tools(
&self,
tools: &Value,
from_backend_type: BackendType,
to_backend_type: BackendType,
) -> CoreResult<Value> {
match (from_backend_type, to_backend_type) {
(_, BackendType::Anthropic) => transform_tools_to_anthropic(tools),
(_, BackendType::Gemini) => transform_tools_to_gemini(tools),
(_, BackendType::LlamaCpp) => transform_tools_for_llamacpp(tools),
_ => Ok(tools.clone()),
}
}
}
This enables fallback from one provider to another while preserving tool definitions.
Tool Choice Transformation¶
In addition to tool definitions, the router also transforms the tool_choice parameter which controls model tool-calling behavior.
Supported Values:
| OpenAI Value | Description | Anthropic | Gemini |
|---|---|---|---|
"auto" | Model decides whether to call tools | {"type": "auto"} | mode: "AUTO" |
"none" | Model should not call tools | Remove tools entirely | mode: "NONE" |
"required" | Model must call at least one tool | {"type": "any"} | mode: "ANY" |
{"type": "function", "function": {"name": "X"}} | Force specific function | {"type": "tool", "name": "X"} | mode: "ANY", allowed_function_names: ["X"] |
Edge Cases:
- Anthropic "none" workaround: Anthropic API does not support
tool_choice=none. When this value is detected, the router removestoolsandtool_choiceentirely from the request. - llama.cpp: Preserves
parallel_tool_callsparameter for parallel function calling support.
Implementation:
// In src/core/tool_calling/tool_choice.rs
pub enum ToolChoiceValue {
Auto,
None,
Required,
Function(String),
}
impl ToolChoiceValue {
pub fn from_openai(value: &Value) -> CoreResult<Self>;
pub fn to_anthropic(&self) -> Option<Value>; // Returns None for "none"
pub fn to_gemini(&self) -> Value;
}
Streaming Tool Call Transformation¶
When streaming is enabled, tool calls are returned incrementally as delta events rather than complete objects. Different providers use distinct streaming formats, requiring real-time transformation to maintain OpenAI API compatibility.
Location: src/core/tool_calling/streaming.rs
Key Components:
| Component | Purpose |
|---|---|
ToolCallAccumulator | Tracks streaming tool call state (index, id, name, arguments) |
StreamingToolCallTransformer | State machine for Anthropic → OpenAI delta transformation |
transform_gemini_streaming_tool_call() | Gemini → OpenAI delta transformation |
Streaming Format Comparison:
| Provider | Format | Characteristics |
|---|---|---|
| OpenAI | Delta chunks with tool_calls[].function.arguments | Arguments arrive in fragments |
| Anthropic | Event-based: content_block_start, content_block_delta, content_block_stop | input_json_delta contains partial JSON |
| Gemini | Complete functionCall objects in each chunk | Arguments arrive complete per chunk |
State Machine (Anthropic):
IDLE → message_start → READY
READY → content_block_start (tool_use) → TOOL_STARTED
TOOL_STARTED → content_block_delta → ACCUMULATING
ACCUMULATING → content_block_delta → ACCUMULATING (loop)
ACCUMULATING → content_block_stop → TOOL_COMPLETE
TOOL_COMPLETE → message_delta → FINISHED
Safety Limits:
To prevent memory exhaustion from malformed streams:
MAX_TOOL_CALLS = 64: Maximum parallel tool calls per messageMAX_ARGUMENTS_SIZE = 1MB: Maximum accumulated arguments per tool call
Example Transformation (Anthropic → OpenAI):
// Anthropic input: content_block_start
{"type": "content_block_start", "index": 0,
"content_block": {"type": "tool_use", "id": "toolu_123", "name": "get_weather"}}
// OpenAI output: delta chunk
{"id": "chatcmpl-...", "choices": [{"delta": {"tool_calls": [
{"index": 0, "id": "toolu_123", "type": "function",
"function": {"name": "get_weather", "arguments": ""}}
]}}]}
Finish Reason Mapping:
| Source (Anthropic/Gemini) | Target (OpenAI) |
|---|---|
tool_use | tool_calls |
end_turn / STOP | stop |
max_tokens / MAX_TOKENS | length |
SAFETY / RECITATION | content_filter |
Multi-Turn Conversation Message Transformation¶
When using tools in multi-turn conversations, the message history contains tool calls from the assistant and tool results from the client. These messages require format transformation when routing to different backends.
Location: src/core/tool_calling/messages.rs
Key Functions:
| Function | Purpose |
|---|---|
transform_tool_message_to_anthropic() | Converts OpenAI tool result to Anthropic format |
transform_tool_message_to_gemini() | Converts OpenAI tool result to Gemini format |
transform_assistant_tool_calls_to_anthropic() | Converts assistant message with tool_calls to Anthropic |
transform_assistant_tool_calls_to_gemini() | Converts assistant message with tool_calls to Gemini |
transform_messages_with_tools() | Transforms entire conversation history |
find_function_name_for_tool_call() | Looks up function name by tool_call_id |
Message Format Differences:
| Aspect | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| Tool result role | tool | user (with content block) | function |
| ID reference | tool_call_id | tool_use_id | By name matching |
| Content format | String | String or structured | response object |
| Assistant role | assistant | assistant | model |
OpenAI Format (Input):
{
"messages": [
{"role": "user", "content": "What's the weather in NYC?"},
{
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"NYC\"}"}
}]
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temperature\": 72, \"condition\": \"sunny\"}"
}
]
}
Anthropic Transformation:
{
"messages": [
{"role": "user", "content": "What's the weather in NYC?"},
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "call_abc123",
"name": "get_weather",
"input": {"location": "NYC"}
}]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "call_abc123",
"content": "{\"temperature\": 72, \"condition\": \"sunny\"}"
}]
}
]
}
Gemini Transformation:
{
"contents": [
{"role": "user", "parts": [{"text": "What's the weather in NYC?"}]},
{
"role": "model",
"parts": [{
"functionCall": {"name": "get_weather", "args": {"location": "NYC"}}
}]
},
{
"role": "function",
"parts": [{
"functionResponse": {
"name": "get_weather",
"response": {"temperature": 72, "condition": "sunny"}
}
}]
}
]
}
Multiple Tool Results:
When multiple tools are called in parallel, consecutive tool result messages are combined for Anthropic into a single user message:
// Multiple OpenAI tool results:
{"role": "tool", "tool_call_id": "call_1", "content": "72F, sunny"}
{"role": "tool", "tool_call_id": "call_2", "content": "3:00 PM EST"}
// Combined Anthropic format:
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "72F, sunny"},
{"type": "tool_result", "tool_use_id": "call_2", "content": "3:00 PM EST"}
]
}
Error Handling:
- Missing
tool_call_id: Returns validation error - Malformed tool calls: Logged as warnings, skipped without failing entire request
- Unknown function name for Gemini: Falls back to
"unknown"with warning log - Malformed JSON arguments: Falls back to empty object
{} is_errorindicator: Preserved when transforming to Anthropic format
Gemini 3 thoughtSignature Support¶
Gemini 3 function calls use a thoughtSignature field that must be preserved and passed back in multi-turn conversations. The router handles this through the transformation pipeline.
What is thoughtSignature?
When Gemini 3+ models return function calls, they include an encrypted thoughtSignature field that encapsulates the model's internal reasoning context. This signature must be included when sending the function result back to continue the conversation correctly.
Response Extraction (Gemini -> Client):
The router extracts thoughtSignature from Gemini responses and includes it in the OpenAI-compatible format:
// Gemini native response (with thoughtSignature)
{
"candidates": [{
"content": {
"parts": [{
"functionCall": {"name": "get_weather", "args": {"location": "NYC"}},
"thoughtSignature": "encrypted_signature_abc123"
}]
}
}]
}
// Router's OpenAI-compatible response
{
"choices": [{
"message": {
"tool_calls": [{
"id": "call_xyz789",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\":\"NYC\"}"},
"extra_content": {
"google": {
"thought_signature": "encrypted_signature_abc123"
}
}
}]
}
}]
}
Request Injection (Client -> Gemini):
When clients send tool results back, the router extracts thought_signature from extra_content.google and injects it as thoughtSignature in the Gemini request:
// Client's OpenAI-format request
{
"messages": [{
"role": "assistant",
"tool_calls": [{
"id": "call_xyz789",
"function": {"name": "get_weather", "arguments": "{}"},
"extra_content": {
"google": {"thought_signature": "encrypted_signature_abc123"}
}
}]
}]
}
// Transformed Gemini request
{
"contents": [{
"role": "model",
"parts": [{
"functionCall": {"name": "get_weather", "args": {}},
"thoughtSignature": "encrypted_signature_abc123"
}]
}]
}
Streaming Support:
The streaming transformer also handles thoughtSignature extraction, including it in tool call delta chunks.
Compatibility Behavior:
- Gemini 2.x models do not return
thoughtSignature- the router gracefully handles this - Clients that don't preserve
extra_content.google.thought_signaturecan still make function calls (but may have degraded conversation continuity on Gemini 3+) - The
extra_contentfield is ignored by non-Gemini backends
Implementation Locations:
| Component | File | Function |
|---|---|---|
| Response extraction | src/infrastructure/backends/gemini/transform.rs | transform_response_gemini(), transform_native_gemini_response() |
| Request injection | src/core/tool_calling/messages.rs | transform_assistant_tool_calls_to_gemini() |
| Streaming extraction | src/infrastructure/backends/gemini/stream.rs | transform_native_gemini_event(), create_tool_call_chunk_with_signature() |
Adding New Backend Types¶
-
Declare the type: add a variant to
BackendTypeConfiginsrc/core/config/models/backend_type.rs. Thetype:field of eachbackends[]entry deserializes into this enum. A new variant also requires the configuration-assistant updates listed under "Configuration Assistant Sync" inAGENTS.md. -
Implement the
Backendtrait: the trait is defined insrc/core/traits.rsand the factory imports it asBackendTrait.// In src/infrastructure/backends/custom_backend.rs use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; use serde_json::Value; use crate::core::CoreError; use crate::core::traits::{ Backend, BackendCapabilities, BackendResponse, BackendType, HealthStatus, }; pub struct CustomBackend { client: Arc<HttpClient>, config: CustomBackendConfig, } #[async_trait] impl Backend for CustomBackend { fn name(&self) -> &str { /* ... */ } fn url(&self) -> &str { /* ... */ } fn weight(&self) -> u32 { /* ... */ } fn supported_models(&self) -> &[String] { /* ... */ } fn supports_model(&self, model: &str) -> bool { /* ... */ } fn backend_type(&self) -> BackendType { BackendType::Custom("custom".to_string()) } fn capabilities(&self) -> BackendCapabilities { /* ... */ } fn metadata(&self) -> &HashMap<String, Value> { /* ... */ } fn request_timeout(&self) -> Duration { /* ... */ } async fn health_check(&self) -> Result<HealthStatus, CoreError> { /* ... */ } async fn execute_chat_completion( &self, request: &Value, headers: Option<HashMap<String, String>>, ) -> Result<BackendResponse, CoreError> { /* ... */ } // Also required: execute_completion, execute_streaming_request, // get_models, get_model_details, get_statistics, get_current_load, // validate_config, transform_request, and transform_response. } -
Register in the backend factory: add an arm to
BackendFactory::create_backend_from_typed_configinsrc/infrastructure/backends/factory/backend_factory.rs.
Adding New Middleware¶
-
Implement Middleware Trait:
// In src/http/middleware/custom_middleware.rs pub struct CustomMiddleware { config: CustomConfig, } impl<S> tower::Layer<S> for CustomMiddleware { type Service = CustomMiddlewareService<S>; fn layer(&self, inner: S) -> Self::Service { CustomMiddlewareService { inner, config: self.config.clone() } } } -
Register in HTTP Router:
Adding New Cache Types¶
- Implement the
CacheStoretrait:// In src/infrastructure/cache/your_store.rs pub struct YourCacheStore { /* ... */ } #[async_trait] impl CacheStore for YourCacheStore { async fn get(&self, key: &str) -> CacheStoreResult<Option<Vec<u8>>> { /* ... */ } async fn set(&self, key: &str, value: &[u8], ttl: Duration) -> CacheStoreResult<()> { /* ... */ } async fn delete(&self, key: &str) -> CacheStoreResult<()> { /* ... */ } async fn clear(&self) -> CacheStoreResult<()> { /* ... */ } async fn stats(&self) -> CacheStoreStats { /* ... */ } }
The RedisCacheStore (src/infrastructure/cache/redis_store.rs) is the shipped shared-state implementation, gated behind the redis-cache Cargo feature. That feature is not in default/full, but official release binaries and container images compile it, so a multi-replica deployment can share the cache and rate-limit counters without a custom build. The supported endpoint topology (one managed endpoint; Sentinel and Cluster out of contract) is defined and enforced in src/core/config/redis_endpoint.rs, which both the response cache and the distributed rate limiter validate against at config load.
- Inject into
ResponseCacheStore:
Adding New Load Balancing Strategies¶
Selection strategies are declared in the SelectionStrategy enum (src/core/models/backend.rs), and selection runs through the BackendPool scorer pipeline (BackendScorer in src/core/scoring.rs).
- Implement
BackendScorer:
// In src/core/scoring.rs (or a new scorer module)
pub struct LeastBusyScorer;
#[async_trait]
impl BackendScorer for LeastBusyScorer {
fn name(&self) -> &'static str { "least_busy" }
async fn prepare(&self, _context: &ScoringContext) {
// Cache data that score() reads synchronously (default implementation is a no-op)
}
fn score(&self, context: &ScoringContext, backend: &dyn Backend) -> f64 {
// Higher values indicate a stronger preference.
// Return f64::NEG_INFINITY to mark the backend as ineligible.
/* ... */
}
}
- Register the scorer with the pool:
The base strategies (RoundRobin, WeightedRoundRobin, LeastLatency, Random, ConsistentHash, and others) are configured through SelectionStrategy (src/core/models/backend.rs); see the Load Balancing Guide for configuration.
Design Decisions¶
Why 4-Layer Architecture?¶
Decision: Use a 4-layer architecture (HTTP → Services → Infrastructure → Core)
Rationale¶
- Clear Separation: Each layer has distinct responsibilities
- Testability: Layers can be tested independently
- Maintainability: Changes in one layer don't affect others
- Flexibility: Easy to swap implementations (e.g., different cache backends)
Trade-offs¶
- ✅ Pros: Clean, maintainable, testable, extensible
- ❌ Cons: More complexity, slight performance overhead
- Verdict: Benefits outweigh costs for a production system
Why Constructor Injection Instead of a DI Container?¶
Decision: Pass services as ordinary constructor arguments; use no runtime service container
Rationale¶
- Compile-time wiring: The service graph is visible in
src/server/state.rsand checked at build time - No runtime resolution failure mode: A missing registration is a compile error, not a startup failure
- Testing: Each component is constructed directly with the mocks and test doubles it needs
Alternatives Considered¶
- Runtime DI container (
src/core/container.rs): implemented early, never wired up, and carried as dead code
Why Arc<RwLock> for Shared State?¶
Decision: Use Arc<RwLock<T>> for shared mutable state
Rationale¶
- Reader-Writer Semantics: Multiple readers, exclusive writers
- Performance: Better than
Arc<Mutex<T>>for read-heavy workloads - Safety: Prevents data races at compile time
Alternatives Considered¶
Arc<Mutex<T>>: Simpler but worse performance for reads- Channels: Too complex for simple shared state
- Atomic types: Not suitable for complex data structures
Why async/await Throughout?¶
Decision: Use async/await for all I/O operations
Rationale¶
- Performance: Non-blocking I/O allows high concurrency
- Resource Efficiency: Lower memory usage than thread-per-request
- Ecosystem: Rust async ecosystem (Tokio, reqwest, axum) is mature
Trade-offs¶
- ✅ Pros: High performance, low resource usage, good ecosystem
- ❌ Cons: Complexity, learning curve, debugging challenges
- Verdict: Essential for high-performance network services
Why Configuration Hot-Reload?¶
Decision: Support configuration hot-reload using file watching
Rationale¶
- Zero Downtime: Update configuration without restarting
- Operations Friendly: Easy to adjust settings in production
- Development: Faster iteration during development
Implementation¶
- File system watcher detects changes
- Validate new configuration before applying
- Atomic updates to avoid inconsistent state
- Fallback to previous config on validation errors
Performance Considerations¶
Memory Management¶
- Connection Pooling: Reuse HTTP connections to reduce allocation overhead
- Smart Caching: LRU eviction prevents unbounded memory growth
- Arc Cloning: Cheap reference counting instead of deep cloning
- Streaming: Process responses in chunks to avoid loading large responses into memory
Concurrency¶
- RwLock for Read-Heavy Workloads: Multiple concurrent readers for backend pool and model cache
- Lock-Free Where Possible: Use atomics for counters and simple state
- Async Task Spawning: Background tasks for health checks and cache updates
- Bounded Channels: Prevent unbounded queuing of tasks
- Streaming Relay Channels: Client-facing SSE relays are the exception. Every relay sends through the one shared relay channel (
services::streaming::sse_relay_channel), which never waits on the client, so the relay task reads the upstream at the upstream's pace and observes the terminal usage however slowly the client reads; a disconnect fails the next send, which stops the relay and drops the upstream. Per-stream buffering is bounded by the upstream reply (its output-token cap andtimeouts.streaming.total).tests/streaming_relay_pacing_audit_test.rsrejects a bounded channel of SSE events and anSsebody that does not serve this channel.
I/O Optimization¶
- Connection Keep-Alive: TCP connections stay open for reuse
- Streaming Responses: Forward SSE chunks without buffering
- Timeouts: Prevent hanging on slow backends
- Retry with Backoff: Avoid overwhelming failing backends
Memory Layout¶
// Optimized data structures for cache efficiency
pub struct Backend {
pub name: String, // Inline string for small names
pub url: Arc<str>, // Shared string for URL
pub weight: u32, // Compact integer
pub is_healthy: AtomicBool, // Lock-free health status
}
// Cache-friendly model storage
pub struct ModelCache {
models: HashMap<String, Arc<ModelInfo>>, // Shared model info
last_updated: AtomicU64, // Lock-free timestamp
ttl: Duration,
}
Benchmarking Results¶
Based on our benchmarks (see benches/performance_benchmarks.rs):
- Request Latency: < 5ms overhead for routing decisions
- Memory Usage: ~50MB base memory, scales linearly with backends
- Throughput: 1000+ requests/second on modest hardware
- Connection Efficiency: 100+ concurrent connections per backend with minimal memory overhead
Rate Limiting¶
The router implements rate limiting to protect against abuse and ensure fair resource allocation across clients.
Key Features: - Dual-window approach: sustained limit (100 req/min) + burst protection (20 req/5s) - Client identification by API key (preferred) or IP address (fallback) - Per-client isolation with automatic cache cleanup - DoS prevention with short TTL for empty responses
Rate Limit V2 Architecture¶
The enhanced rate limiting system (rate_limit_v2/) provides a modular, high-performance implementation:
Module Structure¶
src/http/middleware/rate_limit_v2/
├── mod.rs # Public API and module exports
├── middleware.rs # Axum middleware integration
├── store.rs # Rate limit storage and client tracking
└── token_bucket.rs # Token bucket algorithm implementation
Components¶
- Token Bucket Algorithm (
token_bucket.rs) - Configurable bucket capacity and refill rate
- Atomic operations for lock-free token consumption
- Automatic token replenishment based on elapsed time
-
Separate buckets for sustained and burst limits
-
Rate Limit Store (
store.rs) - Per-client state tracking with
DashMapfor concurrent access - Automatic cleanup of expired client entries
- Configurable TTL for inactive clients (default: 1 hour)
-
Memory-efficient with bounded storage
-
Middleware Integration (
middleware.rs) - Extracts client identifier (API key → IP address fallback)
- Checks both sustained and burst limits before processing
- Returns HTTP 429 (Too Many Requests) with
Retry-Afterheader - Prometheus metrics for monitoring rate limit hits
Configuration Example¶
rate_limiting:
enabled: true
sustained:
max_requests: 100
window_seconds: 60
burst:
max_requests: 20
window_seconds: 5
cleanup_interval_seconds: 300
Decision Flow¶
Request arrives
↓
Extract client ID (API key or IP)
↓
Check sustained limit (100 req/min)
↓ OK
Check burst limit (20 req/5s)
↓ OK
Process request
For detailed configuration information, see the Rate Limiting section in the configuration guide.
Model Fallback System¶
The router implements a configurable model fallback system that automatically routes requests to alternative models when the primary model is unavailable.
Key Features: - Automatic fallback chain execution (e.g., gpt-5.6-sol → gpt-5.6-terra → gpt-5.6-luna) - Cross-provider fallback support with a canonical request payload on every hop - Triggering from configured HTTP errors, timeouts, connection failures, model lookup failures, and backend health - Prometheus metrics for monitoring fallback usage
For detailed configuration and implementation, see error-handling.md section on model fallback.
Circuit Breaker¶
The router contains a per-backend circuit-breaker state machine with Admin inspection and force controls. The ordinary LLM proxy path records request outcomes in it and consults it during selection: an open backend circuit is excluded so traffic routes around it, and when every compatible circuit is open the router returns the standard service-unavailable error and participates in configured fallback. It complements active health checks, retry, and model fallback on that path.
Three-State Machine:
| State | Behavior |
|---|---|
| Closed | Normal operation. Failures are counted. |
| Open | Fast-fail mode. Requests rejected immediately. |
| HalfOpen | Recovery testing. Limited requests allowed. |
Key Features: - Per-backend isolation with independent state - Lock-free atomic operations for minimal hot-path overhead - Admin endpoints for manual control (/admin/circuit/*) - A metrics collector registered in the main metrics registry and populated by the proxy path
For detailed configuration and implementation, see error-handling.md section on circuit breaker.
File Storage¶
The router provides OpenAI Files API compatible file storage with persistent metadata.
Key Features: - Persistent metadata storage with sidecar JSON files - Automatic recovery on server restart - Orphan file detection and cleanup - Pluggable backends (memory/persistent)
For detailed architecture and implementation, see File Storage Guide.
Image Generation Architecture¶
The router provides a unified interface for image generation across multiple backends (OpenAI GPT Image, DALL-E, and Google Gemini/Nano Banana) with automatic parameter translation.
Multi-Backend Image Generation¶
OpenAI → Gemini Parameter Conversion¶
When using Nano Banana (Gemini) models, OpenAI-style parameters are automatically converted to Gemini's native format:
Size to Aspect Ratio Mapping¶
OpenAI size | Gemini aspectRatio | Gemini imageSize | Notes |
|---|---|---|---|
256x256 | 1:1 | 1K | Minimum Gemini size |
512x512 | 1:1 | 1K | Minimum Gemini size |
1024x1024 | 1:1 | 1K | Default |
1536x1024 | 3:2 | 1K | Landscape |
1024x1536 | 2:3 | 1K | Portrait |
1792x1024 | 16:9 | 1K | Wide landscape |
1024x1792 | 9:16 | 1K | Tall portrait |
2048x2048 | 1:1 | 2K | nano-banana-pro and nano-banana-2 |
4096x4096 | 1:1 | 4K | nano-banana-pro and nano-banana-2 |
auto | 1:1 | 1K | Default fallback |
Request Transformation¶
OpenAI Format (Input):
Gemini Format (Converted):
{
"contents": [
{
"parts": [{"text": "A serene Japanese garden"}]
}
],
"generationConfig": {
"imageConfig": {
"aspectRatio": "3:2",
"imageSize": "1K"
}
}
}
Conversion Implementation¶
The conversion is handled by src/infrastructure/backends/gemini/image_generation.rs:
pub fn convert_openai_to_gemini(request: &OpenAIImageRequest)
-> CoreResult<(String, GeminiImageRequest)>
{
// 1. Map model name
let gemini_model = map_model_to_gemini(&request.model);
// 2. Parse size to aspect ratio and size category
let parsed_size = parse_openai_size(&request.size, &request.model)?;
// 3. Build Gemini request with imageConfig
let gemini_request = GeminiImageRequest {
contents: vec![GeminiContent { parts: vec![...] }],
generation_config: Some(GeminiGenerationConfig {
image_config: Some(GeminiImageConfig {
aspect_ratio: Some(parsed_size.aspect_ratio.to_gemini_string()),
image_size: Some(parsed_size.size_category.to_gemini_image_size()),
}),
}),
};
Ok((gemini_model, gemini_request))
}
Streaming Image Generation (SSE)¶
For GPT Image models, the router supports true SSE passthrough for streaming image generation:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │────stream:true─▶│ Router │────stream:true─▶│ OpenAI │
│ │ │ │ │ │
│ │◀───SSE events──│ Passthrough│◀───SSE events──│ │
└─────────────┘ └─────────────┘ └─────────────┘
SSE Event Types:
| Event | Description |
|---|---|
image_generation.partial_image | Intermediate preview during generation |
image_generation.complete | Final image data |
image_generation.usage | Token usage for billing |
done | Stream completion |
Implementation (src/proxy/image_gen.rs):
async fn handle_streaming_image_generation(...) -> Result<Response, StatusCode> {
// 1. Keep stream: true in backend request
// 2. Make streaming request via bytes_stream()
// 3. Forward SSE events through tokio channel
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut stream = backend_response.bytes_stream();
while let Some(chunk) = stream.next().await {
// Parse SSE format (event:/data: lines)
// Forward events to client
for line in chunk_str.lines() {
if let Some(event_type) = line.strip_prefix("event:") { ... }
if let Some(data) = line.strip_prefix("data:") {
let event = Event::default().event(event_type).data(data);
tx.send(Ok(event));
}
}
}
});
Ok(Sse::new(UnboundedReceiverStream::new(rx)).into_response())
}
GPT Image Model Features¶
The router supports enhanced parameters for GPT Image models (gpt-image-1, gpt-image-1.5, gpt-image-1-mini):
| Parameter | Description | Values |
|---|---|---|
output_format | Image file format | png, jpeg, webp |
output_compression | Compression level | 0-100 (jpeg/webp only) |
background | Transparency control | transparent, opaque, auto |
quality | Generation quality | low, medium, high, auto (GPT Image 2.5 adds xhigh and max) |
stream | Enable SSE streaming | true, false |
partial_images | Preview count | 0-3 |
Model Support Matrix¶
| Feature | GPT Image 1.5 | GPT Image 1 | GPT Image 1 Mini | DALL-E 3 | DALL-E 2 | Nano Banana | Nano Banana Pro |
|---|---|---|---|---|---|---|---|
| Streaming | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| output_format | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| background | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Custom quality | ✅ | ✅ | ✅ | standard/hd | ❌ | ❌ | ❌ |
| Image Edit | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
| Image Variations | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
| Max Resolution | 1536px | 1536px | 1536px | 1792px | 1024px | 1024px | 4096px |
Image Edit and Variations¶
The router provides OpenAI-compatible image editing and variations endpoints through /v1/images/edits and /v1/images/variations.
Image Editing (/v1/images/edits)¶
Endpoint: POST /v1/images/edits
Allows editing an existing image with a text prompt and optional mask. Supported by GPT Image models and DALL-E 2.
Request Format (multipart/form-data):
image: <file> # Original image (PNG, required)
prompt: <string> # Edit instructions (required)
mask: <file> # Optional mask image (PNG)
model: <string> # Model name (default: "gpt-image-2")
n: <integer> # Number of images (default: 1)
size: <string> # Output size (e.g., "1024x1024")
response_format: <string> # "url" or "b64_json"
Implementation (src/proxy/image_edit.rs): - Multipart form parsing for image and mask files - Image validation (format, size, aspect ratio) - Model-specific parameter transformation - Proper error handling for invalid inputs
Supported Features¶
- Transparent PNG mask support for targeted editing
- Multiple image generation (n parameter)
- Flexible output sizes
- Both URL and base64 response formats
Image Variations (/v1/images/variations)¶
Endpoint: POST /v1/images/variations
Creates variations of a given image. Served by nano-banana, nano-banana-2, nano-banana-2-lite, and nano-banana-pro through the Gemini image backend, and by dall-e-2 on a backend that still serves it (OpenAI shut it down on 2026-05-12).
Request Format (multipart/form-data):
image: <file> # Source image (PNG, required)
model: <string> # Model name (default: "nano-banana")
n: <integer> # Number of variations (default: 1, max: 10)
size: <string> # Output size ("256x256", "512x512", "1024x1024")
response_format: <string> # "url" or "b64_json"
Implementation (src/proxy/image_edit.rs): - Image file validation and preprocessing - DALL-E 2-specific routing - Error handling for unsupported models - Consistent response formatting
Key Features¶
- Generate multiple variations in a single request
- Automatic image format validation
- Standard OpenAI response format compatibility
Image Utilities Module¶
The image_utils.rs module provides shared utilities for image processing:
Functions¶
validate_image_format(): Validates PNG/JPEG format and dimensionsparse_multipart_image_request(): Extracts images from multipart formscheck_image_dimensions(): Validates size constraintsformat_image_error_response(): Standardized error responses
Validation Rules¶
- Maximum file size: 4MB (configurable)
- Supported formats: PNG (required for edits/variations), JPEG (generation only)
- Aspect ratio constraints per model
- Transparent PNG requirement for masks
Control-Plane Agent (Continuum Hub)¶
The control-plane agent is an optional, outbound-only task that connects a router to a Continuum Hub control plane for fleet management: enrollment, liveness, usage metering, (opt-in) policy sync with local enforcement, (opt-in) fleet request-parameter configuration, (opt-in) provider batch dispatch with lifecycle reporting, and hub-defined synthetic provider/model probes with a router-local monthly budget ceiling. The hub is never in the request path. Routers remain the data plane and connect out to the hub; no inbound hub traffic reaches the router.
The agent is gated behind the control-plane Cargo feature, which stays out of the crate's default/full feature set. Official release binaries are built with the feature enabled in the Release Packaging workflow, so for the GitHub Release archives, the Docker images, and the .deb packages built from them the opt-in is the control_plane.enabled config switch, which defaults to false. A source build without the feature carries no extra dependency and no runtime cost. The control_plane config section is always parsed, so config validate accepts it in any build; a feature-less build that sets control_plane.enabled: true logs a warning at startup and does not start the agent.
What the agent does¶
- Enroll: On first start, it exchanges a single-use
enrollment_tokenfor a per-router credential (tenant_id,router_id,router_credential). On a409name conflict it retries with a fresh name. - Persist credential: The credential is written to
state_file(default./data/control-plane/state.json,0600on Unix) and reused on restart, so the single-use token is not spent again. It is never logged. If the Hub later rejects the stored credential, the agent marks the identityrejected, logs the failure, and backs off; an administrator can exchange a new token throughPOST /admin/control-plane/enrollwithout deleting the state file. - Heartbeat: The first heartbeat is sent at startup rather than after one interval, and its first 2xx response tells policy sync that the Hub has stored the router's capabilities, which triggers an immediate policy refetch (prompt-logging consent window). After that it sends a heartbeat on the configured interval with a router inventory: router version, configured backends with per-backend health, the models served, an aggregate health summary, token-supply and saturation telemetry, cumulative guardrail counters, and two read-only descriptions that need no capability:
smart_routing_inventory(whether smart routing runs, policy and profile counts, classifier method and backend name, virtual model) andguardrail_provider_inventory(each configured guardrail provider's name, type, stages, position, and whether it runs), counts and names only, read from the running pipeline and guardrail service (src/control_plane/smart_routing.rs,src/control_plane/guardrail/provider_inventory.rs). When policy sync is active, each beat also echoes the applied cursor plus the typed policy capabilities this router actually enforces, last-known-good cursor/digest, and optional value-free rejection evidence so the Hub can distinguish applied, pending, unsupported, and rejected revisions. - Fleet configuration: When
control_plane.config_sync.enabledis set, enrollment and every inventory-carrying heartbeat include maskedrequest_params,kv_routing, and modelfallbacksnapshots. The agent polls, fetches, validates, and atomically applies revision-bound public configuration without admitting inbound Hub traffic. - Usage push: It buffers one metadata-only usage record per proxied request (built from the existing token-usage path) and pushes them in bounded batches on the configured cadence. A record may also carry the model-experiment arm that served it (
experiment_id,experiment_variant_id) and, withusage.quality_judgment, a decision-modelqualityjudgment in basis points. The judgment is attached only while the record is still buffered: the loop seals its slot when it forms the record into a batch, since a formed batch is re-sent byte for byte and the hub deduplicates onidempotency_key, so sequencing, charging, and the push order never wait for a judgment. - Reconnect with backoff: Enrollment, heartbeat, and usage-push failures use exponential backoff with full jitter (base 1s, cap 60s), reset on success.
The HTTPS poll client and the policy WebSocket stream both verify the Hub certificate against the operating system trust store. Install an internal Hub CA in the host or container trust store before enabling the agent. Certificate and hostname verification remain enabled; a CA that the platform does not trust is rejected. The policy stream logs the first connection failure at warning level and sends identical retry failures to debug while the poll loop covers delivery.
The router-initiated enrollment endpoint is an inbound Admin operation, not an inbound Hub channel. It is compiled only with control-plane, remains behind the existing Admin authentication and audit middleware, validates HTTPS except for loopback development endpoints, serializes with startup enrollment, and stores only the Hub-issued credential. Replacing a healthy credential requires explicit replace: true; a credential observed as rejected is eligible for recovery without manual file surgery. A successful exchange reports pending_restart because the already-running agent keeps its immutable startup identity until restart.
The broader control_plane configuration remains startup-owned. Its masked value is readable, but the runtime editable-section catalog omits it and section-level PUT/PATCH fail with an explanatory 409. Operators may therefore choose either the router-side token exchange plus a restart, or the existing source-config enrollment flow; policy and agent configuration continue to come from the source file.
Engine metrics identity¶
Backend configuration can carry an explicit engine_identity with a closed kind and canonical UUID. The outbound Hub inventory reads it from live configuration, while AppProxy ROUTER reconciliation copies the snapshot endpoint UUID to every replica. This metadata never changes the separate backend_id accounting path. The wire contract is additive protocol v0 and preserves unknown future kinds; local configuration rejects them.
Privacy invariant: metering metadata only¶
Usage records carry only metering metadata: token counts, model, provider, latency, cache and batch flags, ids, and timestamps. Prompt and completion bodies never leave the router. The wire type (continuum_protocol::UsageRecord) has a closed, fully typed field set, so there is no field that could carry request or response content.
Token counts on usage records¶
Every usage record splits the prompt into two disjoint buckets, which is how the Hub prices them: input_tokens is the non-cached input, billed at the input rate, and cached_input_tokens is the prompt served from a provider-side cache, billed at the cached rate. Hub input_tokens excludes cache reads for every backend shape.
The router gets there from one invariant on its internal token usage: prompt_tokens is the inclusive count of every prompt token (non-cached input plus cache reads plus cache writes), and the cache read is a subset of it. The record carries input_tokens = prompt_tokens - cache_read and cached_input_tokens = cache_read, which is exactly the billable input router-local enforcement charges against input_tpm and the monthly budget, so the router's counters and the Hub's metering agree. Cache writes stay in input_tokens.
Wire shapes that report an exclusive count are converted where they enter:
| Shape | Provider count | Converted to the inclusive count by |
|---|---|---|
| OpenAI Chat Completions, vLLM, Gemini OpenAI-compatible | prompt_tokens includes prompt_tokens_details.cached_tokens | Already inclusive |
| OpenAI Responses API | input_tokens includes input_tokens_details.cached_tokens | Already inclusive |
Native Gemini (generateContent), reached by /v1/responses and by any Gemini-compatible backend that answers /v1/chat/completions in the native candidates shape | usageMetadata.promptTokenCount includes cachedContentTokenCount | Both native conversions emit the inclusive prompt count plus the cache read: transform_native_gemini_response as prompt_tokens and prompt_tokens_details.cached_tokens, and the Responses converter as input_tokens and input_tokens_details.cached_tokens. A native-shape chat stream reports the same counts on one trailing usage chunk (choices: [] beside usage, the OpenAI include_usage shape) that the shared stream transformer appends on the stream's terminal event from that same builder; it is emitted once rather than on every event carrying usageMetadata, because Gemini repeats cumulative totals on every native event, and the latest-wins streaming tap needs only that single chunk. A type: gemini backend answering /anthropic/v1/messages or /v1/chat/completions is normally reached through Gemini's own OpenAI-compatible surface instead, which reports prompt_tokens_details.cached_tokens directly and is covered by the first row |
| Anthropic Messages (native, streaming tracker, Bedrock runtime) | input_tokens excludes cache_read_input_tokens and cache_creation_input_tokens | The usage parser adds both back; the streaming tracker takes all three from message_start and, when present, the cumulative values on message_delta |
Anthropic behind /v1/chat/completions | Same as native | The non-streaming response transform emits the inclusive prompt_tokens plus prompt_tokens_details.cached_tokens. Streaming does the same through the same helper: the shared stream transformer accumulates the counts from message_start and every message_delta, and emits them at message_stop as one usage-only chunk (choices: [] plus usage, the OpenAI include_usage shape), which is what both the native streaming tap and the pipeline observability tap meter. The finish chunk carries no usage. The same applies to a Unix-socket Anthropic backend and to Bedrock endpoint_type: runtime and mantle, which share the transformer |
OpenAI-compatible or Responses backend behind /anthropic/v1/messages | Inclusive upstream count | The client-facing Anthropic body reports the exclusive input_tokens (upstream prompt minus both cache buckets); the router's own taps meter the upstream inclusive count, and this router's own re-parsing of the bridged body (a non-streaming response, its local cache replay, or a bridged stream it reconstructs itself) adds the cache buckets back to the same total. A downstream router metering a bridged stream with its native streaming tracker reads the deferred input and cache values from message_delta, so it lands on the same total |
Responses backend behind /v1/chat/completions (the responses_only bridge) | input_tokens includes input_tokens_details.cached_tokens | One mapping serves both arms: the non-streaming body and the streaming include_usage chunk carry the inclusive prompt_tokens plus prompt_tokens_details.cached_tokens. The streamed arm is metered from the upstream's raw terminal usage (response.completed, or response.incomplete when it carries one), parsed by the shared usage parser, so a client that did not ask for include_usage is still charged |
Anthropic behind /v1/responses | Same as native | The non-streaming converter and the stream translator emit the inclusive Responses input_tokens with input_tokens_details.cached_tokens and cache_write_tokens; the usage parser also accepts the legacy singular input_token_details spelling so older peers and cached or stored bodies meter the same way |
| Bedrock Converse | inputTokens excludes cacheReadInputTokens and cacheWriteInputTokens | The response and stream transforms emit the inclusive prompt_tokens |
| Local cache replay | Every prompt token is a cache read | Recorded as input_tokens = 0 and cached_input_tokens = the prompt |
| Provider batch completion | No cache reads reported | Unchanged |
For a compressed request, uncompressed_input_tokens defaults to the inclusive prompt count and never falls below input_tokens + cached_input_tokens.
TokenUsage::from_json_value clamps both cache-subset fields for every shape above rather than trusting the provider's raw count:
cache_read_input_tokensis clamped toprompt_tokensfirst, andcache_creation_input_tokensis clamped to the remaining prompt tokens.- If a provider over-reports a cache bucket (such as
cachedContentTokenCount > promptTokenCount), the Hub'scached_input_tokenscannot exceed the inclusive prompt count, and billable input stays0because the entire prompt is treated as cached. No per-request log line is emitted when clamping adjusts a value. - Streaming
/anthropic/v1/messagesbridged from an OpenAI-compatible or Responses backend applies the same clamp against the upstream inclusive prompt count rather than re-summing an Anthropic exclusive split that saturated on the over-report, so streaming and non-streaming requests record identical usage while the client-facing Anthropic wire still mirrors the provider's report.
Router-observed output counting and the stream-usage finalizer¶
Every streaming relay owns a StreamUsageFinalizer (src/services/streaming/usage_finalizer.rs) from its first event, finishes it once at normal stream completion, and routes the finalized record through record_finalized_stream_usage in src/services/streaming/transformer.rs (while the Anthropic Messages ingress feeds the same finalized record into record_stream_end_stats). This covers the chat transformer's OpenAI, thinking, Anthropic-source, and Gemini arms, the three Unix-socket relays, the mid-stream fallback relay (one finalizer per provider attempt), the chat pipeline tap, the responses_only bridge, the /v1/responses pass-through, and the seven Anthropic Messages stream arms. Each finalizer embeds an OutputTokenCounter (src/services/streaming/output_counter.rs) fed from the relay's single event parse, ensuring that streams whose backends never report usage still emit a single usage event with the router's observed count.
The counter counts one text delta per choice, content block, or candidate part per event, along with UTF-8 character and byte counts, by wire shape:
- OpenAI chat chunk (
choices[].delta):content,reasoning_content,reasoning, andtool_calls[].function.argumentsand.name;n > 1counts one delta per choice. - OpenAI
/v1/completionschunk:choices[].text. - Anthropic
content_block_delta:text_delta,thinking_delta, andinput_json_delta. - Responses delta event:
response.output_text.delta,response.reasoning_text.delta,response.reasoning_summary_text.delta,response.refusal.delta, andresponse.function_call_arguments.delta(read fromdata.typeor from the SSEevent:name on theresponses_onlybridge). - Native Gemini chunk (
candidates[].content.parts[]): one per non-empty text part and one perfunctionCallpart, including the characters of its compactargs.
The delta count matches the engine's native output granularity on vLLM, SGLang, llama.cpp, and mlxcel (which emit one token per chunk). Engines that batch multiple tokens per chunk are corrected via post-stream /tokenize (token_source = engine_tokenize, described below) rather than a chars / 4 heuristic.
Finalized records resolve token counts, sources, and outcomes according to a strict precedence ladder:
- Output count and
token_source:engine_final: the backend's terminal usage block (a usage object alongside empty/absentchoicesor choices that all have a non-nullfinish_reason; provisional whenn > 1finishes choices across separate chunks and superseded if a subsequent per-chunk usage arrives).engine_continuous: the latest per-chunk usage (a usage object riding alongside a choice still streaming with a nullfinish_reason, as in vLLM'scontinuous_usage_stats).router_delta_count(estimated: true): the router's own delta count.none: zero when no output was observed.
- Input count: resolved from the same engine usage block, else from a prompt-only engine report (such as a native Anthropic
message_startwithout amessage_delta), else fromestimate_input_textover the dispatched body (estimated: true, walkingmessages[],system,instructions, andinput), else zero.estimatedistruewhenever either input or output is router-estimated. - Outcome:
usage_missing: the stream reached its normal end without engine output usage (a prompt-only report still counts asusage_missingbecause output came from the router).usage_missing_total{backend}increments before queueing, whereasusage_records_total{outcome="usage_missing"}counts records received by the usage loop.length: the finish reason waslength(including Anthropicmax_tokens, GeminiMAX_TOKENS, and Responsesmax_output_tokens; sticky once observed).completed: any other normal stream completion.- Terminating exit outcomes (
client_cancelled,timeout_first_byte,timeout_mid_stream,upstream_error,fallback_source,guardrail_blocked): recorded with the tokens observed up to termination.
- Character and byte counters:
output_charsandoutput_bytesare always populated on finalized streams;input_charsandinput_bytesuse the relay's input estimate when present or fall back to the request ledger's counts.
The chat pipeline tap (src/services/streaming/middleware/observability.rs) uses latest-wins semantics so a final usage block supersedes earlier engine_continuous chunks, finalizing once at [DONE], at source EOF, or in Drop. Native Anthropic message_start and message_delta events are accumulated by the Anthropic tracker rather than read directly by the finalizer (message_delta is output-only and would drop the prompt); the relay supplies the tracker's inclusive snapshot at stream end as engine usage only when a message_delta reported output_tokens.
Normal-end sites determine their exit via StreamUsageFinalizer::normal_end_exit: if the last event before stream end was an error (event: error, data type of error or response.failed, OpenAI-wire data: {"error":{...}}, a non-null top-level error without choices, or an upstream transport read error, even if followed by [DONE]), the exit is upstream_error rather than completed or usage_missing. In the mid-stream fallback relay, the accumulator's estimated_tokens() uses the same delta count for min_accumulated_tokens comparisons, and reasoning-only partials (without content or tool calls) never enter continuation mode.
Engine-reported partial usage¶
Two rungs of the token-source ladder produce engine-reported counts even when a stream terminates before a final usage block arrives:
engine_continuous(per-chunk usage): every OpenAI-wire streaming dispatch (/v1/chat/completionsattempts and fallback hops, native Gemini streams,/anthropic/v1/messagesconversions, and/v1/responsesconversions) normalizesstream_optionsthroughapply_router_stream_options(src/services/streaming/stream_options.rs):include_usageis forced totrue. Every/v1/chat/completionsclient's stream follows its owninclude_usage: a client that did not setinclude_usage: truereceives nousage(the chat ingress removesusagefrom every chunk and drops the trailing usage-only chunk), and a client that setinclude_usage: truereceivesusageonly on the trailing usage-only chunk. Both run after the usage record, the response cache, and the guardrail gate have read the stream (src/proxy/chat_stream_usage.rs), and both cover an engine that sends usage regardless of the flag; see Usage on chat streams.continuous_usage_stats: trueis injected for backend types listed inCONTINUOUS_USAGE_BACKEND_TYPESand stripped for all other backend types (even if client-supplied), while preserving other client keys.- Multi-choice requests (
n != 1) never carrycontinuous_usage_stats: vLLM's per-chunkusagereports only the choice in that chunk, which would under-bill an interruptedn > 1stream. Those streams rely on the trailing usage block (summed across choices) orrouter_delta_countif interrupted. - When active on
n = 1, vLLM emits cumulativeusageon every chunk, so client cancellations and timeouts record the engine's latest count. The Anthropic conversion preserves the raw event classification and emits exactly one terminalmessage_delta: from the finish chunk it holdsmessage_deltaandmessage_stopuntil the trailing usage-only chunk,[DONE], or the end of the upstream stream, so the final counts ride thatmessage_deltaandmessage_stopis always the last event, whether the engine sends its usage on the finish chunk or after it (issue #2251).
Per-chunk usage support by engine¶
| Backend type | Per-chunk usage | Injected | Evidence |
|---|---|---|---|
vllm | Verified: stream_options.continuous_usage_stats gives a cumulative usage on every chunk | Yes | StreamOptions.continuous_usage_stats in vllm/entrypoints/generate/base/protocol.py at vllm-project/vllm 187a0eb98aa42341d703f83421d693fa7585581b; the OpenAI base model tolerates extra fields |
sglang | Upstream source has the option, not injected | No | StreamOptions.continuous_usage_stats in python/sglang/srt/entrypoints/openai/protocol.py at sgl-project/sglang f9cee8d2b2d96a626db1968ba81e4d72bb31794a; the release that first shipped it is not pinned, it attaches usage only to chunks with content, per choice, and no capture exists |
tensorrt-llm | Upstream source has the option, not injected | No | StreamOptions.continuous_usage_stats in tensorrt_llm/serve/openai_protocol.py at NVIDIA/TensorRT-LLM 7179c5b01b9d19acef69a6526330eb72f3b70e10; OpenAIBaseModel uses extra="forbid", so a server older than the field rejects the whole request with 400 |
| every other type | Not applicable | No | The key is removed from what the client sent, so the OpenAI cloud, Azure, Gemini, and every self-hosted type never see it |
The allow-list is CONTINUOUS_USAGE_BACKEND_TYPES; an entry is added only with a captured stream from that engine, recorded by the procedure in tests/fixtures/continuous_usage_stats/README.md.
engine_tokenize(post-stream tokenizer correction): controlled byusage.tokenize_correction(disabled by default) insrc/services/streaming/tokenize_correction.rs. When a stream sink is about to emit arouter_delta_countrecord and the feature is enabled at stream end, candidate evaluation proceeds in order (recording one sample inusage_tokenize_corrections_total{result}for each eligible stream):- Eligibility and admission:
- Records with another
token_source, no retained text, or no served model are not candidates and emit immediately without incrementing the metric; a disabled block or missing Tokio runtime also emits immediately without counting. unsupported: the backend was removed from configuration, has no engine tokenizer route (TokenizeEngine::of), or is nothttp(s).skipped_truncated: retained text reached the counter's 100,000-byte cap.shed:max_concurrentcorrection tasks are already in flight.
- Records with another
- Deferred execution and validation:
- Admitted records wait in a background task until the relay drops the stream's
TokenizeCorrectionHandle(when the relay task finishes and drops its client sender), then POST the retained text once to the engine's tokenize endpoint viatokenizer_request(using the backend's effective auth, dispatch hop count,add_special_tokens: falseoradd_special: falseonllama-server, and the served model id) within atimeout_msbudget measured from stream end. - Responses are read up to 1 MiB. Counts that are impossible for the retained text (
0for non-empty text, or exceeding the UTF-8 byte length) are rejected aserrorwith a rate-limitedWARNnaming the backend, engine, and failure kind (never the text). - The record is emitted once:
applied(token_source: engine_tokenize,estimatedtrue only if the input remains router-estimated), or falling back torouter_delta_countontimeoutorerror. The streamoutcomenever changes (usage_missingstaysusage_missing), and the tokenize call bypasses circuit admission and settlement.
- Admitted records wait in a background task until the relay drops the stream's
- Deferred side effects and shutdown:
- All record side effects (per-key stats,
llm_tokens_total, export row, policy grant charge, write-ahead log completion, experiment share, and usage event with quality ticket) execute together during that deferred emission. - If the runtime shuts down before the task emits, dropping the task's WAL guard writes the attempt end line so boot replay does not misclassify it as
router_restart(only an abrupt kill/crash leaves the WAL entry open). Because the control-plane agent flushes its final usage push at the start of graceful shutdown without awaiting in-flight tokenize tasks, corrections still running during shutdown may miss the final push within a window bounded bytimeout_ms.
- All record side effects (per-key stats,
- Scope and exclusions:
- Retained text concatenates
delta.content,delta.reasoning_content(orreasoning), and tool-call name/argument fragments in arrival order; because chat-template control tokens between segments are absent, the resulting count is a lower bound on generated tokens. - Attached by the chat and Responses context builders (including the
responses_onlybridge) and byrecord_stream_end_stats_correctableon the/anthropic/v1/messagesOpenAI-wire conversion. - Excluded paths (emitted immediately with
router_delta_countand not counted inusage_tokenize_corrections_total): mid-stream fallback attempts, native Anthropic/Bedrock/Responses-bridge relays on the Anthropic ingress (no engine tokenizer route), and the/v1/responsesconversion stream (retains no text).
- Retained text concatenates
- Eligibility and admission:
Stream exits and their usage outcome¶
Every exit across the OpenAI-wire streaming relays finishes its StreamUsageFinalizer once per attempt so interrupted streams record the tokens generated prior to termination:
- Hand-rolled relays (the transformer's OpenAI, thinking, TCP Anthropic-source, and Gemini arms, the three Unix-socket relays, and the mid-stream fallback relay): latch the exit reason on every
breakfrom the read loop (src/services/streaming/relay_exit.rs, first-write-wins except that a terminal guardrail block overrides a normal end), finish once after the loop, and callfinish_relaybefore any earlyreturn. The upstream stream is dropped immediately when the client disconnects, metering only what was observed before the drop. - Anthropic ingress,
/v1/responses, andresponses_onlybridge relays: run their read loop to anOption<StreamExit>and finalize in a single tail (record_anthropic_stream_end,CompletedResponsePersister::finish, pass-through stream-end recording, orBridgeStreamEnd::record).tests/stream_early_exit_audit_test.rsenforces single-tail finalization and verifies that early exits never write duplicate stats rows.
| Exit | outcome | token_source and output |
|---|---|---|
The normal end ([DONE], or the end of the body) | completed, length, usage_missing | the ladder above |
The client left: a failed send, the relay's tx.closed() race, a keep-alive to a gone client, or a pipeline tap dropped by the forwarding task | client_cancelled | engine_final or engine_continuous when a usage block arrived before the cancel, else router_delta_count (none with no delta) |
The pipeline's first_byte window, the first read of the plain, thinking-pattern, HTTP Anthropic, or Unix-socket relays outlasting the resolved streaming.first_byte, or a mid-stream relay attempt with no first chunk inside streaming.first_byte | timeout_first_byte | none, output 0, input estimated |
The pipeline's total budget (backend_stream) or chunk_inactivity, the transformer's total-budget check, a later read of those relays outlasting chunk_interval, or a mid-stream timeout/inactivity_timeout with no successor | timeout_mid_stream | the deltas generated before the timeout |
| A handshake error status, too many upstream error events or read errors, or an error event as the last event | upstream_error | none and the handshake http_status for a status, else what was generated |
A guardrail block (GateForward::Blocked, a gate's Block at [DONE] or at the end of the body, the pipeline gate's mid-stream cut) | guardrail_blocked | every delta the backend generated up to the block, including the ones the gate held and never released |
| A mid-stream attempt whose output a fallback hop continued | fallback_source | the output this attempt generated; the successor's record carries only its own (see Model fallback) |
Pipeline exit propagation, dial attempts, and surface-specific rules¶
- Pipeline
StreamExitSlot(src/services/streaming/middleware/exit_slot.rs): communicates termination causes from lower layers up to the observability tap.ChunkTimeoutLayerwrites the timeout class before yieldingTimeoutError, and the guardrail driver writesguardrail_blockedbefore dropping the tap's source. When a pipeline guardrail gate runs an end-of-stream orbuffer_fullcheck after the tap has already seen[DONE]or EOF, the tap defers its normal-end record until the gate either polls again (confirming normal completion) or drops the stream (recording the slot'sguardrail_blockedoutcome). Client disconnects between[DONE]and source EOF still record the normal end. - Streamed dial attempts and zero-output caps: every streamed dial (including direct TCP or Unix-socket streams without a fallback chain) registers an attempt on the request ledger after circuit admission so its record carries
attempt_seq: 1and/admin/statsattributes the backend name. Non-hopped handshake errors (4xx, or 5xx after chain exhaustion) record as failed attempts (upstream_errorwithhttp_status). Zero-output records with estimated input (token_source: none,estimated: true) charge the usage record and hub policy grant without incrementing local token counters, capping estimated input atMAX_ATTEMPT_INPUT_ESTIMATE_TOKENS(32,768) so base64image_urlpayloads are not repeatedly billed across zero-output hops. - Anthropic,
/v1/responses, and conversion specifics:- On the Anthropic ingress, a read timeout or error encountered after the client has already disconnected is recorded as
client_cancelledwithout sending anerrorevent. - Prompt-only intermediate reports (Anthropic
message_startbeforemessage_delta, or Gemini intermediateusageMetadatabefore the terminalcandidatesTokenCount) do not count asengine_final; streams cut before the output report combine that engine input count withrouter_delta_countoutput. - On
/v1/responses, guardrail blocks use the gate's last usage report for Chat Completions sources and the relay's observed source events for Anthropic or Gemini sources. - The Anthropic ingress stats row records finalized stream tokens even on failure (
event: errororresponse.failedsetsoutcome: upstream_errorandsuccess: false). Pre-stream handshake failures on these non-chat relays and WAL logging for/v1/responses,/anthropic/v1/messages, pass-through, and bridge streams remain outside this rule.
- On the Anthropic ingress, a read timeout or error encountered after the client has already disconnected is recorded as
Timing fields on usage records¶
Every usage record carries latency_ms and either ttft_ms (paired with ttft_source) or ttft_absent_reason:
latency_ms: total router-side request duration from handler entry to response completion. On local cache replays,/v1/chat/completionsand/v1/responsestime the cache lookup itself, whereas SSE and Anthropic Messages replays time from handler entry.ttft_ms: router-observed time to first byte. For provider-served traffic, the clock starts at provider dispatch (excluding routing, transformation, and admission) and ends at the first streamed byte (streaming) or when upstream response headers arrive (non-streaming, including Unix-socket header buffer completion and Bedrock signed send completion). Because non-streaming responses generate server-side before headers arrive, non-streamingttft_msapproximates full upstream latency and attributes most time to prefill.ttft_source: identifies the producer ofttft_ms(sent asttft_sourceon the wire, withttft_kindaccepted as a legacy serde alias; omitted wheneverttft_msis absent):streamed_first_token: provider dispatch to first streamed byte.response_headers: provider dispatch to upstream response headers on non-streaming paths.cache_replay: local cache hit duration (where first and last byte coincide withlatency_ms, and zero output tokens exclude the record from TPOT/decode calculations).
ttft_absent_reason: explains whyttft_msis absent (mutually exclusive withttft_ms; unknown values from newer peers deserialize asunknown):batch: provider batch completions, which omitttft_msandttft_sourceby design so asynchronous batch windows do not skew interactive TTFT or goodput SLOs.no_first_frame: a dispatched stream that ended before yielding any frame.transport_unobservable: reserved for transports that expose no header timestamp.not_observed: requests that never reached an upstream or cache, failed dispatches before headers arrived, or discarded retry/fallback attempts (outcomeother thancompleted,length, orusage_missing), which also skipcontinuum_backend_ttft_seconds. Every relay, including/anthropic/v1/messages, keeps a TTFT observed before cancellation, timeout, or an upstream error. A dispatched relay that ended before any frame reportsno_first_frameinstead.
Reconciliation against engine counters¶
usage.reconciliation (src/services/stats/reconciliation.rs) continuously compares router ledger output token counts against backend engine generation counters, compiled unconditionally regardless of the control-plane feature. It taps record_request_stats_with_headers_inner and record_finalized_stream_usage using the ledger_row_output_tokens rule (rows with usage or an attempt override that reached a backend in the reconciled set and were not served from local cache) and feeds an 8,192-sample bounded queue drained by a background task spawned in serve().
At each window close, the reconciler compares ledger (window tokens plus any carry) against the latest EngineStatsSnapshot.generation_tokens_total collected within that window:
- No engine snapshot in window (
no_engine_data): if a baseline exists,ledgertokens carry forward into the next window; before the first baseline, they are dropped because the initial engine snapshot already includes prior generation. - First engine snapshot (
no_engine_data): establishes the baseline with no carry. - Snapshot below baseline (
reset): indicates an engine restart; the post-restart counter becomes the new engine delta. - Normal comparison: computes
delta = value - baselineanddrift_ratio = (delta - ledger) / max(delta, 1), recordingwithin_tolerancewhenabs(drift_ratio) <= max_driftordrift(with awarn!log) otherwise.
Operational caveats and window sizing¶
- Supported engines: only vLLM, SGLang, and llama.cpp/mlxcel expose generation counters; Ollama, LM Studio, and hosted providers report
no_engine_data. Boot-replayedrouter_restartrecords bypass both seams because their generation predates the first baseline. - Shared backends and window alignment: backends shared with other routers or direct clients will drift by external traffic and should set
backends[].reconciliation: false(which also skips queueing). Because window boundaries are not synchronized with metric scrapes,windowshould be at least2 * engine_stats.interval(enforced by aconfig validatewarning). - Cumulative Admin totals:
GET /admin/statssums all compared windows (within_tolerance,drift, andreset), cancelling out boundary shifts between adjacent windows. Residual skew can still occur from requests in flight when the first baseline is captured, requests in flight at the latest window close, or tokens generated between the last pre-restart scrape and an enginereset.tests/usage_reconciliation_load_test.rsverifies< 1%drift across completed, cancelled, fallback, and timed-out streams against mock vLLM engines.
Usage record outcome and attempt identity¶
The usage ledger is attempt-scoped: each model execution emits one record carrying additive fields (#[serde(default, skip_serializing_if = "Option::is_none")], PROTOCOL_VERSION 0):
outcome: how the attempt ended (completed,length,client_cancelled,timeout_first_byte,timeout_mid_stream,upstream_error,guardrail_blocked,usage_missing,router_restart, orfallback_sourcewhen partial streamed output was delivered and continued by a mid-stream fallback hop), orrefusedwhen the router declined the request before any backend attempt began (see Refused requests).refusal_reason: the bounded machine code for arefusedrecord, taken from the client-facingerror.codevocabulary (model_not_found,service_unavailable,router_loop_detected, and so on) and falling back torefusedwhen the code is missing or malformed. Present only onrefused; the hub drops it on any other outcome.token_sourceandestimated: howoutput_tokenswas obtained (engine_final,engine_continuous,router_delta_count,engine_tokenize,char_estimate, ornone), withestimated: truewhenever either token count is router-estimated.streamandhttp_status: whether the client requested streaming, and the most recent upstream HTTP status for the attempt (absent when no headers arrived, on cache replays, and on batch completions; cleared at the start of each retry dispatch).request_id,attempt_seq, andparent_attempt:request_idis the router-mintedreq_<32-hex>identifier shared across all attempts of an API request (never the client'sx-request-idortraceparent).attempt_seqis the 1-based dispatch sequence number, andparent_attemptnames the prior attempt that a retry or fallback continued/replaced. All three are absent on arefusedrecord: no attempt began, and the record carries no attempt identity.purpose: attributes the caller (client,guardrail,classifier,action_safety, orquality_judgment). Internal purposes carry the triggering request'skey_id,request_id, andattempt_seqwithout charging the local policy usage grant.reasoning_tokens: subset ofoutput_tokensread fromcompletion_tokens_details.reasoning_tokensoroutput_tokens_details.reasoning_tokens(and legacy singularoutput_token_details), clamped tooutput_tokens.input_chars,input_bytes,output_chars, andoutput_bytes: UTF-8 character and byte counts of message/system/input text (excluding tool arguments and images) for cross-engine tokenizer normalization.audio_seconds: whole-second billed duration (ceilofusage.secondswhenusage.type == "duration", or top-leveldurationon non-token responses) for/v1/audio/transcriptionsand/v1/audio/translations. Duration-metered audio records emitoutcome: completed,token_source: none, zero token counts, and price againstPriceEntry.audio_microusd_per_second.
Every attempt record charges the hub ledger, local cost-center ledger, and router_usage_seq regardless of outcome (except local cache replays, which record output_tokens: 0 with cache_hit_type). Each record carries a distinct {router_id}-{uuid} idempotency_key.
Refused requests¶
A request the router declines before dispatch produces one refused record, so the hub sees the request that no backend ever saw. The record comes from RequestRejection (the pre-backend rejection metadata attached only when no backend attempt has begun) and is emitted once by the outermost rejection observer (src/http/middleware/request_rejection.rs), which makes it one record per request no matter how many retry or fallback rounds the refusal needed.
Only a routing refusal qualifies: no admissible backend or an unknown model (Routing), a key entitlement that does not permit the request (Policy), and a per-backend rate-limit refusal (RateLimit with backend or store_unavailable). Authentication failures never produce one, because they carry no key identity and would let unauthenticated traffic fill the ledger; admission shedding and input-guardrail rejections are not routing refusals. The record carries zero token counts, token_source: none, ttft_absent_reason: not_observed, and the response status the client saw. Its provider is the router sentinel, and key_id or model may be empty when the refusal was decided before either resolved. It costs nothing: it prices to zero and the hub keeps it out of every usage rollup. Bursts stay bounded the same way every other usage producer is: the event goes into the bounded control-plane channel with try_send, and a full channel drops it and counts usage_records_dropped_total{reason="channel_full"}.
Producer stamping, local stats, and audit export¶
- Producer stamping rules:
- Non-streaming recorders stamp the request ledger's current attempt (advanced by
begin_attemptacross buffered retries, pre-stream dials, and Anthropic web-search turns),completedorlength,engine_final, upstream status,request_id, and text counts. - Chat and Responses streaming relays stamp
stream: true,completedorlengthfrom the terminalfinish_reasonorresponse.incompleteevent, theattempt_seqcaptured at connection dial (or allocated from the request's sequence allocator on a mid-stream hop), and the handshake status on raw HTTP relays. - Spawned Anthropic Messages stream relays inherit a copy of the request's
RequestLedgerCellviaspawn_propagated(src/proxy/model_experiments/scope.rs), preservingrequest_id, client text counts, attempt position, and pre-spawn handshake status. - Cache replays stamp
completed,engine_final, no status, and cached reasoning/text counts; batch completions stampcompletedwith the submittingrequest_idand no status; web-search failures stampupstream_errorandnone. - Pre-dispatch refusals stamp
refused,none, zero counts, the client-visible status, and no attempt identity; the rejection observer emits them, not a backend producer. - Before emission, the router enforces hub drop/clamp rules locally (
parent_attemptwithin1..attempt_seq, validrequest_id,http_statusin100..=599,chars <= bytes, andreasoning_tokens <= output_tokens).
- Non-streaming recorders stamp the request ledger's current attempt (advanced by
- Local statistics (
GET /admin/statsand Prometheus): outcomes populateby_outcomeonGET /admin/stats(pre-upgrade snapshots restore windowed rows ascompleted), theoutcomelabel onllm_tokens_total, andusage_records_total{outcome, token_source, purpose}. Partial streams and superseded attempts record astokens_onlyrows in the auxiliary ring so period token totals include them without inflating client request or latency counts. - Local audit export (
admin.stats.usage_export):src/services/stats/usage_export.rsmaintains a boundedVecDeque<UsageExportRecord>onStatsCollector(off by default), appended at the non-streaming recorder seam,record_finalized_stream_usage, andrecord_cache_replay_export. Records contain wire-string enums,api_key_idaskey_id, and resolvedbackend_id(no text or raw keys), evict byretentionandmax_records, persist across stats snapshots, and serve viaGET /admin/usage/export.
Router-initiated calls¶
Internal LLM calls made by the router on behalf of a client request emit one attempt-scoped usage record per dispatch via record_attempt_usage, tagged with an internal purpose:
purpose | Producer | Enabled by |
|---|---|---|
guardrail | The LLM guardrail providers (self_hosted_classifier, custom_classifier) in src/services/guardrail/providers/llm_backend.rs, and the systemone provider | guardrails.providers[] |
classifier | The smart-routing LLM classifier (src/services/smart_routing/llm_classifier.rs) and the System One classifier | smart_routing.classifier.method: llm, hybrid, system_one, hybrid_system_one, or a System One shadow |
action_safety | The action-safety LLM evaluator (src/services/action_safety.rs) and its System One evaluator | safeguards.action_safety |
quality_judgment | The usage-quality judgment and the fallback quality check, both System One consumers | usage.quality_judgment, fallback.quality_check |
- Attribution and error mapping:
- Each record carries the triggering request's
key_id(resolved viaPolicyStore::hub_identity_for_tokenwithout charging local grants),request_id, and a distinctattempt_seq, withparent_attempt: None,stream: false, and no TTFT.backendnames the pinnedbackends[]entry (resolvingbackend_id) orhost[:port]for inline endpoints. - Replies with usage blocks record engine counts; 2xx replies without usage record
usage_missingwithchar_estimate; malformed 2xx System One payloads (SystemOneError::Malformed) recordupstream_errorwith status200and zero output; non-2xx responses and transport errors recordupstream_error; deadline expirations and abandoned dispatches recordtimeout_first_bytewith estimated input and zero output. Pre-dispatch refusals (open circuit, disabled target) emit no record.
- Each record carries the triggering request's
- Sequencing and ring isolation:
- All copies of a request's ledger cell share one atomic sequence allocator, reserving sequence
1exclusively for the client's first attempt so internal calls never collide with client attempt numbering orparent_attemptlinks. - Internal records are stored in a dedicated bounded ring (
MAX_INTERNAL_RECORDS, 25% of the 100,000-row client ring, persisted asinternal_recordsin snapshots) and reported underby_purposeonGET /admin/stats, preventing streaming guardrail window checks from evicting client history or skewing client request counts, latency percentiles, or control-plane load summaries. - Internal records cannot consume the final 10% of the control-plane usage channel, shedding first under
usage_records_dropped_total{reason="channel_full"}during slow hub pushes so billable client records are preserved.
- All copies of a request's ledger cell share one atomic sequence allocator, reserving sequence
Artifact identity on usage records¶
A model id names what the client asked for, not which weights answered. Every record that reached a backend can carry two operator-declared identity fields so backends serving one id from a different quantization, revision, or re-exported checkpoint remain distinguishable. Both are additive (#[serde(default, skip_serializing_if = "Option::is_none")] on the wire, PROTOCOL_VERSION 0) and absent when nothing is declared.
model_digestis the canonical digest (sha256:orblake3:plus 64 lowercase hex) of theartifactthe operator declared for the serving backend and the served model (metadata.artifact.digest, resolved byConfig::served_model_identity: the backend's ownmodel_configskey first, then the metadata layer, never the built-in catalog). It is resolved at the serving seam against the configuration the request was routed with, the waybackend_idis: the non-streaming draft at capture, the streaming context at everymark_provider_dispatched(so a fallback hop overwrites it with the identity of the backend and model that produced the stream), the Responses arms where their context is built, and the batch job at submit. A local cache replay carries neither field, since no backend served it and the cache does not retain the identity of the request that populated it.draft_modelis the served name of the speculative-decoding draft declared for the same(backend, model)(metadata.draft_model.name). Under speculative decoding the engine reports accepted tokens only; those tokens are attributed to the target model named inmodel, rejected draft tokens are unobservable from any engine'susageand are not counted, anddraft_modelnames the draft for attribution review without counting anything. The draft's own digest stays in the router's metadata and onGET /admin/models.
Both values are statements the operator signs, not values the fleet asserts: nothing is read from an engine. A malformed identity never reaches a record, because model_configs refuses to load it and the metadata layer drops it with a warning.
Idempotent batching¶
Each batch is formed once with a stable batch_id, stable per-record idempotency_keys, and a fixed sent_at_ms and chain hash, so every attempt sends the same bytes. On a push failure the same batch is retried after backoff; the hub deduplicates on idempotency_key, so a duplicate delivery after a reconnect does not double-count. On any 200 ack the batch is dropped from the buffer. The memory buffer is bounded at 20 batches, so a prolonged hub outage cannot grow memory without bound.
Durability across outages and restarts¶
Two durability mechanisms make ledger record loss recoverable and counted:
- Disk spool:
- When the 20-batch memory buffer overflows, the oldest batch (with quality tickets sealed and a stable
batch_id) is written atomically to<state_file>.usage-spool/{seq:020}.json(directory0700, files0600, temp-write, rename, and directory fsync) instead of dropping records. - The push loop drains spooled files before the memory buffer and deletes a file only on a terminal outcome (
200ack,413, or unreadable oversize ack).control_plane.usage_spool_max_bytes(default 256 MiB;0disables spooling) caps disk usage by evicting the oldest files and retiring their sequences. - On graceful shutdown, any unacknowledged pending batch and remaining memory buffer batches are spooled so the next boot delivers them first. Spooled records are included in heartbeat backlog gauges and
GET /admin/control-plane/status.
- When the 20-batch memory buffer overflows, the oldest batch (with quality tickets sealed and a stable
- Write-ahead attempt log (WAL):
- Every buffered
/v1/chat/completionsdispatch (execute_single_retry_attempt_observed) and streaming dispatch (StreamObservabilityContext::mark_provider_dispatched) appends a start line to<state_file>.usage-wal.jsonl(0600) before execution and an end line when its guard finishes or drops, recording attempt identity, hub attribution, backend,stream, start time, and input-token estimate (never prompt content). - A dedicated writer thread appends via a bounded channel and compacts the file to open attempts once it exceeds
control_plane.usage_wal_max_bytes. - On the next boot (after attaching the cost-center sidecar and before the first push), unfinished start entries are replayed as
outcome: router_restartrecords (output_tokens: 0,token_source: none,estimated: true). Other ingress families (/v1/responses,/anthropic/v1/messages, passthrough) do not write WAL entries yet.
- Every buffered
- Disk trust, metrics, and shutdown:
- Symlinks, non-regular files, files with permissions wider than
0600, or spool files written under a different enrollment identity are never replayed; invalid spool files are deleted and counted inusage_durability_corrupt_total{kind="spool_file"}(andusage_records_dropped_total{reason="spool_overflow"}on enrollment mismatch). - Remaining drops are tracked in
usage_records_dropped_total{reason}(channel_full,buffer_overflow,spool_overflow,batch_rejected_413,wal_open_bound,wal_dropped;ack_oversizedstays at zero because an oversized 2xx ack is a delivered batch); see Usage ledger durability. - Graceful shutdown waits up to one hub request timeout plus 5 seconds for the final push, spool flush, and WAL barrier.
- Symlinks, non-regular files, files with permissions wider than
Tamper evidence¶
Every pushed usage batch is cryptographically linked to its predecessor via three additive UsageBatch fields (PROTOCOL_VERSION 0):
batch_hash: 64-character lowercase hex SHA-256 of the batch.prev_batch_hash:batch_hashof the immediately preceding batch built by this router (absent on the first batch of a chain).chain_gap: count of built batches permanently lost since the previous batch (absent when zero).
Hash construction, chain state, and local WORM export¶
- Hash and canonical JSON (
continuum_protocol::usage_batch_hash):- Computes SHA-256 over
prev_batch_hash,router_id,batch_id, decimalsent_at_ms, and decimalchain_gap(each terminated by0x0a), followed by the concatenated raw 32-byte SHA-256 digests of each record's canonical JSON (sorted object keys, no whitespace, omittedNonefields, built withoutserde_json/preserve_order). - Batches are stamped once when formed in
UsageBuffer::drain_frontso retries and spool replays remain byte-identical. The Hub verifies hashes before normalization (match,mismatch,absent, ormalformedonGET /api/admin/v1/fleetand/api/admin/v1/routers/{router_id}/usage-chain).
- Computes SHA-256 over
- Chain state and gap accounting (
<state_file>.usage-chain.json):- Persists
(router_id, last_batch_hash, last_batch_id, last_sent_at_ms, pending_gap)with mode0600before a batch reaches the hub or spool and after every permanent loss. If a write fails, the sidecar is removed best-effort and counted inusage_ledger_export_failures_total{target="chain_state"}so a crash restarts the chain cleanly rather than forking it. - When a built batch is permanently lost before acknowledgment (
413, spool write failure/refusal, spool byte-cap eviction, corrupt spool file, or shutdown flush failure without spool),pending_gapincrements (usage_chain_gaps_total) and is emitted aschain_gapon the next built batch. Pre-batch record drops and oversized2xxacks do not incrementchain_gap. A413batch with more than one record is abandoned the same way, but its records return to the queue ahead of the spool and arrive in the following, smaller batches under the same idempotency keys, so the local export lists them in both the abandoned batch and the later ones.
- Persists
- Local export (
usage.ledger_export.path) and verification:- Appends every built batch in build order as JSONL (
{"batch_hash":...,"prev_batch_hash":...,"batch":{...}}) via a 64-slot non-blocking channel and a single blocking-pool writer (O_APPEND | O_CREAT, mode0600). Paths conflicting withcontrol_plane.state_fileor<state_file>.*are rejected at config load (ledger_export_path_conflicts_with_state_file) and before open. - When the file reaches
max_bytes(default 1 GiB) withrotate: true, it is renamed to<path>.<sent_at_ms>and the new file begins with a self-verifying{"anchor":true,"prev_batch_hash":"<hex>","rotated_from":...}line. Withmax_filesset, the oldest rotated regular files beyond the cap (ordered by the<sent_at_ms>-<n>in the name, never following symlinks) are deleted after each rotation and when the writer starts. - Operators can enforce WORM immutability (
chattr +aor object-lock storage) and audit files offline withpython3 -I scripts/verify-usage-ledger.py <file> [--prev <hex>](see Usage ledger tamper evidence).
- Appends every built batch in build order as JSONL (
Cadence and policy¶
The hub returns cadence settings (heartbeat interval, usage-push interval, batch size) in the enrollment response, and those govern the agent. A per-field override in the control_plane config section wins over the hub value for that one setting. Policy sync and enforcement are opt-in via control_plane.policy.enabled; without that block the agent is metering-only and does not call the policy endpoints.
Fleet public configuration¶
Opt-in via control_plane.config_sync.enabled. The remotely manageable surface is restricted to public, typed request_params, kv_routing, and model fallback schemas; it rejects arbitrary dotted keys, environment variable expansions, credentials, TLS material, prompts/completions, fallback.quality_check, and deployment-local KV routing leaves (prefix_routing.salt_echo, kv_cache_index.backend, and kv_cache_index.event_sources with transport: event_bus). Operators can pin an entire section with *_immutable, while local leaves and indexed fallback collections take precedence over Hub values.
- Capabilities, bounds, and masked observation:
- Mutable sections advertise schema version
1, path encoding version1, hot-reload and dry-run support, and hard bounds: 1,820 desired leaves, 6 path segments (up to 256 UTF-8 bytes each; model IDs with/,:,., or Unicode stay a single segment), 1 MiB encoded content, 64 request-param model scopes, and fallback limits of 64 model keys, 10 targets per chain, and 64 trigger status codes. - If combined local-plus-Hub fallback leaves exceed 1,820 (or 2,076 aggregate), the Router pins the entire fallback section to the local value and reports an immutable value-free digest. Inventory snapshots report digests, path segments, source (
hub,local_file,local_pin), sensitivity, and reload class without ever exposing parameter values.
- Mutable sections advertise schema version
- Fallback composition and cycle detection:
- Indexed collections are atomic: a shorter higher-precedence chain replaces the lower chain completely, and any local member pins its collection. Non-empty per-model custom chains override global chains.
- The Router runs iterative cycle detection across the effective fallback graph regardless of enable switches and reconciles the process-lifetime fallback service live without restarting or mutating local files.
- Outbound sync and atomic application:
- The agent polls
POST /api/agent/v1/config/sync, fetches desired content fromPOST /api/agent/v1/config/content, verifies identity fields and the canonical SHA-256 digest over sorted length-prefixed paths, merges Hub leaves beneath local pins, validates the completeConfig, persists the overlay (0600, bound to enrollment identity), and swaps the lock-freeArc<Config>snapshot. - Transport errors and timeouts leave assignments unacknowledged for retry while serving last-known-good; only deterministic validation outcomes are reported to
POST /api/agent/v1/config/apply-result(applied,validated,rejected, orsuperseded).
- The agent polls
- Implementation:
src/control_plane/config_sync.rs,src/control_plane/client.rs,src/control_plane/agent.rs,src/control_plane/inventory.rs, andcrates/continuum-protocol/src/config.rs.
Fleet backend configuration¶
Opt-in via control_plane.config_sync.backends (backend_config_v1), allowing the Hub to manage backend topology under the backends.mode ownership contract:
disabled(default): advertises no backend-management capability.overlay: the Hub creates, updates, and removes only Hub-owned backend IDs while preserving all Router-local backends; any ID or runtime-name collision rejects the revision.authoritative: after a compare-and-swap first adoption, the Hub snapshot governs all backends except those inimmutable_backend_ids(removing omitted local backends via graceful drain, and requiring an explicit confirmation flag for an empty snapshot).
Key guarantees across modes:
- Compare-and-swap adoption: authoritative first adoption verifies
(backend_id, local_config_digest)preconditions over the Router's secret-free canonical local export, rejecting mismatches withadoption_digest_mismatch. - Validation, pre-activation probe, and atomic apply: the agent polls
POST /api/agent/v1/backend-config/sync, fetchesPOST /api/agent/v1/backend-config/content, verifies the canonical digest, preserves the Hub-assignedbackend_id, and runs one bounded pre-activation health check via the candidate-probe primitive for enabled new or materially changed backends (skipping network I/O for weight-only or model-list-only edits). Validated snapshots persist (0600) and publish atomically alongside a lock-free ownership ledger. - Credential binding: snapshots reference credentials by
credential_refID and bundle digest (backend_credentials_v1), activating only when every referenced ID resolves for the matching provider in an acknowledged bundle (allowing newer rotated bundle digests when all referenced IDs still resolve) and never persisting secrets to disk. - Implementation:
src/control_plane/backend_sync.rs,src/control_plane/config_sync.rs(BackendHubLayer),src/control_plane/agent.rs,src/control_plane/client.rs,src/control_plane/inventory.rs, andcrates/continuum-protocol/src/backend_config.rs.
Hub-declared model aliases¶
A Hub backend profile can declare per-model aliases (model_aliases: a target model from allowed_models mapped to up to 50 caller-facing names). The Router advertises backend_model_aliases_v1 in heartbeat capabilities whenever the backend-sync executor runs (backends.mode is not disabled), and the Hub sends aliases only to a Router that advertises it.
- Dispatch: each target becomes a
model_configsentry with those aliases on the Hub-owned backend, andmodelsstays exactlyallowed_models. A request for an alias is rewritten to the target before backend selection by the same rule as an operator-declaredmodel_configsalias, so selection picks among every user-routable backend serving the target, the upstream receives the target id, and/v1/modelslists the alias. - Internal and disabled backends: an
internalprofile's aliases are validated but not applied, and a disabled Hub backend resolves no alias. - Precedence: a name some backend serves literally is never rewritten. Hub aliases take precedence over
model-metadata.yamlaliases. The top-levelmodel_aliasesrewrite still runs first on the ingresses that apply it; when itsexactmap names a Hub alias, the Router logs one warning per activating apply naming the backend id. -
Rejections (
model_policy_invalid, scoped to the profile, previous snapshot stays active):Detail Cause model alias target is not an allowed modelAn alias map key is not in the profile's allowed_models.too many aliases for one modelMore than 50 aliases for one target. empty,too_long,control_characterA malformed alias name. model alias collides with another hub profileAn alias equals another profile's alias or allowed model (scoped to the later profile ID). model alias collides with a local backend modelAn alias equals a model or alias of a preserved local backend, matches a glob entry in its models, or is the dated form of a non-wildcardmodelsentry (or the reverse, for examplellama3.2andllama3.2-20250101), disabled backends included. Inauthoritativemode, a replaced local backend does not count. -
Restart: a restored snapshot re-applies its aliases. A local edit that now collides logs a warning and the snapshot still activates; the literally served local name keeps winning.
Hub backend tasks¶
Opt-in via control_plane.config_sync.backends.tasks (backend_tasks_v1, independent of backends.mode). The Router polls POST /api/agent/v1/backend-tasks/sync, executes read-only tasks over its own outbound network path, and reports results to POST /api/agent/v1/backend-tasks/result:
candidate_probe: materializes a schema-v1 candidate profile (allowing an empty approved-model list for discovery) and runsvalidate,health_check, anddiscover_modelsthrough the sharedsrc/backend_probe/primitive used byPOST /admin/backends/probewithout registering the candidate in the live pool or sending synthetic chat prompts. Task-scoped credential leases stay in memory for that task only and must match the candidate's explicitcredential_refand provider. Results are strictly metadata-only (health, integer latency, credential classification, catalog source, bounded model IDs, and a closed error code; never upstream error bodies or secrets).local_export: returns an allowlisted, secret-free view of local backends (reporting unsupported fields such as raw API keys, OAuth state, or custom headers as bounded codes inunsupported_fields, and tagginghub_owned,immutable, anddrainingentries) alongside each entry's compare-and-swap adoption digest.- Idempotency and capability latch: results are persisted in an enrollment-bound durable outbox keyed by task ID so redeliveries never re-execute.
backend_tasks_v1is advertised from a runtime attachment latch only while the outbox and task loop are actively running, andGET /admin/control-plane/statusreportsbackend_tasksattachment state with a boundedreasoncode when detached. - Implementation:
src/backend_probe/,src/control_plane/backend_tasks/,src/control_plane/backend_sync.rs,src/control_plane/agent.rs,src/control_plane/status.rs,src/control_plane/inventory.rs, andcrates/continuum-protocol/src/backend_config.rs.
Token-supply and saturation telemetry¶
The heartbeat and enrollment inventory carry a metadata-only, integer-only RouterInventory.supply block plus six per-backend BackendInfo fields (src/control_plane/supply.rs, src/control_plane/capacity.rs, src/control_plane/inventory.rs):
- Pressure counters (
accepted_requests,rejected_requests,active_requests,retried_requests,fallback_requests):accepted_requestsandrejected_requestsare independent pressure counters rather than a partition (accepted + rejectedcan exceed total requests because API admission runs before handler-level hub-policy or batch refusals, while rate-limit429s on/admincount as rejected without entering API admission).fallback_requestsincrements once per request when a fallback backend actually serves the response (shared across pre-stream and mid-stream hops), and streamingactive_requestsguards ride the response body until the final SSE frame or client disconnect.
- Per-key rejections of Hub-issued keys (
rejections_by_key,rejections_by_key_other):- Cumulative
(key_id, stage, count)rows sincecounters_since_ms, fed from the same rejection funnel asrequest_rejected_total, but only for key ids resolved against the Hub key table (by the policy enforcement middleware, or byhub_auth_contextinblockingmode). Local keys, AppProxy keys, and unattributed rejections stay inrejected_requestsonly. - Membership is first come, first served for the process epoch, bounded by the protocol's
MAX_KEY_REJECTION_KEYS(32 keys) andMAX_KEY_REJECTION_ENTRIES(256 rows), so each row is monotonic. Rejections of further Hub keys, or of key ids that failkey_rejection_key_id_problem, add torejections_by_key_other, which is always sent (0included) so the Hub can tell a capable router from an older one. - The funnel also covers admission, input guardrail, and routing rejections, so the per-key sum can exceed
rejected_requests. Router stages map toKeyRejectionStagethrough an exhaustive match. Implementation:src/control_plane/supply/key_rejections.rsandsrc/http/middleware/request_rejection.rs.
- Cumulative
- Semantics of absent vs. zero fields:
- Optional fields are omitted ("unknown") rather than zeroed when unmeasured:
max_concurrencyis always absent,circuit_stateis omitted when a breaker is disabled or has never observed the backend (read via non-materializingpeek_state), andobserved_rpm_capacity/observed_input_tpm_capacity/observed_output_tpm_capacityare reported only as 15-minute high-water marks after an upstream429corroborates a 60-second window with at least two completions. queue_depthandshed_requestsare reported as literal0because the router has no request queue or load shedding.counters_since_msis stamped once at process start (process_start_info()).
- Optional fields are omitted ("unknown") rather than zeroed when unmeasured:
Guardrail telemetry¶
The inventory also carries a RouterInventory.guardrails block (continuum_protocol::GuardrailSummary): cumulative, metadata-only counters describing content-safety guardrail activity, mirroring the local Prometheus families (guardrail_checks_total, guardrail_blocks_total, guardrail_verdicts_total, guardrail_stream_buffer_cap_trips_total) so the Hub can observe fleet-wide guardrail activity without scraping individual routers.
| Field | Meaning |
|---|---|
checks_total | Cumulative per-provider checks across every stage. |
blocks_total | Cumulative content block verdicts, equal to the sum of by_category. Fail-closed infrastructure refusals are excluded; they count under errors_total / fail_closed_total. |
transforms_total | Cumulative mode-applied verdicts that rewrote content rather than refusing it. |
flags_total | Cumulative mode-applied verdicts recorded without acting on them. |
errors_total | Cumulative checks that failed to complete (timeout, transport error, non-success status, unparseable body), equal to fail_open_total + fail_closed_total. |
fail_open_total | Failed checks served anyway: traffic that was never actually inspected. |
fail_closed_total | Failed checks refused: availability paid for enforcement. |
stream_buffer_cap_trips_total | Cumulative streams that reached the 4 MiB gate buffer cap. |
by_category | Blocks keyed by category id, an object rather than an array. The router only ever writes a GuardrailCategoryId literal (12 values, including deny_list and the other catch-all), while an id a newer peer classifies is carried through unchanged. |
verdicts | The single aggregated per-request verdict by (stage, mode, result). Router-only and additive. |
stream_buffer_cap_trips | Streams that reached the 4 MiB gate buffer cap, by (strategy, outcome). Router-only and additive. |
counters_since_ms | Process-start epoch these cumulative counters are measured from. |
Accounting invariants and privacy bounds¶
- Per-provider vs. per-stage denominators:
blocks_total(andby_category) counts per provider check alongsidechecks_total(including monitor-mode matches; note that the Hub's rustdoc definesblocks_totalas enforce-mode blocked requests, so aligning the two requires a coordinated protocol change). By contrast,transforms_totalandflags_totalcount once per stage evaluation and are derived directly fromverdicts(just asstream_buffer_cap_trips_totalis derived fromstream_buffer_cap_trips) insideGuardrailSnapshot::into_summary. - Effective fail-policy disposition (
fail_open_totalvs.fail_closed_total):errors_totalcovers both service timeouts and hard provider errors (GuardrailtraitResult::Err). Because monitor mode never gates traffic, a failed check in monitor mode always incrementsfail_open_totalregardless of its configuredon_errorpolicy;fail_closed_totalincrements only when both an enforcing mode and a fail-closed policy actually refused the request. Fail-closed infrastructure errors never incrementblocks_totalorby_category. - In-process tracker and presence semantics: counters are maintained in an independent in-process tracker (
src/control_plane/guardrail.rs) rather than read from the optional Prometheus registry. The block is reported (with zeros before the first check) whenever a guardrail service is configured and omitted only when guardrails are disabled.counters_since_mssharesprocess_start_info()withSupplySummary. - Closed label space, privacy, and wire fixture:
- Breakdowns use fixed-size atomic arrays indexed by closed enums:
by_categoryuses the 12 compile-timeGuardrailCategoryIdliterals (folding unknown third-partyGuardrailCategory::Otherstrings intootherand enforcing Hub bounds insrc/control_plane/guardrail/summary_bounds.rs), whileverdictsandstream_buffer_cap_tripsfold unknown values intounknown. - No prompt/completion text, matched spans, rules, provider names, or error strings ever enter the summary.
crates/continuum-protocol/tests/fixtures/guardrail_summary.jsonis pinned to the vendored Hub revision and verified against live tracker output intests/control_plane_guardrail_telemetry_test.rs.
- Breakdowns use fixed-size atomic arrays indexed by closed enums:
- Implementation:
src/control_plane/guardrail.rs,src/control_plane/inventory.rs,src/services/guardrail/service.rs,src/services/guardrail/gate.rs, andcrates/continuum-protocol/src/guardrails.rs.
Policy sync and local enforcement¶
When control_plane.policy.enabled is set (with the control-plane feature compiled and the agent enabled), the agent maintains a live hub policy snapshot and enforces it locally after API-key authentication on every API request.
Delivery paths and bounded envelope decode¶
Two delivery paths feed the in-memory PolicyStore with full, idempotent PolicyEnvelope snapshots (the org's hashed API key table, tier limits, per-key month-to-date budget snapshots, and agent settings):
- Poll (
POST /api/agent/v1/policy/sync): runs everypolicy.poll_interval_secs(default 5s), bounding revocation and budget propagation by the poll interval. - Stream (
GET /api/agent/v1/policy/stream?cursor=<opaque>): a persistent WebSocket push channel delivering full-envelope JSON text frames in under 5 seconds, reconnecting with backoff from the last applied cursor on disconnect (disable viapolicy.stream_enabled: false). - Bounded envelope decode (
MAX_POLICY_SYNC_RESPONSE_BYTES, ~152.6 MiB insrc/control_plane/client.rs):- Both the HTTP poll path (
Content-Lengthpre-check plus running chunk total) and the WebSocket stream (both message and frame ceilings) enforce the same ~152.6 MiB cap, sized as the term-by-term sum of the Hub's distribution budgets (100 MiB key-table allowance, 1,024 tiers with 4 MiB request-params budget, 256 KiB guardrail budget, 5,000 cost-center assignments, and 1,024 cost centers). - Oversize envelopes fail loudly with
HubError::ResponseTooLarge, log anerror!with the limit and observed size (never body content), incrementcontrol_plane_policy_sync_failure_total{reason="envelope_too_large"}(pre-initialized at0alongsidereason="other"), and surfacepolicy_sync.oversize_refusals/last_oversize_refusal_msonGET /admin/control-plane/statuswhile preserving the last-known-good store snapshot. - Smaller Hub responses (
enroll, usage acks, probe acks) enforce their own derived byte ceilings; an oversize usage ack is treated as terminal for its already-processed batch so the push loop does not stall.
- Both the HTTP poll path (
Per-request enforcement rules¶
For a presented API key whose SHA-256 matches a synced KeyEntry (constant-time comparison):
- Revocation:
revokedentries (covering revocation, expiry, and org suspension) are rejected immediately. - Rate limits:
- Per-tier
rpmuses a fixed 60-second window (returning429on overflow);input_tpm(excluding cache-read input tokens) andoutput_tpmuse post-response windows fed by usage taps, rejecting subsequent requests once the window is full. - Each router counts on its own by default. With
control_plane.policy.minute_limits.redis, the three windows live in one Redis or Valkey store that a single script checks and charges, and a failed or late store call falls back to the local windows (control_plane_minute_limits_fallback_total{reason}). - With
control_plane.policy.minute_limits.reserve_tokens, admission reserves each metered request's estimated tokens until its response finishes and admits only while usage plus reservations is under the limit. See Hub tier minute limits.
- Per-tier
- Monthly budgets:
tokens_per_monthandrequests_per_monthare checked against the Hub'sKeyUsageSnapshotplus the local delta accumulated since that snapshot (reset on a newer snapshot or UTC month rollover). - Model allowlist: requests for models outside the tier's
model_allowlistare rejected with403 model_not_allowed(an absent allowlist permits all served models). A model the router does not know under any name (no user-routable backend, model experiment, alias, virtual model, fallback chain, or decisions target) is rejected with404 model_not_foundinstead, the same response the router returns without a tier. - Request-parameter limits: tiers can bound
temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k, andmin_p(transmitted as integer microunits) globally or per alias-resolved model scope. The Router intersects Hub bounds with localrequest_paramslimits, rejects empty intersections, and freezes the effective policy before cache lookup, substitution, arbitrage, routing, retry, or fallback across Chat Completions, Completions, Responses, and Anthropic Messages. - Request priority (
request_priority_v1, vLLM scheduling):- A tier may set an integer floor
Pin[-100, 100](where lower integers indicate higher urgency in vLLM's--scheduling-policy priorityqueue), clamping the effective priority tomax(client_value, P). - At ingress, client
X-Vllm-Priorityheaders are collapsed and clamped across/v1/chat/completions,/v1/completions,/v1/responses,/anthropic/v1/messages, and/v1/embeddings(and top-levelbody.priorityon/v1/embeddingsis clamped in place without injecting it when absent). - On outbound
type: vllmhops (afterrequest_extensionsmerge and beforeinternal_streaming), the router appliesX-Vllm-Priorityoverbody.priorityand writes the clamped integer to bothbody["priority"]andX-Vllm-Priorityon/v1/chat/completionsand/v1/completions(or clamps existing fields on/v1/embeddings), returningx-continuum-request-priority: <applied>on the response. Non-vllmhops are untouched and incrementcontinuum_request_priority_skipped_total{reason="backend_type"}. - Dispatch and TTFT seams record
continuum_request_priority_requests_total{priority,status_class}andcontinuum_request_priority_ttft_seconds{priority}(capping distinct integer labels at 16 before"other", plus"unmanaged"). Unmatched keys are governed only whencontrol_plane.policy.unmatched_key: rejectis set.
- A tier may set an integer floor
- Anthropic user profile (
anthropic_user_profile_v1):- A
KeyEntrymay carryanthropic_user_profile_idandanthropic_user_profile_passthrough. The Hub sends them only to a router that advertisesanthropic_user_profile_v1, whichPolicyStore::capabilitiesdoes unconditionally. - In
blockingmodehub_auth_contextcopies the pair onto the Hub key'sApiKey. Inpermissivemode, where a Hub key gets noAuthContext, the enforcement middleware attaches aHubKeyUserProfileextension for an admitted key. resolve_user_profile(src/http/middleware/user_profile_attribution.rs) runs after enforcement and applies the same precedence as for a local key: a single well-formed client header on a passthrough key, then the key's profile, then a backend's static profile. A localAuthContextwins over the extension, and a malformed Hub profile ID is never sent. See Per-customer attribution for Anthropic.- The persisted key table keeps both fields (omitted at their defaults, so
KEY_TABLE_FORMAT_VERSIONstays1), and they are part of the security fingerprint, so setting or clearing them rewrites the file without throttling.
- A
- Prompt-logging consent (
prompt_logging_consent_v1):- A
KeyEntrymay carryprompt_logging_consent(allowedordenied), the effective value the Hub composes from the key and its team (continuum-hub#1975).PolicyStore::capabilitiesadvertises the string unconditionally. Delivery is what the capability gates: the enum is closed, and a router that does not know a value refuses the envelope. - In
blockingmodehub_auth_contextcopies it onto the Hub key'sApiKey. Inpermissivemode the enforcement middleware attaches aHubKeyPromptLoggingConsentextension for an admitted key that states one.resolve_key_prompt_logging_consent(src/services/prompt_log_consent.rs) reads both: adeniedfrom either wins, because a sync can withdraw consent between auth and enforcement. Otherwise theApiKeyvalue applies, then the extension, so a local key that states nothing does not hide a Hub value on a shared token. A locally configured key sets theApiKeyvalue throughapi_keys[].prompt_logging_consent(#2819), so the resolver covers a deployment with no Hub. - While a Hub policy store is attached but holds no policy (
has_policy()is false), the middleware marks a key-authenticated requestPolicyUnavailable, and the resolver returnsdeniedfor it, so it is not captured under anydefault_consent. Request handling still fails open, and a keyless request followsdefault_consent. - With no Hub policy store, the dynamic auth middleware marks every presented token that did not authenticate
Stated(Denied), so an unknown, deleted, rotated, disabled, or expired token is not captured under anydefault_consent(#2879). With a store attached it marks only an inactive local key'sdenied(inactive_key_states_denied), because enforcement already refuses a token no table recognizes and an unconditionaldeniedthere would override a valid Hub key'sallowed. prompt_logging_capture_basisis the gate the capture path calls (src/services/prompt_log_capture/, #2320). It takes router-owned types, so it compiles under every feature set. The chat completions, Responses, and Anthropic Messages ingresses evaluate it after the enforcement middleware and before any body is copied; an admitted request's handle rides a task-local scope to the buffered arm and the stream-end usage sink, which write the one redacted record through a dedicated writer thread.src/services/prompt_log_export/(#2328) delivers those files: one background task perprompt_logging.export.sinksentry, started inserve()only while the section is enabled, tails them on a persisted cursor, masks each record again, and delivers bounded batches at least once. Nothing on the request path touches it.- The persisted key table keeps the value (omitted when unset, so
KEY_TABLE_FORMAT_VERSIONstays1), and it is part of the security fingerprint, so a withdrawal rewrites the file without throttling and a restart cannot restore the earlier value.
- A
- Rate-limit headers: enforced responses include
x-continuum-ratelimit-limit,-remaining, and-resetfor the most constrained dimension plus suffixed triples (-rpm,-input-tpm,-output-tpm,-monthly-tokens,-monthly-requests), andretry-afteron429responses. - Refusal body format: policy refusals (revoked key, model allowlist, tier and budget limits, cost-center limits,
unmatched_key: reject) use the OpenAI error envelope on OpenAI routes and the Anthropic envelope ({"type":"error","error":{"type":"rate_limit_error","message":...}}) on/anthropic/*routes. Status codes and headers do not depend on the route.
Atomic tier state, governed policies, and capability reporting¶
- Atomic tier-policy state: full snapshots are validated before swapping the live
ArcSwap, and poll requests capture the acceptance generation before network I/O so delayed responses cannot overwrite newer stream revisions. Heartbeats read capability/status evidence and the applied cursor from a single atomic publication (RouterInventory.policy_status). - Guardrail policy and provider content (
guardrail_policy_v1,guardrail_provider_content_v1):- Both capabilities are advertised from
PolicyStore::capabilitieson a single latch only after a live guardrail-policy reconciler attaches. - Wire acceptance records receipt, while active acknowledgment is withheld (
pending) until the reconciler confirms execution of that exactguardrail_policy_digest(or reportsguardrails_unavailableonRejectedPolicyRevisionif composition fails, without blocking tier policy in the same envelope). GuardrailPolicy.provider_content(crates/continuum-protocol/src/guardrails/provider_content.rs, 256 KiB canonical bound) composes viacompose_hub_guardrail_policy_checked(src/services/guardrail/composition/provider_content.rs): appendingpolicy_promptidempotently tocustom_classifierprompts, taking the stricter per-entity PII action, and compilingmatch_list_deny/match_list_allowintoRegexSets (src/services/guardrail/matchlist/governed.rs, 256 KiB per pattern, 8 MiB per statement; violations reject withinvalid_guardrail_policy). While the reconciler is attached, Admin API guardrail writes edit only the local block. See Guardrails: provider content.
- Both capabilities are advertised from
- Smart routing policy (
smart_routing_policy_v1):PolicyEnvelope.smart_routing(crates/continuum-protocol/src/smart_routing.rs, 256 KiB canonical bound) carries routing rules, model tier profiles, and classifier selection referencing pool backend names.- The reconciler (
src/control_plane/smart_routing_policy.rs) claims single-writer ownership at startup viagovern_smart_routing(causing hot-reload local-only applies and direct Admin writes without a config channel to stand down or return503 hub_governed_no_config_channel), previews effective backends viaConfigSyncStore::preview_effective, composes rules with localsmart_routing:(compose_hub_smart_routing_policy_checkedinsrc/services/smart_routing/composition.rs, keeping localenabledand takingintercept_allas the logical AND), and reloads the tier registry, policies, classifiers, and lock-freeLoadMonitor(AtomicBoolswitch) atomically insrc/services/smart_routing/runtime.rs. - Wire-level validation or uninterpretable methods/tiers reject with
invalid_smart_routing_policyordigest_mismatch, while execution gaps (missing/disabled backend, unpinned LLM classifier, explicit-model route target, orLocallyDisabledwhenenabled: false) decline the whole statement assmart_routing_unavailableand run local configuration alone. smart_routing_policy_v1is advertised unconditionally oncegovern_smart_routingattaches the reconciler (even when localsmart_routing.enabledisfalse, so the router honestly reportssmart_routing_unavailablerather than flapping capability strings on reload). See Smart Routing Governance.
- Rejection slot precedence (
src/control_plane/policy_store/acknowledgment.rs): the single heartbeat rejection slot prioritizes guardrail execution, guardrail envelope, smart routing execution, smart routing envelope, and then code-independent tracks (cost_center_v2,model_budgets, credentials), gated byPolicyHeartbeatState.rejected_sourceso a standing smart-routing decline never masks a guardrail refusal. - Stable backend identity (
stable_backend_identity_v1): advertised unconditionally because config validation, heartbeat inventory, and usage taps are compiled and wired as a single unit.backends[].backend_idis operator-authored (never derived from mutable display names or(provider, model)pairs) and resolved at each serving seam against the activeConfigsnapshot.
The capability strings a router can advertise, among them request_param_limits_v1, request_priority_v1, anthropic_user_profile_v1, prompt_logging_consent_v1, guardrail_policy_v1, guardrail_provider_content_v1, smart_routing_policy_v1, and stable_backend_identity_v1, are what let the Hub derive a propagation state from bounded evidence rather than from a free-form report:
| Derived state | Router evidence |
|---|---|
unsupported | The capability is absent from RouterPolicyStatus.capabilities, so this router will never acknowledge that policy family. |
pending | The capability is advertised but no acknowledgment for the stated digest has arrived yet. |
active | AppliedPolicyRevision.guardrail_policy_digest (or smart_routing_policy_digest) equals the digest the Hub authored, including the canonical cleared-policy digest after an authoritative clear. |
stale | The acknowledged digest names an earlier policy than the one the Hub last stated. |
error | RejectedPolicyRevision names the digest with invalid_guardrail_policy or guardrails_unavailable (guardrails), invalid_smart_routing_policy or smart_routing_unavailable (smart routing), or the shared digest_mismatch. |
Usage records for hub-matched keys are attributed with the hub key_id, which is what the hub aggregates into the budget snapshots the router enforces against, closing the metering loop.
Cost-center limits, per-model budget fallback, and resilience¶
- Cost-center limits V2 (
cost_center_limits_v2insrc/control_plane/cost_center/):- Advertised only while the owner-only sidecar (
<state_file>.cost-center.json, mode0600) is attached and bound to the enrolled router identity. (The legacy V1cost_centersmember parses for compatibility but is never enforced or advertised.) - Each
cost_centers_v2statement (key ownership, router fallback, provider/model and stable-backend fallbacks, monthly caps, plus the dynamic budget snapshot andincluded_router_usage_seqwatermark) is validated and applied atomically, or refused withinvalid_cost_center_policy,digest_mismatch,stale_budget_snapshot, orinvalid_usage_watermarkwhile retaining last-known-good state. - Admission reservation: on meterable request paths, fixed pre-dispatch tiers (key assignment and router fallback) and lower tiers (stable backend, then provider/model, when every candidate backend for the effective target maps to an exhausted center) refuse exhausted allocations at admission and hold an atomic reservation (1 request + bounded byte-derived prompt estimate + clamped output ceiling) on the response body until completion or disconnect.
- Sequenced accounting and watermark reconciliation: at the usage seam, the usage loop stamps the monotonic
router_usage_seqand records exact metered tokens (input_tokens + output_tokens) into an unreflected ledger under the final serving(key_id, backend_id, provider, model). Newer Hub snapshots discard only ledger entries at or belowincluded_router_usage_seq(and permanently dropped/rejected records retire their entries). Sell spend is enforced strictly from Hub-authoredsell_spend_status: exhaustedverdicts without local price math.
- Advertised only while the owner-only sidecar (
- Per-model budget fallback (
model_budget_fallback_v1insrc/control_plane/model_budget.rs):- Advertised once the budget sidecar attaches. Tri-state
model_budgetsstatements assign exact(provider, model)offerings to per-key monthly pools with ordered fallback targets. - On
/v1/chat/completionsand/v1/completions, selection runs after Hub substitution/arbitrage and before request-param composition, cache lookup, cost-center admission, and dispatch. When a source pool is exhausted, the middleware rewrites the request to the first fallback target with headroom that passes allowlist, backend restriction, visibility, provider, health, and circuit-breaker gates, locking the target against downstream cross-model or mid-stream fallback. - Clients can opt out via
x-continuum-budget-fallback: off(receiving429 insufficient_quotawithretry-after). Applied fallbacks setx-fallback-used: true,x-fallback-model,x-fallback-reason: budget_exhausted, andx-fallback-attempts, stampbudget_fallback_pool_idandrequested_model/requested_provideron the usage record, and incrementcontrol_plane_model_budget_fallback_total{outcome}.
- Advertised once the budget sidecar attaches. Tri-state
- Surface parity: terminal token accounting and
key_idattribution apply identically across/v1/chat/completions,/anthropic/v1/messages(all native and bridged sub-paths), streaming/v1/responses(conversion and pass-through arms), and theresponses_onlychat bridge, consistently excluding cache-read tokens frominput_tpmand monthly budgets. - Fail-open invariant and unmatched-key policy:
- Until the first policy sync or sidecar restore (
policy_sync.source: none), only local authentication and rate limits apply. - Once a policy is active, requests without a matching Hub key or local
AuthContextare rejected with401 invalid_api_keyby default (control_plane.policy.unmatched_key: reject, including newly issued Hub keys during the sync propagation window). Operators can monitorcontrol_plane_unmatched_key_total{action}or opt intounmatched_key: fail_open(which emits a startupWARNandconfig validatewarning). Inapi_keys.mode: blocking, non-revoked Hub keys authenticate even if absent from the local key store.
- Until the first policy sync or sidecar restore (
- Restored key table (
control_plane.policy.persist_key_table: true):- Persists an allowlist projection of key hashes, tier bindings/limits, revocation flags, usage snapshots, and timestamps (never plaintext keys or provider/search credentials) to
<state_file>.policy-keys.json(0600). - Any security-relevant change (key removal, revocation, re-tiering, limit tightening, or a change to the key's Anthropic user-profile pair,
anthropic_user_profile_idoranthropic_user_profile_passthrough, or to itsprompt_logging_consent) unlinks the previous sidecar and fsyncs the parent directory on the applying thread before enforcing or acknowledging the change, preventing a crash from leaving a stale permissive file on disk. - On startup before the Hub is reachable, a valid sidecar belonging to the current enrollment and younger than
policy.key_table_max_age_secs(default 24h, range 60s to 7d, tracked on both wall and monotonic clocks) is installed as arestoredsnapshot (policy_sync.source: restored), enforcing key auth and tier limits without advertising an applied cursor to the Hub until a live sync replaces it or the staleness bound expires. consent_confirmedrecords whether the table was written after the Hub acknowledged a heartbeat, which is when itsprompt_logging_consentvalues can be trusted. A confirmed sidecar keeps serving its stated consent through a Hub outage. A sidecar without the field reads as unconfirmed, so key-authenticated requests are not captured until the first confirmed envelope arrives. An older release refuses a sidecar that carriesconsent_confirmedand cold-starts, so rolling back drops the restored table instead of misreading it.
- Persists an allowlist projection of key hashes, tier bindings/limits, revocation flags, usage snapshots, and timestamps (never plaintext keys or provider/search credentials) to
- Per-user web-search credentials (
search_credentials_v1):- Latched and advertised only while a
web_searchconfiguration section is active (releasing immediately on config removal). - Stores
PolicyEnvelope.search_credentialsin memory indexed byuser_id, recomputing the acknowledged digest over both the tier digest and the credential set metadata. - For Hub keys, web-search executors enforce the tier's
web_search_enabledgate, prefer the key owner's credential over the deployment key, and meter searches into task-local fields copied onto the usage record (web_search_queries,web_search_providerrestricted toserper/brave/exa, andweb_search_credential_source), including Anthropicweb_search_20250305emulation turns.
- Latched and advertised only while a
- Inspecting applied policy:
GET /admin/control-plane/policyand the WebUI Integrations page expose a secret-free projection of tier limits, substitution rules, equivalence classes, routing windows, optimization settings, model-budget pools, andpolicy_status.
Governance compatibility matrix¶
The policy envelope carries governed smart routing (smart_routing_policy_v1) and guardrail provider extensions (provider_content and provider_rows inside guardrail_policy_v1, advertised by guardrail_provider_content_v1 and guardrail_provider_rows_v1 on the same reconciler latch). The first router version that executes all of these capabilities is v1.32.0.
| Router version | guardrail_policy_v1 | guardrail_provider_content_v1 / guardrail_provider_rows_v1 | provider_content / provider_rows in a guardrail statement | smart_routing_policy_v1 |
|---|---|---|---|---|
| v1.16.x and earlier | Not advertised (unsupported) | Not advertised | Ignored | Not advertised (unsupported) |
| v1.17.0 to v1.31.x | Advertised once the guardrail reconciler attaches | Not advertised | A capability-gated Hub strips the unsupported members and recomputes guardrail_policy_digest over the stripped policy, reporting the stripped members as unsupported (if sent ungated with a digest, refused as digest_mismatch) | Not advertised (unsupported) |
| v1.32.0 and later | Advertised once the guardrail reconciler attaches | Advertised with guardrail_policy_v1 on the same latch | Composed and executed under guardrail_policy_digest (or refused as guardrails_unavailable, guardrail_backend_missing, guardrail_backend_disabled, guardrail_providers_unavailable, or invalid_guardrail_policy) | Advertised once the smart routing reconciler attaches (govern_smart_routing); refusals report invalid_smart_routing_policy, smart_routing_unavailable, or digest_mismatch |
A v1.32.0 router built without a configuration manager (embedded library build or hot reload disabled) attaches neither reconciler and advertises none of the governance capabilities (unsupported). Neither policy is persisted across restarts; after a restart the router runs local configuration until the next full-snapshot envelope arrives.
Hub-delivered provider credentials¶
The policy envelope can carry an optional org-scoped provider_credentials set ({provider, credential_id, version, secret}) under the tri-state wire contract (absent keeps the last set, [] revokes all, non-empty replaces the set atomically).
- Selection by
backend_type(proxy::oauth_helper::effective_backend_secret): Maps each backend'sbackend_type(openai,anthropic,gemini,bedrock, etc.) to a provider name. When the backend has a localapi_keyconfigured and the Hub has delivered a credential for that provider, the Hub secret substitutes for the outbound auth header value (note: third-party proxy endpoints labeled with a canonicalbackend_typeand a localapi_keyalso receive that provider's credential). - Scope and memory safety: Applies to OpenAI chat, native Anthropic Messages/
count_tokens, native Gemini embeddings/images, the Responses API, and batch dispatch (model discovery and health checks continue using the local key). Secrets remain in memory only and are redacted from logs, errors, andDebug/Displayoutput.
Backend-scoped credentials (backend_credentials_v1)¶
PolicyEnvelope.backend_credentials indexes credentials by credential_id rather than provider so multiple Hub-managed backends sharing a provider can bind to distinct secrets:
- Digest acknowledgment: The router recomputes
backend_credential_digestover(credential_id, provider, version)metadata (never hashing secret bytes) and echoes it inAppliedPolicyRevision.backend_credential_digest. - Effective-auth precedence: A Hub-owned backend with a
credential_refuses only that backend-scoped credential (matched by ID and canonical provider) without requiring a localapi_key. If the reference cannot be resolved (missing, revoked, wrong provider, or not yet restored after a restart), the backend is marked credential-unavailable and excluded from dispatch without falling back to org-wide or local secrets. Local and immutable backends retain the org-wide behavior.
Optimization policy and prompt cache execution¶
The Hub distributes the organization's optimization policy inside the policy envelope (exact_cache, prefix_cache, and semantic_cache booleans; integer semantic_similarity_bps and cache_ttl_secs), paired with per-tier cache_enabled (default on) and batch_eligible (default off) flags. If the optimization block is absent, exact and prefix caching default to on and semantic caching defaults to off.
- Effective per-request decision and headers:
- For each admitted Hub-keyed request, the enforcement middleware combines tier flags, org optimization policy, and client
x-continuum-cache/x-continuum-batch(on|off) headers into a request extension. Client overrides can only tighten (offalways wins;onis honored only when permitted by the tier and org policy). - Enforced responses emit
x-continuum-cache: hit|miss|bypass(mirroringx-cache) andx-continuum-batch: on|offalongside thex-continuum-ratelimit-*headers.
- For each admitted Hub-keyed request, the enforcement middleware combines tier flags, org optimization policy, and client
- Exact, prefix, and semantic cache execution:
- Exact cache: gates
ResponseCacheStore(memory, Redis, or tiered) across OpenAI chat (streaming and non-streaming) and non-streaming Responses and Anthropic Messages when a single backend is allowed with no fallback chain, usingcache_ttl_secswhen set. - Prefix cache (
pfx:namespace): consulted after an exact-cache miss on non-streaming OpenAI Chat Completions, Responses, and Anthropic Messages. Prefix keys preserve the full prompt and all response-shaping parameters (max_tokens,top_p), store only naturally completed responses (finish_reason: stop,stop_reason: end_turn, or completed Responses output), and replay only when the stored completion fits the caller's token limit, stampingCacheHitType::Prefix. - Semantic cache (
sem:namespace): triple-gated onresponse_cache.semantic.enabled(with a pinnedbackendandembedding_model),control_plane.optimization.semantic_cache_enabled, and the Hub'ssemantic_cacheflag. Embeds the final user message on deterministic requests and matches candidates at or abovethreshold(orsemantic_similarity_bps), stampingCacheHitType::Semantic(X-Cache: HIT,X-Cache-Mode: semantic). Seedocs/en/architecture/kv-cache.md.
- Exact cache: gates
- Namespace isolation, guardrail scope, and
StorePermit:- Every cache key binds
proxy::cache_identity::ResponseCacheCaller(combining the local key identity and resolved Hubkey_idso permissive-auth Hub keys never share an empty-string namespace). - When guardrails are active,
response_cache_route_identityfolds inGuardrailService::response_cache_scope(canonicalGuardrailsConfigdigest, hub-governed allow/deny pattern sets, matchedrouteskey, caller bypass-list status, andCARGO_PKG_VERSION) and attaches aStorePermit(infrastructure::cache::store_permit). - The service advances its store epoch on every policy/provider swap and whenever an enforced output check passes only due to
fail_open, preventingResponseCacheStore::insert*or the semantic store from caching uninspected or stale-policy replies.
- Every cache key binds
- Cache-hit metering: local cache replays emit a single metadata-only usage record (
cache_hit = true, all prompt tokens incached_input_tokens, billableinput_tokens = 0,output_tokens = 0) that does not debit TPM windows or monthly budgets. On/v1/responses, a request-scoped mark suppresses the downstream non-streaming recorder from emitting a duplicate provider-served event.
Batch dispatch and lifecycle reporting¶
Gated behind control_plane.batch.enabled (and policy.enabled), the router serves the OpenAI-compatible Batch API (/v1/batches, /v1/batches/{id}, /v1/batches/{id}/cancel, /v1/batches/{id}/results) and reports asynchronous job transitions to the Hub:
- Dispatch, provider parity, and key scoping:
- Admissions require an effective
batch_eligibledecision (x-continuum-batch: offalways denies). The batch client resolvescontrol_plane.batch.backend(or the first OpenAI-compatible or native Anthropic backend), routing native Anthropic backends to the Anthropic Message Batches API (/v1/messages/batches) and normalizing Anthropicprocessing_statusintoBatchJobStatus(Bedrock batch is not supported). - Because router keys multiplex onto a single upstream provider account, retrieve, cancel, list (
GET /v1/batches, unpaginated), and results (409before completion,502on fetch error) filter strictly to jobs owned by the caller's key, returning404for unknown, foreign, or already-terminal IDs without contacting the provider.
- Admissions require an effective
- Separate batch rate-limit pool: submissions charge the tier's
batch_rpmand in-flightbatch_queue_depthgauge (plus a global 100k tracked-job memory cap returning503), completely isolated from synchronousrpm/input_tpm/output_tpmlimits.- Pressure admission runs after any routing-window deferral and final backend resolution, but before an optional cost-center model scan. A global reservation and the per-key queue slot bound concurrent scans.
batch_rpmcounts an attempt once it enters this dispatch phase, even if the scan, cost-center check, or provider call later fails. Early returns and request cancellation release the queue and global reservations; successful job registration assumes their ownership.
- Pressure admission runs after any routing-window deferral and final backend resolution, but before an optional cost-center model scan. A global reservation and the per-key queue slot bound concurrent scans.
- Cost-center admission at provider dispatch:
- Immediately before provider create, the router evaluates the Hub V2 allocation in key, router, stable-backend, then provider/model precedence. OpenAI input files are scanned only when provider/model ownership is the first resolvable tier. An exhausted request, token, or Hub-authored sell-spend dimension returns
429 insufficient_quotabefore provider create, while batch retrieval and cancellation remain available. - This is a current-state exhaustion check, not a reservation or a must-fit estimate. Only completed batch usage is sequenced and accounted, and sell spend is never estimated locally. Batches admitted before a cost center becomes exhausted can complete later and overshoot its cap;
batch_rpmandbatch_queue_depthlimit submission pressure but do not provide a hard bound on asynchronous budget overshoot. - A scanned single-model input uses that model for both admission and the eventual completion record. A mixed-model or model-empty input keeps the
batchplaceholder because one aggregated completion record cannot be assigned to an individual model without inventing an identity. Key, router, and stable-backend allocations still apply, but an individual provider/model assignment cannot refuse that mixed batch unless the Hub explicitly assigns the placeholder.
- Immediately before provider create, the router evaluates the Hub V2 allocation in key, router, stable-backend, then provider/model precedence. OpenAI input files are scanned only when provider/model ownership is the first resolvable tier. An exhausted request, token, or Hub-authored sell-spend dimension returns
- Lifecycle polling and completion usage:
- An in-memory poller tracks non-terminal jobs and pushes metadata-only transitions (
submitted->in_progress->completed/failed/cancelled/expired) toPOST /api/agent/v1/batcheswith at-least-once delivery and per-transition idempotency keys (disabling delivery after a single log entry if the Hub returns404). Jobs stuck past 26 hours are force-transitioned toexpiredto release their in-flight slot without emitting completion usage. - On
completed, the router releases the slot and emits one usage record (batched = true,provider_batch_id, completion timestamp, and token totals aggregated best-effort from the first 64 MiB of the output file).
- An in-memory poller tracks non-terminal jobs and pushes metadata-only transitions (
Cache-hit type metering¶
Usage records also carry cache_hit_type for local response-cache attribution. When a response is served from the router's own local cache, the record's cache_hit_type stamps which cache mode served it: exact for an exact-match hit, prefix for a prompt-prefix hit, and semantic for a similarity hit on the opt-in semantic path; all three are live serving paths, mutually exclusive per request, so a hit is stamped and metered exactly once. Every other usage tap, including a provider-side cache read and a completed batch job, leaves cache_hit_type as None; that kind of cache read is already captured by cached_input_tokens, not this field. The field carries no prompt or completion content and defaults to None when absent.
Synthetic provider/model probes¶
When the Hub delivers an enabled PolicyEnvelope.probes policy with at least one (provider, model) target (and control-plane, control_plane.enabled, and control_plane.policy.enabled are all active), the router runs synthetic health probes from its own network path:
- Tri-state scheduling and execution:
- Omission retains the current policy, a delivered policy replaces it immediately on the scheduler watch channel (cancelling undispatched targets mid-round without overlapping rounds), and a disabled policy clears it.
- Each round (running immediately on enable and every
interval_secsthereafter) resolves each target against the live model service and backends matching the target's canonicalprovider_for_config_type(), selects a backend via the pool's strategy, and dispatches one non-streamingPROBE_PROMPTcall through the internalBackend::execute_probe_chat_completionseam (src/core/probe.rs,src/infrastructure/common/executor/probe.rs). Probes use normal request transformation and effective auth (including Hub credentials) while bypassing substitution, arbitrage, fallback, caching, guardrails, batching, and retries.
- Privacy, per-router budget ceiling, and delivery:
- Results carry metadata only (status class
ok/auth_failed/rate_limited/timeout/provider_error/network_error/unknown, sanitized reason codes, timings, and integer token counts; never error bodies or prompts) and emit noUsageEventor key-budget charge (and caller-suppliedx-continuum-probeheaders are ignored by accounting). monthly_budget_microusd(0= unlimited) enforces a per-router UTC-month ceiling persisted in an owner-only sidecar next tocontrol_plane.state_file, reserving conservative max cost before each paid dispatch and failing closed when pricing or sidecar state is unavailable (reportingbudget_exhaustedon exhaustion).- Reports push to
POST /api/agent/v1/probesfrom a bounded drop-oldest buffer with stable(report_id, probe_id)retry identity and rate-limited404handling.
- Results carry metadata only (status class
- Implementation:
src/control_plane/probes/,src/core/traits.rs,src/core/probe.rs,src/infrastructure/common/executor/probe.rs, andcrates/continuum-protocol/src/probe.rs.
Vendored protocol crate¶
The wire contract lives in crates/continuum-protocol/ (a feature-gated PATH dependency compiled only under control-plane so default and offline builds never resolve the private continuum-hub repository):
- Provenance and CI drift check:
crates/continuum-protocol/src/andtests/are a byte-identical copy of the Hub revision named on the provenance line incrates/continuum-protocol/Cargo.toml.scripts/check-vendored-protocol.shverifies every file offline againstcrates/continuum-protocol/UPSTREAM.sha256(allowing only tracked entries incrates/continuum-protocol/LOCAL-AHEADand rejecting out-of-tree build inputs or un-bumped manifest edits). Re-vendoring usesscripts/refresh-vendored-protocol-manifest.sh(see the Development Guide). - Router-side boundary: router-specific bounds and helpers (such as guardrail summary emission ceilings) live under
src/control_plane/, never inside the vendored crate. - Control-plane implementation map:
src/control_plane/(agent, HTTP client, credential store, inventory builder, usage buffer, backoff, policy store, sync loops, enforcement middleware, optimization/cache metering, andbatch/), with configuration schema insrc/core/config/control_plane.rsandconfig.yaml.example.
Realtime WebSocket Proxy¶
GET /v1/realtime?model=<id> (and the /realtime SDK alias) proxies bidirectional OpenAI Realtime-compatible WebSocket sessions (session.update, input_audio_buffer.append, response.done, function calls) byte-for-byte without protocol translation. Compiled under the default realtime Cargo feature and active when realtime.enabled: true is set, the route mounts inside the standard API group behind API authentication and rate limiting.
Session lifecycle¶
- Pre-upgrade checks (HTTP rejections):
- Validates the
Originheader when present, rejecting (403) browser origins that are neither same-origin nor listed inserver.cors.allow_origins(withserver.cors.enabled: true); non-browser requests withoutOriginpass through. - Requires the
modelquery parameter (400), resolves aliases and suffixes through the shared metadata pipeline (404when unknown), requires theaudiocapability (400), and rewrites aliases to the canonical catalog ID for upstream dispatch. - Selects a backend via
select_admissible_backend(503when none is admissible) and acquires a permit from therealtime.max_sessionssemaphore (503when full).
- Validates the
- Backend dial: maps the backend scheme (
http->ws,https->wss), joinsrealtime.backend_path, forwards the canonicalmodelquery parameter, and connects using the backend's effective credential (never forwarding the client'sAuthorizationheader) withinhandshake_timeout. Dial failures record a circuit failure and close the client leg with code1011; connected sessions record a circuit success. - Bidirectional relay and deadlines: runs one pump per direction with send-await backpressure, a 4 MiB message/frame cap, close-frame propagation (
1011on abnormal termination), an activity-refreshed idle timer, and an optional absolute session ceiling (closing both legs with1000; disabling both triggers a startup config validation warning). - Privacy, usage metering, and metrics:
- Frame payloads carry raw user speech and are never logged; request logs redact
Sec-WebSocket-Protocolandproxy-authorization. - A passive observer (
src/proxy/realtime_usage.rs) inspects backend text frames up to 256 KiB after client delivery and records eachresponse.doneevent as an attempt-scoped usage record viarecord_realtime_response(src/services/stats/middleware.rs, withstream: true,http_status: 101, and outcome mapped fromresponse.status). - Under the
metricsfeature, exportsrealtime_active_sessions,realtime_sessions_total,realtime_frames_relayed_total,realtime_session_duration_seconds, andrealtime_usage_events_total.
- Frame payloads carry raw user speech and are never logged; request logs redact
- Implementation:
src/proxy/realtime.rs,src/server/routes.rs,src/core/config/models/realtime.rs, andsrc/metrics/realtime.rs(see Realtime configuration).
Dynamo Worker Discovery¶
An optional, watch-only etcd discovery integration (dynamo Cargo feature, compiled into release binaries and opted in via dynamo.enabled: true) for NVIDIA Dynamo prefill and decode worker fleets:
- Spectator etcd watch and gapless resync:
- Watches
v1/instances/<ns>/(Endpoint records) andv1/mdc/<ns>/(model cards, retained opaquely for N-2 forward compatibility) under Dynamo'sDYN_NAMESPACE, joining records by(component, endpoint, instance_id)without ever acquiring leases or writing/deleting etcd keys. - Each pass performs a range-get across both prefixes, records the store revision, and opens a watch at
revision + 1(recovering from broken streams via bounded-backoff full resyncs so compacted revisions never cause drift).
- Watches
- Live reload: a discovery supervisor (
src/dynamo/supervisor.rs) rebuilds the etcd client and resyncs the registry wheneverenabled,discovery.etcd.namespace, or etcd connection/auth settings change (preserving the registry snapshot on backoff-only edits). - ZMQ
kv_metricsload signals: watchesv1/event_channels/namespace/<ns>/for direct-mode ZMQ publisher endpoints, subscribes to four-frame msgpackEventEnvelope/ActiveLoadsamples, deduplicates by sequence, and stores the latest sample per(worker_id, dp_rank). Samples older thandynamo.load.max_staleness(default 3s) degrade routing for that worker to round-robin without evicting the worker. - Implementation:
src/dynamo/(records.rs,apply.rs,registry.rs,watcher.rs,supervisor.rs,config.rs,admin.rs,apply_channels.rs,load/),src/lib.rs,src/server/serve.rs,src/metrics/dynamo.rs, andsrc/core/config/models/dynamo.rs(see Dynamo Worker Discovery).
This architecture supports a production LLM router that scales to thousands of requests while remaining maintainable and extensible. The separation of concerns makes it straightforward to add new features, swap implementations, and test each component in isolation.