Skip to content

feat(proxy): add POST /v1/callbacks/logs to replay logging payloads through callbacks - #31134

Merged
ishaan-berri merged 40 commits into
litellm_internal_stagingfrom
litellm_proxy_callbacks_logs_ingest
Jun 24, 2026
Merged

ishaan-berri merged 40 commits into
litellm_internal_stagingfrom
litellm_proxy_callbacks_logs_ingest

Conversation

@ishaan-berri

@ishaan-berri ishaan-berri commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Relevant issues

First piece of the litellm-rust realtime work: the Rust gateway owns the connection (Python isn't in the request path), so it needs to push finished log/usage events back to Python. This PR adds the Python side — a single generic endpoint that replays a prebuilt StandardLoggingPayload through LiteLLM's normal success/failure callback fan-out.

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem

Screenshots / Proof of Fix

Started a real proxy against a local Postgres and POSTed a StandardLoggingPayload — it lands in LiteLLM_SpendLogs and triggers the full cost-tracking fan-out (user + team spend), exactly like a normal completion.

1. POST the payload (admin key):

$ curl -X POST http://localhost:4000/v1/callbacks/logs -H "Authorization: Bearer sk-1234" --data @payload.json
{"processed":1,"failed":0}

2. Row count went 379 → 380, and the row is correct:

-[ RECORD 1 ]-------+------------------------
request_id          | cb-logs-test-1782252248
call_type           | acompletion
model               | gpt-4o-mini
api_key             | rust-gateway-test-key
spend               | 0.0123
total_tokens        | 42
prompt_tokens       | 30
completion_tokens   | 12
user                | user-cb-logs-test
team_id             | team-cb-logs-test
custom_llm_provider | openai

3. It went through the real callback path, not a raw insert — spend was attributed to the user and team:

db_spend_update_writer.py - User Spend transactions: {'user-cb-logs-test': 0.0123}
db_spend_update_writer.py - Team Spend transactions: {'team-cb-logs-test': 0.0123}

4. Auth guard:

no auth   -> 401
wrong key -> 401
admin key -> 200
non-admin -> 403

5. Unit tests:

$ pytest tests/test_litellm/proxy/logging_endpoints/test_callback_logs_endpoints.py -q
7 passed

Type

🆕 New Feature

Changes

Adds POST /v1/callbacks/logs (admin-only). It takes a batch of {status, standard_logging_payload, error?} records, and for each one reconstructs a minimal Logging object, sets the prebuilt payload on model_call_details["standard_logging_object"] (so the handler skips rebuilding it), and calls the existing async_success_handler / async_failure_handler. That means spend logs + every registered callback (Langfuse, Datadog, etc.) fire unchanged — there is no spend-logs-specific or callback-specific logic in the endpoint, just the replay.

The endpoint is generic on purpose: the contract is the self-describing StandardLoggingPayload (it carries call_type, model, usage, cost, metadata), so realtime is the first producer but completions/responses can use it later with no changes.

Files:

  • litellm/proxy/logging_endpoints/callback_logs_endpoints.py — the endpoint + replay helpers
  • litellm/proxy/proxy_server.py — register the router
  • tests/test_litellm/proxy/logging_endpoints/test_callback_logs_endpoints.py — replay (success/failure), admin guard, partial-failure isolation

Real E2E Proof it worked

litellm_realtime_logs

@codecov

codecov Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.71795% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...proxy/logging_endpoints/callback_logs_endpoints.py 98.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds POST /v1/callbacks/logs (admin-only), enabling external producers like the litellm-rust realtime gateway to push finished StandardLoggingPayload records back into LiteLLM's Python callback fan-out — spend logs, Langfuse, Datadog, etc. — without being in the Python request path. The Rust side adds a RealTimeStreaming collector, a LiteLLMPythonProxyAPILogger background worker, and the auth infrastructure needed to hash credentials correctly before they reach the wire.

  • Python endpoint (callback_logs_endpoints.py): Rebuilds a minimal Logging object per record, pre-seeds standard_logging_object so callbacks skip re-building it, and dispatches to async_success_handler / async_failure_handler. Batch size is bounded by MAX_CALLBACK_LOG_RECORDS, per-record failures are returned by index, and auth is enforced to PROXY_ADMIN only.
  • Rust gateway: RealTimeStreaming observes upstream→client events only (client events are explicitly not observed, preventing spend-log manipulation). The master key is SHA-256 hashed via auth::hash_token before it enters any payload, matching Python's hash_token exactly — confirmed by a pinned unit test.
  • Both previously flagged security items are resolved on HEAD: key hashing and upstream-only observation are present and tested.

Confidence Score: 5/5

Safe to merge. The new endpoint is admin-only with a bounded batch size, and the two critical security concerns from the prior review round are demonstrably fixed in this commit.

The key-hashing and upstream-only-observe fixes are both present and unit-tested. The Python replay path is clean: stream is forced to False, cost attribution metadata is mapped correctly, and failure isolation prevents one bad record from aborting the batch. The only finding is a redundant decorator-level Depends that FastAPI silently deduplicates anyway.

No files require special attention. The most sensitive logic — auth hashing in auth/mod.rs and the observe arm selection in providers/src/realtime.rs — has explicit comments and dedicated tests.

Important Files Changed

Filename Overview
litellm/proxy/logging_endpoints/callback_logs_endpoints.py New admin-only POST /v1/callbacks/logs endpoint that replays pre-built StandardLoggingPayload records through LiteLLM's callback fan-out. Auth guard, stream=False override, and per-record failure isolation are all correct. Minor: the decorator-level Depends(user_api_key_auth) is redundant with the function-parameter dependency.
litellm/types/proxy/callback_logs_endpoints.py Type definitions for the callback-logs endpoint. Batch size is bounded by MAX_CALLBACK_LOG_RECORDS via Field(max_length=...). Per-record failure reporting via CallbackLogFailure is clean.
tests/test_litellm/proxy/logging_endpoints/test_callback_logs_endpoints.py Unit tests using monkeypatching — no real network calls. Covers success/failure replay, admin-only guard, partial-failure isolation, and batch-size limit. All 7 tests pass per the PR description.
litellm-rust/crates/ai-gateway/src/auth/mod.rs Adds hash_token (SHA-256 hex digest) matching Python's hash_token exactly, with a unit test that pins the known output for 'sk-1234'. The previous raw-key-in-spend-logs issue is fixed here.
litellm-rust/crates/ai-gateway/src/routes/realtime/mod.rs Bridge now hashes the master key via auth::hash_token before seeding RequestMetadata.user_api_key_hash, and passes the observe closure to service::run which only invokes it on upstream→client events. Both previous security items are resolved.
litellm-rust/crates/providers/src/realtime.rs observe(&event) is now called only in the upstream arm. The client arm has a comment explicitly explaining why observe must not be called there. Previous fabricated-response.done manipulation vector is closed.
litellm-rust/crates/ai-gateway/src/realtime/streaming.rs New RealTimeStreaming collector: O(1) observe accumulates session id, model, and usage; log_messages stamps end_time and fans the payload to all CustomLoggers. Well-tested with CapturingLogger and FailingLogger scenarios.
litellm-rust/crates/ai-gateway/src/integrations/litellm_python_proxy_api.rs Background worker POSTs batched LogRecords to /v1/callbacks/logs with bearer_auth(master_key). Channel-full/closed errors are non-fatal. Tunables are env-overridable. Clean design.

Reviews (5): Last reviewed commit: "feat(proxy): bound callback-logs batch +..." | Re-trigger Greptile

Comment thread litellm/proxy/logging_endpoints/callback_logs_endpoints.py Outdated
Comment thread litellm/proxy/logging_endpoints/callback_logs_endpoints.py Outdated
Comment thread litellm/proxy/logging_endpoints/callback_logs_endpoints.py Outdated
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ ishaan-jaff
❌ ishaan-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

@ishaan-berri

Copy link
Copy Markdown
Contributor Author

Verified end-to-end locally (Rust gateway → /v1/callbacks/logs → DB)

Ran the full stack locally — the Python proxy from this branch + the Rust litellm-ai-gateway binary from this branch — drove a real OpenAI realtime WebSocket session through the gateway, and the usage landed in LiteLLM_SpendLogs:

request_id   | sess_Du948SyGSUnHeSvBexr2C   ← OpenAI realtime session id
call_type    | realtime
model        | gpt-realtime
total_tokens | 118  (prompt 114, completion 4)
api_key      | 88dc28d0…6b4b  (SHA-256 of the master key)

It also shows up in the LiteLLM UI Request Logs (model gpt-realtime, 118 tokens, type LLM/Success). Screenshot below.

cargo build + clippy -D warnings + cargo test -p litellm-ai-gateway all green; observe() is lock-free on the hot path; request_id == the OpenAI session id by rule.

ishaan-jaff and others added 17 commits June 23, 2026 21:08
The new /v1/callbacks/logs route was dropped by both component
allowlists, failing test_gateway_plus_backend_covers_full_app. It's an
admin-only spend-logging route, so it belongs on the backend (control
plane) alongside the existing /callbacks family.
…oint

Switch Dict/List from typing to builtin dict/list to satisfy the ruff
strict-rule budget (UP006).
Run npm run gen:api to add the CallbackLogRecord/CallbackLogsRequest/
CallbackLogsResponse types and the /v1/callbacks/logs path, keeping the
dashboard types in sync with the proxy OpenAPI spec.
A replayed StandardLoggingPayload is a terminal, fully-aggregated event —
the producer (e.g. the rust realtime gateway) already collected the whole
session before POSTing. Marking the rebuilt Logging object as streaming made
async_success_handler wait for a complete_streaming_response that never
arrives, so the spend log was never written. Realtime sessions now land in
LiteLLM_SpendLogs.
…cks/logs

integrations/ mirrors litellm/integrations/: a sync, typed CustomLogger trait
(base contract), a typed StandardLoggingPayload, and LiteLLMPythonProxyAPILogger
— the first concrete logger, owning a bounded channel + background worker that
batches and POSTs to the Python proxy's /v1/callbacks/logs.
1:1 with Python's RealTimeStreaming: observe() accumulates O(1) usage/model/id
per event (never buffers frames); log_messages() builds one StandardLoggingPayload
on session close and fans out to the CustomLogger callbacks. request_id == the
OpenAI realtime session id (sess_…), with the gateway id as fallback.
…observe)

