refactor(tracing): generate existing HTTP request models from Rust schemas (#44591)

* test(tracing): pin HTTP request compatibility

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(tracing): generate existing HTTP request models from Rust schemas

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(tracing): bind trace query params to generated request models

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(rust): raise the native wheel size gate to 48 MB

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* perf(tracing): read the trace list clock without a thread-pool dispatch

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(traces): share the trace page-size bounds between schema and reader

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(ui): type trace request queries against the generated OpenAPI schema

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: encode the trace contract boundary in AGENTS.md

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format trace request aliases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 17:37:52 +00:00 • committed by GitHub
parent d946706744
commit 9b6a6a0b71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 666 additions and 42 deletions

View file

@ -214,7 +214,7 @@ def main(
native_module: Final = load_native_module(native_path)
native_module_loads: Final = native_module is not None
panic_test_hook_absent: Final = native_module is not None and not hasattr(native_module, "_panic_for_test")
native_size_limit: Final = 45_000_000
native_size_limit: Final = 48_000_000
native_size_within_limit: Final = native_member.file_size <= native_size_limit
validations: Final = (
(f"Python tag is {EXPECTED_PYTHON_TAG}", python_tag == EXPECTED_PYTHON_TAG),

View file

@ -15,6 +15,7 @@ use litellm_traces::{
ListTracesParams, ReadAccessParams, SpanDetailParams, SpanErrorParams, TraceIdentityParams,
TraceSpansParams,
},
request::{TRACE_PAGE_SIZE_MAX, TRACE_PAGE_SIZE_MIN},
resolve_trace, to_ui_content,
};
@ -133,7 +134,7 @@ impl TraceReader {
cursor: Option<&str>,
page_size: u32,
) -> Result<Option<Trace>, ReadError<S::Error>> {
if !(1..=500).contains(&page_size) {
if !(u32::from(TRACE_PAGE_SIZE_MIN)..=u32::from(TRACE_PAGE_SIZE_MAX)).contains(&page_size) {
return Err(ReadError::InvalidParameters);
}
let Some(trace_ref) = reference(store, access, trace_id, trace_ref).await? else {

View file

@ -4,3 +4,8 @@
- Keep ClickHouse schema, row encoding and queries in `litellm-traces-clickhouse`; keep PyO3 conversion in `python-bridge`
- Test decoding and normalization through the public API
- Expose one top-level `Error` enum in `src/error.rs` for decoding and normalization failures
- Own the tracing HTTP contracts: request types in `src/request.rs` and response views exported from `src/schema.rs`
- Python and the dashboard consume them only through generated code: `uv run scripts/generate_trace_types.py` writes `litellm/rust_bridge/trace/generated/`, and `npm run gen:api` in `ui/litellm-dashboard` regenerates `schema.d.ts` from the proxy's OpenAPI
- Declare each bound once as a constant and read it from both the schema attribute and the runtime check
- GET request types accept unknown fields because the routes ignore unknown query parameters; body request types use `deny_unknown_fields`
- Changing a request or response shape changes the public API; ship it in its own behavior-change PR

View file

@ -1,6 +1,8 @@
fn main() {
println!(
"{}",
serde_json::to_string_pretty(&litellm_traces::schema::schemas()).unwrap()
);
let schemas = if std::env::args().nth(1).as_deref() == Some("--requests") {
litellm_traces::schema::request_schemas()
} else {
litellm_traces::schema::schemas()
};
println!("{}", serde_json::to_string_pretty(&schemas).unwrap());
}

View file

@ -15,6 +15,7 @@ mod normalize;
mod otlp;
pub mod query;
mod query_access;
pub mod request;
mod resolve;
#[cfg(feature = "schema")]
pub mod schema;

View file

@ -0,0 +1,56 @@
pub const TRACE_PAGE_SIZE_MIN: u16 = 1;
pub const TRACE_PAGE_SIZE_MAX: u16 = 500;
#[macro_rules_attribute::apply(request_type)]
#[derive(Clone, Debug)]
pub struct TraceListRequest {
/// Window start, unix ms. Default: 24h ago
#[serde(default)]
pub start_ms: Option<i64>,
/// Window end, unix ms. Default: now
#[serde(default)]
pub end_ms: Option<i64>,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(length(max = 512)))]
pub cursor: Option<String>,
}
#[macro_rules_attribute::apply(request_type)]
#[derive(Clone, Debug)]
pub struct TraceDetailRequest {
#[serde(default)]
pub trace_ref: String,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(length(max = 512)))]
pub cursor: Option<String>,
#[serde(default)]
#[cfg_attr(
feature = "schema",
schemars(range(min = TRACE_PAGE_SIZE_MIN, max = TRACE_PAGE_SIZE_MAX))
)]
pub page_size: Option<u16>,
}
#[macro_rules_attribute::apply(request_type)]
#[derive(Clone, Debug)]
pub struct TraceSpanRequest {
#[serde(default)]
pub trace_ref: String,
}
#[macro_rules_attribute::apply(request_type)]
#[derive(Clone, Debug)]
pub struct TraceErrorPageRequest {
#[serde(default)]
pub trace_ref: String,
#[serde(default)]
#[cfg_attr(feature = "schema", schemars(length(max = 512)))]
pub cursor: Option<String>,
}
#[macro_rules_attribute::apply(request_type)]
#[derive(Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct TraceQueryRequest {
pub sql: String,
}

