Skip to content

Architecture Guide

This document covers Continuum Router's architecture, design decisions, and extension points.

Table of Contents

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

  1. Separation of Concerns: Each layer has a single, well-defined responsibility
  2. Dependency Inversion: Higher layers depend on abstractions, not concrete implementations
  3. Testability: Each component can be unit tested in isolation
  4. Extensibility: New features can be added without modifying existing code
  5. Performance: Minimal overhead while maintaining clean architecture
  6. 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-native x-api-key: <key> header; Authorization: Bearer takes precedence when both are present
    • Supports multiple API keys configured in config.yaml
    • Returns 401 Unauthorized for invalid/missing keys
  • admin_auth.rs: Separate authentication for admin endpoints (/admin/*)

    • Supports explicit none, bearer token, HTTP Basic, IP-whitelist, and shared-store API-key modes
    • Protects sensitive operations (config reload, circuit-breaker control, health management)
    • Configurable through admin.auth; a missing block fails closed and a block without method defaults to api_key
  • files_auth.rs: Authentication middleware for Files API (/v1/files/*)

    • Validates API keys specifically for file upload/download/deletion operations
    • Prevents unauthorized file access and manipulation
    • Integrates with file storage service for permission checks
  • admin_audit.rs: Audit logging middleware for admin operations

    • Records all admin API calls with timestamps and caller identification
    • Logs parameters and outcomes of sensitive operations
    • Provides audit trail for compliance and security monitoring
    • Configurable log levels and retention policies
  • cors.rs: CORS (Cross-Origin Resource Sharing) middleware

    • Enables embedding the router in web applications, Tauri apps, and Electron apps
    • Supports wildcard origins (*), exact origins, and port wildcards (http://localhost:*)
    • Custom scheme support for desktop apps (e.g., tauri://localhost)
    • Configurable methods, headers, credentials, and preflight cache duration
    • Applied early in the middleware stack for proper preflight handling
  • rate_limit_v2/: Enhanced rate limiting system (see Rate Limiting section)

    • Token bucket algorithm with per-client tracking
    • Separate limits for sustained rate and burst protection
    • Automatic cleanup of expired client entries
    • Detailed metrics for monitoring

2. Services Layer (src/services/)

Responsibility: Orchestrate business logic and coordinate between infrastructure components

Components

  • Backend Service (backend_service.rs): Manage backend pool, load balancing, health checks
  • Model Service (model_service.rs): Aggregate models from backends, handle caching, enrich with metadata
  • Proxy Service (proxy_service.rs): Route requests, handle retries, manage streaming
  • Health Service (health_service.rs): Monitor service health, track status
  • Web Search Service (search/): Router-managed web_search tool for self-hosted backends. Pluggable provider trait with Serper, Exa, Brave, and SearXNG implementations, bounded tool-execution loop with iteration cap / wall-clock / byte-budget guards (see Web Search)
  • System One Client (systemone/): Shared client for System One decision models across the generic, typesafe, foundry, and llama_cpp provider profiles (hosted TypeSafe Jev, Microsoft-Decision-1 on Foundry, and self-hosted Laya / Kev / Nimble / VLLM Decision 2/D3). Config block: SystemOneTargetConfig in src/core/config/models/systemone.rs. See System One Decision Models for wire details.
    • Typed queries and resilience: Executes noul, choice, score, and box questions over a state, with one bounded retry on 429 / 529 / connect failure, capped error bodies, and backend: references resolved under the pinned-backend contract.
    • Cache invalidation: cache_target_identity() includes a monotonic CacheTopologyGeneration counter that increments whenever the live Config snapshot reloads, invalidating downstream caches without hashing credentials.
    • Client caching and consumers: ClientCache (systemone/cache.rs) maintains one built client per consumer and router (or per consumer, router, and target name for /v1/decisions via get_or_build_keyed). Consumed by the guardrail provider, smart-routing classifier, action-safety evaluator, answer-quality fallback check, and public /v1/decisions native transport.
  • Answer Quality (answer_quality/): Consumer-neutral answer-quality battery asking three noul questions (answered, refusal, truncated, or any subset via questions), evaluated by AnswerJudgment::worst_problem over a head-and-tail-capped {user_message, ai_response} state built from the final user message and reply. Driven by fallback.quality_check (src/proxy/quality_fallback.rs) after the guardrail output gate on non-streaming chat replies, advancing the served chain via execute_quality_hop (src/proxy/request.rs) within max_extra_hops, max_fallback_attempts, and the non-streaming chain budget.
  • Usage Quality Judgment (src/control_plane/usage_quality/, control-plane feature): Extends the answer-quality battery with grounded and language_match to evaluate served chat replies (buffered or streamed) after the guardrail output gate. For route-by-route wire extraction rules, see System One Decision Models.
    • Non-blocking evaluation and call sharing: UsageQualityPlan samples the request and captures capped text before dispatch, attaching a QualityTicket to the UsageEvent. Once the reply is finalized, finish_text_with spawns the decision call in the background (bounded by a per-router in-flight cap) or reuses the judgment from fallback.quality_check when both consumers share a call.
    • Usage batch sealing: The usage loop maps, sequences, charges, and buffers records immediately, sealing each QualityTicket when forming a push batch. Judgments ready before batch formation attach to the record (deduplicated by the Hub on idempotency_key); later arrivals count as late without blocking accounting or batch pushes.
    • Streaming accumulation: Streamed requests pass a shared, take-once StreamQualityHandle on StreamObservabilityContext::usage_quality (excluding cache replays). Wire relays feed a bounded head-and-tail StreamReplyAccumulator (src/services/answer_quality/stream.rs, equivalent to cap_middle of the full reply) and settle once after the output gate's finalize: judged on a clean finish, or skipped if the gate cut/rewrote the stream, a mid-stream hop spliced generations, an error occurred, the client disconnected, or no relay claimed the handle. Every outcome carries a stream label.
    • Multi-surface ingress support: Implements QualityRequest (src/services/answer_quality/state.rs) across OpenAI Chat Completions (including the responses_only bridge in src/proxy/responses_only/bridge_usage.rs), Anthropic Messages (src/services/answer_quality/anthropic.rs, src/http/handlers/anthropic/user_turn.rs, and stream_output.rs), and /v1/responses (src/services/answer_quality/responses.rs and src/http/handlers/responses_quality.rs), using request-scoped plan slots (with_request_plan, claim_current_ticket, take_request_plan in src/control_plane/usage_quality/scope.rs) for buffered fallback chains and CompletedResponsePersister / passthrough relays for streams. Model-experiment usage records also carry experiment_id and experiment_variant_id (ExperimentArm).
  • System One Classifier (smart_routing/system_one_classifier.rs): Smart-routing method: system_one / hybrid_system_one classifier.
    • Single-call scoring: Queries score (complexity levels, rounded to nearest) and choice (domains) in one decision call via the shared client, setting ClassificationResult confidence to the minimum of both answers.
    • Gating and shadow mode: SmartRouter gates results on min_confidence (falling back to rules, or escalating to LLM under hybrid_system_one), copies capabilities and language from the rule classifier, and in shadow mode runs concurrently with the active classifier to record agreement without altering routing.
    • Caching and candidate pre-filtering: Cache entries are namespaced by cache_namespace ({version, target, questions}), SystemOneClient::cache_target_identity(), and bounded post-guardrail state. Before classification and policy selection, collect_smart_routing_candidates_for_request (src/proxy/smart_routing_candidates.rs) intersects the caller API key's allowed_models and allowed_backends, backend health, circuit-breaker admission, and chat output eligibility (excluding decision-only models as excluded_incompatible).

Key Files

src/services/
├── mod.rs              # Module exports
├── model_service.rs    # Model aggregation service
├── proxy_service.rs    # Request proxying and routing
├── health_service.rs   # Health monitoring service
├── deduplication.rs    # Request deduplication service
├── responses/          # Responses API support
│   ├── mod.rs
│   ├── converter.rs    # Response format conversion
│   ├── router.rs       # Routing strategy determination (pass-through vs conversion)
│   ├── passthrough.rs  # Direct pass-through for OpenAI/Azure and `responses_api: native` backends
│   ├── passthrough_lifecycle.rs # GET/DELETE/cancel forwarded to the backend that served a response
│   ├── passthrough_routes.rs    # Bounded per-router (owner, response id) -> backend bindings
│   ├── session.rs      # Session management
│   ├── stream_service.rs # Streaming service orchestration
│   └── streaming.rs    # Streaming response handling
├── answer_quality/     # Answer-quality battery and decision state
│   ├── battery.rs      # QualityQuestion, AnswerJudgment, judge()
│   └── state.rs        # {user_message, ai_response[, tool_results]} state builder and caps
├── search/             # Router-managed web search service
│   ├── mod.rs          # SearchProvider trait, SearchError, SearchResult, build_provider
│   ├── executor.rs     # WebSearchExecutor: tool injection, tool-execution loop
│   └── providers.rs    # Concrete provider implementations (Serper, Exa, Brave, SearXNG)
├── streaming/          # Streaming utilities
│   ├── mod.rs
│   ├── parser.rs       # Stream parsing logic
│   └── transformer.rs  # Stream transformation (OpenAI/Anthropic)
├── tokenize/           # Engine tokenizer access, always compiled
│   ├── wire.rs         # Per-backend-type capability, paths, payloads; shared with count_tokens
│   ├── response.rs     # Engine response readers over the capped bytes, no JSON tree
│   ├── lenient.rs      # Visitors that read a wrong-typed field as absent
│   ├── token_strs.rs   # Compact token-string list: one buffer plus u32 offsets
│   ├── upstream.rs     # Time budget and capped body read, shared with count_tokens
│   ├── validate.rs     # Client contract of /tokenize and /detokenize
│   ├── dispatch.rs     # Exact-model resolution (no retry, no fallback)
│   ├── call.rs         # The upstream call, or the /apply-template then /tokenize pair
│   ├── chat_render.rs  # messages options each llama-server engine honors, and the pair's bodies
│   └── handlers.rs     # The two handlers mounted by build_api_routes
└── systemone/          # Shared System One decision-model client
    ├── mod.rs          # Module docs and re-exports
    ├── cache.rs        # ClientCache: one client per consumer, router, and target key
    ├── client.rs       # SystemOneClient: target resolution, bounded retry, decoding
    ├── metering.rs     # Attempt-level usage recording per SystemOneConsumer
    └── types.rs        # Question / Answer / Usage wire types and question validation

3. Infrastructure Layer (src/infrastructure/)

Responsibility: Provide concrete implementations of external systems and technical capabilities

Components

  • Backends (backends/): Specific backend implementations (OpenAI, Anthropic, Gemini, vLLM, SGLang, Ollama, llama.cpp, MLxcel, LM Studio, Continuum Router)
  • Auth (auth/): OAuth 2.0 Device Authorization Grant (RFC 8628) subsystem. The AuthStrategyRegistry is 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 on 401. The streaming request builders consult the same registry (initial dispatch, per-hop mid-stream fallback, auto backend selection, and the Unix socket transport). See src/proxy/oauth_helper.rs for the shared proxy auth-helper.
  • Cache (cache/): Caching implementations (LRU/TTL-based TtlLruCache, CacheStore trait for pluggable backends, InMemoryCacheStore, SHA256-keyed ResponseCacheStore, retry-aware cache)
  • KV Cache Index (kv_index/): Shared index that maps token prefix hashes to backends for KV cache-aware routing. Provides InMemoryKvIndex (DashMap + LRU eviction, single-router) and RedisKvIndex (Redis sorted sets, multi-router). Includes KvOverlapScorer which implements BackendScorer to score backends by cache overlap, load, and health. The event consumer ingests real-time cache events from vLLM SSE streams to keep the index current.
  • Configuration (config/): Configuration loading, watching, validation
  • HTTP Client (common/http_client.rs): HttpClientFactory presets for every backend client (the single client factory)
  • Backend HTTP Protocol (backend_http/): Per-backend http_version selection (auto uses the shared proxy client; http1 and http2 use sibling clients built from ProxyClientTuning and cached process-wide). AppState::backend_client resolves the client from the routed configuration on each dispatch, and BackendClients carries that choice into mid-stream relay tasks. It also emits dispatch observability (continuum_backend_response_wait_seconds via ResponseWait and rate-limited warnings at 100 in-flight HTTP/2 requests).

Key Files

src/infrastructure/
├── mod.rs              # Infrastructure exports and utilities
├── auth/               # OAuth 2.0 Device Authorization Grant (RFC 8628)
│   ├── mod.rs          # Public re-exports
│   ├── device_flow.rs  # RFC 8628 state machine + ReqwestDeviceFlowClient
│   ├── token_store.rs  # Atomic write (tempfile+rename), 0600 perms, ~ expansion
│   ├── strategy.rs     # AsyncAuthStrategy trait + OAuthAuthStrategy (proactive refresh)
│   ├── registry.rs     # AuthStrategyRegistry: hot-reloadable map of backend → strategy
│   └── error.rs        # AuthError type
├── backends/           # Backend implementations
│   ├── mod.rs
│   ├── anthropic/      # Native Anthropic Claude backend
│   │   ├── mod.rs      # Backend implementation & request transformation
│   │   └── stream.rs   # SSE stream transformer (Anthropic → OpenAI)
│   ├── gemini/         # Native Google Gemini backend
│   │   ├── mod.rs      # Backend implementation with TTFB optimization
│   │   └── stream.rs   # SSE stream transformer (Gemini → OpenAI)
│   ├── openai/         # OpenAI-compatible backend
│   │   ├── mod.rs
│   │   ├── backend.rs  # OpenAI backend implementation
│   │   └── models/     # OpenAI-specific model definitions
│   ├── factory/        # Backend factory pattern
│   │   ├── mod.rs
│   │   └── backend_factory.rs  # Creates backends from config
│   ├── pool/           # Backend pooling and management
│   │   ├── mod.rs
│   │   ├── backend_pool.rs     # Connection pool management
│   │   └── backend_manager.rs  # Backend lifecycle management
│   ├── generic/        # Generic backend implementations
│   │   └── mod.rs
│   ├── llamacpp/       # llama.cpp / llama-server backend
│   │   ├── mod.rs
│   │   ├── backend.rs     # LlamaCppBackend implementation
│   │   ├── model_parser.rs # Hybrid response format parsing
│   │   └── props.rs       # /props endpoint for tool calling detection
│   ├── continuum_router/  # Continuum Router / Backend.AI GO backend
│   │   ├── mod.rs
│   │   └── backend.rs     # ContinuumRouterBackend implementation
│   └── vllm.rs         # vLLM/SGLang/Ollama/LM Studio backend implementation
├── common/             # Shared infrastructure utilities
│   ├── mod.rs
│   ├── executor.rs     # Request execution with retry/metrics
│   ├── headers.rs      # HTTP header utilities
│   ├── http_client.rs  # HTTP client factory with pooling
│   ├── statistics.rs   # Backend statistics collection
│   └── url_validator.rs # URL validation and security
├── transport/          # Transport abstraction layer
│   ├── mod.rs               # Transport enum (HTTP, UnixSocket)
│   ├── unix_socket.rs       # Unix Domain Socket client (Linux/macOS)
│   └── unix_socket_windows.rs  # Windows AF_UNIX client via socket2
├── cache/              # Caching implementations
│   ├── mod.rs
│   ├── store.rs          # CacheStore trait (async, byte-oriented cache backend abstraction)
│   ├── memory_store.rs   # InMemoryCacheStore: TtlLruCache-backed CacheStore implementation
│   ├── lru_cache.rs      # LRU cache with TTL and eviction
│   ├── response_cache.rs # SHA256-keyed LLM response cache (uses Arc<dyn CacheStore>)
│   └── retry_cache.rs    # Retry-aware cache
├── kv_index/           # KV cache prefix-hash index and overlap scoring
│   ├── mod.rs            # Re-exports KvCacheIndex, InMemoryKvIndex, RedisKvIndex, KvOverlapScorer
│   ├── index.rs          # KvCacheIndex trait + InMemoryKvIndex (DashMap/LRU) + RedisKvIndex (sorted sets)
│   ├── event_consumer.rs # KvEventConsumerManager: subscribes to vLLM SSE KV cache event streams
│   ├── scorer.rs         # KvOverlapScorer: BackendScorer impl using KV cache affinity + load + health
│   └── types.rs          # KvCacheEvent, KvCacheEventType, ConsumerStatus, KvEventConsumerConfig
├── config/             # Configuration management
│   ├── mod.rs
│   ├── loader.rs       # Configuration loading
│   ├── validator.rs    # Configuration validation
│   ├── timeout_validator.rs # Timeout configuration validation
│   ├── watcher.rs      # File watching for hot-reload
│   ├── migrator.rs     # Configuration migration orchestrator
│   ├── migration.rs    # Migration types and traits
│   ├── migrations.rs   # Specific migration implementations
│   ├── fixer.rs        # Auto-correction logic
│   ├── backup.rs       # Backup management
│   └── secrets.rs      # Secret/API key management
└── lock_optimization.rs # Lock and concurrency optimization

4. Core Layer (src/core/)

Responsibility: Define domain models, business rules, and fundamental abstractions

Components

  • Models (models/): Core domain entities (Backend, Model, Request, Response)
  • Traits (traits.rs): Core interfaces and contracts
  • Errors (errors.rs): Domain-specific error types and handling
  • Retry (retry/): Retry policies and strategies
  • Scoring (scoring.rs): BackendScorer trait for composable backend scoring pipeline; each scorer assigns a signal-specific score and BackendPool sums them to select the best backend

Key Files

src/core/
├── mod.rs              # Core exports and utilities
├── models/             # Domain models
│   ├── mod.rs
│   ├── backend.rs      # Backend domain model
│   ├── model.rs        # LLM model representation
│   ├── request.rs      # Request models
│   └── responses.rs    # Response models (Responses API)
├── traits.rs           # Core traits and interfaces
├── errors.rs           # Error types and handling
├── container.rs        # Unwired legacy (not used at runtime)
├── scoring.rs          # BackendScorer trait: composable async scoring pipeline for backend selection
├── hashing.rs          # SHA256 prefix key computation (compute_hash)
├── prefix_key.rs       # Prefix key extraction from request bodies
├── async_utils.rs      # Async utility functions
├── duration_utils.rs   # Duration parsing utilities
├── streaming/          # Streaming models
│   ├── mod.rs
│   └── models.rs       # Streaming-specific models
├── retry/              # Retry mechanisms
│   ├── mod.rs
│   ├── policy.rs       # Retry policies
│   └── strategy.rs     # Retry strategies
├── circuit_breaker/    # Circuit breaker pattern
│   ├── mod.rs          # Module exports
│   ├── config.rs       # Configuration models
│   ├── state.rs        # State machine and breaker logic
│   ├── error.rs        # Circuit breaker errors
│   ├── metrics.rs      # Prometheus metrics
│   └── tests.rs        # Unit tests
├── files/              # File processing utilities
│   ├── mod.rs          # Module exports
│   ├── resolver.rs     # File reference resolution in chat requests
│   ├── transformer.rs  # Message transformation with file content
│   └── transformer_utils.rs # Transformation utility functions
├── tool_calling/       # Tool calling transformation
│   ├── mod.rs          # Module exports
│   ├── definitions.rs  # Tool/function type definitions
│   └── transform.rs    # Backend-specific transformations
└── config/             # Configuration models
    ├── mod.rs
    ├── models/            # Configuration data models (modular structure)
    │   ├── mod.rs         # Public module exports
    │   ├── config.rs      # Main Config struct, ServerConfig, BackendConfig
    │   ├── backend_type.rs # BackendType enum definitions
    │   ├── model_metadata.rs # ModelMetadata, PricingInfo, CapabilityInfo
    │   ├── global_prompts.rs # GlobalPrompts configuration
    │   ├── samples.rs     # Sample generation configurations
    │   ├── validation.rs  # Configuration validation logic
    │   └── error.rs       # Configuration-specific errors
    ├── timeout_models.rs # Timeout configuration models
    ├── cached_timeout.rs # Cached timeout resolution
    ├── optimized_retry.rs # Optimized retry configuration
    ├── metrics.rs      # Metrics configuration
    └── rate_limit.rs   # Rate limit configuration

Library Entry Point (src/server/)

Responsibility: Expose Continuum Router as an embeddable Rust library crate via a ContinuumRouter builder API

The src/server/ module decouples all initialization logic from the binary entry point (main.rs), enabling downstream Rust projects to embed the router directly without spawning a subprocess. The binary main.rs becomes a thin CLI wrapper (~220 lines) that delegates to this module.

Key Files:

src/server/
├── mod.rs      # ContinuumRouter struct, ContinuumRouterBuilder with fluent API
├── init.rs     # Individual service initialization functions (HTTP client, health checker,
│               # circuit breaker, file service, API key store, rate limiting)
├── state.rs    # initialize_services() - assembles all services into AppState
├── routes.rs   # Route builders for API, admin, files, WebUI, metrics, and CORS
└── serve.rs    # Server lifecycle: bind listeners, hot-reload, graceful shutdown

Builder API:

use continuum_router::{ContinuumRouter, Config};

// From a config file (enables hot-reload when called)
let router = ContinuumRouter::from_config_file("config.yaml")
    .await?
    .enable_hot_reload(true)
    .build()
    .await?;
router.serve("0.0.0.0:8080").await?;

// From a programmatic Config struct (no file dependency)
let router = ContinuumRouter::from_config(Config::default())
    .enable_health_checks(false)
    .with_config_dir("/etc/continuum")
    .build()
    .await?;

// Embed into an existing Axum application
let app = axum::Router::new()
    .nest("/llm", router.into_router());

Builder Override Semantics:

Each enable_* method registers an explicit override. When None (not called), the builder respects whatever the config file or Config::default() says. When Some(value), the override wins regardless of the config source.

Method Override field Effect
enable_hot_reload(bool) overrides.hot_reload Start/suppress the config file watcher
enable_health_checks(bool) overrides.health_checks Start/suppress background health monitoring
enable_circuit_breaker(bool) overrides.circuit_breaker Enable/disable circuit breaker state machine
enable_metrics(bool) overrides.metrics Enable/disable Prometheus metrics collection
enable_files_api(bool) overrides.files_api Enable/disable the /v1/files API
without_control_plane_agent() overrides.no_control_plane_agent Declare that serve() will not run: no control-plane usage channel or usage write-ahead log, and a startup warning when control_plane.enabled is on
with_http_client(client) custom_http_client Inject a pre-configured reqwest::Client
with_probe_http_client(client) custom_probe_http_client Inject the client health checks and model catalog fetches use (own pool by default)
with_config_dir(path) config_dir Set base directory for prompt file resolution
with_config_manager(mgr) config_manager Attach a pre-created ConfigManager (CLI use-case)

ContinuumRouter struct (returned by build()):

Method Description
serve(addr) Run as a standalone server; blocks until shutdown signal
into_router() Return the Axum Router for embedding into another app
state() Access the shared Arc<AppState>
config_handle() Access the ConfigManager (if loaded from a file)
shutdown() Consume and drop the router, releasing all resources

Design Decisions:

  • initialize_prompt_file_cache accepts Option<&Path> instead of &Args, removing the CLI dependency from library code.
  • perform_container_health_check (Docker HEALTHCHECK) remains in main.rs because it is binary-only and creates no TCP listening sockets.
  • load_configuration (CLI fallback logic) also remains in main.rs to keep the library free of CLI concerns.
  • All init::* and state::* functions are pub(crate) or pub(super); only ContinuumRouter, ContinuumRouterBuilder, and the init / routes sub-modules are re-exported from lib.rs.

Core Components

Backend Pool

Location: src/infrastructure/backends/pool/ (backend_pool.rs + lifecycle.rs)

Purpose: The single source of truth for backend membership, selection, and lifecycle. It holds Arc<dyn Backend> execution objects, runs the health-aware selection (round-robin / weighted / least-latency / consistent-hash / prefix-aware, plus the composable scorer pipeline with in-flight counting and per-backend stats), and owns the graceful-drain and hot-reload lifecycle.

Every live HTTP request path (chat completions, streaming chat, Responses, Anthropic Messages and count_tokens, image generation) dispatches its final backend pick through this pool:

  • Candidate resolution and selection: Each path resolves and pre-filters its own candidate set (model lookup, internal-backend filter, per-key allow-list, retry-state exclusion) and hands the names to the shared selection seam (proxy::selection::select_pooled_backend). The seam health-filters the candidates and calls BackendPool::select_from_candidates so the configured selection_strategy and any registered scorers (such as KvOverlapScorer when prefix_routing.enabled and a KV index are configured) govern traffic distribution instead of candidate-list order.
  • Consistent-hash ring cache: The consistent-hash ring cache is a small bounded LRU keyed by the participating backend-name vector (including order), so alternating per-model candidate subsets reuse their own rings without unbounded growth. Each selection call clones an Arc ring snapshot before walking it, preventing a ring built for one candidate set from being interpreted against another and keeping the cache lock out of the ring walk.
  • Compile-time visibility enforcement (UserRoutableCandidates): Candidate names travel as proxy::selection::UserRoutableCandidates rather than a bare Vec<String>. Because the user-routing visibility filter must run before the per-key allow-list (so a model served only by internal: true or enabled: false backends reports model-not-found rather than a misleading 403 Forbidden), UserRoutableCandidates encodes that proof at the type level:
    • Its only ordinary constructors are filter_user_routing_candidates (names) and filter_user_routing_backends (typed BackendConfig entries). All other operations are narrowings that cannot admit a hidden backend, so any dispatch path that omits the filter fails to compile.
    • UserRoutableCandidates::bypass_for_management is the sole explicit bypass and has no callers; router-internal consumers that legitimately address hidden backends (management probes, health monitoring, the admin catalog, hub inventory, model discovery) resolve backends without going through the user-routing seam.
pub struct BackendPool {
    backends: Arc<RwLock<Vec<Arc<dyn Backend>>>>,
    counter: Arc<AtomicUsize>,
    selection_strategy: SelectionStrategy,
    stats: Arc<RwLock<HashMap<String, BackendStats>>>,
    scorers: Arc<RwLock<Vec<Arc<dyn BackendScorer>>>>,
    // Lifecycle side tables (keyed by backend name), see lifecycle.rs:
    backend_states: Arc<RwLock<HashMap<String, BackendState>>>, // Active/Draining/Removed
    draining: Arc<RwLock<Vec<DrainingEntry>>>,                  // + drain timestamps
    // ... hash-ring cache, epsilon, scorer threshold ...
}

impl BackendPool {
    // Health/scorer-aware selection over the live backends.
    pub async fn select_backend(&self, model: Option<&str>) -> CoreResult<Arc<dyn Backend>> { /* ... */ }

    // Candidate-restricted selection used by every live HTTP path:
    // intersects the caller's pre-filtered candidate names with pool membership
    // (by name only, preserving order), then runs the scorer pipeline and the
    // configured strategy over exactly that subset.
    pub async fn select_from_candidates(
        &self,
        candidates: &[String],
        model: Option<&str>,
        context: &ScoringContext,
    ) -> CoreResult<Arc<dyn Backend>> { /* ... */ }

    // Graceful drain (out of rotation, in-flight requests finish) + cleanup.
    pub async fn drain_backend(&self, name: &str) -> Option<Arc<dyn Backend>> { /* ... */ }
    pub async fn cleanup_draining(&self) -> usize { /* ... */ }

    // In-place hot reload: add/drain/recreate backends from new config.
    pub async fn update_from_config(&self, configs: &[BackendConfig], factory: &BackendFactory) { /* ... */ }
}

