Repository navigation
feat(proxy): add POST /v1/callbacks/logs to replay logging payloads through callbacks - #31134
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds
Confidence Score: 5/5Safe 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.
|
| 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
|
|
Verified end-to-end locally (Rust gateway →
|
…hrough the success/failure callback fan-out
…l-failure handling
…ass with payload logging
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).
UP006: builtin generics over typing.Dict/List.
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.
b4476b0 to
5130ad7
Compare
|
@greptileai review Rebased onto current |
…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.
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.
…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.
|
@greptile review Addressed the review feedback:
Tests added on both sides (rust |
|
@greptile review — note: the two P1 security items are already fixed on the current commit ( |
… 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.
…ference, spend = callback)
… litellm_proxy_callbacks_logs_ingest # Conflicts: # litellm-rust/crates/ai-gateway/Cargo.toml # litellm-rust/crates/ai-gateway/src/main.rs
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.
Relevant issues
First piece of the
litellm-rustrealtime 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 prebuiltStandardLoggingPayloadthrough LiteLLM's normal success/failure callback fan-out.Pre-Submission checklist
make test-unitScreenshots / Proof of Fix
Started a real proxy against a local Postgres and POSTed a
StandardLoggingPayload— it lands inLiteLLM_SpendLogsand triggers the full cost-tracking fan-out (user + team spend), exactly like a normal completion.1. POST the payload (admin key):
2. Row count went 379 → 380, and the row is correct:
3. It went through the real callback path, not a raw insert — spend was attributed to the user and team:
4. Auth guard:
5. Unit tests:
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 minimalLoggingobject, sets the prebuilt payload onmodel_call_details["standard_logging_object"](so the handler skips rebuilding it), and calls the existingasync_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 carriescall_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 helperslitellm/proxy/proxy_server.py— register the routertests/test_litellm/proxy/logging_endpoints/test_callback_logs_endpoints.py— replay (success/failure), admin guard, partial-failure isolationReal E2E Proof it worked