View file

@ -36,6 +36,13 @@ fn received<T: JsonSchema>() -> Schema {
.into_root_schema_for::<T>()
}
fn requested<T: JsonSchema>() -> Schema {
SchemaSettings::draft2020_12()
.for_deserialize()
.into_generator()
.into_root_schema_for::<T>()
}
fn emitted<T: JsonSchema>() -> Schema {
SchemaSettings::draft2020_12()
.for_serialize()
@ -58,3 +65,28 @@ pub fn schemas() -> BTreeMap<&'static str, Schema> {
("SpanErrorPage", emitted::<crate::SpanErrorPage>()),
])
}
pub fn request_schemas() -> BTreeMap<&'static str, Schema> {
BTreeMap::from([
(
"TraceListRequest",
requested::<crate::request::TraceListRequest>(),
),
(
"TraceDetailRequest",
requested::<crate::request::TraceDetailRequest>(),
),
(
"TraceSpanRequest",
requested::<crate::request::TraceSpanRequest>(),
),
(
"TraceErrorPageRequest",
requested::<crate::request::TraceErrorPageRequest>(),
),
(
"TraceQueryRequest",
requested::<crate::request::TraceQueryRequest>(),
),
])
}

View file

@ -0,0 +1,93 @@
#![cfg(feature = "schema")]
use litellm_traces::request::{
TraceDetailRequest, TraceErrorPageRequest, TraceListRequest, TraceQueryRequest,
TraceSpanRequest,
};
use litellm_traces::schema::request_schemas;
use rstest::rstest;
use serde_json::json;
#[rstest]
#[case::list("TraceListRequest")]
#[case::detail("TraceDetailRequest")]
#[case::span("TraceSpanRequest")]
#[case::error_page("TraceErrorPageRequest")]
fn get_request_schemas_ignore_unknown_fields(#[case] name: &str) {
let schemas = request_schemas();
let schema = serde_json::to_value(&schemas[name]).unwrap();
assert_ne!(schema["additionalProperties"], false);
}
#[rstest]
fn query_request_schema_rejects_unknown_fields() {
let schemas = request_schemas();
let schema = serde_json::to_value(&schemas["TraceQueryRequest"]).unwrap();
assert_eq!(schema["additionalProperties"], false);
}
#[rstest]
fn request_schemas_preserve_explicit_constraints() {
let schemas = request_schemas();
let detail = serde_json::to_value(&schemas["TraceDetailRequest"]).unwrap();
let list = serde_json::to_value(&schemas["TraceListRequest"]).unwrap();
let span = serde_json::to_value(&schemas["TraceSpanRequest"]).unwrap();
let error_page = serde_json::to_value(&schemas["TraceErrorPageRequest"]).unwrap();
let query = serde_json::to_value(&schemas["TraceQueryRequest"]).unwrap();
assert_eq!(detail["properties"]["page_size"]["minimum"], 1);
assert_eq!(detail["properties"]["page_size"]["maximum"], 500);
assert_eq!(list["properties"]["cursor"]["maxLength"], 512);
assert_eq!(detail["properties"]["cursor"]["maxLength"], 512);
assert_eq!(error_page["properties"]["cursor"]["maxLength"], 512);
assert!(list["properties"]["start_ms"].get("minimum").is_none());
assert!(list["properties"]["start_ms"].get("maximum").is_none());
assert!(list["properties"]["end_ms"].get("minimum").is_none());
assert!(list["properties"]["end_ms"].get("maximum").is_none());
assert_eq!(detail["properties"]["trace_ref"]["default"], "");
assert_eq!(span["properties"]["trace_ref"]["default"], "");
assert_eq!(error_page["properties"]["trace_ref"]["default"], "");
assert_eq!(query["required"], json!(["sql"]));
assert_eq!(
list["properties"]["start_ms"]["description"],
"Window start, unix ms. Default: 24h ago"
);
assert_eq!(
list["properties"]["end_ms"]["description"],
"Window end, unix ms. Default: now"
);
}
#[rstest]
fn request_models_deserialize_defaults_and_null_cursors() {
let list: TraceListRequest = serde_json::from_value(json!({})).unwrap();
let detail: TraceDetailRequest = serde_json::from_value(json!({})).unwrap();
let span: TraceSpanRequest = serde_json::from_value(json!({})).unwrap();
let error_page: TraceErrorPageRequest = serde_json::from_value(json!({})).unwrap();
assert!(list.start_ms.is_none());
assert!(list.end_ms.is_none());
assert!(list.cursor.is_none());
assert_eq!(detail.trace_ref, "");
assert!(detail.cursor.is_none());
assert!(detail.page_size.is_none());
assert_eq!(span.trace_ref, "");
assert_eq!(error_page.trace_ref, "");
assert!(error_page.cursor.is_none());
let null_cursor: TraceListRequest = serde_json::from_value(json!({"cursor": null})).unwrap();
assert!(null_cursor.cursor.is_none());
}
#[rstest]
fn get_request_models_ignore_unknown_fields_and_query_model_rejects_them() {
assert!(serde_json::from_value::<TraceListRequest>(json!({"unknown": true})).is_ok());
assert!(serde_json::from_value::<TraceDetailRequest>(json!({"unknown": true})).is_ok());
assert!(serde_json::from_value::<TraceSpanRequest>(json!({"unknown": true})).is_ok());
assert!(serde_json::from_value::<TraceErrorPageRequest>(json!({"unknown": true})).is_ok());
assert!(
serde_json::from_value::<TraceQueryRequest>(json!({"sql": "SELECT 1", "unknown": true}))
.is_err()
);
assert!(serde_json::from_value::<TraceQueryRequest>(json!({})).is_err());
}