The collector is owned on the splice task and observed via a synchronous &mut
callback threaded through providers::realtime::realtime() — no Arc/Mutex/atomic
on the per-frame hot path. On session close the bridge flushes one payload.
AppState carries the registered loggers; main spawns the proxy logger.
@ishaan-berri
ishaan-berri force-pushed the litellm_proxy_callbacks_logs_ingest branch from b4476b0 to 5130ad7 Compare June 24, 2026 04:10
@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review

Rebased onto current litellm_internal_staging and consolidated: this PR now carries both the Python /v1/callbacks/logs ingest endpoint and the Rust ai-gateway realtime logging glue (CustomLogger callback layer + RealTimeStreaming collector). The gateway base comes from the already-merged #31135 — the Rust diff here is only the logging glue (integrations/ + realtime/streaming.rs + lock-free observe wiring). Verified end-to-end: a live realtime session writes a LiteLLM_SpendLogs row.

…l plane

Add a 'Request logging' guide to the ai-gateway README: how to point the gateway
at a LiteLLM proxy via LITELLM_PROXY_BASE_URL (+ LITELLM_MASTER_KEY for the
admin-only /v1/callbacks/logs POST), and the non-blocking / one-payload-per-session
behavior.
Channel capacity, batch size, and flush interval now read from
LITELLM_LOG_CHANNEL_CAPACITY / LITELLM_LOG_BATCH_SIZE / LITELLM_LOG_FLUSH_INTERVAL_MS,
falling back to the DEFAULT_* consts on missing/invalid/non-positive values.
Grouped behind an EgressTunables::from_env() read once at logger construction.
Comment thread litellm-rust/crates/ai-gateway/src/routes/realtime/mod.rs
Comment thread litellm-rust/crates/ai-gateway/src/io/realtime.rs
Mirror of Python's litellm/constants.py rule — magic numbers and fixed strings
go in src/constants.rs, not inline in feature modules; env-overridable tunables
keep their DEFAULT_* value there.
tests/test_litellm/proxy/logging_endpoints wasn't in any coverage-uploading
job, so callback_logs_endpoints.py showed only import-level coverage (~35%) on
codecov/patch despite being ~98% covered locally. Add it to proxy-infra's
test-path so the test is exercised under --cov.
@ishaan-berri
ishaan-berri requested a review from a team June 24, 2026 04:30
…e raw credential