The mutable lifecycle state lives in side tables keyed by backend name (backend_states, draining), mirroring the pool's existing name-keyed stats map, so the hot selection path keeps its plain Vec<Arc<dyn Backend>> layout. AppState holds exactly one backend_pool; the proxy/passthrough layers project the chosen Arc<dyn Backend> to a lightweight PooledBackend { name, url } handle at the selection boundary.

The pool also owns the per-backend engine statistics store (src/infrastructure/backends/engine_stats/), whose background poller reads each self-hosted engine's load or metrics endpoint (vLLM /metrics, SGLang /v1/loads, llama.cpp /slots and /metrics, Ollama /api/ps, LM Studio /api/v0/models) and normalizes it into a TTL-bounded EngineStatsSnapshot. Prometheus scrape refresh (backend_engine_*{backend}), GET /admin/backends/{name}/engine-stats, WebUI dashboard cards, Backend::get_current_load(), the KV overlap scorer, and selection saturation admission hints (routing.engine_load) all read this store (see Metrics).

Health Checker

Location: src/health.rs → src/services/health_service.rs

Purpose: Monitor backend health with configurable thresholds, automatic recovery, and accelerated warmup detection

pub struct HealthChecker {
    backends: Arc<RwLock<Vec<Backend>>>,
    config: HealthConfig,
    status_map: Arc<RwLock<HashMap<String, HealthStatus>>>,
}

pub struct HealthConfig {
    pub interval: Duration,
    pub timeout: Duration,
    pub unhealthy_threshold: u32,     // Failures before marking unhealthy
    pub healthy_threshold: u32,       // Successes before marking healthy
    pub warmup_check_interval: Duration, // Accelerated interval during warmup (default: 1s)
    pub max_warmup_duration: Duration,   // Max time in warmup mode (default: 300s)
}

pub enum HealthStatus {
    Healthy,      // Backend responding with HTTP 200
    Unhealthy,    // Connection failure or error
    WarmingUp,    // HTTP 503 - backend loading (accelerated checks)
    Unknown,      // Initial state
}

Accelerated Warmup Health Checks

When a backend returns HTTP 503 (Service Unavailable), it enters the WarmingUp state. During this state:

  • Health checks run at warmup_check_interval (default: 1 second) instead of the normal interval
  • This reduces model availability detection from ~30 seconds to ~1 second
  • After max_warmup_duration, the backend is marked as Unhealthy
  • Particularly useful for llama.cpp backends that return HTTP 503 during model loading

Transport Layer

Location: src/infrastructure/transport/

Purpose: Provide a unified transport abstraction for backend communication over HTTP/HTTPS or Unix Domain Sockets

The transport layer enables secure local LLM communication via Unix sockets, eliminating the need for TCP port exposure.

URL Schemes

Scheme Transport Example
http:// TCP/HTTP http://localhost:8080/v1
https:// TCP/HTTPS https://api.openai.com/v1
unix:// Unix Socket unix:///var/run/llama.sock

Transport Enum

pub enum Transport {
    Http { url: String },
    UnixSocket { socket_path: PathBuf },
}

impl Transport {
    pub fn from_url(url: &str) -> Result<Self, TransportError>;
    pub fn is_unix_socket(&self) -> bool;
    pub fn is_http(&self) -> bool;
}

Unix Socket Client

The UnixSocketClient provides HTTP-over-Unix-socket communication:

pub struct UnixSocketClient {
    socket_path: PathBuf,
    config: UnixSocketClientConfig,
}

impl UnixSocketClient {
    pub async fn get(&self, endpoint: &str, headers: Option<Vec<(String, String)>>)
        -> Result<UnixSocketResponse, UnixSocketError>;
    pub async fn post(&self, endpoint: &str, headers: Option<Vec<(String, String)>>, body: Bytes)
        -> Result<UnixSocketResponse, UnixSocketError>;
    pub async fn health_check(&self) -> Result<bool, UnixSocketError>;
}

Security Features

  • Path Traversal Protection: Validates socket paths to prevent directory traversal attacks
  • CRLF Injection Protection: Validates endpoints and headers for HTTP header injection
  • Response Size Limits: Configurable max response size (default: 100MB)

Platform Support

Platform Support
Linux Full support via native AF_UNIX (tokio::net::UnixStream)
macOS Full support via native AF_UNIX (tokio::net::UnixStream)
Windows Full support via socket2 crate (Windows 10 1809+ / Build 17063+)
Other Returns PlatformNotSupported error

Windows AF_UNIX support uses the socket2 crate for socket creation and raw Winsock handle conversion to tokio-compatible types. The implementation is isolated in unix_socket_windows.rs and activated via #[cfg(windows)] gates.

Model Aggregation Service

Location: src/models/ (modular structure)

Purpose: Aggregate and cache model information from all backends, enrich with metadata, with cache stampede prevention

Module Structure:

src/models/
├── mod.rs             # Public module exports
├── types.rs           # Model, AggregatedModel, ModelList, SingleModelResponse types
├── metrics.rs         # ModelMetrics tracking (includes stampede metrics)
├── cache.rs           # ModelCache with stale-while-revalidate & singleflight
├── config.rs          # ModelAggregationConfig
├── fetcher.rs         # Model fetching from backends
├── handlers.rs        # HTTP handlers for /v1/models and /v1/models/{model} endpoints
├── background_refresh.rs  # Background periodic cache refresh service
├── pattern_matching.rs    # 6-phase model ID lookup pipeline (date / format suffix normalization, HuggingFace repo-prefix stripping, wildcard)
├── utils.rs           # Utility functions (normalize_model_id, etc.)
└── aggregation/       # Core aggregation logic
    ├── mod.rs         # ModelAggregationService implementation
    └── tests.rs       # Unit tests

For the model ID resolution pipeline used during metadata enrichment, see Automatic Quantization and Format Suffix Handling in the configuration guide. For policy guidance on when a new mapping should be added as an explicit YAML alias versus an extension to the peel allowlist or the HuggingFace prefix-strip layer, see Aliases vs. suffix normalization: when to use which. The prefix-strip layer composes with the suffix peel: a single lookup can strip a vendor/repo prefix and then peel an allowlisted suffix token, so unsloth/Qwen3.6-35B-A3B-GGUF routes to qwen3.6-35b-a3b metadata without a hand-registered alias.

Cache Stampede Prevention

The model aggregation service implements four strategies to prevent cache stampede (thundering herd problem):

  1. Singleflight Pattern: Only one aggregation request runs at a time. Concurrent requests wait for the ongoing aggregation to complete, then share the result.

  2. 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.

  3. Background Periodic Refresh: A background task proactively refreshes the cache before expiration. Requests never block on cache refresh (except cold start).

  4. Supersession Stamps: Every aggregation records the pool's membership_generation and the cache's invalidation_epoch immediately before snapshotting the pool, and compares both at store time:

    • Immediate expiration on stamp drift: If either stamp moved during the fan-out (a backend added, removed, or drained by hot reload, the Admin API, or AppProxy, or a clear_cache() call from a configuration change that left membership identical), the result is stored as an already-expired entry instead of Fresh. Readers within the stale-serve bound still receive the previous list while a follow-up refresh starts immediately (in-place bounded at three passes for background revalidation, or as one spawned background task on the request path).
    • Race-free ordering: Stamps are read before taking the pool snapshot. Because the membership comparison is not fused with the insert lock like the epoch comparison, the store re-reads membership_generation after insertion and expires the entry in place if it moved. Hot reload bumps both stamps back-to-back before its health-check round trip, so a single reload reaches an in-flight fan-out as one event.
    • Bounded in-place retry: Between passes, the in-place retry releases the aggregation lock and backs off with jitter so a POST /v1/models/refresh queued behind a pass does not wait for the entire chain. If every pass in a chain is superseded, the entry remains expired with a 2 s re-arm hold before the next revalidation (lifted immediately by any Fresh publish).
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 refresh
  • coalesced_requests: Requests that waited for ongoing aggregation
  • background_refreshes: Background refresh operations initiated
  • background_refresh_successes/failures: Background refresh outcomes
  • singleflight_lock_acquired: Times the aggregation lock was acquired

Proxy Module

Location: src/proxy/ (modular structure)

Purpose: Handle request proxying, backend selection, file resolution, and image generation/editing

Module Structure:

src/proxy/
├── mod.rs             # Public module exports
├── backend.rs         # Backend selection and routing logic
├── request.rs         # Request execution with retry logic
├── files.rs           # File reference resolution in requests
├── image_gen.rs       # Image generation handling (DALL-E, Gemini, GPT Image)
├── image_edit.rs      # Image editing support (/v1/images/edits)
├── image_utils.rs     # Image processing utilities (multipart, validation)
├── handlers.rs        # HTTP handlers for proxy endpoints
├── utils.rs           # Utility functions (error responses, etc.)
└── tests.rs           # Unit tests

Key Responsibilities

  • Backend Selection: Intelligent routing to available backends
  • File Resolution: Resolve file references in chat requests
  • Image Generation: Support for OpenAI (DALL-E, GPT Image) and Gemini (Nano Banana) image models
  • Image Editing: Image editing and variations endpoints
  • Request Retry: Automatic retry with exponential backoff
  • Error Handling: Standardized error responses in OpenAI format
  • Internal Streaming: Stream eligible buffered chat and completions requests from the backend so a client cancel is metered (proxy.internal_streaming)

Internal streaming of buffered requests

proxy.internal_streaming (src/proxy/internal_streaming.rs and internal_streaming_read.rs) transforms eligible buffered requests on the wire inside make_http_request: after all payload transforms run, the dispatch is sent with stream: true and stream_options shaped by the shared apply_router_stream_options writer (include_usage: true, plus continuous_usage_stats: true on vllm backends; any client stream_options is discarded first).

  • Scope and route eligibility: The chat completions and completions handlers run their post-policy work inside a task-local scope (client_route), so their retries, fallback hops, deduplication, web-search turns, quality hops, and disaggregated or shim dispatch qualify. The /v1/decisions emulation hop does not qualify, and the Anthropic Messages and Responses ingresses, embeddings, rerank, images, files, synthetic probes (which use Backend trait executors), and router-initiated calls never reach this arm.
  • Backend and request conditions: Within client_route, eligibility requires the backend's effective internal-streaming flag, an OpenAI-wire backend type (excluding azure), HTTP(S) transport, and a compatible request shape (n: 1, no logprobs, response_format of text or json_object, and none of functions, function_call, audio, audio modalities, or web_search_options, whose output the assembler does not rebuild).

A non-2xx reply is read and classified, and a 2xx application/json reply is passed through unchanged. A 2xx stream is read by one relay loop under the dispatch's own timeout, parsed with SseParser in standard mode, fed to a StreamUsageFinalizer, and assembled by services::streaming::assemble (shared with streaming cache replay) into a standard buffered response body. The read settles in a single tail (enforced by tests/stream_early_exit_audit_test.rs):

  • Failure before any output: If no data event arrived, an error event arrived first, a transport error or timeout occurred after a role-only chunk, or the body ended with neither [DONE] nor a finish_reason, nothing is recorded by the internal-streaming reader and the failure retries like a normal buffered body read (covered by the retry seam's per-attempt record). Output is defined as a non-zero router delta count, a reported engine completion count, or any chat delta field beside role with a non-empty value (so a refusal still makes the failure final).
  • Completed stream: Leaves its finalized usage on the request ledger keyed by the attempt. The row recording that attempt (the served row, a superseded web-search turn, or a quality hop) consumes it as a buffered row: token counts, outcome (usage_missing without engine usage), and token source come from the stream, while TTFT remains the response-header observation and stream stays false.
  • Failure after output: Including a body that ends without [DONE] and without any finish_reason, returns a BackendRequestFailure carrying the finalized partial usage (partial). The retry seam records the attempt (upstream_error or timeout_mid_stream, with the partial count) and treats it as final like replay: disabled (TriggerReason::PartialOutput), preventing retries, fallback hops, or disaggregated unified fallback from re-sending the request.
  • Client disconnect: A read dropped after the first event (because the client disconnected and the handler future was dropped) synchronously records one client_cancelled attempt from Drop (when inside a Tokio runtime) and drops the upstream body unread. The circuit breaker records settle_success on an assembled body and settle_error on a failure.

Retry Handler

Location: src/services/deduplication.rs

Purpose: Implement exponential backoff with jitter and request deduplication

EnhancedRetryHandler composes the core retry engine with a DeduplicationManager; configuration uses the public string-duration RetryConfig (max_attempts, initial_delay, max_delay, backoff_multiplier, jitter, retryable status/error lists, and total timeout). The retry state machine itself lives in src/core/retry/strategy.rs.

Per-Backend Replay Policy and Attempt Tracking

  • Replay policy (backends[].replay): Enforced at execute_single_retry_attempt_observed (src/proxy/request.rs), the single seam every retry attempt passes through. A failed dispatch to a replay: disabled backend that is not provably undelivered (BackendRequestFailure::not_sent, set only on a failed connect or a router-side refusal before sending) terminates the retry loop and raises a per-request barrier that sets the fallback chain's trigger to TriggerReason::ReplayDisabled (which never hops). The streaming connection phase and mid-stream relay, provider-shaped ingress chains (src/http/ingress_fallback.rs::seal_replay_barrier), the quality-hop driver, and disaggregated unified fallback apply the same rule. Requests that a replay: disabled backend may serve also skip deduplication. (RetryState::backend_attempted is deprecated and unused by selection.)
  • Attempt tracking: The same seam begins an attempt on the request ledger (begin_attempt in src/services/stats/request_ledger_attempts.rs) after selection, the hop dial bound, and circuit admission succeed, immediately before the request leaves, so a refusal prior to dispatch is never recorded as an attempt.
    • Each attempt claims the next attempt_seq of its client request and names the previous attempt as parent_attempt.
    • On error, the seam immediately records the failed attempt via record_attempt_usage (src/services/stats/attempts.rs) before any retry sleep or chain hop, capturing BackendRequestFailure::upstream_status and any token usage reported in the error body.
    • The terminal attempt is recorded by the request-level recorder, which stamps the ledger's current attempt (a request-level failure row records no attempt). The pre-stream dials of connect_with_pre_stream_fallback, the Anthropic web-search invoker, and the web-search loop's superseded-turn hook use the same two calls.

Request Deduplication (In-Flight Coalescing)

Non-streaming POST requests under 10 KB that no replay: disabled backend may serve and that pass may_coalesce (src/services/deduplication/eligibility.rs) are coalesced in flight by execute_with_deduplication_attributed:

  • Endpoint and temperature eligibility:
    • /v1/chat/completions and /v1/completions are admitted only with an explicit, numerically zero temperature (an omitted temperature is not zero because OpenAI defaults to 1, whereas the response cache treats a missing value as 0). Eligibility inspects the request body before dispatch, so a backends[].request_extensions.body.overrides.temperature applied inside the attempt is not visible.
    • /v1/embeddings, /v1/rerank, /embed_sparse, and /v1/models are always eligible, as is the /v1/decisions emulation hop (which sends temperature: 0).
    • /v1/images/generations and all other endpoints are never eligible. The web-search tool loop and answer-quality fallback run their turns and hops inside without_coalescing so each turn or hop is recorded as its own attempt with its own tokens.
  • Leader election and in-flight waiter lifecycle:
    • The first request admitted under a key becomes the leader and owns an in-flight marker in the DashMap via a single atomic DashMap::entry operation (DeduplicationManager::admit), guaranteeing two identical requests never both lead.
    • Subsequent identical requests join while the marker is in flight and wait on its tokio::sync::watch channel (src/services/deduplication/waiters.rs) for the leader's outcome (success or error, so waiters see the leader's 503 or 429 with Retry-After rather than a 408).
    • When the leader settles, it removes its marker before publishing the outcome to joined waiters; publishing first would hand a settled result to a new arrival between publish and removal. Reusing completed answers is handled by the response cache.
    • The wait budget is computed from the leader's maximum allowed execution time: one attempt's total timeout (standard.total or image-generation total, scaled for fallback hops and clamped by timeouts.limits), plus retry.timeout (clamped by max_retry_timeout), plus timeouts.connection on a fallback hop (for the hop dial permit). Expiry fails the waiter with 408 timeout.
    • If a leader disconnects before finishing, its drop guard removes the marker and closes the channel; waiting requests re-join once with one promoted to leader, and a waiter that loses a second leader executes its own retry loop without deduplication.
  • Waiter accounting (src/services/stats/coalesced.rs):
    • A waiter receiving the leader's result is accounted like a local response-cache replay (note_coalesced_result): recorded under no backend (not_dispatched) and attempt_seq: 0 (even if an earlier attempt failed; Hub mapping floors the sequence at 1 and drops any parent), with the leader's prompt tokens and zero completion tokens.
    • It emits a usage export record with cache_hit_type: exact and, on control-plane builds, a metadata-only record_local_cache_hit event that charges no token window or budget. Request-count rate limiting in middleware still counts every caller.
  • Deduplication key composition (src/services/deduplication/key_headers.rs):
    • Keys combine the configuration snapshot, endpoint, dispatched body (after smart routing, Hub substitution, and alias or experiment dispatch rewrite model), effective backend allow-list, and a closed allow-list of inbound headers matched case-insensitively in canonical order:
      • Credential headers (is_credential_header: authorization, x-api-key, x-goog-api-key, etc.).
      • Provider headers affecting upstream behavior (anthropic-version, anthropic-beta, openai-organization, openai-project, openai-beta, x-vllm-priority).
      • Router control headers (x-backend, x-continuum-router-hops, x-smart-route-bypass, and Hub opt-outs x-continuum-substitution, -arbitrage, -budget-fallback, -compression, -batch).
      • Dynamo shim worker-picker pins (x-dynamo-prefill-instance-id, x-dynamo-prefill-dp-rank, x-dynamo-worker-instance-id, x-dynamo-dp-rank; client-supplied x-dynamo-* headers are stripped before the handler).
      • Headers referenced by the active configuration snapshot (smart_routing.routing_policies[].when.header and model_experiments[].sticky_key: header:<name>).
    • All other headers (X-Trace-ID, traceparent, x-request-id, user-agent, x-stainless-*) are excluded. Because callers sharing an API key (or unauthenticated callers) share one leader generation, callers that must not share results should use separate API keys.
pub struct EnhancedRetryHandler {
    pub retry_handler: RetryHandler,
    deduplication: DeduplicationManager,
    enable_deduplication: bool,
}

Circuit Breaker

Location: src/core/circuit_breaker/

Purpose: Maintain an independent per-backend three-state circuit-breaker state machine

pub struct CircuitBreaker {
    states: Arc<DashMap<String, BackendCircuitState>>,
    config: CircuitBreakerConfig,
    metrics: Option<CircuitBreakerMetrics>,
}

pub struct CircuitBreakerConfig {
    pub enabled: bool,
    pub failure_threshold: u32,           // Failures before opening (default: 5)
    pub failure_rate_threshold: f64,      // Failure rate threshold (default: 0.5)
    pub minimum_requests: u32,            // Min requests before rate calculation
    pub timeout: Duration,                // Open-state cooldown (default: 60s)
    pub half_open_max_requests: u32,      // Max requests in half-open state
    pub half_open_success_threshold: u32, // Successes needed to close
}

pub enum CircuitState {
    Closed,    // Normal operation - requests pass through
    Open,      // Failing fast - requests rejected immediately
    HalfOpen,  // Testing recovery - limited requests allowed
}

Key Features

  • Per-backend circuit breakers with independent state
  • Atomic operations for lock-free state checking in hot path
  • Automatic state transitions based on success/failure patterns
  • Sliding window for failure rate calculation
  • A metrics collector that can be attached by an embedding application
  • Admin endpoints for inspection and manual control

The standard LLM proxy path records backend outcomes and consults the breaker during candidate selection and immediately before dispatch. Open circuits are removed from ordinary inference traffic, half-open probes are capacity-limited, and the normal metrics registry exposes the live breaker counters. Active health filtering, retry, and fallback remain complementary layers.

Container (Unwired Legacy)

Location: src/core/container.rs

Container (src/core/container.rs) is a leftover from an earlier design that was never wired up at runtime. It is constructed only inside its own unit tests and registers no service.

The router does not use a runtime DI container. Services are ordinary constructor arguments; see Dependency Injection.

Data Flow

Request Processing Flow

sequenceDiagram
    participant Client
    participant HTTPLayer as HTTP Layer
    participant ProxyService as Proxy Service
    participant BackendService as Backend Service
    participant ModelService as Model Service
    participant Backend as LLM Backend

    Client->>HTTPLayer: POST /v1/chat/completions
    HTTPLayer->>HTTPLayer: Apply Middleware (auth, logging, metrics)
    HTTPLayer->>ProxyService: Forward Request

    ProxyService->>ModelService: Get Model Info
    ModelService->>ModelService: Check Cache
    alt Cache Miss
        ModelService->>BackendService: Get Backends for Model
        BackendService->>Backend: Query Models
        Backend-->>BackendService: Model List
        BackendService-->>ModelService: Filtered Backends
        ModelService->>ModelService: Update Cache
    end
    ModelService-->>ProxyService: Model Available on Backends

    ProxyService->>BackendService: Select Healthy Backend
    BackendService->>BackendService: Apply Load Balancing
    BackendService-->>ProxyService: Selected Backend

    ProxyService->>Backend: Forward Request
    Backend-->>ProxyService: Response (streaming or non-streaming)

    ProxyService->>ProxyService: Apply Response Processing
    ProxyService-->>HTTPLayer: Processed Response
    HTTPLayer-->>Client: HTTP Response

Model Name Resolution and Model Experiments

Before backend selection, every model-routed ingress resolves the name it dispatches through one seam, dispatch_model_for_request in src/proxy/alias_dispatch.rs:

  • Resolution pipeline: Runs after model_aliases, smart routing, the request-parameter policy snapshot, and the per-key model gate (all of which evaluate the client-supplied model name):
    1. Resolves any model experiment (src/proxy/model_experiments/): assigns the client-facing name to a weighted variant via rendezvous hashing over a SHA-256 sticky key, continues under the variant's upstream model, and narrows the request's backend allow-list if the variant pins a backend.
    2. Applies metadata alias dispatch to the resulting name.
  • Return contract (DispatchModel): Carries the client-facing name, the dispatched name, and the experiment assignment so selection errors and fallback attribution keep reporting what the client sent, and a fallback: within_variant experiment can suppress cross-model fallback chains.
  • Request-scoped attribution: Uses a tokio::task_local! cell installed by model_experiment_middleware around the API route group (the same mechanism as the upstream TTFT scope). The dispatch seam records the assignment, the response-cache route identity folds it into the exact, prefix, and semantic cache namespaces, and the middleware records model_experiment_* metrics, emits opt-in attribution headers, and applies response_model: client once the handler returns.

Guardrail Layer

When guardrails are configured (guardrails.enabled: true), a content-safety layer (GuardrailService in src/services/guardrail/) wraps backend calls across OpenAI chat completions (including the responses_only bridge to upstream /v1/responses), /v1/responses, legacy /v1/completions, and native Anthropic Messages. Stage coverage by route is tabulated in Guardrails: Coverage by route. When guardrails are unconfigured, handlers bypass this seam with zero overhead.

  • Unified gating seam and wire views: Driven through src/services/guardrail/gate.rs so every dispatch path shares the same policy and block renderers. Each wire defines its own block shape (Wire::OpenAi, Wire::Responses, Wire::Completions, Wire::Anthropic) and adjacent request/reply views (responses_input_view.rs, responses_view.rs, completions_view.rs, anthropic_input_view.rs).
  • Wire-neutral input masking (masking.rs): Each wire enumerates inspectable strings with their JSON locations. Masking verdicts are re-derived per string by the providers that returned them, written back in place (token by token for tool-call arguments in masking_args.rs), and re-checked before dispatch within a deadline, failing closed if masking cannot be applied or a provider fails afterward. Scoped passes run through GuardrailService::run_input_pass (service_pass.rs).
  • Terminal block responses: Every block on every wire is rendered by response::block_into_response, which attaches a process-local GuardrailBlocked marker. Both the ingress fallback driver and the /v1/decisions chain (TriggerReason::GuardrailBlocked) treat marked responses as committed so a guardrail block never hops to a fallback model.
  • Dynamic service slot (GuardrailServiceHandle): Stored in AppState behind a lock-free, late-settable arc-swap slot (src/services/guardrail/handle.rs) rather than a startup-frozen Option. Request paths load the handle once per request with a single atomic load and reuse it across gates. A router started with guardrails disabled can materialize a service later via guardrail_service_factory (src/server/state.rs, including backend: provider resolution) on a local enabled: true hot-reload or an enforceable Hub guardrail policy. If a Hub policy demands enforcement the router cannot execute, it is rejected whole with guardrails_unavailable on the control-plane policy store while local configuration continues serving.

The layer hooks the request lifecycle at two points:

  • Input pre-call: Before backend dispatch, once per request, awaiting all input providers. A Block verdict short-circuits the request without calling the backend; a Transform rewrites the prompt before dispatch. On OpenAI chat completions, it runs once for both streaming and non-streaming arms (src/proxy/chat_input_gate.rs) before response-cache lookup, global prompt injection, and disaggregated dispatch, so a blocked streaming request receives a standard non-streamed block response before SSE starts. (GuardrailService::run_input_during_call remains available for library callers.)
  • Output post-call: After the backend returns, over the generated text, before the response is returned or cached. For streaming responses, this runs as a rolling-window check (buffer_full, chunked, or passthrough).

GuardrailService owns all policy (monitor vs. enforce mode, bypass allowlist, per-route provider subsetting, per-category thresholds, allow/deny lists, per-provider timeouts, and fail-open/fail-closed disposition), aggregates verdicts from configured providers (OpenAI Moderation, self-hosted classifier, PII, AWS Bedrock, Azure Content Safety) using a most-severe-wins rule, and emits Prometheus metrics and an audit-log entry for every decision.

sequenceDiagram
    participant Client
    participant Gate as Guardrail Gate
    participant Service as GuardrailService
    participant Backend as LLM Backend

    Client->>Gate: request (prompt / messages)
    Gate->>Service: input pre-call
    alt Block (enforce)
        Service-->>Client: block response (content_filter / refusal / error)
    else Allow or Transform
        Service-->>Gate: verdict
        Gate->>Backend: forward (possibly transformed) request
        Backend-->>Gate: response
        Gate->>Service: output post-call
        alt Block (enforce)
            Service-->>Client: block response
        else Allow or Transform
            Service-->>Client: response (possibly sanitized)
        end
    end

For the operator-facing guide (concepts, provider setup, configuration, threshold tuning, admin controls, and metrics), see Guardrails.

Claude Code Safeguards Layer

When safeguards.action_safety.enabled: true is configured, ActionSafetyEvaluator (src/services/action_safety.rs) evaluates tool actions proposed by conversion-backend responses before the client acts on them. When the section is absent, safeguard-bearing requests pass through unchanged on native paths and synthesize nothing on conversion paths.

  • Action extraction and evaluation: Driven from the Anthropic Messages handlers and the /v1/responses conversion arm:
    • Extracts proposed tool_use blocks (or /v1/responses tool_calls) in wire order and dispatches once per action to the pinned evaluator backend.
    • Attaches verdicts as safeguard_results via action_safety::wire: a dangerous_tool_use check keyed by tool-use id (not_flagged / flagged) plus an unavailable check when any action resolves indeterminate. On streaming /v1/responses, the results event is emitted before [DONE]; on streaming Anthropic Messages, the terminal message_delta and message_stop are held back within the per-request action budget until pending evaluations complete, and the verdicts ride on that message_delta as delta.safeguard_results (#2682). A reply that proposes no tool use carries an available check with an empty tool_uses map.
    • System One evaluator (safeguards.action_safety.evaluator: systemone): Uses SystemOneEvaluator (src/services/action_safety/systemone.rs) over the shared SystemOneClient (cached per router) to issue one decision call per action over {tool, arguments, user_request, recent_tool_results}. In advisory mode, it runs concurrently with the chat-model evaluator and feeds diagnostics and safeguards_action_safety_advisory_total; in enforce mode, its fail-closed mapping produces the emitted verdict.
    • Fail-closed guarantee: Evaluator unavailability, timeouts, malformed/unknown verdicts, refusals, and budget exhaustion all resolve to non-allow outcomes, never a synthesized allow. Evaluator usage is attributed separately from generation, and diagnostics log only evaluation outcomes (never payloads, tool arguments, or credentials).
  • Client-facing POST /v1/decisions endpoint (alias /v1/systemone): Exposes the System One decision protocol outward (src/proxy/decisions/):
    • Validates the request body with shared question rules before dispatch, applies the per-key model gate, and walks fallback.fallback_chains via FallbackService::execute_with_fallback_snapshot.
    • Target passthrough vs. chat emulation: Each hop is either a passthrough to decisions.targets[] via SystemOneClient cached in ClientCache by router and target name (src/services/decisions/targets.rs), or an emulation on a chat model (src/services/decisions/emulation.rs). Emulation converts the question map into a forced emit_decision tool call, applies chat's composed request_params policy and input guardrail pre-call gate (src/proxy/decisions/emulation_gate.rs), dispatches through the chat retry loop (proxy::request::execute_decision_chat_hop), and maps the resulting tool arguments back to one-hot answers marked emulated: true.
    • Routing and access control: Resolves emulated model names via dispatch_model_for_request (metadata aliases and model experiments), rejects inline-endpoint targets for API keys with a non-empty allowed_backends (treated as an unavailable backend on chain hops so the chain skips it without sending), and records usage on the caller's key under the serving model via the non-streaming stats tap.

For the operator-facing guide (native passthrough, adapter setup, client-side workarounds, and the protocol record), see Integrating with Claude Code.

Responses-API Routing for responses_only Models

Some OpenAI Pro models (currently gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro, gpt-5.5-pro) are exposed exclusively via /v1/responses and return 404 not_found from /v1/chat/completions. The responses_only: true capability flag in model-metadata.yaml (and the built-in OpenAI registry) marks these models so the router transparently dispatches them to the Responses API surface, regardless of which client surface the request arrived on.

The flag is consulted at the proxy layer right after model lookup, before backend dispatch. The same model metadata pipeline that resolves capability information also resolves responses_only, so the lookup composes with backend-specific overrides, the external metadata file, and built-in defaults in the standard priority order (see Metadata Priority and Alias Resolution).

sequenceDiagram
    participant Client
    participant ChatHandler as POST /v1/chat/completions
    participant AnthropicHandler as POST /anthropic/v1/messages
    participant Lookup as is_responses_only_model()
    participant Converter as Request Converter
    participant Backend as OpenAI / Azure OpenAI

    alt OpenAI surface
        Client->>ChatHandler: chat.completion request
        ChatHandler->>Lookup: (backend, model)
    else Anthropic surface
        Client->>AnthropicHandler: messages request
        AnthropicHandler->>Lookup: (backend, model)
    end

    alt responses_only = true
        alt Backend is OpenAI / Azure OpenAI
            Lookup-->>Converter: Convert to /v1/responses payload
            Converter->>Backend: POST /v1/responses
            Backend-->>Converter: Responses API response (or SSE)
            Converter-->>Client: Translated to original surface shape
        else Backend is not OpenAI / Azure OpenAI
            Lookup-->>Client: 400 invalid_request_error
        end
    else responses_only = false
        Lookup-->>Backend: Standard /v1/chat/completions dispatch
        Backend-->>Client: chat.completion / Anthropic Messages
    end

Key properties:

  • The dispatch is transparent on both surfaces: clients keep using /v1/chat/completions or /anthropic/v1/messages and receive responses in the matching shape.
  • Only OpenAI and Azure OpenAI backends serve /v1/responses; other backend types are rejected with 400 invalid_request_error before any upstream call.
  • The first dispatch per (backend, model) pair logs at info level 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 notify crate, publishes updates via tokio::sync::watch channel
  • 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:

  1. When a backend is removed from config, it's marked as Draining
  2. New requests skip draining backends
  3. Existing in-flight requests/streams continue uninterrupted (Arc reference counting)
  4. Background cleanup task runs every 10 seconds
  5. 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

  1. Path Traversal Protection: Validates paths to prevent directory traversal attacks
  2. File Size Limits: Maximum 10MB configuration files to prevent DoS
  3. Format Validation: Only processes .yaml, .yml, and .toml files
  4. System Directory Protection: Blocks access to sensitive system paths
  5. Test Mode Relaxation: Uses conditional compilation for test-friendly validation

Example Migration: v1.0 to v2.0

// V1ToV2Migration implementation
fn migrate(&self, config: &mut Value) -> Result<(), MigrationError> {
    // Convert single backend_url to backends array
    if let Some(backend_url) = config.get("backend_url") {
        let mut backends = Vec::new();
        let mut backend = Map::new();
        backend.insert("url".to_string(), backend_url.clone());

        // Move models to backend
        if let Some(model) = config.get("model") {
            backend.insert("models".to_string(), 
                Value::Sequence(vec![model.clone()]));
        }

        backends.push(Value::Mapping(backend));
        config["backends"] = Value::Sequence(backends);

        // Remove old fields
        config.remove("backend_url");
        config.remove("model");
    }
    Ok(())
}

Configuration Loading Flow

graph TD
    A[Application Start] --> B[Config Manager Init]
    B --> C{Config File Specified?}
    C -->|Yes| D[Load Specified File]
    C -->|No| E[Search Standard Locations]

    E --> F{Config File Found?}
    F -->|Yes| G[Load Config File]
    F -->|No| H[Use CLI Args + Env Vars + Defaults]

    D --> I[Parse YAML]
    G --> I
    H --> J[Create Config from Args]

    I --> K[Apply Environment Variable Overrides]
    J --> K

    K --> L[Apply CLI Argument Overrides]
    L --> M[Validate Configuration]
    M --> N{Valid?}
    N -->|Yes| O[Return Config]
    N -->|No| P[Exit with Error]

    O --> Q[Start File Watcher for Hot Reload]
    Q --> R[Application Running]

    Q --> S[Config File Changed]
    S --> T[Reload and Validate]
    T --> U{Valid?}
    U -->|Yes| V[Apply New Config]
    U -->|No| W[Log Error, Keep Old Config]

    V --> R
    W --> R

Dependency Injection

Nothing is resolved from a service container at runtime. Services are ordinary constructor arguments: src/server/state.rs builds them during startup, AppState (src/lib.rs) holds them, and each component receives the handles it needs. Where a consumer must not be bound to one concrete implementation, that argument is a trait object rather than a concrete type.

BackendService (src/core/traits.rs) is the seam where this matters. It exposes get_backend, list_backends, get_healthy_backends, and get_stats over the router's backends, and BackendManager (src/infrastructure/backends/pool/backend_manager.rs) is its only production implementation, delegating to a BackendPool and an optional Arc<dyn HealthChecker>. BackendPool is Arc-backed, so BackendManager::from_pool wraps the running pool instead of copying it.

src/core/container.rs still carries a Container from an earlier design that was never wired up: it is constructed only inside its own unit tests and registers no service.

Service Construction

Two sites in src/server/state.rs wrap the shared pool as a BackendService and hand it to a consumer.

// src/server/state.rs: the guardrail service factory.
pub(crate) fn guardrail_service_factory(
    client: reqwest::Client,
    backend_pool: BackendPool,
    topology: LiveBackendTopology,
) -> GuardrailServiceFactory {
    Arc::new(move |g_cfg| {
        // BackendManager is the only production BackendService. from_pool
        // shares the router's live pool rather than rebuilding it.
        let backend_resolver: Arc<dyn BackendService> =
            Arc::new(BackendManager::from_pool(backend_pool.clone()));

        // The resolver travels bundled with a live view of the configured
        // backend topology, so a `backend:` guard-model reference is checked
        // against the visibility contract on every guard call.
        let guardrail_service = GuardrailService::from_config(
            g_cfg,
            client.clone(),
            Some(GuardBackendAccess::new(backend_resolver, topology.clone())),
        );
        Arc::new(guardrail_service)
    })
}

// src/server/state.rs: the smart-routing LLM classifier receives the same
// kind of resolver, so classification dispatches through production backends,
// plus a live view of each backend's `request_extensions`, so a classification
// call carries the same extension headers user traffic does.
if let Some(llm_classifier) = LlmClassifier::from_runtime_config(
    config,
    classifier_config,
    Arc::new(BackendManager::from_pool(backend_pool.clone())),
    backend_topology.extensions_source(),
) {
    router = router.with_llm_classifier(llm_classifier, classifier_config.clone());
}

Service Dependencies

A consumer keeps the trait object it was handed and calls the trait methods on it. Nothing is looked up.

// src/services/smart_routing/llm_classifier.rs
pub struct LlmClassifier {
    backend_resolver: Arc<dyn BackendService>,
    extensions_source: BackendExtensionsSource,
    backend_name: String,
    // ... prompt, schema, cache, and config fields
}

impl LlmClassifier {
    async fn send_request(&self, request_payload: &Value) -> Result<Value, String> {
        // The backend's current extension headers, then the recursion guard,
        // which replaces any configured spelling of it.
        let headers = self.dispatch_headers();

        let backend = self
            .backend_resolver
            .get_backend(&self.backend_name)
            .await
            .map_err(|e| {
                format!(
                    "classifier backend '{}' is not available in the live backend pool: {e}",
                    self.backend_name
                )
            })?;

        // Dispatching through the resolved backend reuses the same request
        // transformation, authentication, and resilience as user traffic.
        let response = backend
            .execute_chat_completion(request_payload, Some(headers))
            .await;
        // ...
    }
}

Benefits

  • Testability: a consumer takes Arc<dyn BackendService>, so a test can hand it a BackendManager over a pool it built itself instead of starting the router.
  • Shared live state: from_pool shares the running pool's backends, stats, and lifecycle state, so a subsystem observes what production traffic observes rather than a second copy.
  • Substitutable resolution: consumers depend on the trait, not on BackendManager, so a different resolver can be swapped in at the construction site without touching them.

Error Handling Strategy

The router implements an error handling strategy with typed errors, automatic recovery, and OpenAI-compatible error responses.

Error Type Hierarchy

  • CoreError: Domain-level errors (validation, service failures, timeouts, configuration)
  • RouterError: Application-level errors combining Core, HTTP, Backend, and Model errors
  • HttpError: HTTP-specific errors (400 BadRequest, 401 Unauthorized, 404 NotFound, 500 InternalServerError, etc.)

Error Handling Principles

  1. Fail Fast: Validate inputs early with clear error messages
  2. Error Context: Include relevant context (field names, operation details)
  3. Retryable Classification: Distinguish between retryable (timeout, 503) and non-retryable (400, 401) errors
  4. User-Friendly Responses: Convert internal errors to OpenAI-compatible error format
  5. Structured Logging: Log errors with appropriate severity and context

Error Recovery Mechanisms

  • Retry with Exponential Backoff: Automatically retry transient failures
  • Model Fallback: Route to alternative models when primary unavailable (see Model Fallback System)
  • Graceful Degradation: Continue with reduced functionality when components fail

For detailed error handling, recovery strategies, monitoring, and troubleshooting, see error-handling.md.

Extension Points

Backend Type Architecture

The router supports multiple backend types with different API formats. Each backend type handles request/response transformation automatically.

Supported Backend Types

Backend Type API Format Authentication Use Case
openai OpenAI Chat Completions Authorization: Bearer OpenAI API
azure OpenAI Chat Completions Authorization: Bearer Azure OpenAI Service
anthropic Anthropic Messages API x-api-key header Claude models via native API
gemini OpenAI-compatible Authorization: Bearer Google Gemini via OpenAI compatibility layer
vllm OpenAI-compatible Authorization: Bearer vLLM inference server
sglang OpenAI-compatible Authorization: Bearer SGLang inference server (sglang.srt)
ollama OpenAI-compatible None (local) Ollama local inference
llamacpp OpenAI-compatible None (local) llama.cpp / llama-server
lmstudio OpenAI-compatible None (local) LM Studio local inference
continuum-router OpenAI-compatible Authorization: Bearer Continuum Router / Backend.AI GO federation
generic OpenAI-compatible Configurable Any OpenAI-compatible API

Anthropic Backend Architecture

The Anthropic backend provides native support for Claude models with automatic format translation:

Key Transformations

Request Format Differences:

Aspect OpenAI Format Anthropic Format
System prompt messages[0].role="system" Separate system parameter
Auth header Authorization: Bearer x-api-key
Max tokens Optional Required (max_tokens)
Images image_url.url source.type + source.data

Extended Thinking Support:

// OpenAI reasoning_effort → Anthropic thinking
{
  "reasoning_effort": "high"  // OpenAI format
}
// Transforms to:
{
  "thinking": {
    "type": "enabled",
    "budget_tokens": 32768   // Mapped from effort level
  }
}

Tool Calling Transformation

The router provides automatic transformation of OpenAI-format tool definitions to backend-native formats, enabling cross-provider tool calling support.

Location: src/core/tool_calling/

Module Structure:

src/core/tool_calling/
├── mod.rs           # Module exports
├── definitions.rs   # Type definitions (ToolDefinition, FunctionDefinition, etc.)
├── transform.rs     # Transformation functions for each backend
├── tool_choice.rs   # Tool choice transformation for each backend
├── response.rs      # Tool call response extraction from backends
├── streaming.rs     # Streaming tool call transformation
└── messages.rs      # Multi-turn conversation message transformation

Supported Backends

Backend Input Format Output Format Notes
OpenAI Native Pass-through Validates tool names
Anthropic OpenAI input_schema format parameters → input_schema
Gemini OpenAI functionDeclarations Nested structure
llama.cpp OpenAI Pass-through Validates with --jinja flag

OpenAI Tool Format (Input)

{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "Get current weather for a location",
        "parameters": {
          "type": "object",
          "properties": {
            "location": { "type": "string" }
          },
          "required": ["location"]
        }
      }
    }
  ]
}

Anthropic Transformation

// transform_tools_to_anthropic()
// Input:  OpenAI format with `function.parameters`
// Output: Anthropic format with `input_schema`
{
  "tools": [
    {
      "name": "get_weather",
      "description": "Get current weather for a location",
      "input_schema": {
        "type": "object",
        "properties": {
          "location": { "type": "string" }
        },
        "required": ["location"]
      }
    }
  ]
}

Gemini Transformation

// transform_tools_to_gemini()
// Input:  OpenAI format
// Output: Gemini nested functionDeclarations format
{
  "tools": [
    {
      "functionDeclarations": [
        {
          "name": "get_weather",
          "description": "Get current weather for a location",
          "parameters": {
            "type": "object",
            "properties": {
              "location": { "type": "string" }
            },
            "required": ["location"]
          }
        }
      ]
    }
  ]
}

