From 9b6a6a0b71633a89466f2e8490bd5c49dab5a68d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:37:52 +0000 Subject: [PATCH] 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 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/scripts/verify_linux_native_wheel.py | 2 +- .../crates/traces-cache/src/reader.rs | 3 +- litellm-rust/crates/traces/AGENTS.md | 5 + .../crates/traces/src/bin/export_schema.rs | 10 +- litellm-rust/crates/traces/src/lib.rs | 1 + litellm-rust/crates/traces/src/request.rs | 56 +++++ litellm-rust/crates/traces/src/schema.rs | 32 +++ .../crates/traces/tests/request_schema.rs | 93 ++++++++ litellm/proxy/tracing_endpoints.py | 43 ++-- litellm/rust_bridge/trace/AGENTS.md | 7 + .../rust_bridge/trace/generated/requests.py | 59 +++++ litellm/tracing/AGENTS.md | 1 + scripts/generate_trace_types.py | 11 +- scripts/trace_codegen/README.md | 8 +- .../schemas/traces/TraceDetailRequest.json | 29 +++ .../schemas/traces/TraceErrorPageRequest.json | 19 ++ .../schemas/traces/TraceListRequest.json | 33 +++ .../schemas/traces/TraceQueryRequest.json | 14 ++ .../schemas/traces/TraceSpanRequest.json | 11 + tests/unit/proxy/test_tracing_endpoints.py | 211 +++++++++++++++++- .../src/components/lens/traces/AGENTS.md | 3 + .../src/components/lens/traces/api.ts | 7 +- .../lens/traces/list/runSearch/runSql.ts | 3 +- .../lens/traces/requestTypes.test-d.ts | 21 ++ .../src/components/lens/traces/types.ts | 7 + .../src/components/networking.tsx | 19 +- 26 files changed, 666 insertions(+), 42 deletions(-) create mode 100644 litellm-rust/crates/traces/src/request.rs create mode 100644 litellm-rust/crates/traces/tests/request_schema.rs create mode 100644 litellm/rust_bridge/trace/AGENTS.md create mode 100644 litellm/rust_bridge/trace/generated/requests.py create mode 100644 scripts/trace_codegen/schemas/traces/TraceDetailRequest.json create mode 100644 scripts/trace_codegen/schemas/traces/TraceErrorPageRequest.json create mode 100644 scripts/trace_codegen/schemas/traces/TraceListRequest.json create mode 100644 scripts/trace_codegen/schemas/traces/TraceQueryRequest.json create mode 100644 scripts/trace_codegen/schemas/traces/TraceSpanRequest.json create mode 100644 ui/litellm-dashboard/src/components/lens/traces/AGENTS.md create mode 100644 ui/litellm-dashboard/src/components/lens/traces/requestTypes.test-d.ts diff --git a/.github/scripts/verify_linux_native_wheel.py b/.github/scripts/verify_linux_native_wheel.py index 465918f5a81..4c4cb399dce 100644 --- a/.github/scripts/verify_linux_native_wheel.py +++ b/.github/scripts/verify_linux_native_wheel.py @@ -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), diff --git a/litellm-rust/crates/traces-cache/src/reader.rs b/litellm-rust/crates/traces-cache/src/reader.rs index 5c68d6e58bd..b0f83a89118 100644 --- a/litellm-rust/crates/traces-cache/src/reader.rs +++ b/litellm-rust/crates/traces-cache/src/reader.rs @@ -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, ReadError> { - 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 { diff --git a/litellm-rust/crates/traces/AGENTS.md b/litellm-rust/crates/traces/AGENTS.md index a9249f31aa8..f31750f8712 100644 --- a/litellm-rust/crates/traces/AGENTS.md +++ b/litellm-rust/crates/traces/AGENTS.md @@ -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 diff --git a/litellm-rust/crates/traces/src/bin/export_schema.rs b/litellm-rust/crates/traces/src/bin/export_schema.rs index 25d1250ef12..edf89fa514f 100644 --- a/litellm-rust/crates/traces/src/bin/export_schema.rs +++ b/litellm-rust/crates/traces/src/bin/export_schema.rs @@ -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()); } diff --git a/litellm-rust/crates/traces/src/lib.rs b/litellm-rust/crates/traces/src/lib.rs index a44679a59f3..7a72183de2c 100644 --- a/litellm-rust/crates/traces/src/lib.rs +++ b/litellm-rust/crates/traces/src/lib.rs @@ -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; diff --git a/litellm-rust/crates/traces/src/request.rs b/litellm-rust/crates/traces/src/request.rs new file mode 100644 index 00000000000..0ade6112f02 --- /dev/null +++ b/litellm-rust/crates/traces/src/request.rs @@ -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, + /// Window end, unix ms. Default: now + #[serde(default)] + pub end_ms: Option, + #[serde(default)] + #[cfg_attr(feature = "schema", schemars(length(max = 512)))] + pub cursor: Option, +} + +#[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, + #[serde(default)] + #[cfg_attr( + feature = "schema", + schemars(range(min = TRACE_PAGE_SIZE_MIN, max = TRACE_PAGE_SIZE_MAX)) + )] + pub page_size: Option, +} + +#[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, +} + +#[macro_rules_attribute::apply(request_type)] +#[derive(Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct TraceQueryRequest { + pub sql: String, +} diff --git a/litellm-rust/crates/traces/src/schema.rs b/litellm-rust/crates/traces/src/schema.rs index cfa1d8e201e..56e166019d7 100644 --- a/litellm-rust/crates/traces/src/schema.rs +++ b/litellm-rust/crates/traces/src/schema.rs @@ -36,6 +36,13 @@ fn received() -> Schema { .into_root_schema_for::() } +fn requested() -> Schema { + SchemaSettings::draft2020_12() + .for_deserialize() + .into_generator() + .into_root_schema_for::() +} + fn emitted() -> Schema { SchemaSettings::draft2020_12() .for_serialize() @@ -58,3 +65,28 @@ pub fn schemas() -> BTreeMap<&'static str, Schema> { ("SpanErrorPage", emitted::()), ]) } + +pub fn request_schemas() -> BTreeMap<&'static str, Schema> { + BTreeMap::from([ + ( + "TraceListRequest", + requested::(), + ), + ( + "TraceDetailRequest", + requested::(), + ), + ( + "TraceSpanRequest", + requested::(), + ), + ( + "TraceErrorPageRequest", + requested::(), + ), + ( + "TraceQueryRequest", + requested::(), + ), + ]) +} diff --git a/litellm-rust/crates/traces/tests/request_schema.rs b/litellm-rust/crates/traces/tests/request_schema.rs new file mode 100644 index 00000000000..598f73021fb --- /dev/null +++ b/litellm-rust/crates/traces/tests/request_schema.rs @@ -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::(json!({"unknown": true})).is_ok()); + assert!(serde_json::from_value::(json!({"unknown": true})).is_ok()); + assert!(serde_json::from_value::(json!({"unknown": true})).is_ok()); + assert!(serde_json::from_value::(json!({"unknown": true})).is_ok()); + assert!( + serde_json::from_value::(json!({"sql": "SELECT 1", "unknown": true})) + .is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); +} diff --git a/litellm/proxy/tracing_endpoints.py b/litellm/proxy/tracing_endpoints.py index 0d3723c45b8..6ec0c6da3e7 100644 --- a/litellm/proxy/tracing_endpoints.py +++ b/litellm/proxy/tracing_endpoints.py @@ -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: diff --git a/litellm/rust_bridge/trace/AGENTS.md b/litellm/rust_bridge/trace/AGENTS.md new file mode 100644 index 00000000000..5132b6ce74c --- /dev/null +++ b/litellm/rust_bridge/trace/AGENTS.md @@ -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 diff --git a/litellm/rust_bridge/trace/generated/requests.py b/litellm/rust_bridge/trace/generated/requests.py new file mode 100644 index 00000000000..dd5304ea09b --- /dev/null +++ b/litellm/rust_bridge/trace/generated/requests.py @@ -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"), +] diff --git a/litellm/tracing/AGENTS.md b/litellm/tracing/AGENTS.md index d71664c38eb..82c4d4ac8a1 100644 --- a/litellm/tracing/AGENTS.md +++ b/litellm/tracing/AGENTS.md @@ -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[, Query()]` and bodies with the generated model; never redeclare field constraints in `Query(...)` or a local model diff --git a/scripts/generate_trace_types.py b/scripts/generate_trace_types.py index 39c93dabf49..52a61452711 100644 --- a/scripts/generate_trace_types.py +++ b/scripts/generate_trace_types.py @@ -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" diff --git a/scripts/trace_codegen/README.md b/scripts/trace_codegen/README.md index f9071001c78..bb4f511a569 100644 --- a/scripts/trace_codegen/README.md +++ b/scripts/trace_codegen/README.md @@ -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` diff --git a/scripts/trace_codegen/schemas/traces/TraceDetailRequest.json b/scripts/trace_codegen/schemas/traces/TraceDetailRequest.json new file mode 100644 index 00000000000..60cb76dfedc --- /dev/null +++ b/scripts/trace_codegen/schemas/traces/TraceDetailRequest.json @@ -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" +} diff --git a/scripts/trace_codegen/schemas/traces/TraceErrorPageRequest.json b/scripts/trace_codegen/schemas/traces/TraceErrorPageRequest.json new file mode 100644 index 00000000000..950dab8e83a --- /dev/null +++ b/scripts/trace_codegen/schemas/traces/TraceErrorPageRequest.json @@ -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" +} diff --git a/scripts/trace_codegen/schemas/traces/TraceListRequest.json b/scripts/trace_codegen/schemas/traces/TraceListRequest.json new file mode 100644 index 00000000000..a19c3965c0b --- /dev/null +++ b/scripts/trace_codegen/schemas/traces/TraceListRequest.json @@ -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" +} diff --git a/scripts/trace_codegen/schemas/traces/TraceQueryRequest.json b/scripts/trace_codegen/schemas/traces/TraceQueryRequest.json new file mode 100644 index 00000000000..1361b89ad90 --- /dev/null +++ b/scripts/trace_codegen/schemas/traces/TraceQueryRequest.json @@ -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" +} diff --git a/scripts/trace_codegen/schemas/traces/TraceSpanRequest.json b/scripts/trace_codegen/schemas/traces/TraceSpanRequest.json new file mode 100644 index 00000000000..4f5bb8fff69 --- /dev/null +++ b/scripts/trace_codegen/schemas/traces/TraceSpanRequest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "trace_ref": { + "default": "", + "type": "string" + } + }, + "title": "TraceSpanRequest", + "type": "object" +} diff --git a/tests/unit/proxy/test_tracing_endpoints.py b/tests/unit/proxy/test_tracing_endpoints.py index 516a0415545..11d63ad545c 100644 --- a/tests/unit/proxy/test_tracing_endpoints.py +++ b/tests/unit/proxy/test_tracing_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/lens/traces/AGENTS.md b/ui/litellm-dashboard/src/components/lens/traces/AGENTS.md new file mode 100644 index 00000000000..bc378fd192c --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/AGENTS.md @@ -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 diff --git a/ui/litellm-dashboard/src/components/lens/traces/api.ts b/ui/litellm-dashboard/src/components/lens/traces/api.ts index 03ea4d068bb..6b9039eae7c 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/api.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/api.ts @@ -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("/v1/traces", { accessToken, query: { start_ms: 0 } }); + const page = await apiClient.get("/v1/traces", { + accessToken, + query: { start_ms: 0 } satisfies TraceListQuery, + }); return page.data.length > 0; }, trace: (traceId, traceRef, cursor) => agentTraceCall(accessToken, traceId, traceRef, cursor), diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts index 96b5ae6455c..73b65f05eae 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/list/runSearch/runSql.ts @@ -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"); diff --git a/ui/litellm-dashboard/src/components/lens/traces/requestTypes.test-d.ts b/ui/litellm-dashboard/src/components/lens/traces/requestTypes.test-d.ts new file mode 100644 index 00000000000..3ef107cbcad --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/traces/requestTypes.test-d.ts @@ -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().toEqualTypeOf<{ + start_ms?: number | null; + end_ms?: number | null; + cursor?: string | null; + }>(); + expectTypeOf().toEqualTypeOf<{ + trace_ref?: string; + cursor?: string | null; + page_size?: number | null; + }>(); + expectTypeOf().toEqualTypeOf<{ trace_ref?: string }>(); + expectTypeOf().toEqualTypeOf<{ + trace_ref?: string; + cursor?: string | null; + }>(); + expectTypeOf().toEqualTypeOf<{ sql: string }>(); +}); diff --git a/ui/litellm-dashboard/src/components/lens/traces/types.ts b/ui/litellm-dashboard/src/components/lens/traces/types.ts index a424e010670..06a2b93b4ae 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/types.ts +++ b/ui/litellm-dashboard/src/components/lens/traces/types.ts @@ -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; +export type TraceDetailQuery = NonNullable; +export type SpanQuery = NonNullable; +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]; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 98f3a120a49..a7e9387cbe6 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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 => { - 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(`/v1/traces`, { accessToken, query }); }; @@ -1983,7 +1992,7 @@ export const agentTraceCall = async ( ): Promise => apiClient.get(`/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 => apiClient.get(`/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 => apiClient.get(`/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) => {