View file

@ -29,6 +29,13 @@ from litellm.proxy.common_utils.http_parsing_utils import is_otlp_trace_request
from litellm.proxy.tracing_runtime import provide_receiver, require_receiver
from litellm.rust_bridge.trace.errors import TraceChanged
from litellm.rust_bridge.trace.generated.models import TraceQueryHelp
from litellm.rust_bridge.trace.generated.requests import (
TraceDetailRequest,
TraceErrorPageRequest,
TraceListRequest,
TraceQueryRequest,
TraceSpanRequest,
)
from litellm.rust_bridge.trace.generated.types import (
AllQueryScope,
OwnedQueryScope,
@ -49,6 +56,10 @@ router = APIRouter(tags=["agent tracing"])
MS_PER_DAY: Final = 24 * 60 * 60 * 1000
async def current_time_ms() -> int:
return int(time.time() * 1000)
@dataclass(frozen=True, slots=True)
class TraceAccessContext:
receiver: TraceReceiver | None
@ -183,28 +194,21 @@ def read_failure(error: TraceChanged | ValueError | OverflowError | RuntimeError
@router.get("/v1/traces", response_model=TracePage)
async def list_agent_traces(
context: Annotated[TraceAccessContext, Depends(provide_trace_access)],
start_ms: Annotated[int | None, Query(description="Window start, unix ms. Default: 24h ago")] = None,
end_ms: Annotated[int | None, Query(description="Window end, unix ms. Default: now")] = None,
cursor: Annotated[str | None, Query(max_length=512)] = None,
now_ms: Annotated[int, Depends(current_time_ms)],
request: Annotated[TraceListRequest, Query()],
) -> TracePage:
now_ms: Final = int(time.time() * 1000)
try:
tracing, scope = context.reader()
return await tracing.list_traces(
scope=scope,
start_ms=start_ms if start_ms is not None else now_ms - MS_PER_DAY,
end_ms=end_ms if end_ms is not None else now_ms,
cursor=cursor,
start_ms=request.start_ms if request.start_ms is not None else now_ms - MS_PER_DAY,
end_ms=request.end_ms if request.end_ms is not None else now_ms,
cursor=request.cursor,
)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
class TraceQueryRequest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
sql: str
@dataclass(frozen=True, slots=True)
class TraceQueryAccess:
storage: ClickHouseStorage
@ -272,13 +276,11 @@ async def help_agent_trace_queries(
async def get_agent_trace(
trace_id: str,
context: Annotated[TraceAccessContext, Depends(provide_trace_access)],
trace_ref: Annotated[str, Query()] = "",
cursor: Annotated[str | None, Query(max_length=512)] = None,
page_size: Annotated[int | None, Query(ge=1, le=500)] = None,
request: Annotated[TraceDetailRequest, Query()],
) -> Trace:
tracing, scope = context.reader()
try:
trace: Final = await tracing.get_trace(trace_id, scope, trace_ref, cursor, page_size)
trace: Final = await tracing.get_trace(trace_id, scope, request.trace_ref, request.cursor, request.page_size)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
if trace is None:
@ -291,11 +293,11 @@ async def get_agent_trace_span(
trace_id: str,
span_id: str,
context: Annotated[TraceAccessContext, Depends(provide_trace_access)],
trace_ref: Annotated[str, Query()] = "",
request: Annotated[TraceSpanRequest, Query()],
) -> SpanDetail:
tracing, scope = context.reader()
try:
span: Final = await tracing.get_span(trace_id, span_id, scope, trace_ref)
span: Final = await tracing.get_span(trace_id, span_id, scope, request.trace_ref)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
if span is None:
@ -308,12 +310,11 @@ async def get_agent_trace_span_error(
trace_id: str,
span_id: str,
context: Annotated[TraceAccessContext, Depends(provide_trace_access)],
trace_ref: Annotated[str, Query()] = "",
cursor: Annotated[str | None, Query(max_length=512)] = None,
request: Annotated[TraceErrorPageRequest, Query()],
) -> SpanErrorPage:
try:
tracing, scope = context.reader()
page: Final = await tracing.get_span_error(trace_id, span_id, scope, trace_ref, cursor)
page: Final = await tracing.get_span_error(trace_id, span_id, scope, request.trace_ref, request.cursor)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
if page is None:

View file

@ -0,0 +1,7 @@
# Trace contract boundary
- `generated/` is output of `scripts/generate_trace_types.py` from the `litellm-traces` schemas; change the Rust type and regenerate, never edit these files
- CI runs the generator with `--check` and fails on drift
- `storage.py` validates every native result against the generated response models before returning it
- Keep the trace methods in `_native.pyi` matching `litellm-rust/crates/python-bridge/src/routes/traces.rs`
- Native trace methods take scalar arguments; moving them to the generated request types changes overflow errors, so it needs its own behavior-change PR

View file

@ -0,0 +1,59 @@
# @generated by scripts/generate_trace_types.py, do not edit
from __future__ import annotations
from typing import Annotated, TypeAlias
from pydantic import BaseModel, ConfigDict, Field
class TraceDetailRequest(BaseModel):
model_config = ConfigDict(
frozen=True,
)
trace_ref: str = ""
cursor: str | None = Field(None, max_length=512)
page_size: int | None = Field(None, ge=1, le=500)
class TraceErrorPageRequest(BaseModel):
model_config = ConfigDict(
frozen=True,
)
trace_ref: str = ""
cursor: str | None = Field(None, max_length=512)
class TraceListRequest(BaseModel):
model_config = ConfigDict(
frozen=True,
)
start_ms: int | None = Field(None, description="Window start, unix ms. Default: 24h ago")
end_ms: int | None = Field(None, description="Window end, unix ms. Default: now")
cursor: str | None = Field(None, max_length=512)
class TraceQueryRequest(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
)
sql: str
class TraceSpanRequest(BaseModel):
model_config = ConfigDict(
frozen=True,
)
trace_ref: str = ""
TraceWireRequests: TypeAlias = Annotated[
TraceDetailRequest | TraceErrorPageRequest | TraceListRequest | TraceQueryRequest | TraceSpanRequest,
Field(..., title="TraceWireRequests"),
]

View file

@ -4,3 +4,4 @@
- Use `litellm.rust_bridge.trace.storage.ClickHouseStorage` for ClickHouse; keep trace schema, SQL and encoding in `litellm-traces`, and generic transport in `litellm-storage-clickhouse`
- Derive tenant fields from authentication and overwrite matching fields supplied by the exporter
- Test confirmed writes, failures, tenant isolation and read behavior through public functions
- Trace routes in `litellm/proxy/tracing_endpoints.py` bind query parameters with `Annotated[<generated request model>, Query()]` and bodies with the generated model; never redeclare field constraints in `Query(...)` or a local model

View file

@ -34,7 +34,7 @@ class GeneratorConfig(BaseModel):
options: tuple[str, ...]
def export(crate: str) -> Mapping[str, Mapping[str, JsonValue]]:
def export(crate: str, extra_args: tuple[str, ...] = ()) -> Mapping[str, Mapping[str, JsonValue]]:
result: Final = subprocess.run(
(
"cargo",
@ -48,6 +48,7 @@ def export(crate: str) -> Mapping[str, Mapping[str, JsonValue]]:
f"export-{crate}-schema",
"--features",
"schema",
*(("--", *extra_args) if extra_args else ()),
),
check=True,
stdout=subprocess.PIPE,
@ -163,8 +164,9 @@ def main() -> int:
sys.stderr.write(f"requires datamodel-code-generator=={config.version}\n")
return 1
domain: Final = export("traces")
requests: Final = export("traces", ("--requests",))
clickhouse: Final = export("traces-clickhouse")
exported: Final = tuple(schema_files(domain, clickhouse))
exported: Final = tuple(schema_files(domain, clickhouse, requests))
schema_results: Final = tuple(publish(path, content, args.check) for path, content in exported)
schema_set_matches: Final = reconcile_schemas(frozenset(path for path, _ in exported), args.check)
with TemporaryDirectory(prefix="trace-codegen-") as temporary:
@ -176,9 +178,11 @@ def main() -> int:
directory,
config,
)
request_models: Final = generate(requests, "requests", directory, config)
python_results: Final = (
publish(GENERATED / "types.py", types.read_text(), args.check),
publish(GENERATED / "models.py", models.read_text(), args.check),
publish(GENERATED / "requests.py", request_models.read_text(), args.check),
)
return 0 if all((schema_set_matches, *schema_results, *python_results)) else 1
@ -186,8 +190,9 @@ def main() -> int:
def schema_files(
domain: Mapping[str, Mapping[str, JsonValue]],
clickhouse: Mapping[str, Mapping[str, JsonValue]],
requests: Mapping[str, Mapping[str, JsonValue]],
) -> Iterator[tuple[Path, str]]:
for crate, schemas in (("traces", domain), ("traces-clickhouse", clickhouse)):
for crate, schemas in (("traces", domain), ("traces-clickhouse", clickhouse), ("traces", requests)):
for name, schema in schemas.items():
yield TOOLING / "schemas" / crate / f"{name}.json", json.dumps(schema, indent=2, sort_keys=True) + "\n"

View file

@ -1,9 +1,13 @@
Run `uv run scripts/generate_trace_types.py` from the repository root to export Rust schemas and regenerate the Python trace contracts. Run the same command with `--check` to compare fresh output with the committed schemas and Python files
Run `uv run scripts/generate_trace_types.py` from the repository root to export Rust schemas and regenerate the Python trace contracts. Run `uv run scripts/generate_trace_types.py --check` to compare fresh output with the committed schemas and Python files
The script pins datamodel-code-generator in its inline dependency metadata. Rust uses the workspace's locked Schemars version through each owning crate's optional `schema` feature. Neither tool is a Python runtime dependency
Each crate exports its own roots using JSON Schema 2020-12. Request parameters use Schemars' deserialization contract. Trace views and query help use its serialization contract. Lens rows use their ClickHouse deserialization schemas, including quoted numbers and numeric boolean flags
The `litellm-traces` Rust request types own the generated request models in `litellm/rust_bridge/trace/generated/requests.py`. The GET routes bind their query parameters directly to the generated models. GET request types allow unknown fields because existing clients' unknown query parameters are ignored. The SQL body model forbids extra fields
Each crate exports its own roots using JSON Schema 2020-12. Request parameters use Schemars' deserialization contract and carry only explicitly declared constraints. Their schemas skip the integer-bounds transform because it would add i64 bounds to `start_ms` and `end_ms`, narrowing what Python accepts, and replace `page_size`'s explicit 1..500 range with 0..65535. Trace views and query help use the serialization contract. Lens rows use their ClickHouse deserialization schemas, including quoted numbers and numeric boolean flags
The templates preserve tuple conversion, immutable tuple defaults, and bounded `ReadOnly` TypedDict fields. Pydantic models use the generator's frozen-model option and each schema's extra-field policy. ClickHouse numeric schemas select bounded, normalized Python scalar types through schema metadata consumed by the model template
Changing the public request contract requires a separate behavior-change PR
Edit the owning Rust contract, schema annotation, or generation configuration, then regenerate. Never edit `litellm/rust_bridge/trace/generated/` manually. The SQL response envelope remains handwritten in `queries.py`

View file

@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"cursor": {
"default": null,
"maxLength": 512,
"type": [
"string",
"null"
]
},
"page_size": {
"default": null,
"format": "uint16",
"maximum": 500,
"minimum": 1,
"type": [
"integer",
"null"
]
},
"trace_ref": {
"default": "",
"type": "string"
}
},
"title": "TraceDetailRequest",
"type": "object"
}

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"cursor": {
"default": null,
"maxLength": 512,
"type": [
"string",
"null"
]
},
"trace_ref": {
"default": "",
"type": "string"
}
},
"title": "TraceErrorPageRequest",
"type": "object"
}

View file

@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"cursor": {
"default": null,
"maxLength": 512,
"type": [
"string",
"null"
]
},
"end_ms": {
"default": null,
"description": "Window end, unix ms. Default: now",
"format": "int64",
"type": [
"integer",
"null"
]
},
"start_ms": {
"default": null,
"description": "Window start, unix ms. Default: 24h ago",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"title": "TraceListRequest",
"type": "object"
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"sql": {
"type": "string"
}
},
"required": [
"sql"
],
"title": "TraceQueryRequest",
"type": "object"
}

View file

@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"trace_ref": {
"default": "",
"type": "string"
}
},
"title": "TraceSpanRequest",
"type": "object"
}

View file

@ -5,12 +5,15 @@ Tests for the agent tracing endpoints (litellm/proxy/tracing_endpoints.py).
from collections.abc import AsyncGenerator, Mapping
from contextlib import asynccontextmanager
from types import ModuleType
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock
from typing import Final, Literal, TypedDict
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from httpx import Response
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
from litellm.constants import TRACE_READ_RETRY_AFTER_SECONDS
from litellm.proxy import tracing_endpoints
@ -97,6 +100,29 @@ SPAN_DETAIL_RESPONSE: Final = {
"output_ui": {"kind": "text", "text": ""},
"attributes": {},
}
SPAN_ERROR_RESPONSE: Final = {
"span_id": "s1",
"message": "span error",
"total_chars": 10,
"next_cursor": None,
}
NOW_MS: Final = 1_800_000_000_000
class RequestValidationError(TypedDict):
type: ReadOnly[str]
loc: ReadOnly[list[str | int]]
def _validation_errors(response: Response) -> tuple[RequestValidationError, ...]:
return tuple(TypeAdapter(list[RequestValidationError]).validate_python(response.json()["detail"]))
def _assert_validation_error(response: Response, error_type: str, location: tuple[str | int, ...]) -> None:
assert response.status_code == 422, response.text
assert any(
error["type"] == error_type and tuple(error["loc"]) == location for error in _validation_errors(response)
)
@pytest.mark.parametrize(
@ -262,6 +288,43 @@ def test_list_traces_defaults_to_last_24h(client, receiver):
assert kwargs["cursor"] is None
@pytest.mark.parametrize(
("params", "expected_start_ms", "expected_end_ms"),
(
({}, NOW_MS - tracing_endpoints.MS_PER_DAY, NOW_MS),
({"start_ms": 123}, 123, NOW_MS),
({"end_ms": -7}, NOW_MS - tracing_endpoints.MS_PER_DAY, -7),
),
)
def test_list_traces_resolves_default_bounds_from_injected_clock(
client: TestClient,
receiver: MagicMock,
params: Mapping[str, int],
expected_start_ms: int,
expected_end_ms: int,
) -> None:
client.app.dependency_overrides[tracing_endpoints.current_time_ms] = lambda: NOW_MS
response: Final = client.get("/v1/traces", params=params)
assert response.status_code == 200, response.text
receiver.list_traces.assert_awaited_once_with(
scope={"all_teams": 0, "user_id": "user", "team_ids": ()},
start_ms=expected_start_ms,
end_ms=expected_end_ms,
cursor=None,
)
def test_list_traces_forwards_large_and_negative_bounds_unchanged(client: TestClient, receiver: MagicMock) -> None:
response: Final = client.get("/v1/traces", params={"start_ms": 2**63, "end_ms": -1, "cursor": "next"})
assert response.status_code == 200, response.text
receiver.list_traces.assert_awaited_once_with(
scope={"all_teams": 0, "user_id": "user", "team_ids": ()},
start_ms=2**63,
end_ms=-1,
cursor="next",
)
def test_get_trace_404_and_200(client, receiver):
assert client.get("/v1/traces/missing").status_code == 404
receiver.get_trace.return_value = TRACE_RESPONSE
@ -289,6 +352,125 @@ def test_trace_detail_passes_scoped_reference(client, receiver, suffix, cursor,
)
@pytest.mark.parametrize("page_size", (1, 500))
def test_trace_detail_accepts_page_size_bounds(client: TestClient, receiver: MagicMock, page_size: int) -> None:
receiver.get_trace.return_value = TRACE_RESPONSE
response: Final = client.get("/v1/traces/t1", params={"trace_ref": "run-one", "page_size": page_size})
assert response.status_code == 200, response.text
receiver.get_trace.assert_awaited_once_with(
"t1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", None, page_size
)
@pytest.mark.parametrize(
("value", "error_type"),
(("0", "greater_than_equal"), ("501", "less_than_equal"), ("abc", "int_parsing")),
)
def test_trace_detail_reports_page_size_validation(
client: TestClient, receiver: MagicMock, value: str, error_type: str
) -> None:
response: Final = client.get("/v1/traces/t1", params={"page_size": value})
_assert_validation_error(response, error_type, ("query", "page_size"))
receiver.get_trace.assert_not_awaited()
def test_trace_read_routes_accept_and_forward_512_character_cursors(
client: TestClient, receiver: MagicMock
) -> None:
cursor: Final = "x" * 512
receiver.get_trace.return_value = TRACE_RESPONSE
receiver.get_span_error = AsyncMock(return_value=SPAN_ERROR_RESPONSE)
client.app.dependency_overrides[tracing_endpoints.current_time_ms] = lambda: NOW_MS
list_response: Final = client.get("/v1/traces", params={"cursor": cursor})
detail_response: Final = client.get("/v1/traces/t1", params={"trace_ref": "run-one", "cursor": cursor})
error_response: Final = client.get(
"/v1/traces/t1/spans/s1/error", params={"trace_ref": "run-one", "cursor": cursor}
)
assert list_response.status_code == 200, list_response.text
assert detail_response.status_code == 200, detail_response.text
assert error_response.status_code == 200, error_response.text
receiver.list_traces.assert_awaited_once_with(
scope={"all_teams": 0, "user_id": "user", "team_ids": ()},
start_ms=NOW_MS - tracing_endpoints.MS_PER_DAY,
end_ms=NOW_MS,
cursor=cursor,
)
receiver.get_trace.assert_awaited_once_with(
"t1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", cursor, None
)
receiver.get_span_error.assert_awaited_once_with(
"t1", "s1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", cursor
)
@pytest.mark.parametrize(
"path",
("/v1/traces", "/v1/traces/t1", "/v1/traces/t1/spans/s1/error"),
)
def test_trace_read_routes_reject_513_character_cursors(
client: TestClient, receiver: MagicMock, path: str
) -> None:
response: Final = client.get(path, params={"cursor": "x" * 513})
_assert_validation_error(response, "string_too_long", ("query", "cursor"))
def test_trace_read_routes_ignore_unknown_query_parameters(client: TestClient, receiver: MagicMock) -> None:
receiver.get_trace.return_value = TRACE_RESPONSE
receiver.get_span.return_value = SPAN_DETAIL_RESPONSE
receiver.get_span_error = AsyncMock(return_value=SPAN_ERROR_RESPONSE)
list_params: Final = {"start_ms": 1, "end_ms": 2, "cursor": "list-cursor"}
list_response: Final = client.get("/v1/traces", params=list_params)
list_unknown_response: Final = client.get("/v1/traces", params={**list_params, "foo": "bar"})
detail_params: Final = {"trace_ref": "run-one", "cursor": "detail-cursor", "page_size": 10}
detail_response: Final = client.get("/v1/traces/t1", params=detail_params)
detail_unknown_response: Final = client.get("/v1/traces/t1", params={**detail_params, "foo": "bar"})
span_response: Final = client.get("/v1/traces/t1/spans/s1", params={"trace_ref": "run-one"})
span_unknown_response: Final = client.get(
"/v1/traces/t1/spans/s1", params={"trace_ref": "run-one", "foo": "bar"}
)
error_params: Final = {"trace_ref": "run-one", "cursor": "error-cursor"}
error_response: Final = client.get("/v1/traces/t1/spans/s1/error", params=error_params)
error_unknown_response: Final = client.get(
"/v1/traces/t1/spans/s1/error", params={**error_params, "foo": "bar"}
)
assert list_response.status_code == 200, list_response.text
assert list_unknown_response.status_code == 200, list_unknown_response.text
assert detail_response.status_code == 200, detail_response.text
assert detail_unknown_response.status_code == 200, detail_unknown_response.text
assert span_response.status_code == 200, span_response.text
assert span_unknown_response.status_code == 200, span_unknown_response.text
assert error_response.status_code == 200, error_response.text
assert error_unknown_response.status_code == 200, error_unknown_response.text
receiver.list_traces.assert_has_awaits(
(
call(scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, start_ms=1, end_ms=2, cursor="list-cursor"),
call(scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, start_ms=1, end_ms=2, cursor="list-cursor"),
)
)
receiver.get_trace.assert_has_awaits(
(
call("t1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", "detail-cursor", 10),
call("t1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", "detail-cursor", 10),
)
)
receiver.get_span.assert_has_awaits(
(
call("t1", "s1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one"),
call("t1", "s1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one"),
)
)
receiver.get_span_error.assert_has_awaits(
(
call("t1", "s1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", "error-cursor"),
call("t1", "s1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", "error-cursor"),
)
)
@pytest.mark.parametrize(
"path,method",
(
@ -606,6 +788,8 @@ def test_sql_and_help_use_authenticated_scope(
result: Final = client.post("/v1/traces/query", json={"sql": "SELECT * FROM otel_traces"})
assert result.status_code == 200, result.text
assert result.json() == SQL_ENVELOPE
assert type(result.json()["data"][0]["value"]) is str
assert type(result.json()["statistics"]["elapsed"]) is float
receiver.storage.query_sql.assert_awaited_once_with("SELECT * FROM otel_traces", expected_scope, "test-secret")
help_result: Final = client.get("/v1/traces/query/help")
assert help_result.status_code == 200, help_result.text
@ -616,6 +800,29 @@ def test_sql_and_help_use_authenticated_scope(
assert receiver.storage.query_sql.await_count == 1
@pytest.mark.parametrize(
("body", "error_type", "location"),
(
(b"{}", "missing", ("body", "sql")),
(b'{"sql": null}', "string_type", ("body", "sql")),
(b'{"sql": 1}', "string_type", ("body", "sql")),
(b'{"sql": "SELECT 1", "extra": true}', "extra_forbidden", ("body", "extra")),
(b"{", "json_invalid", ("body", 1)),
(b"[]", "model_attributes_type", ("body",)),
),
)
def test_sql_query_rejects_invalid_request_bodies(
client: TestClient, receiver: MagicMock, body: bytes, error_type: str, location: tuple[str | int, ...]
) -> None:
client.app.dependency_overrides[tracing_endpoints.provide_trace_query_secret] = lambda: "test-secret"
receiver.storage.query_sql = AsyncMock()
response: Final = client.post(
"/v1/traces/query", content=body, headers={"content-type": "application/json"}
)
_assert_validation_error(response, error_type, location)
receiver.storage.query_sql.assert_not_awaited()
@pytest.mark.parametrize("auth", (UserAPIKeyAuth(), UserAPIKeyAuth(team_id="a", project_id="p")))
def test_sql_rejects_missing_identity_without_querying(
client: TestClient, receiver: MagicMock, auth: UserAPIKeyAuth

View file

@ -0,0 +1,3 @@
- Derive trace request and response types from `paths` and `components` in `@/lib/http/schema` (see `types.ts`); never hand-write a trace request or response shape
- Check every trace request `query` or body literal with `satisfies` against those types
- `requestTypes.test-d.ts` pins the generated request shapes; a failure there means the backend contract changed

View file

@ -9,7 +9,7 @@ import {
apiClient,
getProxyBaseUrl,
} from "../../networking";
import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./types";
import type { SpanDetail, SpanErrorPage, Trace, TraceListQuery, TracePage } from "./types";
export interface TraceWindow {
readonly startMs: number;
@ -53,7 +53,10 @@ export function liveTracesApi(accessToken: string): TracesApi {
}),
list: (window) => agentTraceListCall({ accessToken, ...window }),
anyRecorded: async () => {
const page = await apiClient.get<TracePage>("/v1/traces", { accessToken, query: { start_ms: 0 } });
const page = await apiClient.get<TracePage>("/v1/traces", {
accessToken,
query: { start_ms: 0 } satisfies TraceListQuery,
});
return page.data.length > 0;
},
trace: (traceId, traceRef, cursor) => agentTraceCall(accessToken, traceId, traceRef, cursor),

View file

@ -1,5 +1,6 @@
import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceQueryBody } from "../../types";
import { valueMatcher } from "@/components/shared/search/language";
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";
@ -71,7 +72,7 @@ export const traceQueryCommand = (sql: string): string =>
[
`curl -s "${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/query" \\`,
` -H "Authorization: Bearer $LITELLM_API_KEY" -H "Content-Type: application/json" -d @- <<'EOF'`,
JSON.stringify({ sql }),
JSON.stringify({ sql } satisfies TraceQueryBody),
"EOF",
].join("\n");

View file

@ -0,0 +1,21 @@
import { expectTypeOf, test } from "vitest";
import type { SpanErrorQuery, SpanQuery, TraceDetailQuery, TraceListQuery, TraceQueryBody } from "./types";
test("trace request aliases match the generated OpenAPI shapes", () => {
expectTypeOf<TraceListQuery>().toEqualTypeOf<{
start_ms?: number | null;
end_ms?: number | null;
cursor?: string | null;
}>();
expectTypeOf<TraceDetailQuery>().toEqualTypeOf<{
trace_ref?: string;
cursor?: string | null;
page_size?: number | null;
}>();
expectTypeOf<SpanQuery>().toEqualTypeOf<{ trace_ref?: string }>();
expectTypeOf<SpanErrorQuery>().toEqualTypeOf<{
trace_ref?: string;
cursor?: string | null;
}>();
expectTypeOf<TraceQueryBody>().toEqualTypeOf<{ sql: string }>();
});

View file

@ -4,6 +4,13 @@ export type Trace = paths["/v1/traces/{trace_id}"]["get"]["responses"][200]["con
export type TracePage = paths["/v1/traces"]["get"]["responses"][200]["content"]["application/json"];
export type SpanErrorPage =
paths["/v1/traces/{trace_id}/spans/{span_id}/error"]["get"]["responses"][200]["content"]["application/json"];
export type TraceListQuery = NonNullable<paths["/v1/traces"]["get"]["parameters"]["query"]>;
export type TraceDetailQuery = NonNullable<paths["/v1/traces/{trace_id}"]["get"]["parameters"]["query"]>;
export type SpanQuery = NonNullable<paths["/v1/traces/{trace_id}/spans/{span_id}"]["get"]["parameters"]["query"]>;
export type SpanErrorQuery = NonNullable<
paths["/v1/traces/{trace_id}/spans/{span_id}/error"]["get"]["parameters"]["query"]
>;
export type TraceQueryBody = components["schemas"]["TraceQueryRequest"];
type ApiSpanDetail =
paths["/v1/traces/{trace_id}/spans/{span_id}"]["get"]["responses"][200]["content"]["application/json"];
export type Span = Trace["spans"][number];

View file

@ -117,7 +117,16 @@ import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity
import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets";
import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab";
import type { RoutingDecision } from "./logs/detail/RoutingDecisionCard";
import type { SpanDetail, SpanErrorPage, Trace, TracePage } from "./lens/traces/types";
import type {
SpanDetail,
SpanErrorPage,
SpanErrorQuery,
SpanQuery,
Trace,
TraceDetailQuery,
TraceListQuery,
TracePage,
} from "./lens/traces/types";
import {
createApiClient,
deriveErrorMessage,
@ -1968,7 +1977,7 @@ export const agentTraceListCall = async ({
endMs: number;
cursor?: string | null;
}): Promise<TracePage> => {
const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined };
const query = { start_ms: startMs, end_ms: endMs, cursor: cursor ?? undefined } satisfies TraceListQuery;
return apiClient.get<TracePage>(`/v1/traces`, { accessToken, query });
};
@ -1983,7 +1992,7 @@ export const agentTraceCall = async (
): Promise<Trace> =>
apiClient.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`, {
accessToken,
query: { trace_ref: traceRef || undefined, cursor: cursor ?? undefined, page_size: 200 },
query: { trace_ref: traceRef || undefined, cursor: cursor ?? undefined, page_size: 200 } satisfies TraceDetailQuery,
});
export const agentTraceSpanCall = async (
@ -1994,7 +2003,7 @@ export const agentTraceSpanCall = async (
): Promise<SpanDetail> =>
apiClient.get<SpanDetail>(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`, {
accessToken,
query: { trace_ref: traceRef || undefined },
query: { trace_ref: traceRef || undefined } satisfies SpanQuery,
});
export const agentTraceSpanErrorCall = async (
@ -2005,7 +2014,7 @@ export const agentTraceSpanErrorCall = async (
): Promise<SpanErrorPage> =>
apiClient.get<SpanErrorPage>(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/error`, {
accessToken,
query: { trace_ref: options.traceRef || undefined, cursor: options.cursor || undefined },
query: { trace_ref: options.traceRef || undefined, cursor: options.cursor || undefined } satisfies SpanErrorQuery,
});
export const adminSpendLogsCall = async (accessToken: string) => {