Validation Rules

  • Tool Name: Must match pattern ^[a-zA-Z0-9_-]+$ (alphanumeric, underscore, hyphen only)
  • Empty Name: Rejected with validation error
  • Missing Fields: Logged as warnings, tool skipped (no silent data loss)
  • Type Field: Must be "function" for standard tool definitions

Integration with Fallback System

The tool calling transformation integrates with the model fallback system via ParameterTranslator:

// In src/core/fallback/translation.rs
impl ParameterTranslator {
    pub fn translate_tools(
        &self,
        tools: &Value,
        from_backend_type: BackendType,
        to_backend_type: BackendType,
    ) -> CoreResult<Value> {
        match (from_backend_type, to_backend_type) {
            (_, BackendType::Anthropic) => transform_tools_to_anthropic(tools),
            (_, BackendType::Gemini) => transform_tools_to_gemini(tools),
            (_, BackendType::LlamaCpp) => transform_tools_for_llamacpp(tools),
            _ => Ok(tools.clone()),
        }
    }
}

This enables fallback from one provider to another while preserving tool definitions.

Tool Choice Transformation

In addition to tool definitions, the router also transforms the tool_choice parameter which controls model tool-calling behavior.

Supported Values:

OpenAI Value Description Anthropic Gemini
"auto" Model decides whether to call tools {"type": "auto"} mode: "AUTO"
"none" Model should not call tools Remove tools entirely mode: "NONE"
"required" Model must call at least one tool {"type": "any"} mode: "ANY"
{"type": "function", "function": {"name": "X"}} Force specific function {"type": "tool", "name": "X"} mode: "ANY", allowed_function_names: ["X"]

Edge Cases:

  • Anthropic "none" workaround: Anthropic API does not support tool_choice=none. When this value is detected, the router removes tools and tool_choice entirely from the request.
  • llama.cpp: Preserves parallel_tool_calls parameter 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 message
  • MAX_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_error indicator: 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_signature can still make function calls (but may have degraded conversation continuity on Gemini 3+)
  • The extra_content field 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

  1. Declare the type: add a variant to BackendTypeConfig in src/core/config/models/backend_type.rs. The type: field of each backends[] entry deserializes into this enum. A new variant also requires the configuration-assistant updates listed under "Configuration Assistant Sync" in AGENTS.md.

  2. Implement the Backend trait: the trait is defined in src/core/traits.rs and the factory imports it as BackendTrait.

    // In src/infrastructure/backends/custom_backend.rs
    use std::collections::HashMap;
    use std::sync::Arc;
    use std::time::Duration;
    
    use async_trait::async_trait;
    use serde_json::Value;
    
    use crate::core::CoreError;
    use crate::core::traits::{
        Backend, BackendCapabilities, BackendResponse, BackendType, HealthStatus,
    };
    
    pub struct CustomBackend {
        client: Arc<HttpClient>,
        config: CustomBackendConfig,
    }
    
    #[async_trait]
    impl Backend for CustomBackend {
        fn name(&self) -> &str { /* ... */ }
        fn url(&self) -> &str { /* ... */ }
        fn weight(&self) -> u32 { /* ... */ }
        fn supported_models(&self) -> &[String] { /* ... */ }
        fn supports_model(&self, model: &str) -> bool { /* ... */ }
        fn backend_type(&self) -> BackendType { BackendType::Custom("custom".to_string()) }
        fn capabilities(&self) -> BackendCapabilities { /* ... */ }
        fn metadata(&self) -> &HashMap<String, Value> { /* ... */ }
        fn request_timeout(&self) -> Duration { /* ... */ }
    
        async fn health_check(&self) -> Result<HealthStatus, CoreError> { /* ... */ }
        async fn execute_chat_completion(
            &self,
            request: &Value,
            headers: Option<HashMap<String, String>>,
        ) -> Result<BackendResponse, CoreError> { /* ... */ }
    
        // Also required: execute_completion, execute_streaming_request,
        // get_models, get_model_details, get_statistics, get_current_load,
        // validate_config, transform_request, and transform_response.
    }
    

  3. Register in the backend factory: add an arm to BackendFactory::create_backend_from_typed_config in src/infrastructure/backends/factory/backend_factory.rs.

    match config.backend_type {
        // ... existing arms ...
        BackendTypeConfig::Custom => {
            let backend = CustomBackend::from_config(config).await?;
            Ok(Arc::new(backend))
        }
    }
    

Adding New Middleware

  1. 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() }
        }
    }
    

  2. Register in HTTP Router:

    // In src/main.rs
    let app = Router::new()
        .route("/v1/models", get(list_models))
        .layer(CustomMiddleware::new(config.custom))
        .layer(LoggingMiddleware::new())
        .with_state(state);
    

Adding New Cache Types

  1. Implement the CacheStore trait:
    // In src/infrastructure/cache/your_store.rs
    pub struct YourCacheStore { /* ... */ }
    
    #[async_trait]
    impl CacheStore for YourCacheStore {
        async fn get(&self, key: &str) -> CacheStoreResult<Option<Vec<u8>>> { /* ... */ }
        async fn set(&self, key: &str, value: &[u8], ttl: Duration) -> CacheStoreResult<()> { /* ... */ }
        async fn delete(&self, key: &str) -> CacheStoreResult<()> { /* ... */ }
        async fn clear(&self) -> CacheStoreResult<()> { /* ... */ }
        async fn stats(&self) -> CacheStoreStats { /* ... */ }
    }
    

The RedisCacheStore (src/infrastructure/cache/redis_store.rs) is the shipped shared-state implementation, gated behind the redis-cache Cargo feature. That feature is not in default/full, but official release binaries and container images compile it, so a multi-replica deployment can share the cache and rate-limit counters without a custom build. The supported endpoint topology (one managed endpoint; Sentinel and Cluster out of contract) is defined and enforced in src/core/config/redis_endpoint.rs, which both the response cache and the distributed rate limiter validate against at config load.

  1. Inject into ResponseCacheStore:
    let store: Arc<dyn CacheStore> = Arc::new(YourCacheStore::new(/* ... */));
    let cache = ResponseCacheStore::with_store(store, capacity, ttl, max_response_size, max_stream_buffer_size);
    

Adding New Load Balancing Strategies

Selection strategies are declared in the SelectionStrategy enum (src/core/models/backend.rs), and selection runs through the BackendPool scorer pipeline (BackendScorer in src/core/scoring.rs).

  1. Implement BackendScorer:
// In src/core/scoring.rs (or a new scorer module)
pub struct LeastBusyScorer;

#[async_trait]
impl BackendScorer for LeastBusyScorer {
    fn name(&self) -> &'static str { "least_busy" }

    async fn prepare(&self, _context: &ScoringContext) {
        // Cache data that score() reads synchronously (default implementation is a no-op)
    }

    fn score(&self, context: &ScoringContext, backend: &dyn Backend) -> f64 {
        // Higher values indicate a stronger preference.
        // Return f64::NEG_INFINITY to mark the backend as ineligible.
        /* ... */
    }
}
  1. Register the scorer with the pool:
pool.register_scorer(Arc::new(LeastBusyScorer)).await;

The base strategies (RoundRobin, WeightedRoundRobin, LeastLatency, Random, ConsistentHash, and others) are configured through SelectionStrategy (src/core/models/backend.rs); see the Load Balancing Guide for configuration.

Design Decisions

Why 4-Layer Architecture?

Decision: Use a 4-layer architecture (HTTP → Services → Infrastructure → Core)

Rationale

  • Clear Separation: Each layer has distinct responsibilities
  • Testability: Layers can be tested independently
  • Maintainability: Changes in one layer don't affect others
  • Flexibility: Easy to swap implementations (e.g., different cache backends)

Trade-offs

  • ✅ Pros: Clean, maintainable, testable, extensible
  • ❌ Cons: More complexity, slight performance overhead
  • Verdict: Benefits outweigh costs for a production system

Why Constructor Injection Instead of a DI Container?

Decision: Pass services as ordinary constructor arguments; use no runtime service container

Rationale

  • Compile-time wiring: The service graph is visible in src/server/state.rs and checked at build time
  • No runtime resolution failure mode: A missing registration is a compile error, not a startup failure
  • Testing: Each component is constructed directly with the mocks and test doubles it needs

Alternatives Considered

  • Runtime DI container (src/core/container.rs): implemented early, never wired up, and carried as dead code

Why Arc<RwLock> for Shared State?

Decision: Use Arc<RwLock<T>> for shared mutable state

Rationale

  • Reader-Writer Semantics: Multiple readers, exclusive writers
  • Performance: Better than Arc<Mutex<T>> for read-heavy workloads
  • Safety: Prevents data races at compile time

Alternatives Considered

  • Arc<Mutex<T>>: Simpler but worse performance for reads
  • Channels: Too complex for simple shared state
  • Atomic types: Not suitable for complex data structures

Why async/await Throughout?

Decision: Use async/await for all I/O operations

Rationale

  • Performance: Non-blocking I/O allows high concurrency
  • Resource Efficiency: Lower memory usage than thread-per-request
  • Ecosystem: Rust async ecosystem (Tokio, reqwest, axum) is mature

Trade-offs

  • ✅ Pros: High performance, low resource usage, good ecosystem
  • ❌ Cons: Complexity, learning curve, debugging challenges
  • Verdict: Essential for high-performance network services

Why Configuration Hot-Reload?

Decision: Support configuration hot-reload using file watching

Rationale

  • Zero Downtime: Update configuration without restarting
  • Operations Friendly: Easy to adjust settings in production
  • Development: Faster iteration during development

Implementation

  • File system watcher detects changes
  • Validate new configuration before applying
  • Atomic updates to avoid inconsistent state
  • Fallback to previous config on validation errors

Performance Considerations

Memory Management

  1. Connection Pooling: Reuse HTTP connections to reduce allocation overhead
  2. Smart Caching: LRU eviction prevents unbounded memory growth
  3. Arc Cloning: Cheap reference counting instead of deep cloning
  4. Streaming: Process responses in chunks to avoid loading large responses into memory

Concurrency

  1. RwLock for Read-Heavy Workloads: Multiple concurrent readers for backend pool and model cache
  2. Lock-Free Where Possible: Use atomics for counters and simple state
  3. Async Task Spawning: Background tasks for health checks and cache updates
  4. Bounded Channels: Prevent unbounded queuing of tasks
  5. Streaming Relay Channels: Client-facing SSE relays are the exception. Every relay sends through the one shared relay channel (services::streaming::sse_relay_channel), which never waits on the client, so the relay task reads the upstream at the upstream's pace and observes the terminal usage however slowly the client reads; a disconnect fails the next send, which stops the relay and drops the upstream. Per-stream buffering is bounded by the upstream reply (its output-token cap and timeouts.streaming.total). tests/streaming_relay_pacing_audit_test.rs rejects a bounded channel of SSE events and an Sse body that does not serve this channel.

I/O Optimization

  1. Connection Keep-Alive: TCP connections stay open for reuse
  2. Streaming Responses: Forward SSE chunks without buffering
  3. Timeouts: Prevent hanging on slow backends
  4. 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

  1. Token Bucket Algorithm (token_bucket.rs)
  2. Configurable bucket capacity and refill rate
  3. Atomic operations for lock-free token consumption
  4. Automatic token replenishment based on elapsed time
  5. Separate buckets for sustained and burst limits

  6. Rate Limit Store (store.rs)

  7. Per-client state tracking with DashMap for concurrent access
  8. Automatic cleanup of expired client entries
  9. Configurable TTL for inactive clients (default: 1 hour)
  10. Memory-efficient with bounded storage

  11. Middleware Integration (middleware.rs)

  12. Extracts client identifier (API key → IP address fallback)
  13. Checks both sustained and burst limits before processing
  14. Returns HTTP 429 (Too Many Requests) with Retry-After header
  15. Prometheus metrics for monitoring rate limit hits

Configuration Example

rate_limiting:
  enabled: true
  sustained:
    max_requests: 100
    window_seconds: 60
  burst:
    max_requests: 20
    window_seconds: 5
  cleanup_interval_seconds: 300

Decision Flow

Request arrives
    ↓
Extract client ID (API key or IP)
    ↓
Check sustained limit (100 req/min)
    ↓ OK
Check burst limit (20 req/5s)
    ↓ OK
Process request

For detailed configuration information, see the Rate Limiting section in the configuration guide.

Model Fallback System

The router implements a configurable model fallback system that automatically routes requests to alternative models when the primary model is unavailable.

Key Features: - Automatic fallback chain execution (e.g., gpt-5.6-sol → gpt-5.6-terra → gpt-5.6-luna) - Cross-provider fallback support with a canonical request payload on every hop - Triggering from configured HTTP errors, timeouts, connection failures, model lookup failures, and backend health - Prometheus metrics for monitoring fallback usage

For detailed configuration and implementation, see error-handling.md section on model fallback.

Circuit Breaker

The router contains a per-backend circuit-breaker state machine with Admin inspection and force controls. The ordinary LLM proxy path records request outcomes in it and consults it during selection: an open backend circuit is excluded so traffic routes around it, and when every compatible circuit is open the router returns the standard service-unavailable error and participates in configured fallback. It complements active health checks, retry, and model fallback on that path.

Three-State Machine:

State Behavior
Closed Normal operation. Failures are counted.
Open Fast-fail mode. Requests rejected immediately.
HalfOpen Recovery testing. Limited requests allowed.

