diff --git a/.github/workflows/test-unit-proxy-infra.yml b/.github/workflows/test-unit-proxy-infra.yml
index 336e53ee3d74..2681400e8b7d 100644
--- a/.github/workflows/test-unit-proxy-infra.yml
+++ b/.github/workflows/test-unit-proxy-infra.yml
@@ -29,6 +29,7 @@ jobs:
tests/test_litellm/proxy/_experimental
tests/test_litellm/proxy/experimental
tests/test_litellm/proxy/common_utils
+ tests/test_litellm/proxy/logging_endpoints
tests/test_litellm/proxy/test_*.py
workers: 2
reruns: 2
diff --git a/.gitignore b/.gitignore
index fda3311fe02a..3563d7c8c2d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -123,3 +123,6 @@ crash.*.log
# and should be committed.
.vscode
.pin_list.txt
+
+# pytest coverage data
+.coverage
diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py
index 2f65f99c2926..b67f7d421278 100644
--- a/backend/routes/allowlist.py
+++ b/backend/routes/allowlist.py
@@ -84,6 +84,8 @@
"/active/callbacks",
"/callbacks",
"/team_callback",
+ # Rust data-plane gateway → proxy control-plane API (logging today, auth later)
+ "/v1/rust_control_plane/",
# Alerting / email / IP allowlist
"/alerting/",
"/email/",
diff --git a/litellm-rust/CLAUDE.md b/litellm-rust/CLAUDE.md
index 0a985b833f3e..7c723e570ef2 100644
--- a/litellm-rust/CLAUDE.md
+++ b/litellm-rust/CLAUDE.md
@@ -77,6 +77,20 @@ such as `ai-gateway`, router hosts, or standalone servers:
- Avoid `expect`/`unwrap` in server startup and request paths unless the panic is
impossible by construction and documented.
+## Constants
+
+Magic numbers and fixed strings go in a crate-level `constants.rs`, never
+hardcoded inline — the Rust mirror of Python's `litellm/constants.py`.
+
+- Each crate that needs them has `src/constants.rs` (declared `mod constants;`);
+ import from it (`use crate::constants::...`). Don't scatter `const` values at
+ the top of feature modules.
+- An env-overridable tunable still lives in `constants.rs` as its `DEFAULT_*`
+ value; the env read (with fallback to that default) happens at the host/config
+ resolution layer, not in `core`/`providers`.
+- Exception: a value that is purely local to one function and has no meaning
+ elsewhere may stay inline, but prefer `constants.rs` when in doubt.
+
## Checks
Run these before pushing Rust changes. The same checks run in GitHub Actions
diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock
index 3b8c83aac164..6fe84f1cfbc7 100644
--- a/litellm-rust/Cargo.lock
+++ b/litellm-rust/Cargo.lock
@@ -571,6 +571,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
+ "sha2",
"subtle",
"tokio",
"tokio-tungstenite",
@@ -1129,6 +1130,17 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml
index 785404c38f18..25ee22130406 100644
--- a/litellm-rust/Cargo.toml
+++ b/litellm-rust/Cargo.toml
@@ -20,6 +20,7 @@ rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+sha2 = "0.10"
subtle = "2"
thiserror = "2.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
diff --git a/litellm-rust/crates/ai-gateway/ARCHITECTURE.md b/litellm-rust/crates/ai-gateway/ARCHITECTURE.md
new file mode 100644
index 000000000000..733953bbdb3b
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/ARCHITECTURE.md
@@ -0,0 +1,12 @@
+# ai-gateway architecture
+
+The Rust ai-gateway does LLM inference (realtime WebSocket). Spend tracking is an
+API callback: it POSTs each finished session to the LiteLLM proxy, which records
+spend and runs the usual callbacks.
+
+```mermaid
+flowchart LR
+ C[client] <--> G[Rust ai-gateway
LLM inference]
+ G <--> O[OpenAI realtime]
+ G -. spend tracking callback .-> P[litellm proxy]
+```
diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml
index c829333a4b19..b08fb89d5e87 100644
--- a/litellm-rust/crates/ai-gateway/Cargo.toml
+++ b/litellm-rust/crates/ai-gateway/Cargo.toml
@@ -15,19 +15,25 @@ required-features = ["server"]
[dependencies]
litellm-core.workspace = true
+# reqwest (rustls + json) is used by io/ocr and ships realtime logs to the
+# Python proxy callbacks API.
reqwest.workspace = true
-tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time"] }
+# `sync` powers the bounded mpsc channel the realtime logger drains.
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
tokio-tungstenite.workspace = true
futures-util.workspace = true
serde_json.workspace = true
axum = { workspace = true, features = ["ws"], optional = true }
serde = { workspace = true, optional = true }
subtle = { workspace = true, optional = true }
+# sha2 hashes the master key into user_api_key_hash (matches the proxy's
+# SHA-256 hash_token) so the plaintext credential never enters a log payload.
+sha2 = { workspace = true, optional = true }
pyo3 = { workspace = true, features = ["auto-initialize"], optional = true }
[features]
default = []
-server = ["dep:axum", "dep:subtle", "dep:serde"]
+server = ["dep:axum", "dep:subtle", "dep:serde", "dep:sha2"]
# Build the gateway's config from the proxy YAML via an embedded Python
# interpreter (links libpython; requires `litellm` importable at runtime).
python-config = ["dep:pyo3"]
diff --git a/litellm-rust/crates/ai-gateway/README.md b/litellm-rust/crates/ai-gateway/README.md
index 649b9b07942e..f913beff6d56 100644
--- a/litellm-rust/crates/ai-gateway/README.md
+++ b/litellm-rust/crates/ai-gateway/README.md
@@ -19,6 +19,7 @@ Dependency direction (acyclic): litellm-core ← litellm-ai-gateway ← litellm-
- **Client endpoint:** `wss:///v1/realtime?model=` (WebSocket)
- **Auth:** `Authorization: Bearer $LITELLM_MASTER_KEY` (fails closed if unset)
- **Health:** `GET /health/readiness`, `GET /health/liveness`, `GET /health/gil`
+- **Request logs:** POSTed to a LiteLLM proxy at `/v1/rust_control_plane/logs` (see [Request logging](#request-logging))
> **Realtime serving is pure Rust.** Python is used at **load time only** — to
> read the config once at boot. The realtime hot path never touches Python.
@@ -65,6 +66,7 @@ overridden at deploy time (e.g. a Render secret file mounted at the same path).
| `OPENAI_API_KEY` | yes | — | Upstream OpenAI key. Referenced by config.yaml as `os.environ/OPENAI_API_KEY` for the gateway→OpenAI dial. |
| `HOST` | no | `127.0.0.1` | **Set to `0.0.0.0` in any container/deploy** or external traffic is refused. |
| `PORT` | no | `4001` | Listen port. Render and most PaaS inject this automatically. |
+| `LITELLM_PROXY_BASE_URL` | no | `http://localhost:4000` | LiteLLM proxy that request logs are POSTed to. See [Request logging](#request-logging). |
> Secrets (`LITELLM_MASTER_KEY`, `OPENAI_API_KEY`) are never baked into the image
> or `render.yaml` — inject them at deploy time only.
@@ -83,6 +85,18 @@ This mode links no libpython and needs no config file, but it only supports one
hard-coded OpenAI deployment. **config.yaml is the recommended path** — use the
stand-in only for the leanest possible build.
+## Request logging
+
+The gateway runs no spend logic. When a session ends it builds one
+`StandardLoggingPayload` and POSTs it to `{LITELLM_PROXY_BASE_URL}/v1/rust_control_plane/logs`
+(admin-only, bearer = `LITELLM_MASTER_KEY`), and the proxy replays it through its
+normal callbacks (spend logs, Langfuse, etc.). The POST is non-blocking: a bounded
+channel drained by a background worker, dropping with a counter if the proxy is
+down. It sends one payload per session. Both env vars are in the table above.
+
+Worker tuning, rarely needed: `LITELLM_LOG_CHANNEL_CAPACITY` (4096),
+`LITELLM_LOG_BATCH_SIZE` (256), `LITELLM_LOG_FLUSH_INTERVAL_MS` (500).
+
## Build & run with Docker
The image is built `--features python-config` and installs litellm **from this
diff --git a/litellm-rust/crates/ai-gateway/src/auth/mod.rs b/litellm-rust/crates/ai-gateway/src/auth/mod.rs
index e2dd51f656d3..438a05130572 100644
--- a/litellm-rust/crates/ai-gateway/src/auth/mod.rs
+++ b/litellm-rust/crates/ai-gateway/src/auth/mod.rs
@@ -12,10 +12,29 @@ use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use axum::http::StatusCode;
+use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use crate::state::AppState;
+/// SHA-256 hex digest of a token — the exact transform the Python proxy applies
+/// (`litellm.proxy.utils.hash_token`).
+///
+/// STRICT REQUIREMENT: a raw key (`LITELLM_MASTER_KEY`, a virtual key, …) must
+/// **never** leave this gateway in a log payload. Spend logs and every callback
+/// integration receive `user_api_key_hash`, so that field must be this hash, not
+/// the credential. Hashing here also means the value matches the key's hash in
+/// `LiteLLM_SpendLogs.api_key`, so realtime spend joins with the rest of LiteLLM.
+pub fn hash_token(token: &str) -> String {
+ let digest = Sha256::digest(token.as_bytes());
+ let mut hex = String::with_capacity(digest.len() * 2);
+ for byte in digest {
+ use std::fmt::Write;
+ let _ = write!(hex, "{byte:02x}");
+ }
+ hex
+}
+
/// Extractor that requires the configured master key as a bearer token.
///
/// Rejections: `500` when no master key is configured (permanent
@@ -52,3 +71,23 @@ impl FromRequestParts for RequireMasterKey {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::hash_token;
+
+ #[test]
+ fn hash_token_matches_python_sha256_hexdigest() {
+ // Must equal hashlib.sha256("sk-1234".encode()).hexdigest() — the value
+ // the proxy stores in LiteLLM_SpendLogs.api_key.
+ assert_eq!(
+ hash_token("sk-1234"),
+ "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b"
+ );
+ // 64 lowercase hex chars, and never the raw input.
+ let h = hash_token("sk-secret");
+ assert_eq!(h.len(), 64);
+ assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
+ assert_ne!(h, "sk-secret");
+ }
+}
diff --git a/litellm-rust/crates/ai-gateway/src/constants.rs b/litellm-rust/crates/ai-gateway/src/constants.rs
new file mode 100644
index 000000000000..3116a4c99326
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/constants.rs
@@ -0,0 +1,29 @@
+//! Crate-level constants for the ai-gateway.
+//!
+//! Per `litellm-rust/CLAUDE.md`, magic numbers and fixed strings live here
+//! (the Rust mirror of Python's `litellm/constants.py`), not inline in feature
+//! modules. Env-overridable tunables keep their `DEFAULT_*` value here; the env
+//! read + fallback happens at the host/config layer.
+
+/// Default LiteLLM control-plane base URL for request-log egress when
+/// `LITELLM_PROXY_BASE_URL` is unset.
+pub(crate) const DEFAULT_PROXY_BASE_URL: &str = "http://localhost:4000";
+
+/// The logs ingest path appended to the proxy base. Not a tunable; it is the
+/// proxy's API contract (the rust-control-plane router on the Python proxy).
+pub(crate) const RUST_CONTROL_PLANE_LOGS_PATH: &str = "/v1/rust_control_plane/logs";
+
+/// Default bounded channel depth for the log-egress worker.
+/// Override: `LITELLM_LOG_CHANNEL_CAPACITY`.
+pub(crate) const DEFAULT_CHANNEL_CAPACITY: usize = 4096;
+
+/// Default max records POSTed per request to the control plane.
+/// Override: `LITELLM_LOG_BATCH_SIZE`.
+pub(crate) const DEFAULT_MAX_BATCH_SIZE: usize = 256;
+
+/// Default partial-batch flush cadence, in ms.
+/// Override: `LITELLM_LOG_FLUSH_INTERVAL_MS`.
+pub(crate) const DEFAULT_FLUSH_INTERVAL_MS: u64 = 500;
+
+/// Provider attributed to realtime sessions in the logging payload.
+pub(crate) const DEFAULT_PROVIDER: &str = "openai";
diff --git a/litellm-rust/crates/ai-gateway/src/integrations/custom_logger.rs b/litellm-rust/crates/ai-gateway/src/integrations/custom_logger.rs
new file mode 100644
index 000000000000..53b599d8c983
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/integrations/custom_logger.rs
@@ -0,0 +1,24 @@
+//! The `CustomLogger` trait — the Rust mirror of Python
+//! `litellm/integrations/custom_logger.py::CustomLogger`.
+//!
+//! Synchronous (no `async_trait`): callbacks are O(1) enqueue-and-return so the
+//! realtime splice never blocks on a logger. Default bodies are no-ops so a
+//! logger can implement only the events it cares about.
+
+use crate::integrations::types::{LogError, LoggingError, StandardLoggingPayload};
+
+pub trait CustomLogger: Send + Sync {
+ /// Record a successful call. Default: no-op.
+ fn log_success_event(&self, _payload: &StandardLoggingPayload) -> Result<(), LogError> {
+ Ok(())
+ }
+
+ /// Record a failed call. Default: no-op.
+ fn log_failure_event(
+ &self,
+ _payload: &StandardLoggingPayload,
+ _error: &LoggingError,
+ ) -> Result<(), LogError> {
+ Ok(())
+ }
+}
diff --git a/litellm-rust/crates/ai-gateway/src/integrations/litellm_python_proxy_api.rs b/litellm-rust/crates/ai-gateway/src/integrations/litellm_python_proxy_api.rs
new file mode 100644
index 000000000000..165a90d8dbee
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/integrations/litellm_python_proxy_api.rs
@@ -0,0 +1,209 @@
+//! A `CustomLogger` that ships finished events to the LiteLLM Python proxy's
+//! `/v1/rust_control_plane/logs` endpoint.
+//!
+//! The callback path is non-blocking: `log_success_event` / `log_failure_event`
+//! build a `LogRecord` and `try_send` it onto a bounded channel, returning a
+//! `LogError` (never panicking, never awaiting) if the channel is full or the
+//! worker has gone away. A spawned background worker drains the channel, batches
+//! records into `{"records":[...]}`, and POSTs them to the proxy with a pooled
+//! `reqwest::Client`.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use reqwest::Client;
+use tokio::sync::mpsc::{self, Receiver, Sender};
+use tokio::time::interval;
+
+use crate::constants::{
+ DEFAULT_CHANNEL_CAPACITY, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_MAX_BATCH_SIZE,
+ DEFAULT_PROXY_BASE_URL, RUST_CONTROL_PLANE_LOGS_PATH,
+};
+use crate::integrations::custom_logger::CustomLogger;
+use crate::integrations::types::{
+ CallbackLogsRequest, LogError, LogRecord, LoggingError, StandardLoggingPayload,
+};
+
+/// Egress worker tunables. Each field defaults to the matching `DEFAULT_*` const
+/// in `crate::constants` and is overridable via an env var (read once at logger
+/// construction).
+struct EgressTunables {
+ channel_capacity: usize,
+ max_batch_size: usize,
+ flush_interval: Duration,
+}
+
+impl EgressTunables {
+ fn from_env() -> Self {
+ Self {
+ channel_capacity: env_positive(
+ "LITELLM_LOG_CHANNEL_CAPACITY",
+ DEFAULT_CHANNEL_CAPACITY,
+ ),
+ max_batch_size: env_positive("LITELLM_LOG_BATCH_SIZE", DEFAULT_MAX_BATCH_SIZE),
+ flush_interval: Duration::from_millis(env_positive(
+ "LITELLM_LOG_FLUSH_INTERVAL_MS",
+ DEFAULT_FLUSH_INTERVAL_MS,
+ )),
+ }
+ }
+}
+
+/// Parse a positive integer env var, falling back to `default` on missing,
+/// unparseable, or non-positive values. Generic over the integer type so one
+/// helper serves both the `usize` capacities and the `u64` interval.
+fn env_positive(name: &str, default: T) -> T
+where
+ T: std::str::FromStr + PartialOrd + From,
+{
+ let zero = T::from(0u8);
+ std::env::var(name)
+ .ok()
+ .and_then(|value| value.trim().parse::().ok())
+ .filter(|n| *n > zero)
+ .unwrap_or(default)
+}
+
+/// Ships realtime logging events to the LiteLLM Python proxy.
+pub struct LiteLLMPythonProxyAPILogger {
+ sink: Sender,
+}
+
+impl LiteLLMPythonProxyAPILogger {
+ /// Spawn the background worker and return a logger handle. `base` is the
+ /// proxy base URL (no trailing path); `master_key` is sent as a bearer token.
+ pub fn start(base: String, master_key: String) -> Arc {
+ let tunables = EgressTunables::from_env();
+ let (sink, receiver) = mpsc::channel::(tunables.channel_capacity);
+ let url = format!(
+ "{}{}",
+ base.trim_end_matches('/'),
+ RUST_CONTROL_PLANE_LOGS_PATH
+ );
+ let client = Client::new();
+ tokio::spawn(worker_loop(
+ receiver,
+ client,
+ url,
+ master_key,
+ tunables.max_batch_size,
+ tunables.flush_interval,
+ ));
+ Arc::new(Self { sink })
+ }
+
+ /// Build a logger from the environment: `LITELLM_PROXY_BASE_URL` (default
+ /// `http://localhost:4000`) and `LITELLM_MASTER_KEY`.
+ ///
+ /// `LITELLM_PROXY_BASE_URL` is treated as the full base and the route is
+ /// appended verbatim, so if the proxy runs under a `SERVER_ROOT_PATH`
+ /// (e.g. served at `https://host/litellm`), include it in the base
+ /// (`LITELLM_PROXY_BASE_URL=https://host/litellm`) and the POST lands at
+ /// `https://host/litellm/v1/rust_control_plane/logs`.
+ pub fn from_env() -> Arc {
+ let base = std::env::var("LITELLM_PROXY_BASE_URL")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or_else(|| DEFAULT_PROXY_BASE_URL.to_string());
+ let key = std::env::var("LITELLM_MASTER_KEY").unwrap_or_default();
+ Self::start(base, key)
+ }
+
+ fn enqueue(&self, record: LogRecord) -> Result<(), LogError> {
+ self.sink.try_send(record).map_err(|err| match err {
+ mpsc::error::TrySendError::Full(_) => LogError::channel_full(),
+ mpsc::error::TrySendError::Closed(_) => LogError::channel_closed(),
+ })
+ }
+}
+
+impl CustomLogger for LiteLLMPythonProxyAPILogger {
+ fn log_success_event(&self, payload: &StandardLoggingPayload) -> Result<(), LogError> {
+ self.enqueue(LogRecord {
+ status: "success".to_string(),
+ payload: payload.clone(),
+ error: None,
+ })
+ }
+
+ fn log_failure_event(
+ &self,
+ payload: &StandardLoggingPayload,
+ error: &LoggingError,
+ ) -> Result<(), LogError> {
+ self.enqueue(LogRecord {
+ status: "failure".to_string(),
+ payload: payload.clone(),
+ error: Some(format!("{}: {}", error.kind, error.message)),
+ })
+ }
+}
+
+/// Drain the channel, batching records and POSTing them to the proxy. Exits when
+/// the channel is closed (all senders dropped) and drained.
+async fn worker_loop(
+ mut receiver: Receiver,
+ client: Client,
+ url: String,
+ master_key: String,
+ max_batch_size: usize,
+ flush_interval: Duration,
+) {
+ let mut ticker = interval(flush_interval);
+ let mut batch: Vec = Vec::with_capacity(max_batch_size);
+
+ loop {
+ tokio::select! {
+ maybe_record = receiver.recv() => {
+ match maybe_record {
+ Some(record) => {
+ batch.push(record);
+ if batch.len() >= max_batch_size {
+ flush(&client, &url, &master_key, &mut batch).await;
+ }
+ }
+ None => {
+ // Channel closed: flush remaining and exit.
+ flush(&client, &url, &master_key, &mut batch).await;
+ break;
+ }
+ }
+ }
+ _ = ticker.tick() => {
+ flush(&client, &url, &master_key, &mut batch).await;
+ }
+ }
+ }
+}
+
+/// POST the current batch (if any), clearing it. Errors are logged, not fatal.
+async fn flush(client: &Client, url: &str, master_key: &str, batch: &mut Vec) {
+ if batch.is_empty() {
+ return;
+ }
+ let records = std::mem::take(batch)
+ .into_iter()
+ .map(LogRecord::into_callback_record)
+ .collect();
+ let body = CallbackLogsRequest { records };
+
+ let response = client
+ .post(url)
+ .bearer_auth(master_key)
+ .json(&body)
+ .send()
+ .await;
+
+ match response {
+ Ok(resp) if resp.status().is_success() => {}
+ Ok(resp) => {
+ eprintln!(
+ "litellm-ai-gateway: callback logs POST returned {} to {url}",
+ resp.status()
+ );
+ }
+ Err(err) => {
+ eprintln!("litellm-ai-gateway: callback logs POST failed to {url}: {err}");
+ }
+ }
+}
diff --git a/litellm-rust/crates/ai-gateway/src/integrations/mod.rs b/litellm-rust/crates/ai-gateway/src/integrations/mod.rs
new file mode 100644
index 000000000000..8799be0c0402
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/integrations/mod.rs
@@ -0,0 +1,10 @@
+//! Pure-Rust logging integrations. Names map 1:1 to Python
+//! `litellm/integrations/`:
+//! - [`custom_logger::CustomLogger`] — the callback trait
+//! - [`litellm_python_proxy_api::LiteLLMPythonProxyAPILogger`] — ships events
+//! to the Python proxy's `/v1/callbacks/logs` endpoint
+//! - [`types`] — the typed `StandardLoggingPayload` wire contract
+
+pub mod custom_logger;
+pub mod litellm_python_proxy_api;
+pub mod types;
diff --git a/litellm-rust/crates/ai-gateway/src/integrations/types.rs b/litellm-rust/crates/ai-gateway/src/integrations/types.rs
new file mode 100644
index 000000000000..d61a1f816a7c
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/integrations/types.rs
@@ -0,0 +1,164 @@
+//! Typed payloads for the LiteLLM `/v1/callbacks/logs` realtime-logging contract.
+//!
+//! Field names below are the EXACT JSON keys the Python replay path + spend-logs
+//! builder read. Note the deliberate mix:
+//! - `startTime` / `endTime` are camelCase (epoch f64 seconds)
+//! - `response_cost` / `prompt_tokens` / etc. are snake_case
+//!
+//! Mirrors Python `litellm/integrations/` + the proxy `CallbackLogsRequest`
+//! contract 1:1.
+
+use serde::Serialize;
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Cumulative token usage for a realtime session.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct Usage {
+ pub prompt_tokens: u64,
+ pub completion_tokens: u64,
+ pub total_tokens: u64,
+}
+
+/// Cost-attribution metadata threaded from the authenticated request.
+#[derive(Clone, Debug, Default)]
+pub struct RequestMetadata {
+ pub user_api_key_hash: Option,
+ pub user_api_key_user_id: Option,
+ pub user_api_key_team_id: Option,
+}
+
+/// A logging-callback failure (e.g. a custom logger raised). Mirrors the Python
+/// failure-event shape: a message plus an exception kind/class name.
+#[derive(Clone, Debug)]
+pub struct LoggingError {
+ pub message: String,
+ pub kind: String,
+}
+
+/// A non-fatal error returned by a `CustomLogger` when it cannot enqueue an
+/// event (channel full or the background worker has shut down).
+#[derive(Clone, Debug)]
+pub struct LogError {
+ pub message: String,
+ pub kind: String,
+}
+
+impl LogError {
+ pub fn channel_full() -> Self {
+ Self {
+ message: "logging channel is full; dropping record".to_string(),
+ kind: "ChannelFull".to_string(),
+ }
+ }
+
+ pub fn channel_closed() -> Self {
+ Self {
+ message: "logging channel is closed; worker has shut down".to_string(),
+ kind: "ChannelClosed".to_string(),
+ }
+ }
+}
+
+impl std::fmt::Display for LogError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}: {}", self.kind, self.message)
+ }
+}
+
+impl std::error::Error for LogError {}
+
+/// Batch wrapper — the top-level request body.
+/// Matches Python `CallbackLogsRequest { records: list[CallbackLogRecord] }`.
+#[derive(Serialize)]
+pub struct CallbackLogsRequest {
+ pub records: Vec,
+}
+
+/// One finished logging event.
+/// Matches `CallbackLogRecord { status, standard_logging_payload, error? }`.
+#[derive(Serialize)]
+pub struct CallbackLogRecord {
+ /// "success" | "failure". On "failure", `error` (or payload.error_str)
+ /// becomes the replayed exception string.
+ pub status: String,
+
+ pub standard_logging_payload: StandardLoggingPayload,
+
+ /// Only meaningful when status == "failure". Omitted on success.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+}
+
+/// The self-describing payload. Field names are the EXACT JSON keys the Python
+/// replay path + spend-logs builder read.
+#[derive(Clone, Debug, Serialize)]
+pub struct StandardLoggingPayload {
+ pub id: String,
+ pub litellm_call_id: String,
+
+ /// e.g. "realtime", "acompletion". Falls back to "acompletion" if absent.
+ pub call_type: String,
+
+ pub model: String,
+ pub custom_llm_provider: String,
+
+ /// Spend ($) written to LiteLLM_SpendLogs.spend.
+ pub response_cost: f64,
+
+ pub prompt_tokens: u64,
+ pub completion_tokens: u64,
+ pub total_tokens: u64,
+
+ /// EPOCH SECONDS as float — camelCase keys, NOT snake_case.
+ #[serde(rename = "startTime")]
+ pub start_time: f64,
+ #[serde(rename = "endTime")]
+ pub end_time: f64,
+
+ pub stream: bool,
+
+ pub metadata: StandardLoggingMetadata,
+
+ /// Optional; stored as request input on the spend log row.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub messages: Option,
+}
+
+/// Cost-attribution keys. The replayer maps these into litellm_params.metadata,
+/// which the spend-logs builder reads to set user / team_id / organization_id.
+#[derive(Clone, Debug, Serialize, Default)]
+pub struct StandardLoggingMetadata {
+ pub user_api_key_hash: Option, // -> SpendLogs.api_key
+ pub user_api_key_user_id: Option, // -> SpendLogs.user
+ pub user_api_key_team_id: Option, // -> SpendLogs.team_id
+
+ // Optional but read by the builder; include when known:
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_api_key_alias: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_api_key_org_id: Option, // -> SpendLogs.organization_id
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user_api_key_end_user_id: Option, // -> SpendLogs.end_user
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub spend_logs_metadata: Option>,
+}
+
+/// The unit handed to a `CustomLogger` sink: a finished payload plus its status
+/// and (on failure) the replayed error string.
+#[derive(Clone, Debug)]
+pub struct LogRecord {
+ pub status: String,
+ pub payload: StandardLoggingPayload,
+ pub error: Option,
+}
+
+impl LogRecord {
+ pub fn into_callback_record(self) -> CallbackLogRecord {
+ CallbackLogRecord {
+ status: self.status,
+ standard_logging_payload: self.payload,
+ error: self.error,
+ }
+ }
+}
diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime.rs b/litellm-rust/crates/ai-gateway/src/io/realtime.rs
index 5d538d95fa47..4047de5cb266 100644
--- a/litellm-rust/crates/ai-gateway/src/io/realtime.rs
+++ b/litellm-rust/crates/ai-gateway/src/io/realtime.rs
@@ -126,6 +126,9 @@ pub(crate) async fn read_event(upstream_rx: &mut UpstreamRx) -> CoreResult(
model: &str,
@@ -133,6 +136,7 @@ pub(crate) async fn splice(
mut upstream_rx: UpstreamRx,
prelude: Option,
idle_timeout: Option,
+ mut observe: impl FnMut(&RealtimeEvent) + Send,
mut client_in: In,
mut client_out: Out,
) -> CoreResult<()>
@@ -165,6 +169,10 @@ where
// client -> upstream
client_event = client_in.next() => {
let Some(event) = client_event else { break }; // client disconnected
+ // NOTE: do NOT observe client events. session.created / response.done
+ // (carrying usage) are server→client events; observing the client arm
+ // would let an authenticated client POST a fabricated response.done and
+ // inflate its own spend log. Logging observes upstream events only.
for outbound in config.transform_realtime_request(&event, model)?.events {
let payload = serde_json::to_string(&outbound)
.map_err(|err| CoreError::InvalidResponse(err.to_string()))?;
@@ -181,6 +189,7 @@ where
Message::Text(text) => {
let event: RealtimeEvent = serde_json::from_str(&text)
.map_err(|err| CoreError::InvalidResponse(err.to_string()))?;
+ observe(&event);
for outbound in config.transform_realtime_response(&event, model)?.events {
client_out
.send(outbound)
@@ -207,11 +216,13 @@ where
/// framework-agnostic; the gateway adapts its axum socket to these. This is the
/// fresh-dial path: dial, then splice. The pool's warm-handoff path skips the dial
/// and calls [`splice`] directly with a buffered `session.created`.
+#[allow(clippy::too_many_arguments)]
pub async fn realtime(
model: &str,
api_key: Option<&str>,
api_base: Option<&str>,
idle_timeout: Option,
+ observe: impl FnMut(&RealtimeEvent) + Send,
client_in: In,
client_out: Out,
) -> CoreResult<()>
@@ -229,6 +240,7 @@ where
upstream_rx,
None,
idle_timeout,
+ observe,
client_in,
client_out,
)
@@ -238,10 +250,12 @@ where
/// Splice a pre-warmed upstream (taken from [`crate::io::realtime_pool`]) to the
/// client. Relays the buffered `session.created` first, then splices exactly like
/// the fresh-dial path — so a warm session is indistinguishable from a fresh one.
+#[allow(clippy::too_many_arguments)]
pub async fn realtime_warm(
model: &str,
handoff: crate::io::realtime_pool::WarmHandoff,
idle_timeout: Option,
+ observe: impl FnMut(&RealtimeEvent) + Send,
client_in: In,
client_out: Out,
) -> CoreResult<()>
@@ -256,6 +270,7 @@ where
handoff.rx,
Some(handoff.session_created),
idle_timeout,
+ observe,
client_in,
client_out,
)
@@ -303,6 +318,7 @@ mod tests {
Some(&key_owned),
None,
None,
+ |_| {},
client_in,
client_out,
)
diff --git a/litellm-rust/crates/ai-gateway/src/lib.rs b/litellm-rust/crates/ai-gateway/src/lib.rs
index a2c228aeb304..6c04fbb7626e 100644
--- a/litellm-rust/crates/ai-gateway/src/lib.rs
+++ b/litellm-rust/crates/ai-gateway/src/lib.rs
@@ -24,5 +24,15 @@ pub mod routes;
#[cfg(feature = "server")]
pub mod state;
+// Realtime request logging. Only the server serves realtime, so these are
+// `server`-gated; `io::realtime` exposes the generic `observe` hook while the
+// collector and callback fan-out live here.
+#[cfg(feature = "server")]
+mod constants;
+#[cfg(feature = "server")]
+pub mod integrations;
+#[cfg(feature = "server")]
+mod realtime;
+
#[cfg(feature = "python-config")]
pub mod python;
diff --git a/litellm-rust/crates/ai-gateway/src/main.rs b/litellm-rust/crates/ai-gateway/src/main.rs
index a0105b3f000a..f9ce97801d39 100644
--- a/litellm-rust/crates/ai-gateway/src/main.rs
+++ b/litellm-rust/crates/ai-gateway/src/main.rs
@@ -16,6 +16,8 @@ use litellm_ai_gateway::routes;
use litellm_ai_gateway::state::AppState;
use litellm_core::router::{Deployment, LiteLLMParams, Router};
+use litellm_ai_gateway::integrations::custom_logger::CustomLogger;
+use litellm_ai_gateway::integrations::litellm_python_proxy_api::LiteLLMPythonProxyAPILogger;
#[cfg(feature = "python-config")]
use litellm_ai_gateway::python;
@@ -39,6 +41,12 @@ async fn main() {
);
}
+ // Spawn the realtime-logging worker (drains a channel → POSTs batches to the
+ // Python proxy's /v1/callbacks/logs). Built here so the spawn lands on the
+ // tokio runtime. `from_env` reads LITELLM_PROXY_BASE_URL + LITELLM_MASTER_KEY.
+ let proxy_logger = LiteLLMPythonProxyAPILogger::from_env();
+ let loggers: Vec> = vec![proxy_logger];
+
let router = Arc::new(build_router());
// Build the pre-warmed realtime pool and register each deployment's upstream
@@ -62,6 +70,7 @@ async fn main() {
let state = AppState {
router,
master_key,
+ loggers: Arc::new(loggers),
realtime_pool,
};
diff --git a/litellm-rust/crates/ai-gateway/src/realtime/mod.rs b/litellm-rust/crates/ai-gateway/src/realtime/mod.rs
new file mode 100644
index 000000000000..82be596ba86b
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/realtime/mod.rs
@@ -0,0 +1,4 @@
+//! Realtime logging collector. Observes the realtime event stream and emits a
+//! `StandardLoggingPayload` to the registered callbacks on session close.
+
+pub mod streaming;
diff --git a/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs b/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs
new file mode 100644
index 000000000000..34c82897808f
--- /dev/null
+++ b/litellm-rust/crates/ai-gateway/src/realtime/streaming.rs
@@ -0,0 +1,352 @@
+//! `RealTimeStreaming` — the realtime logging collector.
+//!
+//! Mirrors Python `litellm.realtime_api.main.RealTimeStreaming`: it observes the
+//! event stream in O(1) (never buffering frames), accumulating just the fields
+//! the spend log needs (model, id, cumulative usage), then on session close
+//! builds a `StandardLoggingPayload` and fans it out to every registered
+//! `CustomLogger`.
+
+use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use litellm_core::realtime::types::RealtimeEvent;
+use serde_json::Value;
+
+use crate::constants::DEFAULT_PROVIDER;
+use crate::integrations::custom_logger::CustomLogger;
+use crate::integrations::types::{
+ RequestMetadata, StandardLoggingMetadata, StandardLoggingPayload, Usage,
+};
+
+/// Current wall-clock time as epoch seconds (float), matching the Python
+/// `startTime`/`endTime` contract.
+fn epoch_seconds() -> f64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs_f64())
+ .unwrap_or(0.0)
+}
+
+/// Status of a finished realtime session, mapped to the callback record status.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SessionStatus {
+ Success,
+ Failure,
+}
+
+/// Accumulates realtime session state and emits a logging payload on close.
+pub struct RealTimeStreaming {
+ callbacks: Vec>,
+ /// REQUEST-ID RULE: the SpendLogs `request_id` == the OpenAI realtime session
+ /// id (`sess_…`), captured from `session.created`. Both `id` and
+ /// `litellm_call_id` are set to that value so the Python writer logs the same
+ /// id regardless of which field it reads. The gateway-generated `rt-…` id
+ /// (the constructor seed) is only a fallback for sessions that fail before
+ /// `session.created` arrives.
+ litellm_call_id: String,
+ /// See the request-id rule above — mirrors `litellm_call_id`.
+ id: String,
+ model: String,
+ custom_llm_provider: String,
+ usage: Usage,
+ response_cost: f64,
+ start_time: f64,
+ end_time: f64,
+ metadata: RequestMetadata,
+ /// Count of logging callbacks that failed to enqueue (non-fatal).
+ dropped: u64,
+}
+
+impl RealTimeStreaming {
+ /// Create a collector for one session. `litellm_call_id` is the gateway's
+ /// per-connection id; `model` is the requested model (a sane default until
+ /// `session.created` reports the upstream model).
+ pub fn new(
+ callbacks: Vec>,
+ litellm_call_id: String,
+ model: String,
+ metadata: RequestMetadata,
+ ) -> Self {
+ let now = epoch_seconds();
+ Self {
+ callbacks,
+ id: litellm_call_id.clone(),
+ litellm_call_id,
+ model,
+ custom_llm_provider: DEFAULT_PROVIDER.to_string(),
+ usage: Usage::default(),
+ response_cost: 0.0,
+ start_time: now,
+ end_time: now,
+ metadata,
+ dropped: 0,
+ }
+ }
+
+ /// Number of logging callbacks that failed to enqueue so far (test/observ.).
+ #[allow(dead_code)]
+ pub fn dropped(&self) -> u64 {
+ self.dropped
+ }
+
+ /// Observe one realtime event. O(1): updates accumulated state only; never
+ /// buffers frames. Safe to call on every event in either direction.
+ pub fn observe(&mut self, event: &RealtimeEvent) {
+ match event.event_type.as_str() {
+ "session.created" | "session.updated" => self.on_session(event),
+ "response.done" => self.on_response_done(event),
+ _ => {}
+ }
+ }
+
+ /// `session.created` / `session.updated` → capture upstream id + model.
+ /// Per the request-id rule, the OpenAI session id becomes BOTH `id` and
+ /// `litellm_call_id`, replacing the gateway-generated fallback.
+ fn on_session(&mut self, event: &RealtimeEvent) {
+ let session = event.data.get("session").and_then(Value::as_object);
+ if let Some(id) = session.and_then(|s| s.get("id")).and_then(Value::as_str) {
+ if !id.is_empty() {
+ self.id = id.to_string();
+ self.litellm_call_id = id.to_string();
+ }
+ }
+ if let Some(model) = session.and_then(|s| s.get("model")).and_then(Value::as_str) {
+ if !model.is_empty() {
+ self.model = model.to_string();
+ }
+ }
+ }
+
+ /// `response.done` → add this response's usage to the cumulative totals.
+ fn on_response_done(&mut self, event: &RealtimeEvent) {
+ let usage = event
+ .data
+ .get("response")
+ .and_then(Value::as_object)
+ .and_then(|r| r.get("usage"))
+ .and_then(Value::as_object);
+ let Some(usage) = usage else { return };
+
+ let input = usage.get("input_tokens").and_then(Value::as_u64);
+ let output = usage.get("output_tokens").and_then(Value::as_u64);
+ let total = usage.get("total_tokens").and_then(Value::as_u64);
+
+ if let Some(input) = input {
+ self.usage.prompt_tokens += input;
+ }
+ if let Some(output) = output {
+ self.usage.completion_tokens += output;
+ }
+ // Prefer the upstream-reported total; otherwise derive it.
+ match total {
+ Some(total) => self.usage.total_tokens += total,
+ None => {
+ self.usage.total_tokens += input.unwrap_or(0) + output.unwrap_or(0);
+ }
+ }
+ }
+
+ /// Set the per-session response cost ($). Cost computation is Python-side in
+ /// the proxy; the gateway forwards 0.0 by default and lets the proxy price.
+ /// Public API (exercised in tests) for the future path where the gateway
+ /// prices realtime sessions itself.
+ #[allow(dead_code)]
+ pub fn set_response_cost(&mut self, cost: f64) {
+ self.response_cost = cost;
+ }
+
+ /// Build the `StandardLoggingPayload` from accumulated state.
+ pub fn build_payload(&self) -> StandardLoggingPayload {
+ StandardLoggingPayload {
+ id: self.id.clone(),
+ litellm_call_id: self.litellm_call_id.clone(),
+ call_type: "realtime".to_string(),
+ model: self.model.clone(),
+ custom_llm_provider: self.custom_llm_provider.clone(),
+ response_cost: self.response_cost,
+ prompt_tokens: self.usage.prompt_tokens,
+ completion_tokens: self.usage.completion_tokens,
+ total_tokens: self.usage.total_tokens,
+ start_time: self.start_time,
+ end_time: self.end_time,
+ stream: true,
+ metadata: StandardLoggingMetadata {
+ user_api_key_hash: self.metadata.user_api_key_hash.clone(),
+ user_api_key_user_id: self.metadata.user_api_key_user_id.clone(),
+ user_api_key_team_id: self.metadata.user_api_key_team_id.clone(),
+ ..Default::default()
+ },
+ messages: None,
+ }
+ }
+
+ /// Finish the session: stamp the end time and fan the payload out to every
+ /// callback. On a logger enqueue error we bump a non-fatal counter (the
+ /// realtime session has already ended; a dropped log must never propagate).
+ pub fn log_messages(&mut self, status: SessionStatus) {
+ self.end_time = epoch_seconds();
+ let payload = self.build_payload();
+
+ match status {
+ SessionStatus::Success => {
+ for callback in &self.callbacks {
+ if let Err(err) = callback.log_success_event(&payload) {
+ self.dropped += 1;
+ eprintln!("litellm-ai-gateway: log_success_event dropped: {err}");
+ }
+ }
+ }
+ SessionStatus::Failure => {
+ let error = crate::integrations::types::LoggingError {
+ message: "realtime session ended in failure".to_string(),
+ kind: "RealtimeSessionError".to_string(),
+ };
+ for callback in &self.callbacks {
+ if let Err(err) = callback.log_failure_event(&payload, &error) {
+ self.dropped += 1;
+ eprintln!("litellm-ai-gateway: log_failure_event dropped: {err}");
+ }
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::integrations::types::{LogError, LoggingError};
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ fn event(raw: &str) -> RealtimeEvent {
+ serde_json::from_str(raw).expect("valid event json")
+ }
+
+ /// A test logger that records the last payload it saw.
+ #[derive(Default)]
+ struct CapturingLogger {
+ calls: AtomicU64,
+ last_model: std::sync::Mutex