Greptile/Veria P1: user_api_key_hash was the plaintext LITELLM_MASTER_KEY, which
fans out to spend logs and every callback (Langfuse/Datadog) and could be
recovered from logs. SHA-256 it (auth::hash_token, matching the proxy's
hash_token); the field is named *_hash and the proxy stores it verbatim when it
isn't sk-prefixed, so the DB value is identical with zero plaintext exposure.
Greptile P1: observe ran on the client->upstream arm too, so an authenticated
client could send a fabricated response.done and inflate its own spend log.
session.created/response.done are server->client events; observe the upstream
arm only.
Greptile P2: cap /v1/callbacks/logs at MAX_CALLBACK_LOG_RECORDS (default 1000,
env-overridable) so one POST can't trigger an unbounded callback/DB fan-out; and
return per-record {index, error} failures so a caller (the rust gateway) can
distinguish a transient callback error from a structurally bad payload.
@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptile review

Addressed the review feedback:

  • P1 (master key): the gateway now SHA-256-hashes the master key (auth::hash_token, matching the proxy's hash_token) before setting user_api_key_hash — the raw credential never enters a log payload, and the DB hash is unchanged.
  • P1 (spend spoofing): observe now fires on the upstream→client arm only, so a client can't fabricate response.done usage.
  • P2 (batch size): /v1/callbacks/logs is bounded by MAX_CALLBACK_LOG_RECORDS (default 1000) → 422 over the cap.
  • P2 (failure detail): the response now returns per-record {index, error} failures.

Tests added on both sides (rust hash_token parity, batch-limit, failure-detail).

@ishaan-berri

Copy link
Copy Markdown
Contributor Author

@greptile review — note: the two P1 security items are already fixed on the current commit (17c5cb6). routes/realtime/mod.rs hashes the key via auth::hash_token (SHA-256, unit-tested), and providers/realtime.rs observes the upstream arm only (the client arm has no observe call). Please re-review HEAD.

… litellm_proxy_callbacks_logs_ingest

# Conflicts:
#	litellm-rust/crates/ai-gateway/src/main.rs
#	litellm-rust/crates/ai-gateway/src/routes/realtime/mod.rs
#	litellm-rust/crates/ai-gateway/src/state.rs
#	litellm-rust/crates/providers/src/realtime.rs
It doesn't need to be env-configurable (only the rust egress tunables are). As an
os.getenv var it tripped tests/documentation_tests/test_env_keys.py, which requires
every env key to be documented in the (separate-repo) config_settings.md. Plain
constant → not scanned → code-quality + documentation checks pass.
… litellm_proxy_callbacks_logs_ingest

# Conflicts:
#	litellm-rust/crates/ai-gateway/Cargo.toml
#	litellm-rust/crates/ai-gateway/src/main.rs
@ishaan-berri
ishaan-berri enabled auto-merge (squash) June 24, 2026 19:42
Comment thread .coverage Outdated
Comment thread backend/routes/allowlist.py Outdated
Comment thread litellm-rust/crates/ai-gateway/src/integrations/litellm_python_proxy_api.rs Outdated
The Python proxy exposes a small internal API for the rust data-plane gateway to
call into (logging now, auth/budgets later). Group it in a dedicated
rust_control_plane_router (prefix /v1/rust_control_plane) so it's clearly distinct
from the proxy's own /callbacks management routes. The log ingest endpoint moves
/v1/callbacks/logs -> /v1/rust_control_plane/logs.
Rename CALLBACK_LOGS_PATH -> RUST_CONTROL_PLANE_LOGS_PATH and point it at the
proxy's new rust_control_plane router.
…neric helper

Per review: the two parsers were identical except the int type. Replace with a
single generic env_positive<T>. Also document that LITELLM_PROXY_BASE_URL is the
full base, so a SERVER_ROOT_PATH is handled by including it there.
@ishaan-berri
ishaan-berri merged commit 4efce80 into litellm_internal_staging Jun 24, 2026
125 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_proxy_callbacks_logs_ingest branch June 24, 2026 22:25
@ishaan-berri ishaan-berri mentioned this pull request Jun 25, 2026
9 of 13 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants