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
- 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
none, bearer token, HTTP Basic, and IP-whitelist modes - Protects sensitive operations (config reload, circuit-breaker control, health management)
- Configurable through
admin.auth; the acceptedapi_keymethod is not operational in the standard server because the middleware is constructed without the API-key store
- Supports
-
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 - Service Registry (
mod.rs): Manage service lifecycle and dependencies - Web Search Service (
search/): Router-managedweb_searchtool for self-hosted backends. Pluggable provider trait, Serper implementation, bounded tool-execution loop with iteration cap / wall-clock / byte-budget guards (see Web Search)
Key Files¶
src/services/
├── mod.rs # Service registry and management
├── backend_service.rs # Backend management service
├── 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 native OpenAI/Azure backends
│ ├── session.rs # Session management
│ ├── stream_service.rs # Streaming service orchestration
│ └── streaming.rs # Streaming response handling
├── 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 stub, Brave stub)
└── streaming/ # Streaming utilities
├── mod.rs
├── parser.rs # Stream parsing logic
└── transformer.rs # Stream transformation (OpenAI/Anthropic)
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, 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. 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 (
http_client.rs): HTTP client management and optimization
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/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 - Container (
container.rs): Dependency injection container - 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 # Dependency injection container
├── 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 |
with_http_client(client) |
custom_http_client |
Inject a pre-configured reqwest::Client |
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. Each path resolves and pre-filters its own candidate set (model lookup, internal-backend filter, per-key allow-list, retry-state exclusion), then hands the names to the shared selection seam (proxy::selection::select_pooled_backend), which health-filters them and calls BackendPool::select_from_candidates so the configured selection_strategy (and any registered scorers, e.g. the KvOverlapScorer registered when prefix_routing.enabled and a KV index are configured) governs traffic distribution instead of candidate-list order. 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 Arc ring snapshot before walking it, which prevents a ring built for one candidate set from being interpreted against another and keeps the cache lock out of the ring walk.
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.
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 three 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).
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
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.
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 (Dependency Injection)¶
Location: src/core/container.rs
Purpose: Manage service lifecycles and dependencies
pub struct Container {
services: Arc<RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>>,
singletons: Arc<RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>,
}
impl Container {
// Register singleton service
pub async fn register_singleton<T>(&self, instance: Arc<T>) -> CoreResult<()>
where T: 'static + Send + Sync { /* ... */ }
// Resolve service dependency
pub async fn resolve<T>(&self) -> CoreResult<Arc<T>>
where T: 'static + Send + Sync { /* ... */ }
}
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
Guardrail Layer¶
When guardrails are configured (guardrails.enabled: true), a content-safety layer wraps the backend call on every request path (OpenAI chat completions, the /v1/responses bridge, and native Anthropic Messages). The layer is implemented as GuardrailService in the Services Layer (src/services/guardrail/) and is driven from the proxy handlers through a single gating seam (src/services/guardrail/gate.rs), so the three dispatch paths share the same policy and the same block-response shapes. When guardrails are unconfigured the handlers never enter this seam, so the request flow is unchanged and adds no overhead.
The service lives in AppState behind GuardrailServiceHandle (src/services/guardrail/handle.rs), a lock-free late-settable slot (arc-swap) rather than a startup-frozen Option: request paths load it with a single atomic load (once per request, reused across that request's gates), and a router that started with guardrails disabled can have a service materialized into the slot later, by the guardrail config watcher on a local enabled: true hot-reload or by the control-plane guardrail policy reconciler on an enforceable hub policy. Materialization goes through the same factory path startup uses (guardrail_service_factory in src/server/state.rs), including the backend resolver for backend: provider references. A hub policy that demands enforcement the router cannot execute is rejected whole with the typed code guardrails_unavailable and recorded on the control-plane policy store; request behavior stays on local configuration.
The layer hooks the request lifecycle at three points:
- Input pre-call: before backend dispatch. A
Blockverdict short-circuits the request without calling the backend; aTransformrewrites the prompt before dispatch. - Input during-call: concurrently with the backend call via
tokio::join!, so a remote classifier's latency overlaps the model latency. A block discards the in-flight response and returns the block instead. - Output post-call: after the backend returns, over the generated text, before the response is returned or cached. For streaming responses this becomes a rolling-window check (
buffer_full/chunked/passthrough).
GuardrailService owns all policy: monitor-versus-enforce mode, the bypass allowlist, per-route provider subsetting, per-category thresholds, allow/deny lists, per-provider timeouts, and the fail-open/fail-closed decision. It aggregates the verdicts of the 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
par during-call overlaps dispatch
Gate->>Backend: forward (possibly transformed) request
Gate->>Service: input during-call
end
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.
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¶
Service Registration¶
Services are registered in the container during application startup:
// In main.rs
async fn setup_services(config: Config) -> Result<ServiceRegistry, Error> {
let container = Arc::new(Container::new());
// Register infrastructure services
container.register_singleton(Arc::new(
HttpClient::new(&config.http_client)?
)).await?;
container.register_singleton(Arc::new(
BackendManager::new(&config.backends)?
)).await?;
// Register core services
container.register_singleton(Arc::new(
BackendServiceImpl::new(container.clone())
)).await?;
container.register_singleton(Arc::new(
ModelServiceImpl::new(container.clone())
)).await?;
// Create service registry
let registry = ServiceRegistry::new(container);
registry.initialize().await?;
Ok(registry)
}
Service Dependencies¶
Services declare their dependencies through constructor injection:
pub struct ProxyServiceImpl {
backend_service: Arc<dyn BackendService>,
model_service: Arc<dyn ModelService>,
retry_handler: Arc<dyn RetryHandler>,
http_client: Arc<HttpClient>,
}
impl ProxyServiceImpl {
pub fn new(container: Arc<Container>) -> CoreResult<Self> {
Ok(Self {
backend_service: container.resolve()?,
model_service: container.resolve()?,
retry_handler: container.resolve()?,
http_client: container.resolve()?,
})
}
}
Benefits¶
- Testability: Services can be mocked for unit testing
- Flexibility: Implementations can be swapped without code changes
- Lifecycle Management: Container manages service initialization and cleanup
- Circular Dependency Detection: Container prevents circular dependencies
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 |
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¶
-
Implement Backend Trait:
// In src/infrastructure/backends/custom_backend.rs pub struct CustomBackend { client: Arc<HttpClient>, config: CustomBackendConfig, } #[async_trait] impl BackendTrait for CustomBackend { async fn health_check(&self) -> CoreResult<()> { /* ... */ } async fn list_models(&self) -> CoreResult<Vec<Model>> { /* ... */ } async fn chat_completion(&self, request: ChatRequest) -> CoreResult<Response> { /* ... */ } } -
Register in Backend Factory:
// In src/infrastructure/backends/mod.rs pub fn create_backend(backend_type: &str, config: &BackendConfig) -> CoreResult<Box<dyn BackendTrait>> { match backend_type { "openai" => Ok(Box::new(OpenAIBackend::new(config)?)), "vllm" => Ok(Box::new(VLLMBackend::new(config)?)), "custom" => Ok(Box::new(CustomBackend::new(config)?)), // New backend _ => Err(CoreError::ValidationFailed { message: format!("Unknown backend type: {}", backend_type), field: Some("backend_type".to_string()), }), } }
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 a reference implementation, gated behind the redis-cache Cargo feature.
- Inject into
ResponseCacheStore:
Adding New Load Balancing Strategies¶
// In src/services/load_balancer.rs
pub enum LoadBalancingStrategy {
RoundRobin,
WeightedRoundRobin,
LeastConnections, // New strategy
Random,
}
impl LoadBalancingStrategy {
pub fn select_backend(&self, backends: &[Backend]) -> Option<&Backend> {
match self {
Self::RoundRobin => /* ... */,
Self::WeightedRoundRobin => /* ... */,
Self::LeastConnections => self.select_least_connections(backends),
Self::Random => /* ... */,
}
}
}
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 Dependency Injection?¶
Decision: Use a custom DI container instead of compile-time injection
Rationale¶
- Runtime Flexibility: Can swap implementations based on configuration
- Service Lifecycle: Centralized management of service initialization/cleanup
- Testing: Easy to inject mocks and test doubles
Alternatives Considered¶
- Manual dependency passing: Too verbose and error-prone
- Compile-time DI (generics): Less flexible, harder to configure
Why Arc> 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
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-4o → gpt-4-turbo → gpt-3.5-turbo) - Cross-provider fallback support with parameter translation - 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/*)
- An optional metrics collector for embedding integrations; it is not registered by the standard server
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 |
Pro models only |
4096x4096 |
1:1 |
4K |
Pro models only |
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 |
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 (e.g., "gpt-image-1", "dall-e-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. Supported by DALL-E 2 only.
Request Format (multipart/form-data):
image: <file> # Source image (PNG, required)
model: <string> # Model name (default: "dall-e-2")
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 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 logs an error and backs off; it does not auto-re-enroll in v0 (removing the state file forces a fresh enrollment). - Heartbeat: On the configured interval it sends a heartbeat with a router inventory: router version, configured backends with per-backend health, the models served, an aggregate health summary, token-supply and saturation telemetry, and cumulative guardrail counters. 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 a full maskedrequest_paramssnapshot. The agent polls, fetches, validates, and atomically applies revision-bound public parameter policy 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.
- Reconnect with backoff: Enrollment, heartbeat, and usage-push failures use exponential backoff with full jitter (base 1s, cap 60s), reset on success.
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.
Idempotent batching¶
Each batch is formed once with a stable batch_id and stable per-record idempotency_keys. 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 buffer is bounded (drop-oldest with a warning and a counter), so a prolonged hub outage cannot grow memory without bound.
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 request-parameter configuration¶
This subsystem is a separate opt-in through control_plane.config_sync.enabled; enabling the agent or policy enforcement alone does not activate it. Its remotely manageable surface is deliberately closed to the public, typed request_params policy. It does not accept arbitrary dotted keys, environment substitutions, provider credentials, router credentials, TLS material, prompts, or completions. An operator may pin the entire section with request_params_immutable: true; otherwise every leaf explicitly present in the local request_params configuration remains a local pin and takes precedence over Hub-owned leaves.
Capability and observation. When the section is mutable, the Router advertises structured section schema version 1, path encoding version 1, hot reload and dry-run support, and hard bounds: 1,820 leaves, six path segments, 256 UTF-8 bytes per segment, 1 MiB encoded content, and 64 exact model scopes. Its inventory carries a complete masked snapshot on enrollment and every inventory-bearing heartbeat. The snapshot contains revision/section/leaf digests, exact path segments, source (hub, local_file, or local_pin), public sensitivity, and reload class, but has no value field. Model identifiers containing /, :, ., or Unicode remain one exact path segment rather than being split or normalized. With request_params_immutable: true, the Router instead advertises only a generic immutable section and its aggregate masked digest; it omits the structured delivery capability and per-leaf entries so Hub cannot assign content the Router has declared immutable.
Outbound transaction. The agent polls POST /api/agent/v1/config/sync; a pending assignment contains identities and masked metadata only. It then fetches exact public content from POST /api/agent/v1/config/content, echoing the authenticated router id, apply id, desired revision/digest, and schema version. Before interpreting a field, the Router requires every identity to agree and recomputes the Hub-compatible canonical SHA-256 digest over the sorted, length-prefixed typed paths and scalar values. It accepts only the registered defaults, overrides, and limits.min|max leaves at global or exact-model scope, with the same numeric and model-key validation as local configuration.
Atomic application and last-known-good. The Router merges Hub-owned leaves underneath local pins, validates the resulting full Config, persists the Hub overlay and acknowledgment outbox atomically with mode 0600, and publishes one lock-free effective snapshot. A request that already captured the previous Arc<Config> continues unchanged; the next request sees the new policy. Dry run performs the same fetch, bounds, digest, merge, and full validation but does not persist or publish the candidate. Duplicate delivery, a lost result acknowledgment, reconnect, restart, and rollback-as-a-new-revision are idempotent. An unsupported schema/path, stale optimistic revision, immutable/local leaf, malformed or duplicate path, invalid scalar/range, digest mismatch, or oversize response is rejected without changing the active snapshot.
Hub transport failures, timeouts, and response-decode failures are not configuration decisions and therefore never produce a terminal rejection: the agent leaves the assignment unacknowledged and retries while serving last-known-good. Only a deterministic validation decision is reported to POST /api/agent/v1/config/apply-result as applied, validated, rejected, or superseded; results and logs contain bounded path/status metadata, never parameter values. The state file also binds persisted content to the enrolled router identity so one router cannot restore another router's overlay.
Implementation: src/control_plane/config_sync.rs (typed adapter, canonical digest, layered effective store, persistence, snapshot), src/control_plane/client.rs and src/control_plane/agent.rs (outbound transaction and retry), src/control_plane/inventory.rs (masked inventory), and crates/continuum-protocol/src/config.rs (wire contract). Configuration reference: control_plane.config_sync in config.yaml.example.
Token-supply and saturation telemetry¶
The heartbeat and enrollment inventory carry a RouterInventory.supply block plus six BackendInfo fields. They are integer-only and metadata-only. Every reported number comes from real instrumentation: active_requests, accepted_requests, rejected_requests, retried_requests, and fallback_requests use RAII guards and counters at the actual admission, retry, and fallback-service seams (src/control_plane/supply.rs).
accepted_requests and rejected_requests are not a partition. They are two independent pressure counters over overlapping populations, so accepted + rejected is not the number of requests the router saw. A request the admission middleware already counted as accepted can still be refused deeper in the handler (today: hub-policy refusals and batch admission), which counts it in both; the rate limiter also runs app-wide while the accept seam covers only the API routes, so a 429 on /admin counts as rejected without ever counting as accepted. rejected / (accepted + rejected) is therefore not a refusal rate and the ratio can exceed 1.0, deliberately: leaving either seam silent would let a router refusing nearly all of one traffic class report zero pressure, which is worse than an overlap. fallback_requests counts a request once, at the point a fallback backend actually serves the response rather than at selection, through a per-request latch shared by every fallback seam, including a pre-stream chain advance and a mid-stream hop.
An absent optional field means "unknown", never 0. max_concurrency (router and per-backend) is absent because the router enforces no concurrency ceiling. The observed-capacity estimator reports observed_rpm_capacity, observed_input_tpm_capacity, and observed_output_tpm_capacity only after a transient upstream 429 corroborates a full 60-second throughput window with at least two successful completions; token dimensions additionally require complete token accounting. The estimator keeps a 15-minute expiring high-water mark, so cold-start, idle, lightly loaded, token-incomplete, and stale observations stay absent rather than becoming a false zero or demand-shaped capacity. It never reads or merges the operator-declared capacity held by the hub. circuit_state is absent for a backend the breaker has never seen, or whose breaker is disabled: a disabled breaker's get_state() returns Closed, which is a structurally blind value, not a measurement, so it is never reported as Closed. queue_depth and shed_requests are the one exception, reported as a genuine, structural 0 rather than absent, because the router serves straight through with no request queue and no load shedding anywhere.
counters_since_ms is stamped once, at process start (process_start_info()), not refreshed on every heartbeat, so a router that has been up for days reports the same value on every beat; a change in it signals a counter reset (a restart), not steady accumulation. circuit_state is read through a non-materializing peek_state, so a heartbeat never creates the per-backend circuit-breaker entry it is only reading, and never changes what /admin lists. A streaming response moves its in-flight guard into the response body itself, so a backend stays counted until its last SSE frame or the client's disconnect, not merely until the handler returns response headers.
Implementation: src/control_plane/supply.rs (router-level supply tracker), src/control_plane/capacity.rs (per-backend observed-capacity estimator), and src/control_plane/inventory.rs (assembly into the wire types). Wire types: crates/continuum-protocol/src/supply.rs and crates/continuum-protocol/src/heartbeat.rs.
Guardrail telemetry¶
The inventory also carries a RouterInventory.guardrails block: cumulative, metadata-only counters describing what the content-safety guardrails actually did. It mirrors the local Prometheus families (guardrail_checks_total, guardrail_blocks_total, guardrail_verdicts_total, guardrail_stream_buffer_cap_trips_total) so the Hub can see fleet-wide guardrail activity without scraping every router.
The block's field set is closed and typed (continuum_protocol::GuardrailSummary), and it is the Hub's field set: this router adopted the Hub's shape in #1150 after the two were written independently and diverged.
| 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. |
blocks_total and the verdict scalars do not share a denominator. blocks_total counts per provider check, alongside checks_total, so one request two providers both refuse is two blocks. transforms_total and flags_total decompose the mode-applied aggregate, one per stage evaluation, so the same request is one verdict.
The two fail-policy counters report what applied, not what was configured. Monitor mode never gates, so a failed check under monitor is traffic that was served without being inspected and lands in fail_open_total whatever its on_error policy says. fail_closed_total therefore requires both a fail-closed policy and an enforcing mode, which is the only combination that can actually refuse anything. The local Prometheus families keep labelling by the configured policy, because they answer a different question: how a provider is configured, rather than what happened to a request.
errors_total covers both failure surfaces. The service's timeout branch and the hard errors providers report through the Guardrail trait's Result (transport failure, non-success status, unparseable body) record at the same seam, so a provider that fails fast is counted exactly like one that hangs (issue #1175). fail_open_total and fail_closed_total partition errors_total, and a provider answering every request with a connection reset or an HTTP 5xx under the default on_error: fail_open shows up as fail_open_total climbing at the request rate rather than as a heartbeat indistinguishable from a healthy guardrail. A fail-closed hard error stays out of blocks_total / by_category: it is an infrastructure refusal, counted by fail_closed_total, so a moderation outage does not read as a flood of uncategorized unsafe content. The Prometheus surface emits kind="error" for the same events.
Three scalars are derived, not tracked twice. transforms_total, flags_total, and stream_buffer_cap_trips_total are computed from the verdicts and stream_buffer_cap_trips breakdowns in GuardrailSnapshot::into_summary, the single place the derivation happens, so the scalar the Hub reads today and the breakdown a later Hub will read cannot drift apart. A test asserts each total against its sum.
verdicts and stream_buffer_cap_trips ride along additively. The Hub declares neither and does not use deny_unknown_fields, so today's Hub ignores them rather than failing on them, and they become consumable once the Hub adds them.
The counters do not come from the Prometheus registry. The metrics component is optional, so a router built or configured without it has no registry to read, and reporting zeros from such a deployment would tell the Hub that a guardrail which is actively blocking traffic is idle. A separate in-process tracker (src/control_plane/guardrail.rs) is written at the same seams that emit the metrics, each behind its own feature gate, so inventory stays honest with Prometheus off.
Absent means "guardrails are off", zeros mean "on and quiet". The block is reported whenever a guardrail service exists, even before the first check runs, and omitted entirely when the router runs no guardrails. Collapsing the two into one absent case would make a misconfigured router indistinguishable from a quiet one, which is the reason to report this at all.
The label space is closed, and that is a correctness property. Guardrail categories reach the router from third-party moderation providers through GuardrailCategory::Other(String), so a map keyed by that string would let one misbehaving provider expand the Hub's stored key space without bound and carry provider-authored text with it. Each breakdown is instead a fixed-size array of atomics indexed by a closed label tuple: blocks by bounded category id, verdicts by (stage, mode, result), and streaming buffer-cap trips by (strategy, outcome). Any category outside the known vocabulary folds into a single other bucket before it is stored, and an unrecognized stage, mode, or result folds onto an unknown slot rather than being dropped, so closing the space costs no blind spot. The reserved match-list pseudo-providers (match_list_deny / match_list_allow) need no special case: their deny verdicts carry the ordinary deny_list category.
by_category is a JSON object because that is the Hub's shape, but its keys are still not free text: the only way a key enters it is GuardrailCategoryId::as_str(), a closed set of twelve compile-time literals, each inside the Hub's ingestion bounds on count (32), byte length (64), and charset ([a-z][a-z0-9_]*). GuardrailSummary::is_bounded() checks all three, and the router's own emission ceiling is asserted against the Hub's ingestion ceiling at compile time.
Privacy. Counts and bounded ids only: no prompt text, no completion text, no matched span, no matched rule, and no redacted-PII value. This holds on the error path too: errors_total and the two fail-policy counters carry no provider name and no failure text. An integration test asserts this against a serialized inventory rather than against the types.
counters_since_ms follows the SupplySummary contract and is stamped from the same process_start_info() value, so both counter families reset together and the Hub reads one restart rather than two.
The Hub's own wire fixture is pinned here. crates/continuum-protocol/tests/fixtures/guardrail_summary.json is a byte-for-byte copy of the Hub's committed fixture, pinned by crates/continuum-protocol/tests/guardrail_summary_hub_fixture.rs in both directions and re-checked against a live tracker read in tests/control_plane_guardrail_telemetry_test.rs. Both repositories previously round-tripped only their own types, which is exactly why the shapes could diverge while both stayed green. Never regenerate that fixture from local types: when the Hub moves it, re-copy it and update the provenance in the same change.
Implementation: src/control_plane/guardrail.rs (counter tracker), src/control_plane/inventory.rs (assembly and the report/omit decision), and the recording seams in src/services/guardrail/service.rs and src/services/guardrail/gate.rs. Wire types: crates/continuum-protocol/src/guardrail_telemetry.rs and crates/continuum-protocol/src/heartbeat.rs.
Policy sync and local enforcement¶
When control_plane.policy.enabled is set (and the control-plane feature is compiled and the agent is enabled), the agent keeps a hub policy snapshot current and the router enforces it locally, after API-key authentication, on every API request.
Delivery paths. Two paths feed the same in-memory policy store, both carrying full PolicyEnvelopes (the org's API key table as hashes, tier limits, per-key month-to-date budget snapshots, and agent settings). Full envelopes make application idempotent: any cursor gap is healed by the next envelope.
- Poll (
POST /api/agent/v1/policy/sync): runs onpolicy.poll_interval_secs(default 5s) for as long as enforcement is enabled. This is the correctness core: on its own it bounds revocation and budget propagation by the poll interval. - Stream (
GET /api/agent/v1/policy/stream?cursor=<opaque>): a WebSocket held open for push-style delivery, so a revocation propagates in under five seconds while connected. Every frame is one complete envelope as JSON text. On disconnect the client reconnects with backoff, presenting the last applied cursor; the poll loop covers the gap in the meantime. Disable withpolicy.stream_enabled: false.
Bounded envelope decode. Both delivery paths refuse an envelope over a derived byte ceiling (~152.6 MiB, MAX_POLICY_SYNC_RESPONSE_BYTES in src/control_plane/client.rs) instead of buffering it without bound: the poll path pre-checks the declared Content-Length and then a running chunk total, and the WebSocket stream sets its message and frame ceilings to the same constant, so the two paths agree by decision rather than by tungstenite's smaller 64 MiB library default (the frame ceiling moves with the message ceiling because the Hub delivers each envelope as one unfragmented text frame). The ceiling is a documented term-by-term sum over the Hub's own distribution budgets (a 100 MiB key-table allowance for the one member with no Hub-side per-org ceiling, sized in the code comment against cumulative rows including revoked and expired keys, which the Hub keeps forever by default; 1,024 tiers with the 4 MiB aggregate request-params budget and character-capped unicode allowlist names; the 256 KiB guardrail canonical budget; 5,000 cost-center assignments plus 1,024 capped centers; and explicit allowances for the members the Hub does not bound at all), sized so a legitimate tenant at every ceiling at once still decodes. A refusal is loud, never silent: it is a distinct typed error (HubError::ResponseTooLarge, never confused with a parse failure), logs at error! with the limit and the observed size (never body content), and increments control_plane_policy_sync_failure_total{reason="envelope_too_large"}, separate from reason="other" for ordinary sync failures, because a persistent over-limit refusal leaves a restarted router permanently fail-open and must be distinguishable from a hub that is temporarily down. Both reason series are instantiated at zero at registration, so a fleet alert sees the series on routers that have refused nothing yet, and the refusal count with its last-occurrence time also appears as policy_sync.oversize_refusals / last_oversize_refusal_ms on GET /admin/control-plane/status, where a nonzero count beside a null last_sync_ms is the cold-start fail-open signature. The store is untouched by a refusal: the last-known-good snapshot keeps enforcing, and no key or limit ever widens. The smaller Hub response decodes (enroll, usage acks, probe acks) carry their own derived bounds with the same typed refusal; an unreadable oversize usage ack is terminal for its already-processed batch rather than retried forever, so the push loop keeps making progress.
What is enforced. For a presented API key whose SHA-256 matches a synced KeyEntry (constant-time compare; plaintext keys never travel over the agent protocol):
- Revocation: a
revokedentry is rejected immediately (revocation, expiry, and org suspension all arrive as this flag). - Rate limits: per-tier
rpmas a fixed 60-second window (the request that would exceed it gets429);input_tpmandoutput_tpmas post-response windows fed by real token counts at the usage taps, so the request following the one that filled the window is rejected. Cache-read input tokens do not count towardinput_tpm. - Monthly budgets:
tokens_per_month/requests_per_monthare enforced against the hub's per-keyKeyUsageSnapshot(month-to-date as ingested by the hub) plus a locally observed delta since that snapshot. The local delta resets when a fresher snapshot arrives or the UTC month rolls over. Overspend is therefore bounded by the sync interval by design (hub ARCHITECTURE.md section 4). - Model allowlist: a request for a model outside the tier's
model_allowlistis rejected with403; an absent allowlist means all served models. - Request-parameter limits: a tier may bound
temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k, andmin_p, with optional exact alias-resolved model scopes. Decimal bounds cross the wire as integer microunits. Every ingress captures one client-requested identity after local alias resolution and applies the samemodels/prefix normalization and exact backend alias mapping used by routing. The Router intersects Hub limits with deployment-localrequest_paramslimits, rejects an empty intersection, and freezes the selected policy before cache lookup, substitution, arbitrage, backend selection, retry, or fallback. The same effective request is used by Chat Completions, Completions, Responses, and Anthropic Messages. - Headers: enforced responses carry
x-continuum-ratelimit-limit/-remaining/-resetfor the most constrained dimension plus a suffixed triple per configured dimension (-rpm,-input-tpm,-output-tpm,-monthly-tokens,-monthly-requests);429s also carryretry-after.
Atomic tier-policy state. A full snapshot is validated and converted before the live ArcSwap changes. Invalid ranges, duplicate tier ids, stale snapshots, and tier-digest mismatches preserve the complete last-known-good snapshot. Poll requests capture the active acceptance generation before network I/O, so a delayed same-millisecond response cannot overwrite a stream revision accepted while that request was in flight; later deliveries from that accepted generation remain valid even when the Hub clock timestamp is unchanged. Heartbeats read capability/status evidence and the applied cursor from one atomic publication, preventing cross-generation acknowledgment pairs. RouterInventory.policy_status advertises request_param_limits_v1, acknowledges a successful Hub-provided cursor/digest pair, and can report one stable rejection code without a free-form value. A successful later snapshot, including an authoritative request_params clear, replaces the old limits and clears the rejection. If a snapshot omits the optional digest, its limits are enforced but the Router does not invent active digest evidence.
Guardrail-policy acknowledgment. RouterInventory.policy_status carries one capability list built in a single place (PolicyStore::capabilities), so no status-writing path can advertise a set the others do not. guardrail_policy_v1 joins request_param_limits_v1 in that list only once a live guardrail-policy reconciler has attached to the policy store, because the capability is a promise about execution rather than a build flag: a router that would receive, validate, and store a hub guardrail policy without ever running it advertises nothing, and the Hub derives unsupported instead of waiting for an acknowledgment that will never arrive.
Receipt and enforcement are acknowledged separately. Accepting an envelope records the guardrail digest the wire layer applied, which is receipt only; whether that policy could be composed into a running guardrail service is decided afterwards by the reconciler. When the reconciler refuses the exact digest the active revision would claim, the claim is withdrawn and the same digest is reported as rejected with guardrails_unavailable instead, so a Fleet view never shows governance in effect while nothing enforces it. The guardrail and tier tracks stay independent in both directions: a refused guardrail body neither discards the tier policy from the same envelope nor blocks its acknowledgment, and a tier rejection says nothing about a guardrail body. An authoritative clear acknowledges the canonical cleared-policy digest rather than reporting nothing, so a cleared tenant converges instead of sitting at pending or stale. Because the store keeps an unexecutable policy applied at the wire level, a later local edit that adds the missing wiring lets the same body compose cleanly and restores the active claim without a new envelope.
Stable backend identity. stable_backend_identity_v1 also sits in that list, and unconditionally, because unlike guardrail policy it has no separate executor that may or may not attach: config validation, heartbeat inventory, and the usage taps are compiled and wired as one unit, so there is no reachable state in which an accepted backends[].backend_id is dropped by one of the three. The capability describes the implementation, not the data. A router whose operator has configured no ids still advertises it and simply reports none, which is exactly the "can report, nothing to report" state the Hub must be able to tell apart from "cannot report at all".
The identity itself is operator-authored and never derived. BackendInfo.name is a mutable display label and a (provider, model) pair is not a backend, so deriving an identity from either would merge two distinct cost owners that share a pair, or split one cost owner across a rename. Each usage tap resolves the id at its serving seam, against the config view the selection actually used, and carries it on the UsageEvent rather than letting the push loop look it up by name later, because a hot reload in that window would rename the backend and silently drop the attribution of usage already served. A fallback hop overwrites it just as it overwrites the TTFT origin, a provider batch carries the id captured at submit, and a local cache hit dispatched no backend and so reports nothing. Inventory resolves through the same function, so the ids an operator discovers there are the ids their usage is charged to.
The capability strings a router can advertise, request_param_limits_v1, guardrail_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 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, guardrails_unavailable, 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 (V2). The policy envelope can carry cost_centers_v2 (Hub issue #736, router issue #1162): one per-router stable allocation statement (the Hub-pre-resolved key_id -> cost_center_id business ownership, the router's own fallback cost center, provider/model fallbacks, router-scoped stable-backend fallbacks, and per-center monthly caps) plus the persisted dynamic budget snapshot it references (consumed-to-date request/token counters, Hub-authored per-dimension verdicts, and the per-router included_router_usage_seq usage watermark). The router advertises cost_center_limits_v2 only while the complete contract is live: the owner-only sidecar next to control_plane.state_file (<state_file>.cost-center.json, 0600, atomic temp-write plus rename) loaded and bound to the enrolled identity, which is what makes the persisted monotonic usage sequence, the last-known-good statement, and the unreflected local deltas durable. The legacy cost_centers V1 member stays parseable for mixed-version compatibility but is never enforced and never acknowledged, and the cost_center_limits_v1 capability is never advertised.
A statement is one atomic apply unit: the router validates bounds, duplicates, references, the NULL-when-uncapped verdict rule, the router-scoped digest, the deterministically minted cursor, snapshot staleness, and the watermark (a value naming a sequence this router never assigned, or regressing below an applied watermark, is impossible), and on any defect refuses the whole unit with a typed code (invalid_cost_center_policy, digest_mismatch, stale_budget_snapshot, invalid_usage_watermark) while retaining the last-known-good unit. Applied and rejected acknowledgments carry the exact stable policy_cursor/policy_digest and dynamic budget_cursor on AppliedPolicyRevision.cost_center_v2 / RejectedPolicyRevision.cost_center_v2, and the last-known-good acknowledgment carries forward across envelopes that make no cost-center statement, so a Hub outage freezes the unit without ever interrupting serving.
Allocation follows the Hub's precedence with a seam per tier chosen so accounting and the serving backend cannot diverge, and the admission gates engage only on request paths that can produce a usage record the Hub will meter (a model listing or a token-count probe is neither gated nor charged). The key assignment and the router fallback are identities fixed before dispatch, so the enforcement middleware refuses exhausted allocations there and holds an atomic reservation (one request plus a deterministic token allowance: a bounded byte-derived prompt estimate plus the request's stated output maximum clamped to the tier's request_params ceiling and a hard bound, or a fixed default) that rides the response body exactly like the admission permit, releasing on the last frame or the client disconnect; reservations count toward every OTHER admission, which closes the concurrent at-limit race the V1 check-then-charge model had, while a single request is refused only at the cap, never on nothing but its own estimate. Accounting for every tier happens at the usage seam: the single usage-loop consumer stamps the persisted monotonic router_usage_seq on each record as it enters the push buffer (so buffer order is sequence order and batches are pushed in sequence order) and settles the record's exact metered usage (input_tokens + output_tokens, one request) into a sequenced unreflected ledger under the cost center computed from the final record's own key_id/backend_id/provider/model, the identical inputs the Hub allocates from at ingest. Accepting a newer snapshot discards exactly the ledger entries at or below the Hub-included watermark: unreflected usage is never reset and included usage is never double-counted. A record permanently dropped from the push buffer, from an unsplittable oversize batch, or by a Hub per-record rejection retires its ledger entry, since the Hub will never include it. A populated statement arriving without a budget block is the documented budget-unavailable state (Hub #750: the snapshot's totals were evaluated under caps that no longer match), so the held counters and Hub-authored verdicts are dropped rather than applied as live enforcement under the new caps, while the local ledger survives. The lower tiers (stable backend, then provider/model, in the Hub's own precedence) are refused at admission when every candidate serving backend for the effective post-rewrite target allocates to a cost center that is itself over cap, which is the condition under which the pre-dispatch decision cannot diverge from the identity that serves and is accounted: every identity the request could serve under is exhausted. The candidates need not resolve to the same cost center; requiring that was sufficient but not necessary, and it let a client keep serving past a cap by naming a model whose backends spread across two exhausted centers. A single candidate with headroom, an unallocated candidate, and cross-model fallback chains stay accounting-enforced and are part of the documented overshoot.
Sell spend is Hub-authored only: the router never computes, decrements, or infers sell spend from the price catalog, and the per-center sell_spend_status verdict (exhausted refuses; unknown, from a newer Hub, never refuses) is the only sell-spend input to enforcement. Overshoot is bounded, not eliminated, and the bound has four documented terms: the fleet term (roughly the snapshot cadence times the number of routers serving the cost center), the estimate term (the reserved allowance is a deterministic estimate that under-reserves large text prompts and over-reserves base64-heavy bodies; it bounds concurrency, never accounting), the at-cap term (each concurrently admitted request can carry the cap past by one allowance, since must-fit semantics would hard-deny legitimate requests on estimate error alone), and the in-process handoff between a response finishing and its record entering the push buffer. There is no zero-overshoot fleet-wide cutoff, by design. Implementation: src/control_plane/cost_center/ (tracker, sequenced ledger, reservations, sidecar persistence), wired through PolicyStore (apply, capabilities, acknowledgment), the enforcement middleware (admission reservation), and the usage loop (sequencing, retirement).
Surface parity. Token accounting and key_id attribution are not limited to /v1/chat/completions: native Anthropic /anthropic/v1/messages (both streaming and non-streaming, across the native-Anthropic, OpenAI-to-Anthropic bridge, Responses-to-Anthropic bridge, and Bedrock-runtime dispatch sub-paths) and streaming /v1/responses (the Anthropic/Gemini/Chat-Completions conversion strategies via a shared stream-end tap, plus the native OpenAI/Azure pass-through strategy tapping its own terminal SSE event) all route their terminal token counts through the same charging and identification path as the OpenAI path, cache-read tokens excluded consistently everywhere. A hub key therefore has its tpm windows and monthly budget debited, and its usage attributed with the hub key_id, the same way regardless of which surface served the request.
Fail-open invariant. Enforcement activates only when the feature is compiled, the agent and the policy block are enabled, and a policy envelope has been synced. Until the first successful sync, and for keys that match no synced entry, only local API-key authentication and local rate limits apply. After a successful sync, a Hub outage or rejected update keeps serving the last-known-good snapshot; it never partially applies a candidate. In api_keys.mode: blocking, a hub-synced non-revoked key authenticates even when it is not in the local key store; all other blocking-mode rules still apply. An envelope refused for exceeding the policy decode bound follows the same rule: the snapshot in force stays in force, a cold-start router stays fail-open, and the refusal is surfaced through control_plane_policy_sync_failure_total{reason="envelope_too_large"}, the policy_sync.oversize_refusals field on GET /admin/control-plane/status, and an error! log rather than a silent backoff, precisely because in the cold-start case those signals are the only protection.
Hub-delivered provider credentials¶
The policy envelope also carries an optional provider_credentials field: an org-scoped, hub M3 vault set of {provider, credential_id, version, secret} entries. It follows the same tri-state wire contract as substitution_rules and equivalence_classes: absent means the hub made no statement and the router keeps its last-received set, an empty array revokes every hub-delivered credential, and a non-empty array replaces the full set. A version bump on a later envelope, delivered on either the poll or the policy stream, swaps the whole PolicyStore snapshot atomically, so a request that already resolved its secret keeps that value while the next request picks up the rotation, with no restart and no dropped in-flight request.
Selection runs on the backend's backend_type, not its base_url. A single value-only resolver (proxy::oauth_helper::effective_backend_secret) maps a backend's backend_type to a provider name (openai, anthropic, gemini, bedrock, and so on) and looks up that provider in the effective credential set. When the hub has delivered a credential for that provider, it substitutes for the outbound auth header value in place of the backend's configured api_key; otherwise the configured key is used unchanged. Injection requires only that the backend carry a local api_key, so has_config_auth and client-header suppression are unchanged and only the secret value is substituted.
Operator note: backend_type is the authoritative provider identity for this injection. A hub-delivered openai credential lands on every backend configured with a local api_key and backend_type: openai, including a third-party or proxy endpoint an operator has labeled openai to reuse the OpenAI-shape request and response transforms, regardless of that backend's base_url. An operator pointing a canonical backend_type at a non-canonical endpoint should confirm that receiving the hub's provider credential for that provider is the intended behavior, since backend_type is the router's only signal for which provider a backend speaks for.
Covered send sites: OpenAI-shape chat (TCP and Unix socket, streaming and non-streaming), native Anthropic Messages and count_tokens (TCP and Unix socket), native Gemini embeddings and image generation/edit (x-goog-api-key), the Responses API (every create strategy, the passthrough arm, and streaming), and batch dispatch. Model discovery and health checks keep using the locally configured key, since those are router-operational rather than the org's request traffic. Provider secrets stay in memory only: never persisted, never logged, redacted from every Debug/Display rendering, and never placed in a client-facing error.
Optimization policy and prompt cache execution¶
The hub defines the org's optimization policy and distributes it inside the same policy envelope; execution is data-plane work on the router, since the hub never sits in the request path. The optional optimization block carries exact_cache, prefix_cache, semantic_cache (all booleans), semantic_similarity_bps (basis points), and cache_ttl_secs (seconds); integers only, no floats on the wire. If the block is absent, the router enables exact and prefix caching and disables semantic caching. Per-tier flags complete the picture: cache_enabled (default on) and batch_eligible (default off).
Effective decision. For each admitted hub-keyed request the enforcement middleware computes an effective decision from the tier flags, the org optimization policy, and the per-request override headers, then attaches it to the request as an extension (the AuthContext pattern) because enforcement runs before the cache-owning handlers. The override headers are x-continuum-cache and x-continuum-batch with values on / off, honored strictly within tier bounds: off always wins, and on is honored only when the tier flag (and, for caching, the org policy) already allows it, so a client can reduce but never expand what its tier permits. Unrecognized values fall back to the tier default.
Cache execution. The decision gates the router's existing exact-match response cache (memory, redis, or tiered; the same ResponseCacheStore used without a hub) on the OpenAI chat and streaming-chat paths plus non-streaming Responses and Anthropic Messages: a disallowed request behaves exactly like a temperature > 0 request today, reported as a bypass. Cache lookup and storage require one allowed backend and no configured fallback chain; the opaque namespace binds the backend and credential grants, so an entry from one provider or authorization context cannot satisfy another route. When cache_ttl_secs is set, stored entries use that TTL instead of the store default. Cached prompt and completion content stays in the customer's infrastructure and never travels to the hub.
Prefix-cache execution. prefix_cache independently gates a second response-cache namespace under pfx:, disjoint from exact-match entries and consulted only after an exact-cache miss, so an org with exact_cache off and prefix_cache on can still use that namespace. A prefix key retains the complete prompt and every effective response-shaping field, including max_tokens and top_p; no request parameter is erased to broaden equivalence, so distinct sampling controls never become cache-equivalent. Two guards keep replay correct: only a naturally-completed response is stored (OpenAI finish_reason: stop, Anthropic stop_reason: end_turn, or completed Responses output, never a length-truncated one), and a stored entry is served only when its completion length fits the new request's token limit (an unbounded request always fits; an unknown stored length fails closed). A prefix hit stamps CacheHitType::Prefix through the same record_local_cache_hit tap used for an exact hit, mutually exclusive with it, so a request is metered exactly once. This path is wired for non-streaming OpenAI Chat Completions, Responses, and Anthropic Messages; streaming responses do not use the prefix-cache namespace.
Response headers. Enforced responses carry the cache outcome as x-continuum-cache: hit|miss|bypass, mirrored from x-cache, and the batch-eligibility decision as x-continuum-batch: on|off, alongside the x-continuum-ratelimit-* triple. The batch value is the eligibility decision only; dispatching requests through provider batch endpoints is a separate feature that consumes this decision.
Cache-hit metering. A response served from the local cache short-circuits before the regular usage taps, so the router emits a dedicated metadata-only usage event at the hit site: cache_hit = true, every input token counted as a cache read (billable input zero), zero output tokens, attributed to the hub key_id. A local hit therefore charges neither the input_tpm / output_tpm windows nor monthly budgets, locally or in the hub's aggregation, preserving the "cache reads do not count" invariant. When the synced price catalog knows the model, the router also logs an advisory list-price savings line; savings of record are computed hub-side at ingest. An empty catalog disables the local display.
Scope notes. The semantic cache path is a deliberate no-op, double-gated by the hub's semantic_cache flag and the operator's control_plane.optimization.semantic_cache_enabled (default off). It has no embeddings client or tenant-isolated vector-similarity index. CacheHitType::Semantic is reserved but has no serving path; only exact and prefix matching operate.
Batch dispatch and lifecycle reporting¶
Batch traffic executes on the router and completes asynchronously at the provider, so the router reports lifecycle state to the hub. The subsystem is gated behind control_plane.batch.enabled (on top of policy.enabled, since eligibility and the batch pool come from the hub-synced tier).
Dispatch. The router exposes the full OpenAI-style Batch API surface: POST /v1/batches (create), GET /v1/batches/{id} (retrieve), GET /v1/batches (list), POST /v1/batches/{id}/cancel (cancel), and GET /v1/batches/{id}/results (raw JSONL results). A submission is admitted only when the request's effective decision is batch_eligible (the tier's batch_eligible flag narrowed by x-continuum-batch: off, where off always wins). The batch client operates outside the synchronous Backend trait; it reuses the router's URL composition and the shared HTTP client, resolving the target backend from control_plane.batch.backend (or the first OpenAI-compatible backend, falling back to the first native Anthropic backend) with its config-resolved base URL and API key. Retrieve, cancel, list, and results are all scoped to ownership: the router resolves the presented key and serves, cancels, or filters to only the batches the tracker attributes to that key, so an unknown id, another key's id, and an already-terminal id all return an identical 404 without contacting the provider, since every router key multiplexes onto one provider account and the provider's own scoping does not isolate router keys. GET /v1/batches filters the provider account's full list down to the caller's owned ids; GET /v1/batches/{id}/results retrieves the batch first to locate its results, answering 409 before they exist and 502 on a provider fetch failure.
Anthropic Message Batches parity. A surface router selects the provider client from the resolved backend: a native Anthropic backend routes every batch operation to the Anthropic Message Batches API (/v1/messages/batches, .../cancel, .../results) with x-api-key plus anthropic-version auth, while every other OpenAI-compatible backend keeps using the OpenAI Batch API; callers and the hub-facing lifecycle reporting see no difference between the two. Anthropic's coarser processing_status (in_progress, canceling, ended) plus cancel_initiated_at map to the same canonical BatchJobState the OpenAI status strings map to, so an Anthropic job registers in the same tracker and rides the same poller and reporting loop unchanged.
Separate batch pool. Batch submissions are charged against a pool that is entirely separate from the synchronous rate limits: the tier's batch_rpm (a per-minute window) and batch_queue_depth (an in-flight gauge, incremented on submit and decremented on the first terminal state). Batch traffic never charges a key's rpm / input_tpm / output_tpm, and synchronous traffic never charges the batch pool. Both are optional (None means no limit at that level), and a separate, tier-independent cap of 100k concurrently tracked jobs bounds tracker memory even then; a submission at that cap is refused with 503.
Lifecycle reporting. Submitted jobs are tracked in memory; a bounded poller polls the provider for each non-terminal job (and client status retrievals also detect changes), and every state transition (submitted -> in_progress -> completed / failed / cancelled / expired) is pushed to the hub's POST /api/agent/v1/batches. Delivery is at-least-once with a bounded, drop-oldest buffer and a stable per-transition idempotency key, so a redelivery after a lost ack is deduplicated by the hub's idempotent, monotonic upsert. Updates are metadata only (ids, a coarse state, timestamps; never provider error text). A 404 disables further lifecycle delivery after one log entry; request serving continues. A job the poller can no longer confirm (the provider is permanently unreachable, its batch backend was removed or renamed by hot-reload, or the provider stalls in a non-terminal status) does not hold its in-flight slot forever: an age-based backstop force-transitions any job still tracked past 26 hours to expired, releasing the slot and reporting the terminal state; a reclaimed job emits no completion usage record.
Completion usage. On the terminal completed transition the router releases the in-flight slot and emits a usage record through the existing usage path with batched = true, the provider_batch_id, and the provider-reported completion time; token totals are aggregated best-effort from the batch output file, and that read is capped at 64 MiB so aggregation on an unusually large batch tolerates a dropped tail rather than growing router memory without bound. The UsageRecord.provider_batch_id field is optional and additive; cache-aware metering uses separate fields.
Scope. GET /v1/batches returns the provider's full list filtered to the caller's own ids and does not paginate. Bedrock's batch API is not supported by this surface router; only native Anthropic backends route to the Message Batches API.
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, and prefix for a prompt-prefix hit; both are live serving paths, mutually exclusive per request, so a hit is stamped and metered exactly once. semantic is reserved and has no serving path. 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 a probe policy (PolicyEnvelope.probes), the router measures provider reachability and credential/model health from its own network path before customer traffic discovers a failure. Probes are active only when every gate holds: the control-plane Cargo feature is compiled, control_plane.enabled is true, control_plane.policy.enabled is true, and the held policy is enabled with at least one (provider, model) target. There is no separate router configuration switch; with any gate off there is no probe task behavior, provider cost, or probe wire traffic.
Tri-state policy. The probes field follows the provider-credential tri-state: an absent field keeps the last held policy, a delivered policy replaces it, and a disabled policy is the authoritative clear. Changes are published to the scheduler on a watch channel, so a clear or replacement interrupts the idle cadence promptly, cancels undispatched targets mid-round, and can never overlap rounds (the scheduler awaits each round before the next may start). A newly enabled policy runs its first round immediately, then every interval_secs, with at most one attempt per target per round.
Execution. Each listed target resolves against the live model service and current configuration, intersected with backends whose canonical provider string (the same provider_for_config_type() mapping used by usage records and hub credentials) exactly matches the target's provider; the live backend pool's configured selection strategy picks from that constrained set. The router then dispatches exactly one non-streaming chat completion carrying only the fixed PROBE_PROMPT, a small fixed output-token cap, and deterministic settings, through an internal backend-execution seam below the HTTP/streaming layers. The seam reuses each backend's normal request transformation and effective authentication (a hub-delivered provider credential over the local key, OAuth, SigV4) while returning only a structured status/transport outcome. No model substitution, price arbitrage, fallback chain, cache lookup or population, guardrail, batching, or automatic retry can touch a probe.
Privacy boundary. A probe result carries metadata only: ids, the coarse status class (ok, auth_failed, rate_limited, timeout, provider_error, network_error, unknown), a reason code from the bounded sanitized vocabulary (http_NNN, timeout, dns_failure, tls_failure, connection_refused, connection_reset, network_error), timings, and integer token counts from the normalized usage object. Provider error bodies are discarded unread; prompts, completions, credentialed URLs, and stack traces never enter a result, a log line, a cache, or durable state. Probe traffic emits no usage event, charges no per-key policy counters, and never enters a usage batch: probe cost is scored only into the hub's own probe_results store, never into billing or SLO pipelines. The reserved x-continuum-probe request header is not consulted by any accounting path, so a caller-supplied copy cannot suppress normal request metering.
Budget scope (per router). monthly_budget_microusd is a hard ceiling for the current UTC month; 0 means unlimited. Before every paid dispatch the router reserves a conservative maximum cost (the held price-catalog entry times fixed input/output token bounds, rounded up) into an owner-only, atomically replaced sidecar next to control_plane.state_file, so the ceiling survives restarts and crash windows. A positive ceiling fails closed (no probe) when the price entry or the persisted state is unavailable; a policy decrease below already-reserved spend stops new probes immediately, and a later increase resumes them. Exhaustion sets budget_exhausted on the next report (an empty report is sent if needed so the hub can observe it), and the ledger resets only when the UTC month changes. The ceiling is enforced per router: the v0 wire contract has no cross-router lease, so several routers sharing one org policy each enforce the ceiling independently rather than as a fleet-wide aggregate.
Delivery. Results queue in a bounded drop-oldest buffer and push to POST /api/agent/v1/probes with the per-router credential. A formed report keeps its report_id, probe_ids, and result set stable across retries; transport and 5xx failures retry with the standard control-plane backoff, and a report is dropped only after a matching acknowledgment (the hub deduplicates on (tenant, router, probe_id)). A 404 is non-fatal: the router logs it rate-limited and retries on a long fixed delay instead of a tight loop. Request serving is never disrupted. Disabling probes or shutting the router down cancels the scheduler cleanly (an in-flight dispatch is abandoned on shutdown) without delaying router shutdown.
Implementation: src/control_plane/probes/ (scheduler, budget ledger, classification, result buffer), the probe seam in src/core/traits.rs / src/core/probe.rs / src/infrastructure/common/executor/probe.rs with per-backend overrides, and crates/continuum-protocol/src/probe.rs (wire contract).
Vendored protocol crate¶
The wire contract lives in a vendored crate at crates/continuum-protocol/ (a typed PATH dependency with validation and public-policy digest helpers, compiled only under the control-plane feature). It is vendored rather than a git dependency because continuum-hub is a private repository; an optional git dependency would be resolved on every build (even feature-off) and break credential-less, offline, and default builds. See crates/continuum-protocol/Cargo.toml for the pinned upstream revision.
Implementation: src/control_plane/ (agent, HTTP client, credential store, inventory builder, usage buffer, backoff, policy store, policy sync loops, enforcement middleware, optimization decision and cache-hit metering, and the batch/ subsystem: the OpenAI and Anthropic provider batch clients, the surface router that picks between them, the create/retrieve dispatch handlers, the list/cancel/results operations, the job tracker, and the lifecycle-reporting + poller loops). Config schema: src/core/config/control_plane.rs. Configuration reference: the control_plane section in config.yaml.example.
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.