Key Features: - Per-backend isolation with independent state - Lock-free atomic operations for minimal hot-path overhead - Admin endpoints for manual control (/admin/circuit/*) - A metrics collector registered in the main metrics registry and populated by the proxy path

For detailed configuration and implementation, see error-handling.md section on circuit breaker.

File Storage

The router provides OpenAI Files API compatible file storage with persistent metadata.

Key Features: - Persistent metadata storage with sidecar JSON files - Automatic recovery on server restart - Orphan file detection and cleanup - Pluggable backends (memory/persistent)

For detailed architecture and implementation, see File Storage Guide.

Image Generation Architecture

The router provides a unified interface for image generation across multiple backends (OpenAI GPT Image, DALL-E, and Google Gemini/Nano Banana) with automatic parameter translation.

Multi-Backend Image Generation

OpenAI → Gemini Parameter Conversion

When using Nano Banana (Gemini) models, OpenAI-style parameters are automatically converted to Gemini's native format:

Size to Aspect Ratio Mapping

OpenAI size Gemini aspectRatio Gemini imageSize Notes
256x256 1:1 1K Minimum Gemini size
512x512 1:1 1K Minimum Gemini size
1024x1024 1:1 1K Default
1536x1024 3:2 1K Landscape
1024x1536 2:3 1K Portrait
1792x1024 16:9 1K Wide landscape
1024x1792 9:16 1K Tall portrait
2048x2048 1:1 2K nano-banana-pro and nano-banana-2
4096x4096 1:1 4K nano-banana-pro and nano-banana-2
auto 1:1 1K Default fallback

Request Transformation

OpenAI Format (Input):

{
  "model": "nano-banana",
  "prompt": "A serene Japanese garden",
  "size": "1536x1024",
  "n": 1
}

Gemini Format (Converted):

{
  "contents": [
    {
      "parts": [{"text": "A serene Japanese garden"}]
    }
  ],
  "generationConfig": {
    "imageConfig": {
      "aspectRatio": "3:2",
      "imageSize": "1K"
    }
  }
}

Conversion Implementation

The conversion is handled by src/infrastructure/backends/gemini/image_generation.rs:

pub fn convert_openai_to_gemini(request: &OpenAIImageRequest)
    -> CoreResult<(String, GeminiImageRequest)>
{
    // 1. Map model name
    let gemini_model = map_model_to_gemini(&request.model);

    // 2. Parse size to aspect ratio and size category
    let parsed_size = parse_openai_size(&request.size, &request.model)?;

    // 3. Build Gemini request with imageConfig
    let gemini_request = GeminiImageRequest {
        contents: vec![GeminiContent { parts: vec![...] }],
        generation_config: Some(GeminiGenerationConfig {
            image_config: Some(GeminiImageConfig {
                aspect_ratio: Some(parsed_size.aspect_ratio.to_gemini_string()),
                image_size: Some(parsed_size.size_category.to_gemini_image_size()),
            }),
        }),
    };

    Ok((gemini_model, gemini_request))
}

Streaming Image Generation (SSE)

For GPT Image models, the router supports true SSE passthrough for streaming image generation:

┌─────────────┐                ┌─────────────┐                ┌─────────────┐
│   Client    │────stream:true─▶│   Router    │────stream:true─▶│   OpenAI    │
│             │                │             │                │             │
│             │◀───SSE events──│  Passthrough│◀───SSE events──│             │
└─────────────┘                └─────────────┘                └─────────────┘

SSE Event Types:

Event Description
image_generation.partial_image Intermediate preview during generation
image_generation.complete Final image data
image_generation.usage Token usage for billing
done Stream completion

Implementation (src/proxy/image_gen.rs):

async fn handle_streaming_image_generation(...) -> Result<Response, StatusCode> {
    // 1. Keep stream: true in backend request
    // 2. Make streaming request via bytes_stream()
    // 3. Forward SSE events through tokio channel

    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();

    tokio::spawn(async move {
        let mut stream = backend_response.bytes_stream();
        while let Some(chunk) = stream.next().await {
            // Parse SSE format (event:/data: lines)
            // Forward events to client
            for line in chunk_str.lines() {
                if let Some(event_type) = line.strip_prefix("event:") { ... }
                if let Some(data) = line.strip_prefix("data:") {
                    let event = Event::default().event(event_type).data(data);
                    tx.send(Ok(event));
                }
            }
        }
    });

    Ok(Sse::new(UnboundedReceiverStream::new(rx)).into_response())
}

GPT Image Model Features

The router supports enhanced parameters for GPT Image models (gpt-image-1, gpt-image-1.5, gpt-image-1-mini):

Parameter Description Values
output_format Image file format png, jpeg, webp
output_compression Compression level 0-100 (jpeg/webp only)
background Transparency control transparent, opaque, auto
quality Generation quality low, medium, high, auto (GPT Image 2.5 adds xhigh and max)
stream Enable SSE streaming true, false
partial_images Preview count 0-3

Model Support Matrix

Feature GPT Image 1.5 GPT Image 1 GPT Image 1 Mini DALL-E 3 DALL-E 2 Nano Banana Nano Banana Pro
Streaming ✅ ✅ ✅ ❌ ❌ ❌ ❌
output_format ✅ ✅ ✅ ❌ ❌ ❌ ❌
background ✅ ✅ ✅ ❌ ❌ ❌ ❌
Custom quality ✅ ✅ ✅ standard/hd ❌ ❌ ❌
Image Edit ✅ ✅ ✅ ❌ ✅ ❌ ❌
Image Variations ❌ ❌ ❌ ❌ ✅ ❌ ❌
Max Resolution 1536px 1536px 1536px 1792px 1024px 1024px 4096px

Image Edit and Variations

The router provides OpenAI-compatible image editing and variations endpoints through /v1/images/edits and /v1/images/variations.

Image Editing (/v1/images/edits)

Endpoint: POST /v1/images/edits

Allows editing an existing image with a text prompt and optional mask. Supported by GPT Image models and DALL-E 2.

Request Format (multipart/form-data):

image: <file>           # Original image (PNG, required)
prompt: <string>        # Edit instructions (required)
mask: <file>            # Optional mask image (PNG)
model: <string>         # Model name (default: "gpt-image-2")
n: <integer>            # Number of images (default: 1)
size: <string>          # Output size (e.g., "1024x1024")
response_format: <string> # "url" or "b64_json"

Implementation (src/proxy/image_edit.rs): - Multipart form parsing for image and mask files - Image validation (format, size, aspect ratio) - Model-specific parameter transformation - Proper error handling for invalid inputs

Supported Features

  • Transparent PNG mask support for targeted editing
  • Multiple image generation (n parameter)
  • Flexible output sizes
  • Both URL and base64 response formats

Image Variations (/v1/images/variations)

Endpoint: POST /v1/images/variations

Creates variations of a given image. Served by nano-banana, nano-banana-2, nano-banana-2-lite, and nano-banana-pro through the Gemini image backend, and by dall-e-2 on a backend that still serves it (OpenAI shut it down on 2026-05-12).

Request Format (multipart/form-data):

image: <file>           # Source image (PNG, required)
model: <string>         # Model name (default: "nano-banana")
n: <integer>            # Number of variations (default: 1, max: 10)
size: <string>          # Output size ("256x256", "512x512", "1024x1024")
response_format: <string> # "url" or "b64_json"

Implementation (src/proxy/image_edit.rs): - Image file validation and preprocessing - DALL-E 2-specific routing - Error handling for unsupported models - Consistent response formatting

Key Features

  • Generate multiple variations in a single request
  • Automatic image format validation
  • Standard OpenAI response format compatibility

Image Utilities Module

The image_utils.rs module provides shared utilities for image processing:

Functions

  • validate_image_format(): Validates PNG/JPEG format and dimensions
  • parse_multipart_image_request(): Extracts images from multipart forms
  • check_image_dimensions(): Validates size constraints
  • format_image_error_response(): Standardized error responses

Validation Rules

  • Maximum file size: 4MB (configurable)
  • Supported formats: PNG (required for edits/variations), JPEG (generation only)
  • Aspect ratio constraints per model
  • Transparent PNG requirement for masks

Control-Plane Agent (Continuum Hub)

The control-plane agent is an optional, outbound-only task that connects a router to a Continuum Hub control plane for fleet management: enrollment, liveness, usage metering, (opt-in) policy sync with local enforcement, (opt-in) fleet request-parameter configuration, (opt-in) provider batch dispatch with lifecycle reporting, and hub-defined synthetic provider/model probes with a router-local monthly budget ceiling. The hub is never in the request path. Routers remain the data plane and connect out to the hub; no inbound hub traffic reaches the router.

The agent is gated behind the control-plane Cargo feature, which stays out of the crate's default/full feature set. Official release binaries are built with the feature enabled in the Release Packaging workflow, so for the GitHub Release archives, the Docker images, and the .deb packages built from them the opt-in is the control_plane.enabled config switch, which defaults to false. A source build without the feature carries no extra dependency and no runtime cost. The control_plane config section is always parsed, so config validate accepts it in any build; a feature-less build that sets control_plane.enabled: true logs a warning at startup and does not start the agent.

What the agent does

  • Enroll: On first start, it exchanges a single-use enrollment_token for a per-router credential (tenant_id, router_id, router_credential). On a 409 name conflict it retries with a fresh name.
  • Persist credential: The credential is written to state_file (default ./data/control-plane/state.json, 0600 on Unix) and reused on restart, so the single-use token is not spent again. It is never logged. If the Hub later rejects the stored credential, the agent marks the identity rejected, logs the failure, and backs off; an administrator can exchange a new token through POST /admin/control-plane/enroll without deleting the state file.
  • Heartbeat: The first heartbeat is sent at startup rather than after one interval, and its first 2xx response tells policy sync that the Hub has stored the router's capabilities, which triggers an immediate policy refetch (prompt-logging consent window). After that it sends a heartbeat on the configured interval with a router inventory: router version, configured backends with per-backend health, the models served, an aggregate health summary, token-supply and saturation telemetry, cumulative guardrail counters, and two read-only descriptions that need no capability: smart_routing_inventory (whether smart routing runs, policy and profile counts, classifier method and backend name, virtual model) and guardrail_provider_inventory (each configured guardrail provider's name, type, stages, position, and whether it runs), counts and names only, read from the running pipeline and guardrail service (src/control_plane/smart_routing.rs, src/control_plane/guardrail/provider_inventory.rs). When policy sync is active, each beat also echoes the applied cursor plus the typed policy capabilities this router actually enforces, last-known-good cursor/digest, and optional value-free rejection evidence so the Hub can distinguish applied, pending, unsupported, and rejected revisions.
  • Fleet configuration: When control_plane.config_sync.enabled is set, enrollment and every inventory-carrying heartbeat include masked request_params, kv_routing, and model fallback snapshots. The agent polls, fetches, validates, and atomically applies revision-bound public configuration without admitting inbound Hub traffic.
  • Usage push: It buffers one metadata-only usage record per proxied request (built from the existing token-usage path) and pushes them in bounded batches on the configured cadence. A record may also carry the model-experiment arm that served it (experiment_id, experiment_variant_id) and, with usage.quality_judgment, a decision-model quality judgment in basis points. The judgment is attached only while the record is still buffered: the loop seals its slot when it forms the record into a batch, since a formed batch is re-sent byte for byte and the hub deduplicates on idempotency_key, so sequencing, charging, and the push order never wait for a judgment.
  • Reconnect with backoff: Enrollment, heartbeat, and usage-push failures use exponential backoff with full jitter (base 1s, cap 60s), reset on success.

The HTTPS poll client and the policy WebSocket stream both verify the Hub certificate against the operating system trust store. Install an internal Hub CA in the host or container trust store before enabling the agent. Certificate and hostname verification remain enabled; a CA that the platform does not trust is rejected. The policy stream logs the first connection failure at warning level and sends identical retry failures to debug while the poll loop covers delivery.

The router-initiated enrollment endpoint is an inbound Admin operation, not an inbound Hub channel. It is compiled only with control-plane, remains behind the existing Admin authentication and audit middleware, validates HTTPS except for loopback development endpoints, serializes with startup enrollment, and stores only the Hub-issued credential. Replacing a healthy credential requires explicit replace: true; a credential observed as rejected is eligible for recovery without manual file surgery. A successful exchange reports pending_restart because the already-running agent keeps its immutable startup identity until restart.

The broader control_plane configuration remains startup-owned. Its masked value is readable, but the runtime editable-section catalog omits it and section-level PUT/PATCH fail with an explanatory 409. Operators may therefore choose either the router-side token exchange plus a restart, or the existing source-config enrollment flow; policy and agent configuration continue to come from the source file.

Engine metrics identity

Backend configuration can carry an explicit engine_identity with a closed kind and canonical UUID. The outbound Hub inventory reads it from live configuration, while AppProxy ROUTER reconciliation copies the snapshot endpoint UUID to every replica. This metadata never changes the separate backend_id accounting path. The wire contract is additive protocol v0 and preserves unknown future kinds; local configuration rejects them.

Privacy invariant: metering metadata only

Usage records carry only metering metadata: token counts, model, provider, latency, cache and batch flags, ids, and timestamps. Prompt and completion bodies never leave the router. The wire type (continuum_protocol::UsageRecord) has a closed, fully typed field set, so there is no field that could carry request or response content.

Token counts on usage records

Every usage record splits the prompt into two disjoint buckets, which is how the Hub prices them: input_tokens is the non-cached input, billed at the input rate, and cached_input_tokens is the prompt served from a provider-side cache, billed at the cached rate. Hub input_tokens excludes cache reads for every backend shape.

The router gets there from one invariant on its internal token usage: prompt_tokens is the inclusive count of every prompt token (non-cached input plus cache reads plus cache writes), and the cache read is a subset of it. The record carries input_tokens = prompt_tokens - cache_read and cached_input_tokens = cache_read, which is exactly the billable input router-local enforcement charges against input_tpm and the monthly budget, so the router's counters and the Hub's metering agree. Cache writes stay in input_tokens.

Wire shapes that report an exclusive count are converted where they enter:

Shape Provider count Converted to the inclusive count by
OpenAI Chat Completions, vLLM, Gemini OpenAI-compatible prompt_tokens includes prompt_tokens_details.cached_tokens Already inclusive
OpenAI Responses API input_tokens includes input_tokens_details.cached_tokens Already inclusive
Native Gemini (generateContent), reached by /v1/responses and by any Gemini-compatible backend that answers /v1/chat/completions in the native candidates shape usageMetadata.promptTokenCount includes cachedContentTokenCount Both native conversions emit the inclusive prompt count plus the cache read: transform_native_gemini_response as prompt_tokens and prompt_tokens_details.cached_tokens, and the Responses converter as input_tokens and input_tokens_details.cached_tokens. A native-shape chat stream reports the same counts on one trailing usage chunk (choices: [] beside usage, the OpenAI include_usage shape) that the shared stream transformer appends on the stream's terminal event from that same builder; it is emitted once rather than on every event carrying usageMetadata, because Gemini repeats cumulative totals on every native event, and the latest-wins streaming tap needs only that single chunk. A type: gemini backend answering /anthropic/v1/messages or /v1/chat/completions is normally reached through Gemini's own OpenAI-compatible surface instead, which reports prompt_tokens_details.cached_tokens directly and is covered by the first row
Anthropic Messages (native, streaming tracker, Bedrock runtime) input_tokens excludes cache_read_input_tokens and cache_creation_input_tokens The usage parser adds both back; the streaming tracker takes all three from message_start and, when present, the cumulative values on message_delta
Anthropic behind /v1/chat/completions Same as native The non-streaming response transform emits the inclusive prompt_tokens plus prompt_tokens_details.cached_tokens. Streaming does the same through the same helper: the shared stream transformer accumulates the counts from message_start and every message_delta, and emits them at message_stop as one usage-only chunk (choices: [] plus usage, the OpenAI include_usage shape), which is what both the native streaming tap and the pipeline observability tap meter. The finish chunk carries no usage. The same applies to a Unix-socket Anthropic backend and to Bedrock endpoint_type: runtime and mantle, which share the transformer
OpenAI-compatible or Responses backend behind /anthropic/v1/messages Inclusive upstream count The client-facing Anthropic body reports the exclusive input_tokens (upstream prompt minus both cache buckets); the router's own taps meter the upstream inclusive count, and this router's own re-parsing of the bridged body (a non-streaming response, its local cache replay, or a bridged stream it reconstructs itself) adds the cache buckets back to the same total. A downstream router metering a bridged stream with its native streaming tracker reads the deferred input and cache values from message_delta, so it lands on the same total
Responses backend behind /v1/chat/completions (the responses_only bridge) input_tokens includes input_tokens_details.cached_tokens One mapping serves both arms: the non-streaming body and the streaming include_usage chunk carry the inclusive prompt_tokens plus prompt_tokens_details.cached_tokens. The streamed arm is metered from the upstream's raw terminal usage (response.completed, or response.incomplete when it carries one), parsed by the shared usage parser, so a client that did not ask for include_usage is still charged
Anthropic behind /v1/responses Same as native The non-streaming converter and the stream translator emit the inclusive Responses input_tokens with input_tokens_details.cached_tokens and cache_write_tokens; the usage parser also accepts the legacy singular input_token_details spelling so older peers and cached or stored bodies meter the same way
Bedrock Converse inputTokens excludes cacheReadInputTokens and cacheWriteInputTokens The response and stream transforms emit the inclusive prompt_tokens
Local cache replay Every prompt token is a cache read Recorded as input_tokens = 0 and cached_input_tokens = the prompt
Provider batch completion No cache reads reported Unchanged

For a compressed request, uncompressed_input_tokens defaults to the inclusive prompt count and never falls below input_tokens + cached_input_tokens.

TokenUsage::from_json_value clamps both cache-subset fields for every shape above rather than trusting the provider's raw count:

  • cache_read_input_tokens is clamped to prompt_tokens first, and cache_creation_input_tokens is clamped to the remaining prompt tokens.
  • If a provider over-reports a cache bucket (such as cachedContentTokenCount > promptTokenCount), the Hub's cached_input_tokens cannot exceed the inclusive prompt count, and billable input stays 0 because the entire prompt is treated as cached. No per-request log line is emitted when clamping adjusts a value.
  • Streaming /anthropic/v1/messages bridged from an OpenAI-compatible or Responses backend applies the same clamp against the upstream inclusive prompt count rather than re-summing an Anthropic exclusive split that saturated on the over-report, so streaming and non-streaming requests record identical usage while the client-facing Anthropic wire still mirrors the provider's report.

Router-observed output counting and the stream-usage finalizer

Every streaming relay owns a StreamUsageFinalizer (src/services/streaming/usage_finalizer.rs) from its first event, finishes it once at normal stream completion, and routes the finalized record through record_finalized_stream_usage in src/services/streaming/transformer.rs (while the Anthropic Messages ingress feeds the same finalized record into record_stream_end_stats). This covers the chat transformer's OpenAI, thinking, Anthropic-source, and Gemini arms, the three Unix-socket relays, the mid-stream fallback relay (one finalizer per provider attempt), the chat pipeline tap, the responses_only bridge, the /v1/responses pass-through, and the seven Anthropic Messages stream arms. Each finalizer embeds an OutputTokenCounter (src/services/streaming/output_counter.rs) fed from the relay's single event parse, ensuring that streams whose backends never report usage still emit a single usage event with the router's observed count.

The counter counts one text delta per choice, content block, or candidate part per event, along with UTF-8 character and byte counts, by wire shape:

  • OpenAI chat chunk (choices[].delta): content, reasoning_content, reasoning, and tool_calls[].function.arguments and .name; n > 1 counts one delta per choice.
  • OpenAI /v1/completions chunk: choices[].text.
  • Anthropic content_block_delta: text_delta, thinking_delta, and input_json_delta.
  • Responses delta event: response.output_text.delta, response.reasoning_text.delta, response.reasoning_summary_text.delta, response.refusal.delta, and response.function_call_arguments.delta (read from data.type or from the SSE event: name on the responses_only bridge).
  • Native Gemini chunk (candidates[].content.parts[]): one per non-empty text part and one per functionCall part, including the characters of its compact args.

The delta count matches the engine's native output granularity on vLLM, SGLang, llama.cpp, and mlxcel (which emit one token per chunk). Engines that batch multiple tokens per chunk are corrected via post-stream /tokenize (token_source = engine_tokenize, described below) rather than a chars / 4 heuristic.

Finalized records resolve token counts, sources, and outcomes according to a strict precedence ladder:

  • Output count and token_source:
    1. engine_final: the backend's terminal usage block (a usage object alongside empty/absent choices or choices that all have a non-null finish_reason; provisional when n > 1 finishes choices across separate chunks and superseded if a subsequent per-chunk usage arrives).
    2. engine_continuous: the latest per-chunk usage (a usage object riding alongside a choice still streaming with a null finish_reason, as in vLLM's continuous_usage_stats).
    3. router_delta_count (estimated: true): the router's own delta count.
    4. none: zero when no output was observed.
  • Input count: resolved from the same engine usage block, else from a prompt-only engine report (such as a native Anthropic message_start without a message_delta), else from estimate_input_text over the dispatched body (estimated: true, walking messages[], system, instructions, and input), else zero. estimated is true whenever either input or output is router-estimated.
  • Outcome:
    • usage_missing: the stream reached its normal end without engine output usage (a prompt-only report still counts as usage_missing because output came from the router). usage_missing_total{backend} increments before queueing, whereas usage_records_total{outcome="usage_missing"} counts records received by the usage loop.
    • length: the finish reason was length (including Anthropic max_tokens, Gemini MAX_TOKENS, and Responses max_output_tokens; sticky once observed).
    • completed: any other normal stream completion.
    • Terminating exit outcomes (client_cancelled, timeout_first_byte, timeout_mid_stream, upstream_error, fallback_source, guardrail_blocked): recorded with the tokens observed up to termination.
  • Character and byte counters: output_chars and output_bytes are always populated on finalized streams; input_chars and input_bytes use the relay's input estimate when present or fall back to the request ledger's counts.

The chat pipeline tap (src/services/streaming/middleware/observability.rs) uses latest-wins semantics so a final usage block supersedes earlier engine_continuous chunks, finalizing once at [DONE], at source EOF, or in Drop. Native Anthropic message_start and message_delta events are accumulated by the Anthropic tracker rather than read directly by the finalizer (message_delta is output-only and would drop the prompt); the relay supplies the tracker's inclusive snapshot at stream end as engine usage only when a message_delta reported output_tokens.

Normal-end sites determine their exit via StreamUsageFinalizer::normal_end_exit: if the last event before stream end was an error (event: error, data type of error or response.failed, OpenAI-wire data: {"error":{...}}, a non-null top-level error without choices, or an upstream transport read error, even if followed by [DONE]), the exit is upstream_error rather than completed or usage_missing. In the mid-stream fallback relay, the accumulator's estimated_tokens() uses the same delta count for min_accumulated_tokens comparisons, and reasoning-only partials (without content or tool calls) never enter continuation mode.

Engine-reported partial usage

Two rungs of the token-source ladder produce engine-reported counts even when a stream terminates before a final usage block arrives:

  • engine_continuous (per-chunk usage): every OpenAI-wire streaming dispatch (/v1/chat/completions attempts and fallback hops, native Gemini streams, /anthropic/v1/messages conversions, and /v1/responses conversions) normalizes stream_options through apply_router_stream_options (src/services/streaming/stream_options.rs):
    • include_usage is forced to true. Every /v1/chat/completions client's stream follows its own include_usage: a client that did not set include_usage: true receives no usage (the chat ingress removes usage from every chunk and drops the trailing usage-only chunk), and a client that set include_usage: true receives usage only on the trailing usage-only chunk. Both run after the usage record, the response cache, and the guardrail gate have read the stream (src/proxy/chat_stream_usage.rs), and both cover an engine that sends usage regardless of the flag; see Usage on chat streams.
    • continuous_usage_stats: true is injected for backend types listed in CONTINUOUS_USAGE_BACKEND_TYPES and stripped for all other backend types (even if client-supplied), while preserving other client keys.
    • Multi-choice requests (n != 1) never carry continuous_usage_stats: vLLM's per-chunk usage reports only the choice in that chunk, which would under-bill an interrupted n > 1 stream. Those streams rely on the trailing usage block (summed across choices) or router_delta_count if interrupted.
    • When active on n = 1, vLLM emits cumulative usage on every chunk, so client cancellations and timeouts record the engine's latest count. The Anthropic conversion preserves the raw event classification and emits exactly one terminal message_delta: from the finish chunk it holds message_delta and message_stop until the trailing usage-only chunk, [DONE], or the end of the upstream stream, so the final counts ride that message_delta and message_stop is always the last event, whether the engine sends its usage on the finish chunk or after it (issue #2251).

Per-chunk usage support by engine

Backend type Per-chunk usage Injected Evidence
vllm Verified: stream_options.continuous_usage_stats gives a cumulative usage on every chunk Yes StreamOptions.continuous_usage_stats in vllm/entrypoints/generate/base/protocol.py at vllm-project/vllm 187a0eb98aa42341d703f83421d693fa7585581b; the OpenAI base model tolerates extra fields
sglang Upstream source has the option, not injected No StreamOptions.continuous_usage_stats in python/sglang/srt/entrypoints/openai/protocol.py at sgl-project/sglang f9cee8d2b2d96a626db1968ba81e4d72bb31794a; the release that first shipped it is not pinned, it attaches usage only to chunks with content, per choice, and no capture exists
tensorrt-llm Upstream source has the option, not injected No StreamOptions.continuous_usage_stats in tensorrt_llm/serve/openai_protocol.py at NVIDIA/TensorRT-LLM 7179c5b01b9d19acef69a6526330eb72f3b70e10; OpenAIBaseModel uses extra="forbid", so a server older than the field rejects the whole request with 400
every other type Not applicable No The key is removed from what the client sent, so the OpenAI cloud, Azure, Gemini, and every self-hosted type never see it

The allow-list is CONTINUOUS_USAGE_BACKEND_TYPES; an entry is added only with a captured stream from that engine, recorded by the procedure in tests/fixtures/continuous_usage_stats/README.md.

  • engine_tokenize (post-stream tokenizer correction): controlled by usage.tokenize_correction (disabled by default) in src/services/streaming/tokenize_correction.rs. When a stream sink is about to emit a router_delta_count record and the feature is enabled at stream end, candidate evaluation proceeds in order (recording one sample in usage_tokenize_corrections_total{result} for each eligible stream):
    • Eligibility and admission:
      • Records with another token_source, no retained text, or no served model are not candidates and emit immediately without incrementing the metric; a disabled block or missing Tokio runtime also emits immediately without counting.
      • unsupported: the backend was removed from configuration, has no engine tokenizer route (TokenizeEngine::of), or is not http(s).
      • skipped_truncated: retained text reached the counter's 100,000-byte cap.
      • shed: max_concurrent correction tasks are already in flight.
    • Deferred execution and validation:
      • Admitted records wait in a background task until the relay drops the stream's TokenizeCorrectionHandle (when the relay task finishes and drops its client sender), then POST the retained text once to the engine's tokenize endpoint via tokenizer_request (using the backend's effective auth, dispatch hop count, add_special_tokens: false or add_special: false on llama-server, and the served model id) within a timeout_ms budget measured from stream end.
      • Responses are read up to 1 MiB. Counts that are impossible for the retained text (0 for non-empty text, or exceeding the UTF-8 byte length) are rejected as error with a rate-limited WARN naming the backend, engine, and failure kind (never the text).
      • The record is emitted once: applied (token_source: engine_tokenize, estimated true only if the input remains router-estimated), or falling back to router_delta_count on timeout or error. The stream outcome never changes (usage_missing stays usage_missing), and the tokenize call bypasses circuit admission and settlement.
    • Deferred side effects and shutdown:
      • All record side effects (per-key stats, llm_tokens_total, export row, policy grant charge, write-ahead log completion, experiment share, and usage event with quality ticket) execute together during that deferred emission.
      • If the runtime shuts down before the task emits, dropping the task's WAL guard writes the attempt end line so boot replay does not misclassify it as router_restart (only an abrupt kill/crash leaves the WAL entry open). Because the control-plane agent flushes its final usage push at the start of graceful shutdown without awaiting in-flight tokenize tasks, corrections still running during shutdown may miss the final push within a window bounded by timeout_ms.
    • Scope and exclusions:
      • Retained text concatenates delta.content, delta.reasoning_content (or reasoning), and tool-call name/argument fragments in arrival order; because chat-template control tokens between segments are absent, the resulting count is a lower bound on generated tokens.
      • Attached by the chat and Responses context builders (including the responses_only bridge) and by record_stream_end_stats_correctable on the /anthropic/v1/messages OpenAI-wire conversion.
      • Excluded paths (emitted immediately with router_delta_count and not counted in usage_tokenize_corrections_total): mid-stream fallback attempts, native Anthropic/Bedrock/Responses-bridge relays on the Anthropic ingress (no engine tokenizer route), and the /v1/responses conversion stream (retains no text).

Stream exits and their usage outcome

Every exit across the OpenAI-wire streaming relays finishes its StreamUsageFinalizer once per attempt so interrupted streams record the tokens generated prior to termination:

  • Hand-rolled relays (the transformer's OpenAI, thinking, TCP Anthropic-source, and Gemini arms, the three Unix-socket relays, and the mid-stream fallback relay): latch the exit reason on every break from the read loop (src/services/streaming/relay_exit.rs, first-write-wins except that a terminal guardrail block overrides a normal end), finish once after the loop, and call finish_relay before any early return. The upstream stream is dropped immediately when the client disconnects, metering only what was observed before the drop.
  • Anthropic ingress, /v1/responses, and responses_only bridge relays: run their read loop to an Option<StreamExit> and finalize in a single tail (record_anthropic_stream_end, CompletedResponsePersister::finish, pass-through stream-end recording, or BridgeStreamEnd::record). tests/stream_early_exit_audit_test.rs enforces single-tail finalization and verifies that early exits never write duplicate stats rows.
Exit outcome token_source and output
The normal end ([DONE], or the end of the body) completed, length, usage_missing the ladder above
The client left: a failed send, the relay's tx.closed() race, a keep-alive to a gone client, or a pipeline tap dropped by the forwarding task client_cancelled engine_final or engine_continuous when a usage block arrived before the cancel, else router_delta_count (none with no delta)
The pipeline's first_byte window, the first read of the plain, thinking-pattern, HTTP Anthropic, or Unix-socket relays outlasting the resolved streaming.first_byte, or a mid-stream relay attempt with no first chunk inside streaming.first_byte timeout_first_byte none, output 0, input estimated
The pipeline's total budget (backend_stream) or chunk_inactivity, the transformer's total-budget check, a later read of those relays outlasting chunk_interval, or a mid-stream timeout/inactivity_timeout with no successor timeout_mid_stream the deltas generated before the timeout
A handshake error status, too many upstream error events or read errors, or an error event as the last event upstream_error none and the handshake http_status for a status, else what was generated
A guardrail block (GateForward::Blocked, a gate's Block at [DONE] or at the end of the body, the pipeline gate's mid-stream cut) guardrail_blocked every delta the backend generated up to the block, including the ones the gate held and never released
A mid-stream attempt whose output a fallback hop continued fallback_source the output this attempt generated; the successor's record carries only its own (see Model fallback)

Pipeline exit propagation, dial attempts, and surface-specific rules

  • Pipeline StreamExitSlot (src/services/streaming/middleware/exit_slot.rs): communicates termination causes from lower layers up to the observability tap. ChunkTimeoutLayer writes the timeout class before yielding TimeoutError, and the guardrail driver writes guardrail_blocked before dropping the tap's source. When a pipeline guardrail gate runs an end-of-stream or buffer_full check after the tap has already seen [DONE] or EOF, the tap defers its normal-end record until the gate either polls again (confirming normal completion) or drops the stream (recording the slot's guardrail_blocked outcome). Client disconnects between [DONE] and source EOF still record the normal end.
  • Streamed dial attempts and zero-output caps: every streamed dial (including direct TCP or Unix-socket streams without a fallback chain) registers an attempt on the request ledger after circuit admission so its record carries attempt_seq: 1 and /admin/stats attributes the backend name. Non-hopped handshake errors (4xx, or 5xx after chain exhaustion) record as failed attempts (upstream_error with http_status). Zero-output records with estimated input (token_source: none, estimated: true) charge the usage record and hub policy grant without incrementing local token counters, capping estimated input at MAX_ATTEMPT_INPUT_ESTIMATE_TOKENS (32,768) so base64 image_url payloads are not repeatedly billed across zero-output hops.
  • Anthropic, /v1/responses, and conversion specifics:
    • On the Anthropic ingress, a read timeout or error encountered after the client has already disconnected is recorded as client_cancelled without sending an error event.
    • Prompt-only intermediate reports (Anthropic message_start before message_delta, or Gemini intermediate usageMetadata before the terminal candidatesTokenCount) do not count as engine_final; streams cut before the output report combine that engine input count with router_delta_count output.
    • On /v1/responses, guardrail blocks use the gate's last usage report for Chat Completions sources and the relay's observed source events for Anthropic or Gemini sources.
    • The Anthropic ingress stats row records finalized stream tokens even on failure (event: error or response.failed sets outcome: upstream_error and success: false). Pre-stream handshake failures on these non-chat relays and WAL logging for /v1/responses, /anthropic/v1/messages, pass-through, and bridge streams remain outside this rule.

Timing fields on usage records

Every usage record carries latency_ms and either ttft_ms (paired with ttft_source) or ttft_absent_reason:

  • latency_ms: total router-side request duration from handler entry to response completion. On local cache replays, /v1/chat/completions and /v1/responses time the cache lookup itself, whereas SSE and Anthropic Messages replays time from handler entry.
  • ttft_ms: router-observed time to first byte. For provider-served traffic, the clock starts at provider dispatch (excluding routing, transformation, and admission) and ends at the first streamed byte (streaming) or when upstream response headers arrive (non-streaming, including Unix-socket header buffer completion and Bedrock signed send completion). Because non-streaming responses generate server-side before headers arrive, non-streaming ttft_ms approximates full upstream latency and attributes most time to prefill.
  • ttft_source: identifies the producer of ttft_ms (sent as ttft_source on the wire, with ttft_kind accepted as a legacy serde alias; omitted whenever ttft_ms is absent):
    • streamed_first_token: provider dispatch to first streamed byte.
    • response_headers: provider dispatch to upstream response headers on non-streaming paths.
    • cache_replay: local cache hit duration (where first and last byte coincide with latency_ms, and zero output tokens exclude the record from TPOT/decode calculations).
  • ttft_absent_reason: explains why ttft_ms is absent (mutually exclusive with ttft_ms; unknown values from newer peers deserialize as unknown):
    • batch: provider batch completions, which omit ttft_ms and ttft_source by design so asynchronous batch windows do not skew interactive TTFT or goodput SLOs.
    • no_first_frame: a dispatched stream that ended before yielding any frame.
    • transport_unobservable: reserved for transports that expose no header timestamp.
    • not_observed: requests that never reached an upstream or cache, failed dispatches before headers arrived, or discarded retry/fallback attempts (outcome other than completed, length, or usage_missing), which also skip continuum_backend_ttft_seconds. Every relay, including /anthropic/v1/messages, keeps a TTFT observed before cancellation, timeout, or an upstream error. A dispatched relay that ended before any frame reports no_first_frame instead.

Reconciliation against engine counters

usage.reconciliation (src/services/stats/reconciliation.rs) continuously compares router ledger output token counts against backend engine generation counters, compiled unconditionally regardless of the control-plane feature. It taps record_request_stats_with_headers_inner and record_finalized_stream_usage using the ledger_row_output_tokens rule (rows with usage or an attempt override that reached a backend in the reconciled set and were not served from local cache) and feeds an 8,192-sample bounded queue drained by a background task spawned in serve().

At each window close, the reconciler compares ledger (window tokens plus any carry) against the latest EngineStatsSnapshot.generation_tokens_total collected within that window:

  • No engine snapshot in window (no_engine_data): if a baseline exists, ledger tokens carry forward into the next window; before the first baseline, they are dropped because the initial engine snapshot already includes prior generation.
  • First engine snapshot (no_engine_data): establishes the baseline with no carry.
  • Snapshot below baseline (reset): indicates an engine restart; the post-restart counter becomes the new engine delta.
  • Normal comparison: computes delta = value - baseline and drift_ratio = (delta - ledger) / max(delta, 1), recording within_tolerance when abs(drift_ratio) <= max_drift or drift (with a warn! log) otherwise.

Operational caveats and window sizing

  • Supported engines: only vLLM, SGLang, and llama.cpp/mlxcel expose generation counters; Ollama, LM Studio, and hosted providers report no_engine_data. Boot-replayed router_restart records bypass both seams because their generation predates the first baseline.
  • Shared backends and window alignment: backends shared with other routers or direct clients will drift by external traffic and should set backends[].reconciliation: false (which also skips queueing). Because window boundaries are not synchronized with metric scrapes, window should be at least 2 * engine_stats.interval (enforced by a config validate warning).
  • Cumulative Admin totals: GET /admin/stats sums all compared windows (within_tolerance, drift, and reset), cancelling out boundary shifts between adjacent windows. Residual skew can still occur from requests in flight when the first baseline is captured, requests in flight at the latest window close, or tokens generated between the last pre-restart scrape and an engine reset. tests/usage_reconciliation_load_test.rs verifies < 1% drift across completed, cancelled, fallback, and timed-out streams against mock vLLM engines.

Usage record outcome and attempt identity

The usage ledger is attempt-scoped: each model execution emits one record carrying additive fields (#[serde(default, skip_serializing_if = "Option::is_none")], PROTOCOL_VERSION 0):

  • outcome: how the attempt ended (completed, length, client_cancelled, timeout_first_byte, timeout_mid_stream, upstream_error, guardrail_blocked, usage_missing, router_restart, or fallback_source when partial streamed output was delivered and continued by a mid-stream fallback hop), or refused when the router declined the request before any backend attempt began (see Refused requests).
  • refusal_reason: the bounded machine code for a refused record, taken from the client-facing error.code vocabulary (model_not_found, service_unavailable, router_loop_detected, and so on) and falling back to refused when the code is missing or malformed. Present only on refused; the hub drops it on any other outcome.
  • token_source and estimated: how output_tokens was obtained (engine_final, engine_continuous, router_delta_count, engine_tokenize, char_estimate, or none), with estimated: true whenever either token count is router-estimated.
  • stream and http_status: whether the client requested streaming, and the most recent upstream HTTP status for the attempt (absent when no headers arrived, on cache replays, and on batch completions; cleared at the start of each retry dispatch).
  • request_id, attempt_seq, and parent_attempt: request_id is the router-minted req_<32-hex> identifier shared across all attempts of an API request (never the client's x-request-id or traceparent). attempt_seq is the 1-based dispatch sequence number, and parent_attempt names the prior attempt that a retry or fallback continued/replaced. All three are absent on a refused record: no attempt began, and the record carries no attempt identity.
  • purpose: attributes the caller (client, guardrail, classifier, action_safety, or quality_judgment). Internal purposes carry the triggering request's key_id, request_id, and attempt_seq without charging the local policy usage grant.
  • reasoning_tokens: subset of output_tokens read from completion_tokens_details.reasoning_tokens or output_tokens_details.reasoning_tokens (and legacy singular output_token_details), clamped to output_tokens.
  • input_chars, input_bytes, output_chars, and output_bytes: UTF-8 character and byte counts of message/system/input text (excluding tool arguments and images) for cross-engine tokenizer normalization.
  • audio_seconds: whole-second billed duration (ceil of usage.seconds when usage.type == "duration", or top-level duration on non-token responses) for /v1/audio/transcriptions and /v1/audio/translations. Duration-metered audio records emit outcome: completed, token_source: none, zero token counts, and price against PriceEntry.audio_microusd_per_second.

Every attempt record charges the hub ledger, local cost-center ledger, and router_usage_seq regardless of outcome (except local cache replays, which record output_tokens: 0 with cache_hit_type). Each record carries a distinct {router_id}-{uuid} idempotency_key.

Refused requests

A request the router declines before dispatch produces one refused record, so the hub sees the request that no backend ever saw. The record comes from RequestRejection (the pre-backend rejection metadata attached only when no backend attempt has begun) and is emitted once by the outermost rejection observer (src/http/middleware/request_rejection.rs), which makes it one record per request no matter how many retry or fallback rounds the refusal needed.

Only a routing refusal qualifies: no admissible backend or an unknown model (Routing), a key entitlement that does not permit the request (Policy), and a per-backend rate-limit refusal (RateLimit with backend or store_unavailable). Authentication failures never produce one, because they carry no key identity and would let unauthenticated traffic fill the ledger; admission shedding and input-guardrail rejections are not routing refusals. The record carries zero token counts, token_source: none, ttft_absent_reason: not_observed, and the response status the client saw. Its provider is the router sentinel, and key_id or model may be empty when the refusal was decided before either resolved. It costs nothing: it prices to zero and the hub keeps it out of every usage rollup. Bursts stay bounded the same way every other usage producer is: the event goes into the bounded control-plane channel with try_send, and a full channel drops it and counts usage_records_dropped_total{reason="channel_full"}.

Producer stamping, local stats, and audit export

  • Producer stamping rules:
    • Non-streaming recorders stamp the request ledger's current attempt (advanced by begin_attempt across buffered retries, pre-stream dials, and Anthropic web-search turns), completed or length, engine_final, upstream status, request_id, and text counts.
    • Chat and Responses streaming relays stamp stream: true, completed or length from the terminal finish_reason or response.incomplete event, the attempt_seq captured at connection dial (or allocated from the request's sequence allocator on a mid-stream hop), and the handshake status on raw HTTP relays.
    • Spawned Anthropic Messages stream relays inherit a copy of the request's RequestLedgerCell via spawn_propagated (src/proxy/model_experiments/scope.rs), preserving request_id, client text counts, attempt position, and pre-spawn handshake status.
    • Cache replays stamp completed, engine_final, no status, and cached reasoning/text counts; batch completions stamp completed with the submitting request_id and no status; web-search failures stamp upstream_error and none.
    • Pre-dispatch refusals stamp refused, none, zero counts, the client-visible status, and no attempt identity; the rejection observer emits them, not a backend producer.
    • Before emission, the router enforces hub drop/clamp rules locally (parent_attempt within 1..attempt_seq, valid request_id, http_status in 100..=599, chars <= bytes, and reasoning_tokens <= output_tokens).
  • Local statistics (GET /admin/stats and Prometheus): outcomes populate by_outcome on GET /admin/stats (pre-upgrade snapshots restore windowed rows as completed), the outcome label on llm_tokens_total, and usage_records_total{outcome, token_source, purpose}. Partial streams and superseded attempts record as tokens_only rows in the auxiliary ring so period token totals include them without inflating client request or latency counts.
  • Local audit export (admin.stats.usage_export): src/services/stats/usage_export.rs maintains a bounded VecDeque<UsageExportRecord> on StatsCollector (off by default), appended at the non-streaming recorder seam, record_finalized_stream_usage, and record_cache_replay_export. Records contain wire-string enums, api_key_id as key_id, and resolved backend_id (no text or raw keys), evict by retention and max_records, persist across stats snapshots, and serve via GET /admin/usage/export.

Router-initiated calls

Internal LLM calls made by the router on behalf of a client request emit one attempt-scoped usage record per dispatch via record_attempt_usage, tagged with an internal purpose:

purpose Producer Enabled by
guardrail The LLM guardrail providers (self_hosted_classifier, custom_classifier) in src/services/guardrail/providers/llm_backend.rs, and the systemone provider guardrails.providers[]
classifier The smart-routing LLM classifier (src/services/smart_routing/llm_classifier.rs) and the System One classifier smart_routing.classifier.method: llm, hybrid, system_one, hybrid_system_one, or a System One shadow
action_safety The action-safety LLM evaluator (src/services/action_safety.rs) and its System One evaluator safeguards.action_safety
quality_judgment The usage-quality judgment and the fallback quality check, both System One consumers usage.quality_judgment, fallback.quality_check
  • Attribution and error mapping:
    • Each record carries the triggering request's key_id (resolved via PolicyStore::hub_identity_for_token without charging local grants), request_id, and a distinct attempt_seq, with parent_attempt: None, stream: false, and no TTFT. backend names the pinned backends[] entry (resolving backend_id) or host[:port] for inline endpoints.
    • Replies with usage blocks record engine counts; 2xx replies without usage record usage_missing with char_estimate; malformed 2xx System One payloads (SystemOneError::Malformed) record upstream_error with status 200 and zero output; non-2xx responses and transport errors record upstream_error; deadline expirations and abandoned dispatches record timeout_first_byte with estimated input and zero output. Pre-dispatch refusals (open circuit, disabled target) emit no record.
  • Sequencing and ring isolation:
    • All copies of a request's ledger cell share one atomic sequence allocator, reserving sequence 1 exclusively for the client's first attempt so internal calls never collide with client attempt numbering or parent_attempt links.
    • Internal records are stored in a dedicated bounded ring (MAX_INTERNAL_RECORDS, 25% of the 100,000-row client ring, persisted as internal_records in snapshots) and reported under by_purpose on GET /admin/stats, preventing streaming guardrail window checks from evicting client history or skewing client request counts, latency percentiles, or control-plane load summaries.
    • Internal records cannot consume the final 10% of the control-plane usage channel, shedding first under usage_records_dropped_total{reason="channel_full"} during slow hub pushes so billable client records are preserved.

Artifact identity on usage records

A model id names what the client asked for, not which weights answered. Every record that reached a backend can carry two operator-declared identity fields so backends serving one id from a different quantization, revision, or re-exported checkpoint remain distinguishable. Both are additive (#[serde(default, skip_serializing_if = "Option::is_none")] on the wire, PROTOCOL_VERSION 0) and absent when nothing is declared.

  • model_digest is the canonical digest (sha256: or blake3: plus 64 lowercase hex) of the artifact the operator declared for the serving backend and the served model (metadata.artifact.digest, resolved by Config::served_model_identity: the backend's own model_configs key first, then the metadata layer, never the built-in catalog). It is resolved at the serving seam against the configuration the request was routed with, the way backend_id is: the non-streaming draft at capture, the streaming context at every mark_provider_dispatched (so a fallback hop overwrites it with the identity of the backend and model that produced the stream), the Responses arms where their context is built, and the batch job at submit. A local cache replay carries neither field, since no backend served it and the cache does not retain the identity of the request that populated it.
  • draft_model is the served name of the speculative-decoding draft declared for the same (backend, model) (metadata.draft_model.name). Under speculative decoding the engine reports accepted tokens only; those tokens are attributed to the target model named in model, rejected draft tokens are unobservable from any engine's usage and are not counted, and draft_model names the draft for attribution review without counting anything. The draft's own digest stays in the router's metadata and on GET /admin/models.

Both values are statements the operator signs, not values the fleet asserts: nothing is read from an engine. A malformed identity never reaches a record, because model_configs refuses to load it and the metadata layer drops it with a warning.

Idempotent batching

Each batch is formed once with a stable batch_id, stable per-record idempotency_keys, and a fixed sent_at_ms and chain hash, so every attempt sends the same bytes. On a push failure the same batch is retried after backoff; the hub deduplicates on idempotency_key, so a duplicate delivery after a reconnect does not double-count. On any 200 ack the batch is dropped from the buffer. The memory buffer is bounded at 20 batches, so a prolonged hub outage cannot grow memory without bound.

Durability across outages and restarts

Two durability mechanisms make ledger record loss recoverable and counted:

  • Disk spool:
    • When the 20-batch memory buffer overflows, the oldest batch (with quality tickets sealed and a stable batch_id) is written atomically to <state_file>.usage-spool/{seq:020}.json (directory 0700, files 0600, temp-write, rename, and directory fsync) instead of dropping records.
    • The push loop drains spooled files before the memory buffer and deletes a file only on a terminal outcome (200 ack, 413, or unreadable oversize ack). control_plane.usage_spool_max_bytes (default 256 MiB; 0 disables spooling) caps disk usage by evicting the oldest files and retiring their sequences.
    • On graceful shutdown, any unacknowledged pending batch and remaining memory buffer batches are spooled so the next boot delivers them first. Spooled records are included in heartbeat backlog gauges and GET /admin/control-plane/status.
  • Write-ahead attempt log (WAL):
    • Every buffered /v1/chat/completions dispatch (execute_single_retry_attempt_observed) and streaming dispatch (StreamObservabilityContext::mark_provider_dispatched) appends a start line to <state_file>.usage-wal.jsonl (0600) before execution and an end line when its guard finishes or drops, recording attempt identity, hub attribution, backend, stream, start time, and input-token estimate (never prompt content).
    • A dedicated writer thread appends via a bounded channel and compacts the file to open attempts once it exceeds control_plane.usage_wal_max_bytes.
    • On the next boot (after attaching the cost-center sidecar and before the first push), unfinished start entries are replayed as outcome: router_restart records (output_tokens: 0, token_source: none, estimated: true). Other ingress families (/v1/responses, /anthropic/v1/messages, passthrough) do not write WAL entries yet.
  • Disk trust, metrics, and shutdown:
    • Symlinks, non-regular files, files with permissions wider than 0600, or spool files written under a different enrollment identity are never replayed; invalid spool files are deleted and counted in usage_durability_corrupt_total{kind="spool_file"} (and usage_records_dropped_total{reason="spool_overflow"} on enrollment mismatch).
    • Remaining drops are tracked in usage_records_dropped_total{reason} (channel_full, buffer_overflow, spool_overflow, batch_rejected_413, wal_open_bound, wal_dropped; ack_oversized stays at zero because an oversized 2xx ack is a delivered batch); see Usage ledger durability.
    • Graceful shutdown waits up to one hub request timeout plus 5 seconds for the final push, spool flush, and WAL barrier.

Tamper evidence

Every pushed usage batch is cryptographically linked to its predecessor via three additive UsageBatch fields (PROTOCOL_VERSION 0):

  • batch_hash: 64-character lowercase hex SHA-256 of the batch.
  • prev_batch_hash: batch_hash of the immediately preceding batch built by this router (absent on the first batch of a chain).
  • chain_gap: count of built batches permanently lost since the previous batch (absent when zero).

Hash construction, chain state, and local WORM export

  • Hash and canonical JSON (continuum_protocol::usage_batch_hash):
    • Computes SHA-256 over prev_batch_hash, router_id, batch_id, decimal sent_at_ms, and decimal chain_gap (each terminated by 0x0a), followed by the concatenated raw 32-byte SHA-256 digests of each record's canonical JSON (sorted object keys, no whitespace, omitted None fields, built without serde_json/preserve_order).
    • Batches are stamped once when formed in UsageBuffer::drain_front so retries and spool replays remain byte-identical. The Hub verifies hashes before normalization (match, mismatch, absent, or malformed on GET /api/admin/v1/fleet and /api/admin/v1/routers/{router_id}/usage-chain).
  • Chain state and gap accounting (<state_file>.usage-chain.json):
    • Persists (router_id, last_batch_hash, last_batch_id, last_sent_at_ms, pending_gap) with mode 0600 before a batch reaches the hub or spool and after every permanent loss. If a write fails, the sidecar is removed best-effort and counted in usage_ledger_export_failures_total{target="chain_state"} so a crash restarts the chain cleanly rather than forking it.
    • When a built batch is permanently lost before acknowledgment (413, spool write failure/refusal, spool byte-cap eviction, corrupt spool file, or shutdown flush failure without spool), pending_gap increments (usage_chain_gaps_total) and is emitted as chain_gap on the next built batch. Pre-batch record drops and oversized 2xx acks do not increment chain_gap. A 413 batch with more than one record is abandoned the same way, but its records return to the queue ahead of the spool and arrive in the following, smaller batches under the same idempotency keys, so the local export lists them in both the abandoned batch and the later ones.
  • Local export (usage.ledger_export.path) and verification:
    • Appends every built batch in build order as JSONL ({"batch_hash":...,"prev_batch_hash":...,"batch":{...}}) via a 64-slot non-blocking channel and a single blocking-pool writer (O_APPEND | O_CREAT, mode 0600). Paths conflicting with control_plane.state_file or <state_file>.* are rejected at config load (ledger_export_path_conflicts_with_state_file) and before open.
    • When the file reaches max_bytes (default 1 GiB) with rotate: true, it is renamed to <path>.<sent_at_ms> and the new file begins with a self-verifying {"anchor":true,"prev_batch_hash":"<hex>","rotated_from":...} line. With max_files set, the oldest rotated regular files beyond the cap (ordered by the <sent_at_ms>-<n> in the name, never following symlinks) are deleted after each rotation and when the writer starts.
    • Operators can enforce WORM immutability (chattr +a or object-lock storage) and audit files offline with python3 -I scripts/verify-usage-ledger.py <file> [--prev <hex>] (see Usage ledger tamper evidence).

Cadence and policy

The hub returns cadence settings (heartbeat interval, usage-push interval, batch size) in the enrollment response, and those govern the agent. A per-field override in the control_plane config section wins over the hub value for that one setting. Policy sync and enforcement are opt-in via control_plane.policy.enabled; without that block the agent is metering-only and does not call the policy endpoints.

Fleet public configuration

Opt-in via control_plane.config_sync.enabled. The remotely manageable surface is restricted to public, typed request_params, kv_routing, and model fallback schemas; it rejects arbitrary dotted keys, environment variable expansions, credentials, TLS material, prompts/completions, fallback.quality_check, and deployment-local KV routing leaves (prefix_routing.salt_echo, kv_cache_index.backend, and kv_cache_index.event_sources with transport: event_bus). Operators can pin an entire section with *_immutable, while local leaves and indexed fallback collections take precedence over Hub values.

  • Capabilities, bounds, and masked observation:
    • Mutable sections advertise schema version 1, path encoding version 1, hot-reload and dry-run support, and hard bounds: 1,820 desired leaves, 6 path segments (up to 256 UTF-8 bytes each; model IDs with /, :, ., or Unicode stay a single segment), 1 MiB encoded content, 64 request-param model scopes, and fallback limits of 64 model keys, 10 targets per chain, and 64 trigger status codes.
    • If combined local-plus-Hub fallback leaves exceed 1,820 (or 2,076 aggregate), the Router pins the entire fallback section to the local value and reports an immutable value-free digest. Inventory snapshots report digests, path segments, source (hub, local_file, local_pin), sensitivity, and reload class without ever exposing parameter values.
  • Fallback composition and cycle detection:
    • Indexed collections are atomic: a shorter higher-precedence chain replaces the lower chain completely, and any local member pins its collection. Non-empty per-model custom chains override global chains.
    • The Router runs iterative cycle detection across the effective fallback graph regardless of enable switches and reconciles the process-lifetime fallback service live without restarting or mutating local files.
  • Outbound sync and atomic application:
    • The agent polls POST /api/agent/v1/config/sync, fetches desired content from POST /api/agent/v1/config/content, verifies identity fields and the canonical SHA-256 digest over sorted length-prefixed paths, merges Hub leaves beneath local pins, validates the complete Config, persists the overlay (0600, bound to enrollment identity), and swaps the lock-free Arc<Config> snapshot.
    • Transport errors and timeouts leave assignments unacknowledged for retry while serving last-known-good; only deterministic validation outcomes are reported to POST /api/agent/v1/config/apply-result (applied, validated, rejected, or superseded).
  • Implementation: src/control_plane/config_sync.rs, src/control_plane/client.rs, src/control_plane/agent.rs, src/control_plane/inventory.rs, and crates/continuum-protocol/src/config.rs.

Fleet backend configuration

Opt-in via control_plane.config_sync.backends (backend_config_v1), allowing the Hub to manage backend topology under the backends.mode ownership contract:

  • disabled (default): advertises no backend-management capability.
  • overlay: the Hub creates, updates, and removes only Hub-owned backend IDs while preserving all Router-local backends; any ID or runtime-name collision rejects the revision.
  • authoritative: after a compare-and-swap first adoption, the Hub snapshot governs all backends except those in immutable_backend_ids (removing omitted local backends via graceful drain, and requiring an explicit confirmation flag for an empty snapshot).

Key guarantees across modes:

  • Compare-and-swap adoption: authoritative first adoption verifies (backend_id, local_config_digest) preconditions over the Router's secret-free canonical local export, rejecting mismatches with adoption_digest_mismatch.
  • Validation, pre-activation probe, and atomic apply: the agent polls POST /api/agent/v1/backend-config/sync, fetches POST /api/agent/v1/backend-config/content, verifies the canonical digest, preserves the Hub-assigned backend_id, and runs one bounded pre-activation health check via the candidate-probe primitive for enabled new or materially changed backends (skipping network I/O for weight-only or model-list-only edits). Validated snapshots persist (0600) and publish atomically alongside a lock-free ownership ledger.
  • Credential binding: snapshots reference credentials by credential_ref ID and bundle digest (backend_credentials_v1), activating only when every referenced ID resolves for the matching provider in an acknowledged bundle (allowing newer rotated bundle digests when all referenced IDs still resolve) and never persisting secrets to disk.
  • Implementation: src/control_plane/backend_sync.rs, src/control_plane/config_sync.rs (BackendHubLayer), src/control_plane/agent.rs, src/control_plane/client.rs, src/control_plane/inventory.rs, and crates/continuum-protocol/src/backend_config.rs.

Hub-declared model aliases

A Hub backend profile can declare per-model aliases (model_aliases: a target model from allowed_models mapped to up to 50 caller-facing names). The Router advertises backend_model_aliases_v1 in heartbeat capabilities whenever the backend-sync executor runs (backends.mode is not disabled), and the Hub sends aliases only to a Router that advertises it.

  • Dispatch: each target becomes a model_configs entry with those aliases on the Hub-owned backend, and models stays exactly allowed_models. A request for an alias is rewritten to the target before backend selection by the same rule as an operator-declared model_configs alias, so selection picks among every user-routable backend serving the target, the upstream receives the target id, and /v1/models lists the alias.
  • Internal and disabled backends: an internal profile's aliases are validated but not applied, and a disabled Hub backend resolves no alias.
  • Precedence: a name some backend serves literally is never rewritten. Hub aliases take precedence over model-metadata.yaml aliases. The top-level model_aliases rewrite still runs first on the ingresses that apply it; when its exact map names a Hub alias, the Router logs one warning per activating apply naming the backend id.
  • Rejections (model_policy_invalid, scoped to the profile, previous snapshot stays active):

    Detail Cause
    model alias target is not an allowed model An alias map key is not in the profile's allowed_models.
    too many aliases for one model More than 50 aliases for one target.
    empty, too_long, control_character A malformed alias name.
    model alias collides with another hub profile An alias equals another profile's alias or allowed model (scoped to the later profile ID).
    model alias collides with a local backend model An alias equals a model or alias of a preserved local backend, matches a glob entry in its models, or is the dated form of a non-wildcard models entry (or the reverse, for example llama3.2 and llama3.2-20250101), disabled backends included. In authoritative mode, a replaced local backend does not count.
  • Restart: a restored snapshot re-applies its aliases. A local edit that now collides logs a warning and the snapshot still activates; the literally served local name keeps winning.

Hub backend tasks

Opt-in via control_plane.config_sync.backends.tasks (backend_tasks_v1, independent of backends.mode). The Router polls POST /api/agent/v1/backend-tasks/sync, executes read-only tasks over its own outbound network path, and reports results to POST /api/agent/v1/backend-tasks/result:

  • candidate_probe: materializes a schema-v1 candidate profile (allowing an empty approved-model list for discovery) and runs validate, health_check, and discover_models through the shared src/backend_probe/ primitive used by POST /admin/backends/probe without registering the candidate in the live pool or sending synthetic chat prompts. Task-scoped credential leases stay in memory for that task only and must match the candidate's explicit credential_ref and provider. Results are strictly metadata-only (health, integer latency, credential classification, catalog source, bounded model IDs, and a closed error code; never upstream error bodies or secrets).
  • local_export: returns an allowlisted, secret-free view of local backends (reporting unsupported fields such as raw API keys, OAuth state, or custom headers as bounded codes in unsupported_fields, and tagging hub_owned, immutable, and draining entries) alongside each entry's compare-and-swap adoption digest.
  • Idempotency and capability latch: results are persisted in an enrollment-bound durable outbox keyed by task ID so redeliveries never re-execute. backend_tasks_v1 is advertised from a runtime attachment latch only while the outbox and task loop are actively running, and GET /admin/control-plane/status reports backend_tasks attachment state with a bounded reason code when detached.
  • Implementation: src/backend_probe/, src/control_plane/backend_tasks/, src/control_plane/backend_sync.rs, src/control_plane/agent.rs, src/control_plane/status.rs, src/control_plane/inventory.rs, and crates/continuum-protocol/src/backend_config.rs.

Token-supply and saturation telemetry

The heartbeat and enrollment inventory carry a metadata-only, integer-only RouterInventory.supply block plus six per-backend BackendInfo fields (src/control_plane/supply.rs, src/control_plane/capacity.rs, src/control_plane/inventory.rs):

  • Pressure counters (accepted_requests, rejected_requests, active_requests, retried_requests, fallback_requests):
    • accepted_requests and rejected_requests are independent pressure counters rather than a partition (accepted + rejected can exceed total requests because API admission runs before handler-level hub-policy or batch refusals, while rate-limit 429s on /admin count as rejected without entering API admission).
    • fallback_requests increments once per request when a fallback backend actually serves the response (shared across pre-stream and mid-stream hops), and streaming active_requests guards ride the response body until the final SSE frame or client disconnect.
  • Per-key rejections of Hub-issued keys (rejections_by_key, rejections_by_key_other):
    • Cumulative (key_id, stage, count) rows since counters_since_ms, fed from the same rejection funnel as request_rejected_total, but only for key ids resolved against the Hub key table (by the policy enforcement middleware, or by hub_auth_context in blocking mode). Local keys, AppProxy keys, and unattributed rejections stay in rejected_requests only.
    • Membership is first come, first served for the process epoch, bounded by the protocol's MAX_KEY_REJECTION_KEYS (32 keys) and MAX_KEY_REJECTION_ENTRIES (256 rows), so each row is monotonic. Rejections of further Hub keys, or of key ids that fail key_rejection_key_id_problem, add to rejections_by_key_other, which is always sent (0 included) so the Hub can tell a capable router from an older one.
    • The funnel also covers admission, input guardrail, and routing rejections, so the per-key sum can exceed rejected_requests. Router stages map to KeyRejectionStage through an exhaustive match. Implementation: src/control_plane/supply/key_rejections.rs and src/http/middleware/request_rejection.rs.
  • Semantics of absent vs. zero fields:
    • Optional fields are omitted ("unknown") rather than zeroed when unmeasured: max_concurrency is always absent, circuit_state is omitted when a breaker is disabled or has never observed the backend (read via non-materializing peek_state), and observed_rpm_capacity / observed_input_tpm_capacity / observed_output_tpm_capacity are reported only as 15-minute high-water marks after an upstream 429 corroborates a 60-second window with at least two completions.
    • queue_depth and shed_requests are reported as literal 0 because the router has no request queue or load shedding. counters_since_ms is stamped once at process start (process_start_info()).

Guardrail telemetry

The inventory also carries a RouterInventory.guardrails block (continuum_protocol::GuardrailSummary): cumulative, metadata-only counters describing content-safety guardrail activity, mirroring the local Prometheus families (guardrail_checks_total, guardrail_blocks_total, guardrail_verdicts_total, guardrail_stream_buffer_cap_trips_total) so the Hub can observe fleet-wide guardrail activity without scraping individual routers.

Field Meaning
checks_total Cumulative per-provider checks across every stage.
blocks_total Cumulative content block verdicts, equal to the sum of by_category. Fail-closed infrastructure refusals are excluded; they count under errors_total / fail_closed_total.
transforms_total Cumulative mode-applied verdicts that rewrote content rather than refusing it.
flags_total Cumulative mode-applied verdicts recorded without acting on them.
errors_total Cumulative checks that failed to complete (timeout, transport error, non-success status, unparseable body), equal to fail_open_total + fail_closed_total.
fail_open_total Failed checks served anyway: traffic that was never actually inspected.
fail_closed_total Failed checks refused: availability paid for enforcement.
stream_buffer_cap_trips_total Cumulative streams that reached the 4 MiB gate buffer cap.
by_category Blocks keyed by category id, an object rather than an array. The router only ever writes a GuardrailCategoryId literal (12 values, including deny_list and the other catch-all), while an id a newer peer classifies is carried through unchanged.
verdicts The single aggregated per-request verdict by (stage, mode, result). Router-only and additive.
stream_buffer_cap_trips Streams that reached the 4 MiB gate buffer cap, by (strategy, outcome). Router-only and additive.
counters_since_ms Process-start epoch these cumulative counters are measured from.

Accounting invariants and privacy bounds

  • Per-provider vs. per-stage denominators: blocks_total (and by_category) counts per provider check alongside checks_total (including monitor-mode matches; note that the Hub's rustdoc defines blocks_total as enforce-mode blocked requests, so aligning the two requires a coordinated protocol change). By contrast, transforms_total and flags_total count once per stage evaluation and are derived directly from verdicts (just as stream_buffer_cap_trips_total is derived from stream_buffer_cap_trips) inside GuardrailSnapshot::into_summary.
  • Effective fail-policy disposition (fail_open_total vs. fail_closed_total): errors_total covers both service timeouts and hard provider errors (Guardrail trait Result::Err). Because monitor mode never gates traffic, a failed check in monitor mode always increments fail_open_total regardless of its configured on_error policy; fail_closed_total increments only when both an enforcing mode and a fail-closed policy actually refused the request. Fail-closed infrastructure errors never increment blocks_total or by_category.
  • In-process tracker and presence semantics: counters are maintained in an independent in-process tracker (src/control_plane/guardrail.rs) rather than read from the optional Prometheus registry. The block is reported (with zeros before the first check) whenever a guardrail service is configured and omitted only when guardrails are disabled. counters_since_ms shares process_start_info() with SupplySummary.
  • Closed label space, privacy, and wire fixture:
    • Breakdowns use fixed-size atomic arrays indexed by closed enums: by_category uses the 12 compile-time GuardrailCategoryId literals (folding unknown third-party GuardrailCategory::Other strings into other and enforcing Hub bounds in src/control_plane/guardrail/summary_bounds.rs), while verdicts and stream_buffer_cap_trips fold unknown values into unknown.
    • No prompt/completion text, matched spans, rules, provider names, or error strings ever enter the summary.
    • crates/continuum-protocol/tests/fixtures/guardrail_summary.json is pinned to the vendored Hub revision and verified against live tracker output in tests/control_plane_guardrail_telemetry_test.rs.
  • Implementation: src/control_plane/guardrail.rs, src/control_plane/inventory.rs, src/services/guardrail/service.rs, src/services/guardrail/gate.rs, and crates/continuum-protocol/src/guardrails.rs.

Policy sync and local enforcement

When control_plane.policy.enabled is set (with the control-plane feature compiled and the agent enabled), the agent maintains a live hub policy snapshot and enforces it locally after API-key authentication on every API request.

Delivery paths and bounded envelope decode

Two delivery paths feed the in-memory PolicyStore with full, idempotent PolicyEnvelope snapshots (the org's hashed API key table, tier limits, per-key month-to-date budget snapshots, and agent settings):

  • Poll (POST /api/agent/v1/policy/sync): runs every policy.poll_interval_secs (default 5s), bounding revocation and budget propagation by the poll interval.
  • Stream (GET /api/agent/v1/policy/stream?cursor=<opaque>): a persistent WebSocket push channel delivering full-envelope JSON text frames in under 5 seconds, reconnecting with backoff from the last applied cursor on disconnect (disable via policy.stream_enabled: false).
  • Bounded envelope decode (MAX_POLICY_SYNC_RESPONSE_BYTES, ~152.6 MiB in src/control_plane/client.rs):
    • Both the HTTP poll path (Content-Length pre-check plus running chunk total) and the WebSocket stream (both message and frame ceilings) enforce the same ~152.6 MiB cap, sized as the term-by-term sum of the Hub's distribution budgets (100 MiB key-table allowance, 1,024 tiers with 4 MiB request-params budget, 256 KiB guardrail budget, 5,000 cost-center assignments, and 1,024 cost centers).
    • Oversize envelopes fail loudly with HubError::ResponseTooLarge, log an error! with the limit and observed size (never body content), increment control_plane_policy_sync_failure_total{reason="envelope_too_large"} (pre-initialized at 0 alongside reason="other"), and surface policy_sync.oversize_refusals / last_oversize_refusal_ms on GET /admin/control-plane/status while preserving the last-known-good store snapshot.
    • Smaller Hub responses (enroll, usage acks, probe acks) enforce their own derived byte ceilings; an oversize usage ack is treated as terminal for its already-processed batch so the push loop does not stall.

Per-request enforcement rules

For a presented API key whose SHA-256 matches a synced KeyEntry (constant-time comparison):

  • Revocation: revoked entries (covering revocation, expiry, and org suspension) are rejected immediately.
  • Rate limits:
    • Per-tier rpm uses a fixed 60-second window (returning 429 on overflow); input_tpm (excluding cache-read input tokens) and output_tpm use post-response windows fed by usage taps, rejecting subsequent requests once the window is full.
    • Each router counts on its own by default. With control_plane.policy.minute_limits.redis, the three windows live in one Redis or Valkey store that a single script checks and charges, and a failed or late store call falls back to the local windows (control_plane_minute_limits_fallback_total{reason}).
    • With control_plane.policy.minute_limits.reserve_tokens, admission reserves each metered request's estimated tokens until its response finishes and admits only while usage plus reservations is under the limit. See Hub tier minute limits.
  • Monthly budgets: tokens_per_month and requests_per_month are checked against the Hub's KeyUsageSnapshot plus the local delta accumulated since that snapshot (reset on a newer snapshot or UTC month rollover).
  • Model allowlist: requests for models outside the tier's model_allowlist are rejected with 403 model_not_allowed (an absent allowlist permits all served models). A model the router does not know under any name (no user-routable backend, model experiment, alias, virtual model, fallback chain, or decisions target) is rejected with 404 model_not_found instead, the same response the router returns without a tier.
  • Request-parameter limits: tiers can bound temperature, top_p, max_tokens, presence_penalty, frequency_penalty, top_k, and min_p (transmitted as integer microunits) globally or per alias-resolved model scope. The Router intersects Hub bounds with local request_params limits, rejects empty intersections, and freezes the effective policy before cache lookup, substitution, arbitrage, routing, retry, or fallback across Chat Completions, Completions, Responses, and Anthropic Messages.
  • Request priority (request_priority_v1, vLLM scheduling):
    • A tier may set an integer floor P in [-100, 100] (where lower integers indicate higher urgency in vLLM's --scheduling-policy priority queue), clamping the effective priority to max(client_value, P).
    • At ingress, client X-Vllm-Priority headers are collapsed and clamped across /v1/chat/completions, /v1/completions, /v1/responses, /anthropic/v1/messages, and /v1/embeddings (and top-level body.priority on /v1/embeddings is clamped in place without injecting it when absent).
    • On outbound type: vllm hops (after request_extensions merge and before internal_streaming), the router applies X-Vllm-Priority over body.priority and writes the clamped integer to both body["priority"] and X-Vllm-Priority on /v1/chat/completions and /v1/completions (or clamps existing fields on /v1/embeddings), returning x-continuum-request-priority: <applied> on the response. Non-vllm hops are untouched and increment continuum_request_priority_skipped_total{reason="backend_type"}.
    • Dispatch and TTFT seams record continuum_request_priority_requests_total{priority,status_class} and continuum_request_priority_ttft_seconds{priority} (capping distinct integer labels at 16 before "other", plus "unmanaged"). Unmatched keys are governed only when control_plane.policy.unmatched_key: reject is set.
  • Anthropic user profile (anthropic_user_profile_v1):
    • A KeyEntry may carry anthropic_user_profile_id and anthropic_user_profile_passthrough. The Hub sends them only to a router that advertises anthropic_user_profile_v1, which PolicyStore::capabilities does unconditionally.
    • In blocking mode hub_auth_context copies the pair onto the Hub key's ApiKey. In permissive mode, where a Hub key gets no AuthContext, the enforcement middleware attaches a HubKeyUserProfile extension for an admitted key.
    • resolve_user_profile (src/http/middleware/user_profile_attribution.rs) runs after enforcement and applies the same precedence as for a local key: a single well-formed client header on a passthrough key, then the key's profile, then a backend's static profile. A local AuthContext wins over the extension, and a malformed Hub profile ID is never sent. See Per-customer attribution for Anthropic.
    • The persisted key table keeps both fields (omitted at their defaults, so KEY_TABLE_FORMAT_VERSION stays 1), and they are part of the security fingerprint, so setting or clearing them rewrites the file without throttling.
  • Prompt-logging consent (prompt_logging_consent_v1):
    • A KeyEntry may carry prompt_logging_consent (allowed or denied), the effective value the Hub composes from the key and its team (continuum-hub#1975). PolicyStore::capabilities advertises the string unconditionally. Delivery is what the capability gates: the enum is closed, and a router that does not know a value refuses the envelope.
    • In blocking mode hub_auth_context copies it onto the Hub key's ApiKey. In permissive mode the enforcement middleware attaches a HubKeyPromptLoggingConsent extension for an admitted key that states one. resolve_key_prompt_logging_consent (src/services/prompt_log_consent.rs) reads both: a denied from either wins, because a sync can withdraw consent between auth and enforcement. Otherwise the ApiKey value applies, then the extension, so a local key that states nothing does not hide a Hub value on a shared token. A locally configured key sets the ApiKey value through api_keys[].prompt_logging_consent (#2819), so the resolver covers a deployment with no Hub.
    • While a Hub policy store is attached but holds no policy (has_policy() is false), the middleware marks a key-authenticated request PolicyUnavailable, and the resolver returns denied for it, so it is not captured under any default_consent. Request handling still fails open, and a keyless request follows default_consent.
    • With no Hub policy store, the dynamic auth middleware marks every presented token that did not authenticate Stated(Denied), so an unknown, deleted, rotated, disabled, or expired token is not captured under any default_consent (#2879). With a store attached it marks only an inactive local key's denied (inactive_key_states_denied), because enforcement already refuses a token no table recognizes and an unconditional denied there would override a valid Hub key's allowed.
    • prompt_logging_capture_basis is the gate the capture path calls (src/services/prompt_log_capture/, #2320). It takes router-owned types, so it compiles under every feature set. The chat completions, Responses, and Anthropic Messages ingresses evaluate it after the enforcement middleware and before any body is copied; an admitted request's handle rides a task-local scope to the buffered arm and the stream-end usage sink, which write the one redacted record through a dedicated writer thread.
    • src/services/prompt_log_export/ (#2328) delivers those files: one background task per prompt_logging.export.sinks entry, started in serve() only while the section is enabled, tails them on a persisted cursor, masks each record again, and delivers bounded batches at least once. Nothing on the request path touches it.
    • The persisted key table keeps the value (omitted when unset, so KEY_TABLE_FORMAT_VERSION stays 1), and it is part of the security fingerprint, so a withdrawal rewrites the file without throttling and a restart cannot restore the earlier value.
  • Rate-limit headers: enforced responses include x-continuum-ratelimit-limit, -remaining, and -reset for the most constrained dimension plus suffixed triples (-rpm, -input-tpm, -output-tpm, -monthly-tokens, -monthly-requests), and retry-after on 429 responses.
  • Refusal body format: policy refusals (revoked key, model allowlist, tier and budget limits, cost-center limits, unmatched_key: reject) use the OpenAI error envelope on OpenAI routes and the Anthropic envelope ({"type":"error","error":{"type":"rate_limit_error","message":...}}) on /anthropic/* routes. Status codes and headers do not depend on the route.

Atomic tier state, governed policies, and capability reporting

  • Atomic tier-policy state: full snapshots are validated before swapping the live ArcSwap, and poll requests capture the acceptance generation before network I/O so delayed responses cannot overwrite newer stream revisions. Heartbeats read capability/status evidence and the applied cursor from a single atomic publication (RouterInventory.policy_status).
  • Guardrail policy and provider content (guardrail_policy_v1, guardrail_provider_content_v1):
    • Both capabilities are advertised from PolicyStore::capabilities on a single latch only after a live guardrail-policy reconciler attaches.
    • Wire acceptance records receipt, while active acknowledgment is withheld (pending) until the reconciler confirms execution of that exact guardrail_policy_digest (or reports guardrails_unavailable on RejectedPolicyRevision if composition fails, without blocking tier policy in the same envelope).
    • GuardrailPolicy.provider_content (crates/continuum-protocol/src/guardrails/provider_content.rs, 256 KiB canonical bound) composes via compose_hub_guardrail_policy_checked (src/services/guardrail/composition/provider_content.rs): appending policy_prompt idempotently to custom_classifier prompts, taking the stricter per-entity PII action, and compiling match_list_deny / match_list_allow into RegexSets (src/services/guardrail/matchlist/governed.rs, 256 KiB per pattern, 8 MiB per statement; violations reject with invalid_guardrail_policy). While the reconciler is attached, Admin API guardrail writes edit only the local block. See Guardrails: provider content.
  • Smart routing policy (smart_routing_policy_v1):
    • PolicyEnvelope.smart_routing (crates/continuum-protocol/src/smart_routing.rs, 256 KiB canonical bound) carries routing rules, model tier profiles, and classifier selection referencing pool backend names.
    • The reconciler (src/control_plane/smart_routing_policy.rs) claims single-writer ownership at startup via govern_smart_routing (causing hot-reload local-only applies and direct Admin writes without a config channel to stand down or return 503 hub_governed_no_config_channel), previews effective backends via ConfigSyncStore::preview_effective, composes rules with local smart_routing: (compose_hub_smart_routing_policy_checked in src/services/smart_routing/composition.rs, keeping local enabled and taking intercept_all as the logical AND), and reloads the tier registry, policies, classifiers, and lock-free LoadMonitor (AtomicBool switch) atomically in src/services/smart_routing/runtime.rs.
    • Wire-level validation or uninterpretable methods/tiers reject with invalid_smart_routing_policy or digest_mismatch, while execution gaps (missing/disabled backend, unpinned LLM classifier, explicit-model route target, or LocallyDisabled when enabled: false) decline the whole statement as smart_routing_unavailable and run local configuration alone.
    • smart_routing_policy_v1 is advertised unconditionally once govern_smart_routing attaches the reconciler (even when local smart_routing.enabled is false, so the router honestly reports smart_routing_unavailable rather than flapping capability strings on reload). See Smart Routing Governance.
  • Rejection slot precedence (src/control_plane/policy_store/acknowledgment.rs): the single heartbeat rejection slot prioritizes guardrail execution, guardrail envelope, smart routing execution, smart routing envelope, and then code-independent tracks (cost_center_v2, model_budgets, credentials), gated by PolicyHeartbeatState.rejected_source so a standing smart-routing decline never masks a guardrail refusal.
  • Stable backend identity (stable_backend_identity_v1): advertised unconditionally because config validation, heartbeat inventory, and usage taps are compiled and wired as a single unit. backends[].backend_id is operator-authored (never derived from mutable display names or (provider, model) pairs) and resolved at each serving seam against the active Config snapshot.

The capability strings a router can advertise, among them request_param_limits_v1, request_priority_v1, anthropic_user_profile_v1, prompt_logging_consent_v1, guardrail_policy_v1, guardrail_provider_content_v1, smart_routing_policy_v1, and stable_backend_identity_v1, are what let the Hub derive a propagation state from bounded evidence rather than from a free-form report:

Derived state Router evidence
unsupported The capability is absent from RouterPolicyStatus.capabilities, so this router will never acknowledge that policy family.
pending The capability is advertised but no acknowledgment for the stated digest has arrived yet.
active AppliedPolicyRevision.guardrail_policy_digest (or smart_routing_policy_digest) equals the digest the Hub authored, including the canonical cleared-policy digest after an authoritative clear.
stale The acknowledged digest names an earlier policy than the one the Hub last stated.
error RejectedPolicyRevision names the digest with invalid_guardrail_policy or guardrails_unavailable (guardrails), invalid_smart_routing_policy or smart_routing_unavailable (smart routing), or the shared digest_mismatch.

Usage records for hub-matched keys are attributed with the hub key_id, which is what the hub aggregates into the budget snapshots the router enforces against, closing the metering loop.

Cost-center limits, per-model budget fallback, and resilience

  • Cost-center limits V2 (cost_center_limits_v2 in src/control_plane/cost_center/):
    • Advertised only while the owner-only sidecar (<state_file>.cost-center.json, mode 0600) is attached and bound to the enrolled router identity. (The legacy V1 cost_centers member parses for compatibility but is never enforced or advertised.)
    • Each cost_centers_v2 statement (key ownership, router fallback, provider/model and stable-backend fallbacks, monthly caps, plus the dynamic budget snapshot and included_router_usage_seq watermark) is validated and applied atomically, or refused with invalid_cost_center_policy, digest_mismatch, stale_budget_snapshot, or invalid_usage_watermark while retaining last-known-good state.
    • Admission reservation: on meterable request paths, fixed pre-dispatch tiers (key assignment and router fallback) and lower tiers (stable backend, then provider/model, when every candidate backend for the effective target maps to an exhausted center) refuse exhausted allocations at admission and hold an atomic reservation (1 request + bounded byte-derived prompt estimate + clamped output ceiling) on the response body until completion or disconnect.
    • Sequenced accounting and watermark reconciliation: at the usage seam, the usage loop stamps the monotonic router_usage_seq and records exact metered tokens (input_tokens + output_tokens) into an unreflected ledger under the final serving (key_id, backend_id, provider, model). Newer Hub snapshots discard only ledger entries at or below included_router_usage_seq (and permanently dropped/rejected records retire their entries). Sell spend is enforced strictly from Hub-authored sell_spend_status: exhausted verdicts without local price math.
  • Per-model budget fallback (model_budget_fallback_v1 in src/control_plane/model_budget.rs):
    • Advertised once the budget sidecar attaches. Tri-state model_budgets statements assign exact (provider, model) offerings to per-key monthly pools with ordered fallback targets.
    • On /v1/chat/completions and /v1/completions, selection runs after Hub substitution/arbitrage and before request-param composition, cache lookup, cost-center admission, and dispatch. When a source pool is exhausted, the middleware rewrites the request to the first fallback target with headroom that passes allowlist, backend restriction, visibility, provider, health, and circuit-breaker gates, locking the target against downstream cross-model or mid-stream fallback.
    • Clients can opt out via x-continuum-budget-fallback: off (receiving 429 insufficient_quota with retry-after). Applied fallbacks set x-fallback-used: true, x-fallback-model, x-fallback-reason: budget_exhausted, and x-fallback-attempts, stamp budget_fallback_pool_id and requested_model/requested_provider on the usage record, and increment control_plane_model_budget_fallback_total{outcome}.
  • Surface parity: terminal token accounting and key_id attribution apply identically across /v1/chat/completions, /anthropic/v1/messages (all native and bridged sub-paths), streaming /v1/responses (conversion and pass-through arms), and the responses_only chat bridge, consistently excluding cache-read tokens from input_tpm and monthly budgets.
  • Fail-open invariant and unmatched-key policy:
    • Until the first policy sync or sidecar restore (policy_sync.source: none), only local authentication and rate limits apply.
    • Once a policy is active, requests without a matching Hub key or local AuthContext are rejected with 401 invalid_api_key by default (control_plane.policy.unmatched_key: reject, including newly issued Hub keys during the sync propagation window). Operators can monitor control_plane_unmatched_key_total{action} or opt into unmatched_key: fail_open (which emits a startup WARN and config validate warning). In api_keys.mode: blocking, non-revoked Hub keys authenticate even if absent from the local key store.
  • Restored key table (control_plane.policy.persist_key_table: true):
    • Persists an allowlist projection of key hashes, tier bindings/limits, revocation flags, usage snapshots, and timestamps (never plaintext keys or provider/search credentials) to <state_file>.policy-keys.json (0600).
    • Any security-relevant change (key removal, revocation, re-tiering, limit tightening, or a change to the key's Anthropic user-profile pair, anthropic_user_profile_id or anthropic_user_profile_passthrough, or to its prompt_logging_consent) unlinks the previous sidecar and fsyncs the parent directory on the applying thread before enforcing or acknowledging the change, preventing a crash from leaving a stale permissive file on disk.
    • On startup before the Hub is reachable, a valid sidecar belonging to the current enrollment and younger than policy.key_table_max_age_secs (default 24h, range 60s to 7d, tracked on both wall and monotonic clocks) is installed as a restored snapshot (policy_sync.source: restored), enforcing key auth and tier limits without advertising an applied cursor to the Hub until a live sync replaces it or the staleness bound expires.
    • consent_confirmed records whether the table was written after the Hub acknowledged a heartbeat, which is when its prompt_logging_consent values can be trusted. A confirmed sidecar keeps serving its stated consent through a Hub outage. A sidecar without the field reads as unconfirmed, so key-authenticated requests are not captured until the first confirmed envelope arrives. An older release refuses a sidecar that carries consent_confirmed and cold-starts, so rolling back drops the restored table instead of misreading it.
  • Per-user web-search credentials (search_credentials_v1):
    • Latched and advertised only while a web_search configuration section is active (releasing immediately on config removal).
    • Stores PolicyEnvelope.search_credentials in memory indexed by user_id, recomputing the acknowledged digest over both the tier digest and the credential set metadata.
    • For Hub keys, web-search executors enforce the tier's web_search_enabled gate, prefer the key owner's credential over the deployment key, and meter searches into task-local fields copied onto the usage record (web_search_queries, web_search_provider restricted to serper/brave/exa, and web_search_credential_source), including Anthropic web_search_20250305 emulation turns.
  • Inspecting applied policy: GET /admin/control-plane/policy and the WebUI Integrations page expose a secret-free projection of tier limits, substitution rules, equivalence classes, routing windows, optimization settings, model-budget pools, and policy_status.

Governance compatibility matrix

The policy envelope carries governed smart routing (smart_routing_policy_v1) and guardrail provider extensions (provider_content and provider_rows inside guardrail_policy_v1, advertised by guardrail_provider_content_v1 and guardrail_provider_rows_v1 on the same reconciler latch). The first router version that executes all of these capabilities is v1.32.0.

Router version guardrail_policy_v1 guardrail_provider_content_v1 / guardrail_provider_rows_v1 provider_content / provider_rows in a guardrail statement smart_routing_policy_v1
v1.16.x and earlier Not advertised (unsupported) Not advertised Ignored Not advertised (unsupported)
v1.17.0 to v1.31.x Advertised once the guardrail reconciler attaches Not advertised A capability-gated Hub strips the unsupported members and recomputes guardrail_policy_digest over the stripped policy, reporting the stripped members as unsupported (if sent ungated with a digest, refused as digest_mismatch) Not advertised (unsupported)
v1.32.0 and later Advertised once the guardrail reconciler attaches Advertised with guardrail_policy_v1 on the same latch Composed and executed under guardrail_policy_digest (or refused as guardrails_unavailable, guardrail_backend_missing, guardrail_backend_disabled, guardrail_providers_unavailable, or invalid_guardrail_policy) Advertised once the smart routing reconciler attaches (govern_smart_routing); refusals report invalid_smart_routing_policy, smart_routing_unavailable, or digest_mismatch

A v1.32.0 router built without a configuration manager (embedded library build or hot reload disabled) attaches neither reconciler and advertises none of the governance capabilities (unsupported). Neither policy is persisted across restarts; after a restart the router runs local configuration until the next full-snapshot envelope arrives.

Hub-delivered provider credentials

The policy envelope can carry an optional org-scoped provider_credentials set ({provider, credential_id, version, secret}) under the tri-state wire contract (absent keeps the last set, [] revokes all, non-empty replaces the set atomically).

  • Selection by backend_type (proxy::oauth_helper::effective_backend_secret): Maps each backend's backend_type (openai, anthropic, gemini, bedrock, etc.) to a provider name. When the backend has a local api_key configured and the Hub has delivered a credential for that provider, the Hub secret substitutes for the outbound auth header value (note: third-party proxy endpoints labeled with a canonical backend_type and a local api_key also receive that provider's credential).
  • Scope and memory safety: Applies to OpenAI chat, native Anthropic Messages/count_tokens, native Gemini embeddings/images, the Responses API, and batch dispatch (model discovery and health checks continue using the local key). Secrets remain in memory only and are redacted from logs, errors, and Debug/Display output.

Backend-scoped credentials (backend_credentials_v1)

PolicyEnvelope.backend_credentials indexes credentials by credential_id rather than provider so multiple Hub-managed backends sharing a provider can bind to distinct secrets:

  • Digest acknowledgment: The router recomputes backend_credential_digest over (credential_id, provider, version) metadata (never hashing secret bytes) and echoes it in AppliedPolicyRevision.backend_credential_digest.
  • Effective-auth precedence: A Hub-owned backend with a credential_ref uses only that backend-scoped credential (matched by ID and canonical provider) without requiring a local api_key. If the reference cannot be resolved (missing, revoked, wrong provider, or not yet restored after a restart), the backend is marked credential-unavailable and excluded from dispatch without falling back to org-wide or local secrets. Local and immutable backends retain the org-wide behavior.

Optimization policy and prompt cache execution

The Hub distributes the organization's optimization policy inside the policy envelope (exact_cache, prefix_cache, and semantic_cache booleans; integer semantic_similarity_bps and cache_ttl_secs), paired with per-tier cache_enabled (default on) and batch_eligible (default off) flags. If the optimization block is absent, exact and prefix caching default to on and semantic caching defaults to off.

  • Effective per-request decision and headers:
    • For each admitted Hub-keyed request, the enforcement middleware combines tier flags, org optimization policy, and client x-continuum-cache / x-continuum-batch (on|off) headers into a request extension. Client overrides can only tighten (off always wins; on is honored only when permitted by the tier and org policy).
    • Enforced responses emit x-continuum-cache: hit|miss|bypass (mirroring x-cache) and x-continuum-batch: on|off alongside the x-continuum-ratelimit-* headers.
  • Exact, prefix, and semantic cache execution:
    • Exact cache: gates ResponseCacheStore (memory, Redis, or tiered) across OpenAI chat (streaming and non-streaming) and non-streaming Responses and Anthropic Messages when a single backend is allowed with no fallback chain, using cache_ttl_secs when set.
    • Prefix cache (pfx: namespace): consulted after an exact-cache miss on non-streaming OpenAI Chat Completions, Responses, and Anthropic Messages. Prefix keys preserve the full prompt and all response-shaping parameters (max_tokens, top_p), store only naturally completed responses (finish_reason: stop, stop_reason: end_turn, or completed Responses output), and replay only when the stored completion fits the caller's token limit, stamping CacheHitType::Prefix.
    • Semantic cache (sem: namespace): triple-gated on response_cache.semantic.enabled (with a pinned backend and embedding_model), control_plane.optimization.semantic_cache_enabled, and the Hub's semantic_cache flag. Embeds the final user message on deterministic requests and matches candidates at or above threshold (or semantic_similarity_bps), stamping CacheHitType::Semantic (X-Cache: HIT, X-Cache-Mode: semantic). See docs/en/architecture/kv-cache.md.
  • Namespace isolation, guardrail scope, and StorePermit:
    • Every cache key binds proxy::cache_identity::ResponseCacheCaller (combining the local key identity and resolved Hub key_id so permissive-auth Hub keys never share an empty-string namespace).
    • When guardrails are active, response_cache_route_identity folds in GuardrailService::response_cache_scope (canonical GuardrailsConfig digest, hub-governed allow/deny pattern sets, matched routes key, caller bypass-list status, and CARGO_PKG_VERSION) and attaches a StorePermit (infrastructure::cache::store_permit).
    • The service advances its store epoch on every policy/provider swap and whenever an enforced output check passes only due to fail_open, preventing ResponseCacheStore::insert* or the semantic store from caching uninspected or stale-policy replies.
  • Cache-hit metering: local cache replays emit a single metadata-only usage record (cache_hit = true, all prompt tokens in cached_input_tokens, billable input_tokens = 0, output_tokens = 0) that does not debit TPM windows or monthly budgets. On /v1/responses, a request-scoped mark suppresses the downstream non-streaming recorder from emitting a duplicate provider-served event.

Batch dispatch and lifecycle reporting

Gated behind control_plane.batch.enabled (and policy.enabled), the router serves the OpenAI-compatible Batch API (/v1/batches, /v1/batches/{id}, /v1/batches/{id}/cancel, /v1/batches/{id}/results) and reports asynchronous job transitions to the Hub:

  • Dispatch, provider parity, and key scoping:
    • Admissions require an effective batch_eligible decision (x-continuum-batch: off always denies). The batch client resolves control_plane.batch.backend (or the first OpenAI-compatible or native Anthropic backend), routing native Anthropic backends to the Anthropic Message Batches API (/v1/messages/batches) and normalizing Anthropic processing_status into BatchJobStatus (Bedrock batch is not supported).
    • Because router keys multiplex onto a single upstream provider account, retrieve, cancel, list (GET /v1/batches, unpaginated), and results (409 before completion, 502 on fetch error) filter strictly to jobs owned by the caller's key, returning 404 for unknown, foreign, or already-terminal IDs without contacting the provider.
  • Separate batch rate-limit pool: submissions charge the tier's batch_rpm and in-flight batch_queue_depth gauge (plus a global 100k tracked-job memory cap returning 503), completely isolated from synchronous rpm / input_tpm / output_tpm limits.
    • Pressure admission runs after any routing-window deferral and final backend resolution, but before an optional cost-center model scan. A global reservation and the per-key queue slot bound concurrent scans. batch_rpm counts an attempt once it enters this dispatch phase, even if the scan, cost-center check, or provider call later fails. Early returns and request cancellation release the queue and global reservations; successful job registration assumes their ownership.
  • Cost-center admission at provider dispatch:
    • Immediately before provider create, the router evaluates the Hub V2 allocation in key, router, stable-backend, then provider/model precedence. OpenAI input files are scanned only when provider/model ownership is the first resolvable tier. An exhausted request, token, or Hub-authored sell-spend dimension returns 429 insufficient_quota before provider create, while batch retrieval and cancellation remain available.
    • This is a current-state exhaustion check, not a reservation or a must-fit estimate. Only completed batch usage is sequenced and accounted, and sell spend is never estimated locally. Batches admitted before a cost center becomes exhausted can complete later and overshoot its cap; batch_rpm and batch_queue_depth limit submission pressure but do not provide a hard bound on asynchronous budget overshoot.
    • A scanned single-model input uses that model for both admission and the eventual completion record. A mixed-model or model-empty input keeps the batch placeholder because one aggregated completion record cannot be assigned to an individual model without inventing an identity. Key, router, and stable-backend allocations still apply, but an individual provider/model assignment cannot refuse that mixed batch unless the Hub explicitly assigns the placeholder.
  • Lifecycle polling and completion usage:
    • An in-memory poller tracks non-terminal jobs and pushes metadata-only transitions (submitted -> in_progress -> completed / failed / cancelled / expired) to POST /api/agent/v1/batches with at-least-once delivery and per-transition idempotency keys (disabling delivery after a single log entry if the Hub returns 404). Jobs stuck past 26 hours are force-transitioned to expired to release their in-flight slot without emitting completion usage.
    • On completed, the router releases the slot and emits one usage record (batched = true, provider_batch_id, completion timestamp, and token totals aggregated best-effort from the first 64 MiB of the output file).

Cache-hit type metering

Usage records also carry cache_hit_type for local response-cache attribution. When a response is served from the router's own local cache, the record's cache_hit_type stamps which cache mode served it: exact for an exact-match hit, prefix for a prompt-prefix hit, and semantic for a similarity hit on the opt-in semantic path; all three are live serving paths, mutually exclusive per request, so a hit is stamped and metered exactly once. Every other usage tap, including a provider-side cache read and a completed batch job, leaves cache_hit_type as None; that kind of cache read is already captured by cached_input_tokens, not this field. The field carries no prompt or completion content and defaults to None when absent.

Synthetic provider/model probes

When the Hub delivers an enabled PolicyEnvelope.probes policy with at least one (provider, model) target (and control-plane, control_plane.enabled, and control_plane.policy.enabled are all active), the router runs synthetic health probes from its own network path:

  • Tri-state scheduling and execution:
    • Omission retains the current policy, a delivered policy replaces it immediately on the scheduler watch channel (cancelling undispatched targets mid-round without overlapping rounds), and a disabled policy clears it.
    • Each round (running immediately on enable and every interval_secs thereafter) resolves each target against the live model service and backends matching the target's canonical provider_for_config_type(), selects a backend via the pool's strategy, and dispatches one non-streaming PROBE_PROMPT call through the internal Backend::execute_probe_chat_completion seam (src/core/probe.rs, src/infrastructure/common/executor/probe.rs). Probes use normal request transformation and effective auth (including Hub credentials) while bypassing substitution, arbitrage, fallback, caching, guardrails, batching, and retries.
  • Privacy, per-router budget ceiling, and delivery:
    • Results carry metadata only (status class ok/auth_failed/rate_limited/timeout/provider_error/network_error/unknown, sanitized reason codes, timings, and integer token counts; never error bodies or prompts) and emit no UsageEvent or key-budget charge (and caller-supplied x-continuum-probe headers are ignored by accounting).
    • monthly_budget_microusd (0 = unlimited) enforces a per-router UTC-month ceiling persisted in an owner-only sidecar next to control_plane.state_file, reserving conservative max cost before each paid dispatch and failing closed when pricing or sidecar state is unavailable (reporting budget_exhausted on exhaustion).
    • Reports push to POST /api/agent/v1/probes from a bounded drop-oldest buffer with stable (report_id, probe_id) retry identity and rate-limited 404 handling.
  • Implementation: src/control_plane/probes/, src/core/traits.rs, src/core/probe.rs, src/infrastructure/common/executor/probe.rs, and crates/continuum-protocol/src/probe.rs.

Vendored protocol crate

The wire contract lives in crates/continuum-protocol/ (a feature-gated PATH dependency compiled only under control-plane so default and offline builds never resolve the private continuum-hub repository):

  • Provenance and CI drift check: crates/continuum-protocol/src/ and tests/ are a byte-identical copy of the Hub revision named on the provenance line in crates/continuum-protocol/Cargo.toml. scripts/check-vendored-protocol.sh verifies every file offline against crates/continuum-protocol/UPSTREAM.sha256 (allowing only tracked entries in crates/continuum-protocol/LOCAL-AHEAD and rejecting out-of-tree build inputs or un-bumped manifest edits). Re-vendoring uses scripts/refresh-vendored-protocol-manifest.sh (see the Development Guide).
  • Router-side boundary: router-specific bounds and helpers (such as guardrail summary emission ceilings) live under src/control_plane/, never inside the vendored crate.
  • Control-plane implementation map: src/control_plane/ (agent, HTTP client, credential store, inventory builder, usage buffer, backoff, policy store, sync loops, enforcement middleware, optimization/cache metering, and batch/), with configuration schema in src/core/config/control_plane.rs and config.yaml.example.

Realtime WebSocket Proxy

GET /v1/realtime?model=<id> (and the /realtime SDK alias) proxies bidirectional OpenAI Realtime-compatible WebSocket sessions (session.update, input_audio_buffer.append, response.done, function calls) byte-for-byte without protocol translation. Compiled under the default realtime Cargo feature and active when realtime.enabled: true is set, the route mounts inside the standard API group behind API authentication and rate limiting.

Session lifecycle

  • Pre-upgrade checks (HTTP rejections):
    • Validates the Origin header when present, rejecting (403) browser origins that are neither same-origin nor listed in server.cors.allow_origins (with server.cors.enabled: true); non-browser requests without Origin pass through.
    • Requires the model query parameter (400), resolves aliases and suffixes through the shared metadata pipeline (404 when unknown), requires the audio capability (400), and rewrites aliases to the canonical catalog ID for upstream dispatch.
    • Selects a backend via select_admissible_backend (503 when none is admissible) and acquires a permit from the realtime.max_sessions semaphore (503 when full).
  • Backend dial: maps the backend scheme (http -> ws, https -> wss), joins realtime.backend_path, forwards the canonical model query parameter, and connects using the backend's effective credential (never forwarding the client's Authorization header) within handshake_timeout. Dial failures record a circuit failure and close the client leg with code 1011; connected sessions record a circuit success.
  • Bidirectional relay and deadlines: runs one pump per direction with send-await backpressure, a 4 MiB message/frame cap, close-frame propagation (1011 on abnormal termination), an activity-refreshed idle timer, and an optional absolute session ceiling (closing both legs with 1000; disabling both triggers a startup config validation warning).
  • Privacy, usage metering, and metrics:
    • Frame payloads carry raw user speech and are never logged; request logs redact Sec-WebSocket-Protocol and proxy-authorization.
    • A passive observer (src/proxy/realtime_usage.rs) inspects backend text frames up to 256 KiB after client delivery and records each response.done event as an attempt-scoped usage record via record_realtime_response (src/services/stats/middleware.rs, with stream: true, http_status: 101, and outcome mapped from response.status).
    • Under the metrics feature, exports realtime_active_sessions, realtime_sessions_total, realtime_frames_relayed_total, realtime_session_duration_seconds, and realtime_usage_events_total.
  • Implementation: src/proxy/realtime.rs, src/server/routes.rs, src/core/config/models/realtime.rs, and src/metrics/realtime.rs (see Realtime configuration).

Dynamo Worker Discovery

An optional, watch-only etcd discovery integration (dynamo Cargo feature, compiled into release binaries and opted in via dynamo.enabled: true) for NVIDIA Dynamo prefill and decode worker fleets:

  • Spectator etcd watch and gapless resync:
    • Watches v1/instances/<ns>/ (Endpoint records) and v1/mdc/<ns>/ (model cards, retained opaquely for N-2 forward compatibility) under Dynamo's DYN_NAMESPACE, joining records by (component, endpoint, instance_id) without ever acquiring leases or writing/deleting etcd keys.
    • Each pass performs a range-get across both prefixes, records the store revision, and opens a watch at revision + 1 (recovering from broken streams via bounded-backoff full resyncs so compacted revisions never cause drift).
  • Live reload: a discovery supervisor (src/dynamo/supervisor.rs) rebuilds the etcd client and resyncs the registry whenever enabled, discovery.etcd.namespace, or etcd connection/auth settings change (preserving the registry snapshot on backoff-only edits).
  • ZMQ kv_metrics load signals: watches v1/event_channels/namespace/<ns>/ for direct-mode ZMQ publisher endpoints, subscribes to four-frame msgpack EventEnvelope/ActiveLoad samples, deduplicates by sequence, and stores the latest sample per (worker_id, dp_rank). Samples older than dynamo.load.max_staleness (default 3s) degrade routing for that worker to round-robin without evicting the worker.
  • Implementation: src/dynamo/ (records.rs, apply.rs, registry.rs, watcher.rs, supervisor.rs, config.rs, admin.rs, apply_channels.rs, load/), src/lib.rs, src/server/serve.rs, src/metrics/dynamo.rs, and src/core/config/models/dynamo.rs (see Dynamo Worker Discovery).

This architecture supports a production LLM router that scales to thousands of requests while remaining maintainable and extensible. The separation of concerns makes it straightforward to add new features, swap implementations, and test each component in isolation.