mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(lens): price agent traces by joining gen_ai.response.id to spend logs (#44738)
* fix(lens): price agent traces by joining gen_ai.response.id to spend logs Trace spend now joins each model call to spend_logs on one key: the span's response id (gen_ai.response.id or the id the normalizers read from OpenInference/LangChain output) against spend_logs.response_id or the upstream id embedded in a managed resp_ id. The litellm.call_id and traceparent transport join paths and the per-row ownership gate are removed; the spend SQL still restricts rows to what the reader can see. A run with some unpriced calls now reports the sum of its priced calls plus priced_calls, instead of an unknown total. * test(lens): cover response id spend join and partial trace totals * chore(lens): regenerate trace types for priced_calls * feat(lens): show partial run cost as a lower bound with priced call count * fix(lens): treat litellm.call_id as the same assigned call id for spend joins The id LiteLLM assigned to a call is either the response id it returned (gen_ai.response.id -> spend_logs.response_id) or its gateway call id (litellm.call_id -> spend_logs.litellm_call_id). Both are exact ids the gateway mints and logs, so the join stays one rule. Transport span matching and the ownership gate stay removed. * test(lens): cover litellm.call_id spend joins and restore captured totals * feat(lens): link each priced model call to its spend log Spans gain spend_log_request_id, the spend_logs.request_id the call was priced from, and spend_match, which says whether a model call matched or why not (no assigned id on the span, no spend log with that id, or an ambiguous match). A model call span is priced from the same ids as the run total, so its cost and the total agree. * test(lens): cover spend log links on model call spans * chore(lens): regenerate trace types for spend log links * feat(lens): open the matched spend log from an LLM step An LLM step's header now shows a Spend log chip with the matched request id and cost; clicking it opens the request log drawer over the run, fetched by the exact spend_logs.request_id instead of the span's own response id. Unpriced steps say why (no assigned id on the span, or no spend log with it). Tree rows show each model call's cost, and a partial run cost shows its priced call count inline. * test(lens): cover the spend log link and unmatched cost reasons * feat(lens): show the spend log link as a bordered LiteLLM Spend Log button * feat(lens): add a back link from the spend log drawer to the agent trace * feat(lens): label the spend log back link Back to Lens trace with the Lens icon * fix(lens): ignore assigned ids that name no spend log when pricing a call An id that names no row no longer vetoes the call, so a span carrying both a response id and a call id still prices from a spend row logged before litellm_call_id existed. An id naming two or more rows makes the call ambiguous, and the match reason comes from the same per-id result, so a single matched row with no cost is reported as matched. * fix(lens): price a trace only from spend logs in its own team A reader with several teams could see the same assigned id in another team's spend log; only rows from the trace's team now price it. The user and key ownership gate stays removed. * perf(lens): resolve each model call's spend once per trace Model call matches are computed once when the trace is resolved and looked up by span index, instead of scanning the model call list for every span and walking the graph again for spans, agents and the run total. * fix(lens): hide a step's Cost fact only when its spend log link shows the cost * chore(lens): drop narrative doc comments from the spend join * fix(lens): price a model call only when its ids agree on one spend log per span * fix(lens): keep pricing spend logs written before litellm_call_id by their request id * fix(lens): price every attempt a model call's ids name when they agree
This commit is contained in:
parent
282d733fb6
commit
5ed7ec8511
66 changed files with 2098 additions and 862 deletions
|
|
@ -88,7 +88,8 @@
|
|||
"call_type"
|
||||
],
|
||||
"stream_opened": [
|
||||
"logger"
|
||||
"logger",
|
||||
"head"
|
||||
],
|
||||
"stream_success": [
|
||||
"logger",
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ impl LegacyLogging {
|
|||
head.bind(py).set_item("cache_key", key)?;
|
||||
head.bind(py).set_item("cache_hit", true)?;
|
||||
}
|
||||
Streaming::Opened.call(py, (self.logger()?.object(py),))?;
|
||||
Streaming::Opened.call(py, (self.logger()?.object(py), head.bind(py)))?;
|
||||
self.stream = Some(DeliveredStream {
|
||||
chunks: PyList::empty(py).unbind(),
|
||||
first_chunk: None,
|
||||
|
|
@ -1768,13 +1768,13 @@ assert logger.calls[1][1] is response
|
|||
fn stream_bindings_deliver_collected_chunks_in_order_without_success_fan_out() {
|
||||
Python::initialize();
|
||||
Python::attach(|py| {
|
||||
let locals = namespace(py, c"first = b'first'\nlast = b'last'\nresponse = None");
|
||||
let locals = namespace(py, c"first = b'first'\nlast = b'last'\nresponse = None\nhead = {'additional_headers': {'request-id': 'req_native'}}");
|
||||
let mut logging = LegacyLogging {
|
||||
operation: crate::LoggingOperation::Messages,
|
||||
..logged(py, &locals, true)
|
||||
};
|
||||
logging
|
||||
.on_stream_open(py, &pyo3::types::PyDict::new(py).into_any().unbind())
|
||||
.on_stream_open(py, &local(&locals, "head").unbind())
|
||||
.unwrap();
|
||||
logging
|
||||
.on_stream_chunk(py, &local(&locals, "first").unbind())
|
||||
|
|
@ -1791,6 +1791,7 @@ assert logger.calls[1][1] is response
|
|||
&locals,
|
||||
c"
|
||||
assert logger.names() == ['stream_opened', 'stream_success'], logger.calls
|
||||
assert logger.calls[0][1] is head
|
||||
chunks = logger.calls[1][1]
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0] is first
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ FAKES = {
|
|||
'success', response, call_type
|
||||
),
|
||||
'after_deployment_failure': lambda kwargs, error, call_type: kwargs['logger'].hook('failure', error, call_type),
|
||||
'stream_opened': lambda logger: logger.record('stream_opened', None),
|
||||
'stream_opened': lambda logger, head: logger.record('stream_opened', head),
|
||||
'stream_success': lambda logger, url_route, endpoint_type, request_body, chunks, start, end, first_chunk: logger.record(
|
||||
'stream_success', list(chunks)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ mod tests {
|
|||
fn row(span_id: &str) -> TraceSpansRow {
|
||||
TraceSpansRow {
|
||||
trace_id: String::new(),
|
||||
original_trace_id: String::new(),
|
||||
span_id: span_id.into(),
|
||||
parent_span_id: String::new(),
|
||||
name: "run".into(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ pub(super) async fn spend<S: TraceStore>(
|
|||
let params = SpendByResponseIdsParams {
|
||||
access: access.clone(),
|
||||
response_ids: lookup.response_ids,
|
||||
provider_request_ids: lookup.provider_request_ids,
|
||||
request_ids: lookup.request_ids,
|
||||
trace_ids: lookup.trace_ids,
|
||||
start_ms: window.start,
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ fn access() -> ReadAccessParams {
|
|||
fn span(index: usize) -> TraceSpansRow {
|
||||
TraceSpansRow {
|
||||
trace_id: "trace".into(),
|
||||
original_trace_id: String::new(),
|
||||
span_id: format!("span-{index}"),
|
||||
parent_span_id: if index == 0 {
|
||||
String::new()
|
||||
|
|
@ -523,6 +524,7 @@ fn spend_row(response_id: &str, cost: f64) -> SpendByResponseIdsRow {
|
|||
litellm_call_id: String::new(),
|
||||
response_id: response_id.into(),
|
||||
upstream_response_id: String::new(),
|
||||
provider_request_id: String::new(),
|
||||
trace_id: String::new(),
|
||||
span_id: String::new(),
|
||||
team_id: "team".into(),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const TTL: Duration = Duration::from_secs(120);
|
|||
fn row(span_id: &str, parent: &str, name: &str, kind: &str, agent: &str) -> TraceSpansRow {
|
||||
TraceSpansRow {
|
||||
trace_id: String::new(),
|
||||
original_trace_id: String::new(),
|
||||
span_id: span_id.into(),
|
||||
parent_span_id: parent.into(),
|
||||
name: name.into(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE {database}.spend_logs
|
||||
ADD COLUMN IF NOT EXISTS provider_request_id String DEFAULT '' AFTER response_id,
|
||||
ADD INDEX IF NOT EXISTS idx_provider_request_id provider_request_id
|
||||
TYPE bloom_filter(0.001) GRANULARITY 1
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
SELECT * FROM (
|
||||
SELECT request_id, litellm_call_id, response_id, upstream_response_id, trace_id, span_id, team_id, api_key, user, spend,
|
||||
SELECT request_id, litellm_call_id, response_id, upstream_response_id, provider_request_id, trace_id, span_id, team_id, api_key, user, spend,
|
||||
toUnixTimestamp64Milli(start_time) AS start_ms
|
||||
FROM (
|
||||
SELECT *,
|
||||
|
|
@ -15,7 +15,8 @@ FROM (
|
|||
OR ({user_id:String} != '' AND user = {user_id:String})
|
||||
OR has({team_ids:Array(String)}, team_id))
|
||||
)
|
||||
WHERE response_id IN {response_ids:Array(String)}
|
||||
WHERE provider_request_id IN {provider_request_ids:Array(String)}
|
||||
OR response_id IN {response_ids:Array(String)}
|
||||
OR upstream_response_id IN {response_ids:Array(String)}
|
||||
OR litellm_call_id IN {request_ids:Array(String)}
|
||||
OR (litellm_call_id = '' AND request_id IN {request_ids:Array(String)})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
SELECT request_id, litellm_call_id, response_id, upstream_response_id, trace_id, span_id, team_id, api_key, user, spend,
|
||||
SELECT request_id, litellm_call_id, response_id, upstream_response_id, provider_request_id, trace_id, span_id, team_id, api_key, user, spend,
|
||||
toUnixTimestamp64Milli(start_time) AS start_ms
|
||||
FROM (
|
||||
SELECT *,
|
||||
|
|
@ -14,7 +14,8 @@ FROM (
|
|||
OR ({user_id:String} != '' AND user = {user_id:String})
|
||||
OR has({team_ids:Array(String)}, team_id))
|
||||
)
|
||||
WHERE response_id IN {response_ids:Array(String)}
|
||||
WHERE provider_request_id IN {provider_request_ids:Array(String)}
|
||||
OR response_id IN {response_ids:Array(String)}
|
||||
OR upstream_response_id IN {response_ids:Array(String)}
|
||||
OR litellm_call_id IN {request_ids:Array(String)}
|
||||
OR (litellm_call_id = '' AND request_id IN {request_ids:Array(String)})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
SELECT * FROM (
|
||||
SELECT o.TraceId AS trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS original_trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
o.ObservationType AS type, toUInt8(o.WrapperCandidate) AS wrapper_candidate, o.AgentName AS agent,
|
||||
o.Framework AS framework, o.StatusCode AS status,
|
||||
substringUTF8(o.StatusMessage, 1, 128) AS status_message,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
SELECT o.TraceId AS trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS original_trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
o.ObservationType AS type, toUInt8(o.WrapperCandidate) AS wrapper_candidate, o.AgentName AS agent,
|
||||
o.Framework AS framework, o.StatusCode AS status,
|
||||
substringUTF8(o.StatusMessage, 1, 128) AS status_message,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
SELECT * FROM (
|
||||
SELECT o.TraceId AS trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS original_trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
o.ObservationType AS type, toUInt8(o.WrapperCandidate) AS wrapper_candidate, o.AgentName AS agent,
|
||||
o.Framework AS framework, o.StatusCode AS status,
|
||||
substringUTF8(o.StatusMessage, 1, 128) AS status_message,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
SELECT o.TraceId AS trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
SELECT o.TraceId AS trace_id, o.SpanAttributes['lens.original_trace_id'] AS original_trace_id, o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
o.ObservationType AS type, toUInt8(o.WrapperCandidate) AS wrapper_candidate, o.AgentName AS agent,
|
||||
o.Framework AS framework, o.StatusCode AS status,
|
||||
substringUTF8(o.StatusMessage, 1, 128) AS status_message,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ pub use contracts::TraceSpansParams;
|
|||
struct TraceSpansRowEncoding {
|
||||
#[serde(default)]
|
||||
pub trace_id: String,
|
||||
#[serde(default)]
|
||||
pub original_trace_id: String,
|
||||
pub span_id: String,
|
||||
pub parent_span_id: String,
|
||||
pub name: String,
|
||||
|
|
@ -181,6 +183,7 @@ struct SpendByResponseIdsParamsEncoding {
|
|||
#[serde(flatten)]
|
||||
pub access: contracts::ReadAccessParams,
|
||||
pub response_ids: Vec<String>,
|
||||
pub provider_request_ids: Vec<String>,
|
||||
pub request_ids: Vec<String>,
|
||||
pub trace_ids: Vec<String>,
|
||||
#[serde(deserialize_with = "super::number::deserialize")]
|
||||
|
|
@ -207,6 +210,8 @@ struct SpendByResponseIdsRowEncoding {
|
|||
pub litellm_call_id: String,
|
||||
pub response_id: String,
|
||||
pub upstream_response_id: String,
|
||||
#[serde(default)]
|
||||
pub provider_request_id: String,
|
||||
pub trace_id: String,
|
||||
pub span_id: String,
|
||||
pub team_id: String,
|
||||
|
|
@ -344,7 +349,7 @@ mod tests {
|
|||
quoted,
|
||||
);
|
||||
round_trip::<TraceSpansRow>(
|
||||
json!({"trace_id": "trace", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "claude-agent-sdk", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
quoted,
|
||||
);
|
||||
round_trip::<SpanDetailRow>(
|
||||
|
|
@ -356,7 +361,7 @@ mod tests {
|
|||
quoted,
|
||||
);
|
||||
round_trip::<SpendByResponseIdsRow>(
|
||||
json!({"request_id": "request", "litellm_call_id": "gateway", "response_id": "response", "upstream_response_id": "upstream", "trace_id": "trace", "span_id": "span", "team_id": "team", "api_key": "key", "user": "user", "spend": 0.125, "start_ms": -1}),
|
||||
json!({"request_id": "request", "litellm_call_id": "gateway", "response_id": "response", "upstream_response_id": "upstream", "provider_request_id": "req_provider", "trace_id": "trace", "span_id": "span", "team_id": "team", "api_key": "key", "user": "user", "spend": 0.125, "start_ms": -1}),
|
||||
quoted,
|
||||
);
|
||||
}
|
||||
|
|
@ -374,7 +379,7 @@ mod tests {
|
|||
quoted,
|
||||
);
|
||||
round_trip::<SpendByResponseIdsParams>(
|
||||
json!({"all_teams": 0, "user_id": "user", "team_ids": ["team-a", "team-b"], "response_ids": ["response"], "request_ids": ["request"], "trace_ids": ["trace"], "start_ms": -1, "end_ms": 10}),
|
||||
json!({"all_teams": 0, "user_id": "user", "team_ids": ["team-a", "team-b"], "response_ids": ["response"], "provider_request_ids": [], "request_ids": ["request"], "trace_ids": ["trace"], "start_ms": -1, "end_ms": 10}),
|
||||
quoted,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,7 +211,10 @@ fn request_id(evidence: &CallEvidence) -> &str {
|
|||
.flatten()
|
||||
.find_map(|key| match key {
|
||||
CallKey::ProviderResponse(id) => Some(id.as_str()),
|
||||
CallKey::LiteLlmRequest(_) | CallKey::Transport | CallKey::GatewayAttempt => None,
|
||||
CallKey::LiteLlmRequest(_)
|
||||
| CallKey::ProviderRequest(_)
|
||||
| CallKey::Transport
|
||||
| CallKey::GatewayAttempt => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ async fn schema_supports_span_rollups_and_spend_joins(
|
|||
Parameter::Strings(vec!["response-1".into()]),
|
||||
),
|
||||
("request_ids".into(), Parameter::Strings(Vec::new())),
|
||||
(
|
||||
"provider_request_ids".into(),
|
||||
Parameter::Strings(Vec::new()),
|
||||
),
|
||||
("trace_ids".into(), Parameter::Strings(Vec::new())),
|
||||
("all_teams".into(), Parameter::Integer(0)),
|
||||
("user_id".into(), Parameter::Text(String::new())),
|
||||
|
|
@ -2223,6 +2227,7 @@ async fn named_and_sql_readers_share_request_log_visibility(
|
|||
"api_key_hash": legacy_key.unwrap_or_default(),
|
||||
}))?,
|
||||
response_ids: vec!["shared-response".into()],
|
||||
provider_request_ids: Vec::new(),
|
||||
request_ids: Vec::new(),
|
||||
trace_ids: Vec::new(),
|
||||
start_ms: timestamp / 1_000_000 - 1,
|
||||
|
|
|
|||
|
|
@ -158,7 +158,8 @@ fn span_row(span: &DecodedSpan, team: &str, key: &str) -> BTreeMap<String, Value
|
|||
.find_map(|key| match key {
|
||||
litellm_traces::CallKey::LiteLlmRequest(id)
|
||||
| litellm_traces::CallKey::ProviderResponse(id) => Some(id.as_str()),
|
||||
litellm_traces::CallKey::Transport
|
||||
litellm_traces::CallKey::ProviderRequest(_)
|
||||
| litellm_traces::CallKey::Transport
|
||||
| litellm_traces::CallKey::GatewayAttempt => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
|
|
|
|||
|
|
@ -601,7 +601,7 @@ async fn an_oversized_span_keeps_the_run_list_available_with_partial_totals(
|
|||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback(
|
||||
async fn assigned_call_ids_require_shared_ownership_through_detail_and_batch_reads(
|
||||
#[future(awt)] migrated_database: TestResult<SeededDatabase>,
|
||||
) -> TestResult {
|
||||
let fixture = migrated_database?;
|
||||
|
|
@ -610,31 +610,41 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
let start_ms = 1_790_000_000_000_i64;
|
||||
let cases = [
|
||||
(
|
||||
"gateway",
|
||||
"provider-request",
|
||||
"gateway",
|
||||
"same-key",
|
||||
"provider_response:same-key",
|
||||
"team-a",
|
||||
"key-a",
|
||||
Some(0.25),
|
||||
),
|
||||
("legacy", "legacy", "", "team-a", "key-a", Some(0.25)),
|
||||
("conflict", "conflict", "different", "team-a", "key-a", None),
|
||||
(
|
||||
"other-key",
|
||||
"provider_response:other-key",
|
||||
"team-a",
|
||||
"key-b",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"foreign-team",
|
||||
"request",
|
||||
"foreign-team",
|
||||
"provider_response:foreign-team",
|
||||
"team-b",
|
||||
"key-a",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"foreign-key",
|
||||
"request",
|
||||
"foreign-key",
|
||||
"call-id-other-key",
|
||||
"litellm_request:call-id-other-key",
|
||||
"team-a",
|
||||
"key-b",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"call-id-foreign-team",
|
||||
"litellm_request:call-id-foreign-team",
|
||||
"team-b",
|
||||
"key-a",
|
||||
None,
|
||||
),
|
||||
("transport-only", "transport:", "team-a", "key-a", None),
|
||||
];
|
||||
insert_rows(
|
||||
client,
|
||||
|
|
@ -643,7 +653,7 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
InsertTable::OtelTraces,
|
||||
cases
|
||||
.iter()
|
||||
.map(|(id, _, _, _, _, _)| {
|
||||
.map(|(id, key, _, _, _)| {
|
||||
BTreeMap::from([
|
||||
("Timestamp".into(), json!(start_ms * 1_000_000)),
|
||||
("Duration".into(), json!(1_000_000)),
|
||||
|
|
@ -652,7 +662,7 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
("ObservationType".into(), json!("llm")),
|
||||
("TeamId".into(), json!("team-a")),
|
||||
("ApiKeyHash".into(), json!("key-a")),
|
||||
("CallKeys".into(), json!([format!("litellm_request:{id}")])),
|
||||
("CallKeys".into(), json!([key])),
|
||||
("CallEvidence".into(), json!("complete")),
|
||||
])
|
||||
})
|
||||
|
|
@ -666,11 +676,11 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
InsertTable::SpendLogs,
|
||||
cases
|
||||
.iter()
|
||||
.map(|(_, request, call_id, team, key, _)| {
|
||||
.map(|(id, _, team, key, _)| {
|
||||
BTreeMap::from([
|
||||
("request_id".into(), json!(request)),
|
||||
("response_id".into(), json!("provider-response")),
|
||||
("litellm_call_id".into(), json!(call_id)),
|
||||
("request_id".into(), json!(format!("request-{id}"))),
|
||||
("response_id".into(), json!(id)),
|
||||
("litellm_call_id".into(), json!(id)),
|
||||
("team_id".into(), json!(team)),
|
||||
("api_key".into(), json!(key)),
|
||||
("start_time".into(), json!(start_ms)),
|
||||
|
|
@ -689,13 +699,13 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
let access = ReadAccessParams {
|
||||
all_teams: false,
|
||||
user_id: String::new(),
|
||||
team_ids: vec!["team-a".into()],
|
||||
team_ids: vec!["team-a".into(), "team-b".into()],
|
||||
};
|
||||
let page = reader
|
||||
.list_traces(&store, &access, 0, 2_000_000_000_000, None, 50)
|
||||
.await?;
|
||||
assert_eq!(page.data.len(), cases.len());
|
||||
for (id, _, _, _, _, expected) in cases {
|
||||
for (id, _, _, _, expected) in cases {
|
||||
let summary = page
|
||||
.data
|
||||
.iter()
|
||||
|
|
@ -707,6 +717,122 @@ async fn gateway_ids_resolve_through_detail_and_batch_reads_with_legacy_fallback
|
|||
.ok_or("missing trace")?;
|
||||
assert_eq!(detail.summary.spend, expected, "{id}");
|
||||
assert_eq!(summary.spend, expected, "{id}");
|
||||
assert_eq!(summary.priced_calls, u64::from(expected.is_some()), "{id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::provider_request(true)]
|
||||
#[case::transport(false)]
|
||||
#[tokio::test]
|
||||
async fn native_cost_correlation_survives_session_grouping_and_excludes_other_owners(
|
||||
#[future(awt)] migrated_database: TestResult<SeededDatabase>,
|
||||
#[case] response_header: bool,
|
||||
#[values(false, true)] grouped: bool,
|
||||
) -> TestResult {
|
||||
let fixture = migrated_database?;
|
||||
let client = &fixture.database.client;
|
||||
let writer = Connection::writer(&fixture.database.url)?;
|
||||
let trace_id = if grouped {
|
||||
"grouped-trace"
|
||||
} else {
|
||||
"original-trace"
|
||||
};
|
||||
let start_ms = 1_790_000_000_000_i64;
|
||||
let keys = if response_header {
|
||||
vec!["provider_response:req_native"]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
insert_rows(
|
||||
client,
|
||||
&writer,
|
||||
DATABASE,
|
||||
InsertTable::OtelTraces,
|
||||
vec![BTreeMap::from([
|
||||
("Timestamp".into(), json!(start_ms * 1_000_000)),
|
||||
("TraceId".into(), json!(trace_id)),
|
||||
("SpanId".into(), json!("native-call")),
|
||||
("SpanName".into(), json!("claude_code.llm_request")),
|
||||
("ObservationType".into(), json!("llm")),
|
||||
("Framework".into(), json!("claude-code")),
|
||||
("TeamId".into(), json!("team-a")),
|
||||
("ApiKeyHash".into(), json!("key-a")),
|
||||
("CallKeys".into(), json!(keys)),
|
||||
(
|
||||
"CallEvidence".into(),
|
||||
json!(if response_header {
|
||||
"complete"
|
||||
} else {
|
||||
"unknown"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"SpanAttributes".into(),
|
||||
json!({"lens.original_trace_id": if grouped { "original-trace" } else { "" }}),
|
||||
),
|
||||
])],
|
||||
)
|
||||
.await?;
|
||||
insert_rows(
|
||||
client,
|
||||
&writer,
|
||||
DATABASE,
|
||||
InsertTable::SpendLogs,
|
||||
["key-a", "key-b"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
BTreeMap::from([
|
||||
("request_id".into(), json!(format!("log-{key}"))),
|
||||
("response_id".into(), json!("msg_native")),
|
||||
("provider_request_id".into(), json!("req_native")),
|
||||
(
|
||||
"trace_id".into(),
|
||||
json!(if response_header {
|
||||
""
|
||||
} else {
|
||||
"original-trace"
|
||||
}),
|
||||
),
|
||||
(
|
||||
"span_id".into(),
|
||||
json!(if response_header { "" } else { "native-call" }),
|
||||
),
|
||||
("team_id".into(), json!("team-a")),
|
||||
("api_key".into(), json!(key)),
|
||||
("start_time".into(), json!(start_ms)),
|
||||
("end_time".into(), json!(start_ms + 1)),
|
||||
("spend".into(), json!(0.25)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.await?;
|
||||
let connection = fixture
|
||||
.readers
|
||||
.connection(client, &QueryScope::All, "fixture-secret")
|
||||
.await?;
|
||||
let (reader, store) = make_reader(client, connection);
|
||||
let access = ReadAccessParams {
|
||||
all_teams: true,
|
||||
user_id: String::new(),
|
||||
team_ids: Vec::new(),
|
||||
};
|
||||
let page = reader
|
||||
.list_traces(&store, &access, 0, 2_000_000_000_000, None, 50)
|
||||
.await?;
|
||||
assert_eq!(page.data.len(), 1);
|
||||
let summary = &page.data[0];
|
||||
let detail = reader
|
||||
.get_trace(&store, &access, trace_id, &summary.trace_ref)
|
||||
.await?
|
||||
.ok_or("missing trace")?;
|
||||
assert_eq!((summary.spend, summary.priced_calls), (Some(0.25), 1));
|
||||
assert_eq!(detail.summary.spend, summary.spend);
|
||||
assert_eq!(
|
||||
detail.spans[0].spend_log_request_id.as_deref(),
|
||||
Some("log-key-a")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,5 +44,6 @@ pub use tenant::Tenant;
|
|||
pub use truncate::{truncate_messages, truncate_value};
|
||||
pub use ui::{ChatRole, UiContent, UiField, UiMessage, UiToolCall, to_ui_content};
|
||||
pub use view::{
|
||||
AgentNode, Span, SpanDetail, SpanErrorPage, SpanStatus, Trace, TracePage, TraceSummary,
|
||||
AgentNode, Span, SpanDetail, SpanErrorPage, SpanStatus, SpendMatch, Trace, TracePage,
|
||||
TraceSummary,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use super::{Extraction, Format, SpanFacts};
|
|||
use crate::{
|
||||
Error,
|
||||
normalize::{
|
||||
CLAUDE_CODE_AGENT, CLAUDE_CODE_EVENTS_SCOPE, CLAUDE_CODE_SCOPE, CallEvidence, CallKey,
|
||||
CLAUDE_CODE_AGENT, CLAUDE_CODE_EVENTS_SCOPE, CLAUDE_CODE_SCOPE, CallEvidence,
|
||||
ObservationType, RoleEvidence, SpanContext, attr, present, tokens,
|
||||
},
|
||||
otlp::DecodedEvent,
|
||||
|
|
@ -265,7 +265,7 @@ impl Format for ClaudeCode {
|
|||
output: llm_output(attributes),
|
||||
calls: present(attributes, &["gen_ai.response.id", "request_id"])
|
||||
.map_or(CallEvidence::Unknown, |id| {
|
||||
CallEvidence::complete(CallKey::ProviderResponse(id))
|
||||
CallEvidence::complete(crate::normalize::claude_call_key(id))
|
||||
}),
|
||||
..base
|
||||
},
|
||||
|
|
|
|||
|
|
@ -216,7 +216,16 @@ impl Instrumentation {
|
|||
|
||||
fn with_call_ids(context: &SpanContext<'_>, facts: SpanFacts) -> SpanFacts {
|
||||
let calls = [
|
||||
present(context.attributes, &["gen_ai.response.id"]).map(CallKey::ProviderResponse),
|
||||
present(context.attributes, &["gen_ai.response.id"]).map(|id| {
|
||||
if matches!(
|
||||
context.scope,
|
||||
crate::normalize::CLAUDE_CODE_SCOPE | crate::normalize::CLAUDE_CODE_EVENTS_SCOPE
|
||||
) {
|
||||
crate::normalize::claude_call_key(id)
|
||||
} else {
|
||||
CallKey::ProviderResponse(id)
|
||||
}
|
||||
}),
|
||||
present(context.attributes, &["litellm.call_id"]).map(CallKey::LiteLlmRequest),
|
||||
]
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -65,16 +65,26 @@ pub enum CallKey {
|
|||
LiteLlmRequest(String),
|
||||
/// The provider response id returned to the caller (`spend_logs.response_id`).
|
||||
ProviderResponse(String),
|
||||
ProviderRequest(String),
|
||||
/// The span is the HTTP request itself; LiteLLM logs its `traceparent` span id.
|
||||
Transport,
|
||||
GatewayAttempt,
|
||||
}
|
||||
|
||||
pub(crate) fn claude_call_key(id: String) -> CallKey {
|
||||
if id.starts_with("msg_") {
|
||||
CallKey::ProviderResponse(id)
|
||||
} else {
|
||||
CallKey::ProviderRequest(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CallKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::LiteLlmRequest(id) => write!(formatter, "litellm_request:{id}"),
|
||||
Self::ProviderResponse(id) => write!(formatter, "provider_response:{id}"),
|
||||
Self::ProviderRequest(id) => write!(formatter, "provider_request:{id}"),
|
||||
Self::Transport => formatter.write_str("transport:"),
|
||||
Self::GatewayAttempt => formatter.write_str("gateway_attempt:"),
|
||||
}
|
||||
|
|
@ -89,6 +99,9 @@ impl FromStr for CallKey {
|
|||
Some(("provider_response", id)) if !id.is_empty() => {
|
||||
Ok(Self::ProviderResponse(id.to_owned()))
|
||||
}
|
||||
Some(("provider_request", id)) if !id.is_empty() => {
|
||||
Ok(Self::ProviderRequest(id.to_owned()))
|
||||
}
|
||||
Some(("litellm_request", id)) if !id.is_empty() => {
|
||||
Ok(Self::LiteLlmRequest(id.to_owned()))
|
||||
}
|
||||
|
|
@ -127,24 +140,45 @@ pub enum CallEvidence {
|
|||
|
||||
impl CallEvidence {
|
||||
pub(crate) fn row_keys(row: &crate::query::named::TraceSpansRow) -> BTreeSet<CallKey> {
|
||||
if row.call_keys.is_empty() && !row.litellm_request_id.is_empty() {
|
||||
let native = Self::native_request(row);
|
||||
let keys = if row.call_keys.is_empty() && !row.litellm_request_id.is_empty() {
|
||||
BTreeSet::from([CallKey::ProviderResponse(row.litellm_request_id.clone())])
|
||||
} else {
|
||||
row.call_keys.iter().cloned().collect()
|
||||
};
|
||||
if !native {
|
||||
return keys;
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return BTreeSet::from([CallKey::Transport]);
|
||||
}
|
||||
keys.into_iter()
|
||||
.map(|key| match key {
|
||||
CallKey::ProviderResponse(id) => claude_call_key(id),
|
||||
key => key,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn native_request(row: &crate::query::named::TraceSpansRow) -> bool {
|
||||
matches!(row.framework.as_str(), "claude-code" | "claude-agent-sdk")
|
||||
&& row.name == "claude_code.llm_request"
|
||||
}
|
||||
|
||||
pub(crate) fn from_row(row: &crate::query::named::TraceSpansRow) -> Self {
|
||||
let kind = row
|
||||
.call_evidence
|
||||
.unwrap_or(if Self::row_keys(row).is_empty() {
|
||||
let keys = Self::row_keys(row);
|
||||
let kind = if Self::native_request(row) {
|
||||
CallEvidenceKind::Complete
|
||||
} else {
|
||||
row.call_evidence.unwrap_or(if keys.is_empty() {
|
||||
CallEvidenceKind::Unknown
|
||||
} else {
|
||||
CallEvidenceKind::Complete
|
||||
});
|
||||
})
|
||||
};
|
||||
match kind {
|
||||
CallEvidenceKind::Complete => Self::Complete(Self::row_keys(row)),
|
||||
CallEvidenceKind::Partial => Self::Partial(Self::row_keys(row)),
|
||||
CallEvidenceKind::Complete => Self::Complete(keys),
|
||||
CallEvidenceKind::Partial => Self::Partial(keys),
|
||||
CallEvidenceKind::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ fn decoded_span(
|
|||
.into_iter()
|
||||
.flatten()
|
||||
.map(|key| match key {
|
||||
crate::CallKey::LiteLlmRequest(id) | crate::CallKey::ProviderResponse(id) => {
|
||||
id.len() + size_of::<crate::CallKey>()
|
||||
}
|
||||
crate::CallKey::LiteLlmRequest(id)
|
||||
| crate::CallKey::ProviderResponse(id)
|
||||
| crate::CallKey::ProviderRequest(id) => id.len() + size_of::<crate::CallKey>(),
|
||||
crate::CallKey::Transport | crate::CallKey::GatewayAttempt => {
|
||||
size_of::<crate::CallKey>()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ pub struct TraceSpansParams {
|
|||
pub struct TraceSpansRow {
|
||||
#[serde(default)]
|
||||
pub trace_id: String,
|
||||
#[serde(default)]
|
||||
pub original_trace_id: String,
|
||||
pub span_id: String,
|
||||
pub parent_span_id: String,
|
||||
pub name: String,
|
||||
|
|
@ -113,6 +115,16 @@ pub struct TraceSpansRow {
|
|||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl TraceSpansRow {
|
||||
pub(crate) fn transport_trace_id(&self) -> &str {
|
||||
if self.original_trace_id.is_empty() {
|
||||
&self.trace_id
|
||||
} else {
|
||||
&self.original_trace_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TracePageSpansParams {
|
||||
#[serde(flatten)]
|
||||
|
|
@ -163,6 +175,7 @@ pub struct SpendByResponseIdsParams {
|
|||
#[serde(flatten)]
|
||||
pub access: ReadAccessParams,
|
||||
pub response_ids: Vec<String>,
|
||||
pub provider_request_ids: Vec<String>,
|
||||
pub request_ids: Vec<String>,
|
||||
pub trace_ids: Vec<String>,
|
||||
pub start_ms: i64,
|
||||
|
|
@ -175,6 +188,8 @@ pub struct SpendByResponseIdsRow {
|
|||
pub litellm_call_id: String,
|
||||
pub response_id: String,
|
||||
pub upstream_response_id: String,
|
||||
#[serde(default)]
|
||||
pub provider_request_id: String,
|
||||
pub trace_id: String,
|
||||
pub span_id: String,
|
||||
pub team_id: String,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::collections::HashMap;
|
|||
use indexmap::IndexMap;
|
||||
|
||||
use crate::{
|
||||
SpendMatch,
|
||||
normalize::{CallKey, ObservationType},
|
||||
query::named::{SpendByResponseIdsRow as SpendRow, TraceSpansRow},
|
||||
};
|
||||
|
|
@ -27,8 +28,11 @@ pub(super) struct Resolution<'a> {
|
|||
types: HashMap<&'a str, ObservationType>,
|
||||
tool_failures: HashMap<&'a str, &'a TraceSpansRow>,
|
||||
pub(super) model_calls: Vec<usize>,
|
||||
call_matches: HashMap<usize, CallMatch<'a>>,
|
||||
}
|
||||
|
||||
pub(super) type CallMatch<'a> = (Option<Requests<'a>>, SpendMatch);
|
||||
|
||||
impl<'a> Resolution<'a> {
|
||||
pub(super) fn new(rows: &'a [TraceSpansRow], spend: &'a [SpendRow]) -> Self {
|
||||
let graph = Graph::new(rows);
|
||||
|
|
@ -36,7 +40,7 @@ impl<'a> Resolution<'a> {
|
|||
let types: HashMap<&str, ObservationType> = (0..rows.len())
|
||||
.map(|index| (graph.id(index), resolved_type(&graph, index, named_agents)))
|
||||
.collect();
|
||||
let model_calls = (0..rows.len())
|
||||
let model_calls: Vec<usize> = (0..rows.len())
|
||||
.filter(|index| {
|
||||
types[graph.id(*index)] == ObservationType::Llm
|
||||
&& !graph
|
||||
|
|
@ -45,7 +49,7 @@ impl<'a> Resolution<'a> {
|
|||
.any(|descendant| types[graph.id(descendant)] == ObservationType::Llm)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
let resolution = Self {
|
||||
ownership: Ownership {
|
||||
team_id: &rows[0].team_id,
|
||||
api_key_hash: &rows[0].api_key_hash,
|
||||
|
|
@ -65,6 +69,16 @@ impl<'a> Resolution<'a> {
|
|||
.map(|row| (row.tool_call_id.as_str(), row))
|
||||
.collect(),
|
||||
model_calls,
|
||||
call_matches: HashMap::new(),
|
||||
};
|
||||
let call_matches = resolution
|
||||
.model_calls
|
||||
.iter()
|
||||
.map(|call| (*call, resolution.resolve_call_match(*call)))
|
||||
.collect();
|
||||
Self {
|
||||
call_matches,
|
||||
..resolution
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +132,24 @@ impl<'a> Resolution<'a> {
|
|||
spend::requests(self.row(index), &self.ownership, self.spend)
|
||||
}
|
||||
|
||||
pub(super) fn call_match(&self, call: usize) -> Option<&CallMatch<'a>> {
|
||||
self.call_matches.get(&call)
|
||||
}
|
||||
|
||||
pub(super) fn call_requests(&self, call: usize) -> Option<Requests<'a>> {
|
||||
self.call_match(call)
|
||||
.and_then(|(requests, _)| requests.clone())
|
||||
}
|
||||
|
||||
fn resolve_call_match(&self, call: usize) -> CallMatch<'a> {
|
||||
if let Some(requests) = self.resolve_call_requests(call) {
|
||||
return (Some(requests), SpendMatch::Matched);
|
||||
}
|
||||
let evidence = self.requests(call);
|
||||
(None, evidence.unmatched_reason())
|
||||
}
|
||||
|
||||
fn resolve_call_requests(&self, call: usize) -> Option<Requests<'a>> {
|
||||
let wrappers = self.graph.ancestors(call).into_iter().filter(|ancestor| {
|
||||
self.kind(*ancestor) == ObservationType::Llm
|
||||
&& self
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::collections::BTreeSet;
|
|||
use indexmap::IndexMap;
|
||||
|
||||
use crate::{
|
||||
CallEvidence, CallEvidenceKind, CallKey,
|
||||
CallEvidence, CallEvidenceKind, CallKey, SpendMatch,
|
||||
query::named::{SpendByResponseIdsRow as SpendRow, TraceSpansRow},
|
||||
};
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ use crate::{
|
|||
pub struct SpendLookup {
|
||||
pub response_ids: Vec<String>,
|
||||
pub request_ids: Vec<String>,
|
||||
pub provider_request_ids: Vec<String>,
|
||||
/// Traces whose transport spans LiteLLM logged by `traceparent`.
|
||||
pub trace_ids: Vec<String>,
|
||||
}
|
||||
|
|
@ -45,13 +46,21 @@ impl SpendLookup {
|
|||
})
|
||||
.collect(),
|
||||
),
|
||||
provider_request_ids: sorted(
|
||||
keys()
|
||||
.filter_map(|(_, key)| match key {
|
||||
CallKey::ProviderRequest(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
trace_ids: sorted(
|
||||
keys()
|
||||
.filter_map(|(row, key)| match key {
|
||||
CallKey::Transport | CallKey::GatewayAttempt
|
||||
if !row.trace_id.is_empty() =>
|
||||
if !row.transport_trace_id().is_empty() =>
|
||||
{
|
||||
Some(row.trace_id.clone())
|
||||
Some(row.transport_trace_id().to_owned())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
|
@ -61,7 +70,10 @@ impl SpendLookup {
|
|||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.response_ids.is_empty() && self.request_ids.is_empty() && self.trace_ids.is_empty()
|
||||
self.response_ids.is_empty()
|
||||
&& self.request_ids.is_empty()
|
||||
&& self.provider_request_ids.is_empty()
|
||||
&& self.trace_ids.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +98,7 @@ pub(super) type Requests<'a> = Vec<&'a SpendRow>;
|
|||
enum KeyFamily {
|
||||
GatewayCall,
|
||||
ProviderResponse,
|
||||
ProviderRequest,
|
||||
Transport,
|
||||
}
|
||||
|
||||
|
|
@ -93,12 +106,14 @@ fn key_family(key: &CallKey) -> KeyFamily {
|
|||
match key {
|
||||
CallKey::LiteLlmRequest(_) => KeyFamily::GatewayCall,
|
||||
CallKey::ProviderResponse(_) => KeyFamily::ProviderResponse,
|
||||
CallKey::ProviderRequest(_) => KeyFamily::ProviderRequest,
|
||||
CallKey::Transport | CallKey::GatewayAttempt => KeyFamily::Transport,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum KeyMatch<'a> {
|
||||
Missing,
|
||||
Conflicting,
|
||||
Unique(&'a SpendRow),
|
||||
Ambiguous(Requests<'a>),
|
||||
}
|
||||
|
|
@ -115,13 +130,13 @@ impl<'a> KeyMatch<'a> {
|
|||
fn unique(&self) -> Option<&'a SpendRow> {
|
||||
match self {
|
||||
Self::Unique(request) => Some(request),
|
||||
Self::Missing | Self::Ambiguous(_) => None,
|
||||
Self::Missing | Self::Conflicting | Self::Ambiguous(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn agrees_with(&self, selected: &[&SpendRow]) -> bool {
|
||||
match self {
|
||||
Self::Missing => false,
|
||||
Self::Missing | Self::Conflicting => false,
|
||||
Self::Unique(request) => selected
|
||||
.iter()
|
||||
.any(|row| row.identity() == request.identity()),
|
||||
|
|
@ -147,6 +162,29 @@ pub(super) enum SpendEvidence<'a> {
|
|||
}
|
||||
|
||||
impl<'a> SpendEvidence<'a> {
|
||||
pub(super) fn unmatched_reason(&self) -> SpendMatch {
|
||||
match self {
|
||||
Self::Unknown => SpendMatch::NoCallId,
|
||||
Self::Partial(_) => SpendMatch::IncompleteEvidence,
|
||||
Self::Complete(matches) if matches.is_empty() => SpendMatch::NoCallId,
|
||||
Self::Complete(matches)
|
||||
if matches.iter().any(|evidence| {
|
||||
matches!(evidence, KeyMatch::Conflicting | KeyMatch::Ambiguous(_))
|
||||
}) =>
|
||||
{
|
||||
SpendMatch::Ambiguous
|
||||
}
|
||||
Self::Complete(matches)
|
||||
if matches
|
||||
.iter()
|
||||
.any(|evidence| matches!(evidence, KeyMatch::Missing)) =>
|
||||
{
|
||||
SpendMatch::NoSpendLog
|
||||
}
|
||||
Self::Complete(_) => SpendMatch::Ambiguous,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn complete_requests(&self) -> Option<Requests<'a>> {
|
||||
match self {
|
||||
Self::Complete(matches) if !matches.is_empty() => {
|
||||
|
|
@ -186,15 +224,16 @@ fn matches<'a>(
|
|||
CallKey::ProviderResponse(id) => {
|
||||
!id.is_empty() && (spend.response_id == *id || spend.upstream_response_id == *id)
|
||||
}
|
||||
CallKey::ProviderRequest(id) => !id.is_empty() && spend.provider_request_id == *id,
|
||||
CallKey::LiteLlmRequest(id) => {
|
||||
!id.is_empty()
|
||||
&& (spend.litellm_call_id == *id
|
||||
|| (spend.litellm_call_id.is_empty() && spend.request_id == *id))
|
||||
}
|
||||
CallKey::Transport | CallKey::GatewayAttempt => {
|
||||
!row.trace_id.is_empty()
|
||||
!row.transport_trace_id().is_empty()
|
||||
&& !row.span_id.is_empty()
|
||||
&& spend.trace_id == row.trace_id
|
||||
&& spend.trace_id == row.transport_trace_id()
|
||||
&& spend.span_id == row.span_id
|
||||
}
|
||||
};
|
||||
|
|
@ -253,7 +292,8 @@ pub(super) fn requests<'a>(
|
|||
let matches = aliases
|
||||
.into_iter()
|
||||
.map(|(_, requests)| {
|
||||
KeyMatch::new(
|
||||
let had_candidates = !requests.is_empty();
|
||||
let matched = KeyMatch::new(
|
||||
requests
|
||||
.into_iter()
|
||||
.filter(|request| {
|
||||
|
|
@ -262,7 +302,12 @@ pub(super) fn requests<'a>(
|
|||
.all(|family| family.contains(&request.identity()))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
);
|
||||
if had_candidates && matches!(matched, KeyMatch::Missing) {
|
||||
KeyMatch::Conflicting
|
||||
} else {
|
||||
matched
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
match evidence.kind() {
|
||||
|
|
@ -280,18 +325,35 @@ pub(super) fn request_cost(requests: &[&SpendRow]) -> Option<f64> {
|
|||
})
|
||||
}
|
||||
|
||||
pub(super) fn total(calls: &[Option<Requests<'_>>]) -> Option<f64> {
|
||||
if calls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let requests: Option<Vec<&SpendRow>> = calls
|
||||
.iter()
|
||||
.map(|requests| requests.as_ref())
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(|calls| calls.into_iter().flatten().copied().collect());
|
||||
let unique: IndexMap<(&str, i64, &str), &SpendRow> = requests?
|
||||
pub(super) fn unique<'a>(requests: impl IntoIterator<Item = &'a SpendRow>) -> Requests<'a> {
|
||||
requests
|
||||
.into_iter()
|
||||
.map(|request| (request.identity(), request))
|
||||
.collect();
|
||||
request_cost(&unique.into_values().collect::<Vec<_>>())
|
||||
.collect::<IndexMap<_, _>>()
|
||||
.into_values()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) struct Priced {
|
||||
pub(super) spend: Option<f64>,
|
||||
pub(super) priced_calls: u64,
|
||||
}
|
||||
|
||||
pub(super) fn total(calls: &[Option<Requests<'_>>]) -> Priced {
|
||||
let priced: Vec<&Requests<'_>> = calls
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|requests| request_cost(requests).is_some())
|
||||
.collect();
|
||||
let spend = if priced.is_empty() {
|
||||
None
|
||||
} else {
|
||||
request_cost(&unique(
|
||||
priced.iter().flat_map(|requests| requests.iter().copied()),
|
||||
))
|
||||
};
|
||||
Priced {
|
||||
spend,
|
||||
priced_calls: priced.len() as u64,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use time::OffsetDateTime;
|
|||
use crate::{
|
||||
normalize::ObservationType,
|
||||
query::named::{ListTracesRow, SpendByResponseIdsRow as SpendRow, TraceSpansRow},
|
||||
view::{AgentNode, Span, SpanStatus, Trace, TraceSummary},
|
||||
view::{AgentNode, Span, SpanStatus, SpendMatch, Trace, TraceSummary},
|
||||
};
|
||||
|
||||
use super::{
|
||||
|
|
@ -23,7 +23,18 @@ fn optional(value: &str) -> Option<String> {
|
|||
fn span(resolution: &Resolution<'_>, index: usize, trace_start_ns: i64) -> Span {
|
||||
let row = resolution.row(index);
|
||||
let status = resolution.status_source(index);
|
||||
let requests = resolution.requests(index).complete_requests();
|
||||
let (requests, spend_match) = if let Some((requests, matched)) = resolution.call_match(index) {
|
||||
(requests.clone(), Some(*matched))
|
||||
} else {
|
||||
(resolution.requests(index).complete_requests(), None)
|
||||
};
|
||||
let spend = requests
|
||||
.as_ref()
|
||||
.and_then(|requests| request_cost(requests));
|
||||
let spend_log_request_id = match (spend_match, requests.as_deref()) {
|
||||
(Some(SpendMatch::Matched), Some([request])) => Some(request.request_id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Span {
|
||||
span_id: row.span_id.clone(),
|
||||
parent_span_id: optional(&row.parent_span_id),
|
||||
|
|
@ -42,9 +53,9 @@ fn span(resolution: &Resolution<'_>, index: usize, trace_start_ns: i64) -> Span
|
|||
input_tokens: row.input_tokens,
|
||||
output_tokens: row.output_tokens,
|
||||
litellm_request_id: optional(&row.litellm_request_id),
|
||||
spend: requests
|
||||
.as_ref()
|
||||
.and_then(|requests| request_cost(requests)),
|
||||
spend,
|
||||
spend_log_request_id,
|
||||
spend_match,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +98,7 @@ fn agents(resolution: &Resolution<'_>) -> Vec<AgentNode> {
|
|||
.filter(|(owner, _)| *owner == name)
|
||||
.map(|(_, requests)| requests.clone())
|
||||
.collect();
|
||||
let priced = total(&owned_calls);
|
||||
AgentNode {
|
||||
name: name.to_owned(),
|
||||
parent_agent,
|
||||
|
|
@ -101,7 +113,8 @@ fn agents(resolution: &Resolution<'_>) -> Vec<AgentNode> {
|
|||
.map(|span| graph.rows[*span].duration_ns)
|
||||
.sum::<u64>() as f64
|
||||
/ NANOS_PER_MS,
|
||||
spend: total(&owned_calls),
|
||||
spend: priced.spend,
|
||||
priced_calls: priced.priced_calls,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -160,6 +173,12 @@ pub fn resolve_trace(
|
|||
} else {
|
||||
calls.iter().map(|call| &rows[*call]).collect()
|
||||
};
|
||||
let priced = total(
|
||||
&calls
|
||||
.iter()
|
||||
.map(|call| resolution.call_requests(*call))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let first_input = spans
|
||||
.iter()
|
||||
.zip(rows)
|
||||
|
|
@ -208,12 +227,8 @@ pub fn resolve_trace(
|
|||
input_tokens: counted.iter().map(|row| u64::from(row.input_tokens)).sum(),
|
||||
output_tokens: counted.iter().map(|row| u64::from(row.output_tokens)).sum(),
|
||||
models: sorted_unique(calls.iter().map(|call| rows[*call].model.as_str())),
|
||||
spend: total(
|
||||
&calls
|
||||
.iter()
|
||||
.map(|call| resolution.call_requests(*call))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
spend: priced.spend,
|
||||
priced_calls: priced.priced_calls,
|
||||
};
|
||||
Some(Trace {
|
||||
summary,
|
||||
|
|
@ -250,5 +265,6 @@ pub fn listed_summary(row: &ListTracesRow) -> TraceSummary {
|
|||
output_tokens: row.output_tokens,
|
||||
models: row.models.clone(),
|
||||
spend: None,
|
||||
priced_calls: 0,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ pub struct Span {
|
|||
pub output_tokens: u32,
|
||||
pub litellm_request_id: Option<String>,
|
||||
pub spend: Option<f64>,
|
||||
pub spend_log_request_id: Option<String>,
|
||||
pub spend_match: Option<SpendMatch>,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SpendMatch {
|
||||
Matched,
|
||||
NoCallId,
|
||||
NoSpendLog,
|
||||
Ambiguous,
|
||||
IncompleteEvidence,
|
||||
}
|
||||
|
||||
/// One distinct agent in a trace: 200 invocations of `researcher` are one node.
|
||||
|
|
@ -50,6 +63,7 @@ pub struct AgentNode {
|
|||
pub tool_calls: u64,
|
||||
pub duration_ms: f64,
|
||||
pub spend: Option<f64>,
|
||||
pub priced_calls: u64,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
|
|
@ -80,6 +94,7 @@ pub struct TraceSummary {
|
|||
pub output_tokens: u64,
|
||||
pub models: Vec<String>,
|
||||
pub spend: Option<f64>,
|
||||
pub priced_calls: u64,
|
||||
}
|
||||
|
||||
#[macro_rules_attribute::apply(response_type)]
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ fn captured_spend_rows(spend_logs: &str) -> (FixtureCapture, Vec<SpendByResponse
|
|||
litellm_call_id: record.litellm_call_id,
|
||||
response_id: record.response_id,
|
||||
upstream_response_id,
|
||||
provider_request_id: String::new(),
|
||||
trace_id: record.trace_id,
|
||||
span_id: record.span_id,
|
||||
team_id: record.team_id,
|
||||
|
|
@ -171,12 +172,13 @@ fn trace_span(span: DecodedSpan) -> TraceSpansRow {
|
|||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|key| match key {
|
||||
CallKey::ProviderResponse(id) => Some(id.clone()),
|
||||
CallKey::ProviderResponse(id) | CallKey::ProviderRequest(id) => Some(id.clone()),
|
||||
CallKey::LiteLlmRequest(_) | CallKey::Transport | CallKey::GatewayAttempt => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
TraceSpansRow {
|
||||
trace_id: span.trace_id,
|
||||
original_trace_id: String::new(),
|
||||
span_id: span.span_id,
|
||||
parent_span_id: span.parent_span_id,
|
||||
name: span.name,
|
||||
|
|
@ -267,6 +269,7 @@ fn unrelated_transport(call: &TraceSpansRow) -> TraceSpansRow {
|
|||
.expect("valid unrelated transport timestamp");
|
||||
TraceSpansRow {
|
||||
trace_id: call.trace_id.clone(),
|
||||
original_trace_id: call.original_trace_id.clone(),
|
||||
span_id: format!("unrelated-transport-{}", call.span_id),
|
||||
parent_span_id: call.parent_span_id.clone(),
|
||||
name: "unrelated-http".into(),
|
||||
|
|
@ -405,7 +408,9 @@ fn redundant_genai_response_id_keeps_call_evidence(
|
|||
let response_ids: Vec<_> = keys
|
||||
.iter()
|
||||
.filter_map(|key| match key {
|
||||
CallKey::ProviderResponse(id) => Some(id.clone()),
|
||||
CallKey::ProviderResponse(id) | CallKey::ProviderRequest(id) => {
|
||||
Some(id.clone())
|
||||
}
|
||||
CallKey::LiteLlmRequest(_) | CallKey::Transport | CallKey::GatewayAttempt => {
|
||||
None
|
||||
}
|
||||
|
|
|
|||
|
|
@ -928,3 +928,28 @@ fn convention_markers_keep_genai_call_evidence(
|
|||
let marked = decode(span, scope, &marked_attributes, vec![]).unwrap();
|
||||
assert_eq!(marked.normalized.calls, plain.normalized.calls);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::request("req_native", CallKey::ProviderRequest("req_native".into()))]
|
||||
#[case::legacy_message("msg_legacy", CallKey::ProviderResponse("msg_legacy".into()))]
|
||||
fn native_claude_preserves_the_provider_id_family(
|
||||
span: Span,
|
||||
#[case] id: &str,
|
||||
#[case] key: CallKey,
|
||||
) {
|
||||
let native = Span {
|
||||
name: "claude_code.llm_request".into(),
|
||||
..span
|
||||
};
|
||||
let decoded = decode(
|
||||
native,
|
||||
"com.anthropic.claude_code.tracing",
|
||||
&[("gen_ai.response.id", id)],
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decoded.normalized.calls,
|
||||
CallEvidence::Complete(std::collections::BTreeSet::from([key]))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ fn llamaindex_wrapped_responses_keep_provider_call_keys(#[case] body: &[u8]) {
|
|||
#[rstest]
|
||||
#[case::request(litellm_traces::CallKey::LiteLlmRequest("request:with:colons".to_owned()))]
|
||||
#[case::response(litellm_traces::CallKey::ProviderResponse("response:with:colons".to_owned()))]
|
||||
#[case::provider_request(litellm_traces::CallKey::ProviderRequest("req_native".into()))]
|
||||
#[case::transport(litellm_traces::CallKey::Transport)]
|
||||
#[case::gateway_attempt(litellm_traces::CallKey::GatewayAttempt)]
|
||||
fn call_keys_round_trip_through_storage(#[case] key: litellm_traces::CallKey) {
|
||||
|
|
@ -305,6 +306,7 @@ fn call_keys_round_trip_through_storage(#[case] key: litellm_traces::CallKey) {
|
|||
#[rstest]
|
||||
#[case::missing_separator("provider_response")]
|
||||
#[case::missing_response("provider_response:")]
|
||||
#[case::missing_provider_request("provider_request:")]
|
||||
#[case::missing_request("litellm_request:")]
|
||||
#[case::transport_id("transport:unexpected")]
|
||||
#[case::gateway_attempt_separator("gateway_attempt")]
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ fn named_requests_preserve_all_access_cases(
|
|||
json!({"trace_id": "trace", "trace_ref": "ref", "span_id": "span", "error_offset": u64::MAX, "error_version": "version"}),
|
||||
));
|
||||
round_trip::<SpendByResponseIdsParams>(request(
|
||||
json!({"response_ids": ["response"], "request_ids": ["request"], "trace_ids": ["trace"], "start_ms": -1, "end_ms": 10}),
|
||||
json!({"response_ids": ["response"], "provider_request_ids": [], "request_ids": ["request"], "trace_ids": ["trace"], "start_ms": -1, "end_ms": 10}),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ fn result_contracts_preserve_public_field_names() {
|
|||
json!({"trace_id": "trace", "trace_ref": "ref", "team_id": "team", "api_key_hash": "key", "user_id": "user", "name": "agent", "service": "service", "input_preview": "input", "status": "STATUS_CODE_OK", "start_ms": -1, "duration_ms": 20, "span_count": u64::MAX, "agent_count": 1, "agent_invocations": 2, "agent_names": ["agent"], "frameworks": ["framework"], "llm_calls": 3, "tool_calls": 4, "input_tokens": 5, "output_tokens": 6, "models": ["model"], "error_count": 0, "request_ids": ["request"]}),
|
||||
);
|
||||
round_trip::<TraceSpansRow>(
|
||||
json!({"trace_id": "trace", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
json!({"trace_id": "trace", "original_trace_id": "original", "span_id": "span", "parent_span_id": "parent", "name": "agent", "type": "agent", "wrapper_candidate": 1, "agent": "agent", "framework": "framework", "status": "STATUS_CODE_ERROR", "status_message": "error", "error_truncated": 1, "start_ns": -1, "duration_ns": u64::MAX, "service": "service", "input_preview": "input", "model": "model", "input_tokens": u32::MAX, "output_tokens": 6, "litellm_request_id": "request", "call_keys": ["provider_response:request"], "call_evidence": "complete", "tool_call_id": "call", "team_id": "team", "api_key_hash": "key", "user_id": "user"}),
|
||||
);
|
||||
round_trip::<SpanDetailRow>(
|
||||
json!({"span_id": "span", "input": "input", "output": "output", "attributes": {"count": "42"}}),
|
||||
|
|
@ -62,6 +62,6 @@ fn result_contracts_preserve_public_field_names() {
|
|||
json!({"span_id": "span", "message": "error", "total_chars": u64::MAX, "version": "version"}),
|
||||
);
|
||||
round_trip::<SpendByResponseIdsRow>(
|
||||
json!({"request_id": "request", "litellm_call_id": "gateway", "response_id": "response", "upstream_response_id": "upstream", "trace_id": "trace", "span_id": "span", "team_id": "team", "api_key": "key", "user": "user", "spend": 0.125, "start_ms": -1}),
|
||||
json!({"request_id": "request", "litellm_call_id": "gateway", "response_id": "response", "upstream_response_id": "upstream", "provider_request_id": "req_provider", "trace_id": "trace", "span_id": "span", "team_id": "team", "api_key": "key", "user": "user", "spend": 0.125, "start_ms": -1}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm.integrations.clickhouse.clickhouse_batch_logger import ClickHouseBatchLogger
|
||||
from litellm.integrations.clickhouse.context import is_lens_analysis
|
||||
from litellm.integrations.clickhouse.schema import SPEND_LOGS_TABLE
|
||||
from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import redact_credentials_in_payload
|
||||
from litellm.tracing.types import SpendLogRecord
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
|
@ -155,6 +156,11 @@ def spend_log_row_from_payload(payload: StandardLoggingPayload, kwargs: Mapping[
|
|||
return SpendLogRecord(
|
||||
request_id=request_id,
|
||||
response_id=strip_cache_hit_suffix(request_id),
|
||||
provider_request_id=(
|
||||
get_provider_request_id(kwargs.get("response_headers"))
|
||||
or get_provider_request_id(hidden_params.get("additional_headers"))
|
||||
or ""
|
||||
),
|
||||
litellm_call_id=payload.get("litellm_call_id") or "",
|
||||
call_type=payload.get("call_type") or "",
|
||||
api_key=metadata.get("user_api_key_hash") or "",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import httpx
|
|||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.llm_response_utils.get_headers import get_provider_request_id
|
||||
from litellm.types.llms.openai import AllMessageValues, OpenAIChatCompletionFinishReason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -495,7 +496,8 @@ def process_response_headers(
|
|||
**processed_headers,
|
||||
**additional_headers,
|
||||
}
|
||||
return additional_headers
|
||||
request_id: Final = get_provider_request_id(response_headers)
|
||||
return {**additional_headers, **({"request-id": request_id} if request_id is not None else {})}
|
||||
|
||||
|
||||
def preserve_upstream_non_openai_attributes(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
_HEADERS: Final = TypeAdapter(Mapping[str, str])
|
||||
_REQUEST_ID_HEADERS: Final = ("request-id", "x-request-id", "llm_provider-request-id", "llm_provider-x-request-id")
|
||||
|
||||
|
||||
def get_provider_request_id(headers: object) -> str | None:
|
||||
try:
|
||||
values: Final = _HEADERS.validate_python(headers)
|
||||
except ValidationError:
|
||||
return None
|
||||
normalized: Final = {name.lower(): value for name, value in values.items()}
|
||||
return next((normalized[name] for name in _REQUEST_ID_HEADERS if normalized.get(name)), None)
|
||||
|
||||
|
||||
def get_response_headers(_response_headers: Mapping[str, str] | None = None) -> dict:
|
||||
"""
|
||||
|
|
@ -29,7 +43,9 @@ def get_response_headers(_response_headers: Mapping[str, str] | None = None) ->
|
|||
if "x-ratelimit-remaining-tokens" in _response_headers:
|
||||
openai_headers["x-ratelimit-remaining-tokens"] = _response_headers["x-ratelimit-remaining-tokens"]
|
||||
llm_provider_headers: Final = _get_llm_provider_headers(_response_headers)
|
||||
return {**llm_provider_headers, **openai_headers}
|
||||
request_id: Final = get_provider_request_id(_response_headers)
|
||||
native_headers: Final = {"request-id": request_id} if request_id is not None else {}
|
||||
return {**llm_provider_headers, **openai_headers, **native_headers}
|
||||
|
||||
|
||||
def _get_llm_provider_headers(response_headers: Mapping[str, str]) -> dict:
|
||||
|
|
|
|||
|
|
@ -2214,6 +2214,7 @@ class BaseLLMHTTPHandler:
|
|||
|
||||
# used for logging + cost tracking
|
||||
logging_obj.model_call_details["httpx_response"] = response
|
||||
logging_obj.model_call_details["response_headers"] = dict(response.headers)
|
||||
|
||||
initial_response: AsyncIterator | AnthropicMessagesResponse
|
||||
if stream:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from typing import (
|
|||
cast, # noqa: TID251 # bounded compatibility calls into legacy Python integrations
|
||||
)
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
|
|
@ -337,9 +339,15 @@ def after_deployment_failure(kwargs: dict[str, object], error: Exception, call_t
|
|||
return hook(kwargs, error, call_type)
|
||||
|
||||
|
||||
def stream_opened(logger: Logging) -> None:
|
||||
_STREAM_HEADERS: Final = TypeAdapter(Mapping[str, str])
|
||||
|
||||
|
||||
def stream_opened(logger: Logging, head: Mapping[str, object]) -> None:
|
||||
logger.stream = True
|
||||
logger.model_call_details["stream"] = True
|
||||
logger.model_call_details["response_headers"] = _STREAM_HEADERS.validate_python(
|
||||
head.get("additional_headers") or {}
|
||||
)
|
||||
|
||||
|
||||
def stream_success(
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ class AgentNode(typing_extensions.TypedDict):
|
|||
tool_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
duration_ms: ReadOnly[float]
|
||||
spend: ReadOnly[float | None]
|
||||
priced_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
|
||||
|
||||
SpanType: TypeAlias = Literal[
|
||||
|
|
@ -77,6 +78,9 @@ SpanType: TypeAlias = Literal[
|
|||
]
|
||||
|
||||
|
||||
SpendMatch: TypeAlias = Literal["matched", "no_call_id", "no_spend_log", "ambiguous", "incomplete_evidence"]
|
||||
|
||||
|
||||
class TraceScope(typing_extensions.TypedDict):
|
||||
all_teams: ReadOnly[Literal[0, 1]]
|
||||
user_id: ReadOnly[str]
|
||||
|
|
@ -120,6 +124,7 @@ class TraceSummary(typing_extensions.TypedDict):
|
|||
output_tokens: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
models: ReadOnly[tuple[str, ...]]
|
||||
spend: ReadOnly[float | None]
|
||||
priced_calls: ReadOnly[Annotated[int, Field(ge=0, le=18446744073709551615)]]
|
||||
|
||||
|
||||
class Span(typing_extensions.TypedDict):
|
||||
|
|
@ -140,6 +145,8 @@ class Span(typing_extensions.TypedDict):
|
|||
output_tokens: ReadOnly[Annotated[int, Field(ge=0, le=4294967295)]]
|
||||
litellm_request_id: ReadOnly[str | None]
|
||||
spend: ReadOnly[float | None]
|
||||
spend_log_request_id: ReadOnly[str | None]
|
||||
spend_match: ReadOnly[SpendMatch | None | None]
|
||||
|
||||
|
||||
class Trace(typing_extensions.TypedDict):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class SpendLogRecord(TypedDict):
|
|||
|
||||
request_id: ReadOnly[str]
|
||||
response_id: ReadOnly[str]
|
||||
provider_request_id: ReadOnly[str]
|
||||
litellm_call_id: ReadOnly[str]
|
||||
call_type: ReadOnly[str]
|
||||
api_key: ReadOnly[str]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"priced_calls": {
|
||||
"format": "uint64",
|
||||
"maximum": 18446744073709551615,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"spend": {
|
||||
"format": "double",
|
||||
"type": [
|
||||
|
|
@ -49,7 +55,8 @@
|
|||
"llm_calls",
|
||||
"tool_calls",
|
||||
"duration_ms",
|
||||
"spend"
|
||||
"spend",
|
||||
"priced_calls"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -120,6 +127,22 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"spend_log_request_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"spend_match": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/SpendMatch"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"start_offset_ms": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
|
|
@ -148,7 +171,9 @@
|
|||
"input_tokens",
|
||||
"output_tokens",
|
||||
"litellm_request_id",
|
||||
"spend"
|
||||
"spend",
|
||||
"spend_log_request_id",
|
||||
"spend_match"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
|
@ -177,6 +202,16 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpendMatch": {
|
||||
"enum": [
|
||||
"matched",
|
||||
"no_call_id",
|
||||
"no_spend_log",
|
||||
"ambiguous",
|
||||
"incomplete_evidence"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TraceSummary": {
|
||||
"properties": {
|
||||
"agent_count": {
|
||||
|
|
@ -245,6 +280,12 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"priced_calls": {
|
||||
"format": "uint64",
|
||||
"maximum": 18446744073709551615,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resolution_limited": {
|
||||
"type": "boolean",
|
||||
"x-python-optional": true
|
||||
|
|
@ -306,7 +347,8 @@
|
|||
"input_tokens",
|
||||
"output_tokens",
|
||||
"models",
|
||||
"spend"
|
||||
"spend",
|
||||
"priced_calls"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"priced_calls": {
|
||||
"format": "uint64",
|
||||
"maximum": 18446744073709551615,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resolution_limited": {
|
||||
"type": "boolean",
|
||||
"x-python-optional": true
|
||||
|
|
@ -137,7 +143,8 @@
|
|||
"input_tokens",
|
||||
"output_tokens",
|
||||
"models",
|
||||
"spend"
|
||||
"spend",
|
||||
"priced_calls"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
|
||||
get_response_headers,
|
||||
_get_llm_provider_headers,
|
||||
get_provider_request_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -72,3 +73,17 @@ def test_get_llm_provider_headers():
|
|||
}
|
||||
result = _get_llm_provider_headers(input_headers)
|
||||
assert result == expected_output, "Unexpected output for _get_llm_provider_headers"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ("request-id", "Request-Id", "x-request-id", "llm_provider-request-id"))
|
||||
def test_native_clients_receive_the_provider_request_id(header: str) -> None:
|
||||
result = get_response_headers({header: "req_test", "unrelated": "value"})
|
||||
assert result["request-id"] == "req_test"
|
||||
assert get_provider_request_id(result) == "req_test"
|
||||
assert result["llm_provider-unrelated"] == "value"
|
||||
assert "unrelated" not in result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("headers", (None, {}, {"request-id": ""}, {"request-id": 42}))
|
||||
def test_invalid_provider_request_ids_remain_absent(headers: object) -> None:
|
||||
assert get_provider_request_id(headers) is None
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ def test_success_row_mapping():
|
|||
assert row["request_id"] == "chatcmpl-abc123"
|
||||
assert row["response_id"] == "chatcmpl-abc123"
|
||||
assert row["litellm_call_id"] == "gateway-call"
|
||||
assert row["provider_request_id"] == ""
|
||||
assert row["spend"] == 0.00042
|
||||
assert (row["prompt_tokens"], row["completion_tokens"], row["total_tokens"]) == (20, 10, 30)
|
||||
assert (row["cache_read_tokens"], row["cache_write_tokens"]) == (5, 7)
|
||||
|
|
@ -186,6 +187,18 @@ def test_success_row_mapping():
|
|||
assert json.loads(row["metadata"])["user_api_key_alias"] == "my-key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header_source", ("response_headers", "additional_headers"))
|
||||
def test_provider_request_id_stays_separate_from_message_and_gateway_ids(header_source: str) -> None:
|
||||
headers: Final = {"request-id": "req_native"}
|
||||
hidden: Final = {"additional_headers": headers} if header_source == "additional_headers" else {}
|
||||
kwargs: Final = {"response_cost": 0.00042, "response_headers": headers if header_source == "response_headers" else None}
|
||||
payload: Final = cast(StandardLoggingPayload, _payload(id="msg_native", hidden_params=hidden))
|
||||
row: Final = spend_log_row_from_payload(payload, kwargs)
|
||||
assert (row["provider_request_id"], row["response_id"], row["litellm_call_id"]) == (
|
||||
"req_native", "msg_native", "gateway-call"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ("success", "failure"))
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_request_metadata_is_redacted_before_clickhouse_logging(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for litellm_core_utils.core_helpers module."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -16,6 +17,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
get_provider_response_headers_from_hidden_params,
|
||||
map_finish_reason,
|
||||
normalize_drop_params,
|
||||
process_response_headers,
|
||||
reconstruct_model_name,
|
||||
redact_nested_match_and_regex_keys,
|
||||
set_provider_response_headers_in_hidden_params,
|
||||
|
|
@ -25,6 +27,14 @@ from litellm.proxy._types import UserAPIKeyAuth
|
|||
from litellm.types.utils import ImageResponse, TranscriptionResponse
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ("request-id", "x-request-id", "llm_provider-request-id"))
|
||||
def test_native_request_id_survives_stream_header_processing(header: str) -> None:
|
||||
processed: Final = process_response_headers(httpx.Headers({header: "req_native"}))
|
||||
|
||||
assert processed["request-id"] == "req_native"
|
||||
assert processed[header if header.startswith("llm_provider-") else "llm_provider-" + header] == "req_native"
|
||||
|
||||
|
||||
class TestBudgetReservationBinding:
|
||||
"""The request-end release skips a reservation a cost callback has claimed, so the claim
|
||||
must land on the one dict auth stamped, through whichever metadata field or auth object
|
||||
|
|
|
|||
|
|
@ -895,6 +895,7 @@ async def test_async_anthropic_messages_handler_streaming_forwards_provider_resp
|
|||
additional_headers = result._hidden_params["additional_headers"]
|
||||
assert additional_headers["llm_provider-x-amzn-requestid"] == "amzn-req-123"
|
||||
assert additional_headers["llm_provider-x-amzn-trace-id"] == "Root=1-abc-def"
|
||||
assert mock_logging_obj.model_call_details["response_headers"] == dict(upstream_response.headers)
|
||||
|
||||
collected = b"".join([chunk async for chunk in result])
|
||||
assert b"message_start" in collected
|
||||
|
|
@ -1601,8 +1602,7 @@ async def test_async_anthropic_messages_handler_forwards_request_timeout(monkeyp
|
|||
expected_response = {"id": "msg_1", "content": []}
|
||||
mock_config.transform_anthropic_messages_response = Mock(return_value=expected_response)
|
||||
|
||||
ok_response = Mock()
|
||||
ok_response.raise_for_status = Mock(return_value=None)
|
||||
ok_response = httpx.Response(200, request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"))
|
||||
mock_client = AsyncMock(spec=AsyncHTTPHandler)
|
||||
mock_client.post = AsyncMock(return_value=ok_response)
|
||||
|
||||
|
|
@ -2072,8 +2072,7 @@ async def test_async_anthropic_messages_handler_passes_api_key_to_agentic_hooks(
|
|||
captured_kwargs.update(call_kwargs)
|
||||
return sentinel_response
|
||||
|
||||
mock_httpx_response = Mock()
|
||||
mock_httpx_response.status_code = 200
|
||||
mock_httpx_response = httpx.Response(200, request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
|
|
|||
|
|
@ -161,4 +161,4 @@ def test_stream_hidden_params_projects_upstream_headers_the_way_the_python_handl
|
|||
assert isinstance(additional, dict)
|
||||
assert additional["llm_provider-request-id"] == "req_upstream_123"
|
||||
assert additional["x-ratelimit-remaining-requests"] == "41"
|
||||
assert "request-id" not in additional
|
||||
assert additional["request-id"] == additional["llm_provider-request-id"]
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ def _supplied_logger() -> Logging:
|
|||
)
|
||||
|
||||
|
||||
def test_native_stream_headers_reach_spend_callbacks() -> None:
|
||||
logger: Final = _supplied_logger()
|
||||
legacy.stream_opened(logger, {"additional_headers": {"llm_provider-request-id": "req_native"}})
|
||||
|
||||
assert logger.stream is True
|
||||
assert logger.model_call_details["response_headers"] == {"llm_provider-request-id": "req_native"}
|
||||
|
||||
|
||||
def test_setup_reuses_a_supplied_logger() -> None:
|
||||
supplied: Final = _supplied_logger()
|
||||
result: Final = setup(
|
||||
|
|
@ -159,9 +167,7 @@ def test_failure_handler_of_an_internal_call_leaves_the_outer_budget_reservation
|
|||
pending.close()
|
||||
|
||||
|
||||
CONTRACT_PATH: Final = (
|
||||
Path(__file__).parents[3] / "litellm-rust/crates/callbacks-legacy-python/python_contract.json"
|
||||
)
|
||||
CONTRACT_PATH: Final = Path(__file__).parents[3] / "litellm-rust/crates/callbacks-legacy-python/python_contract.json"
|
||||
|
||||
|
||||
def test_the_rust_contract_matches_the_shim_signatures() -> None:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ function makeTrace(scene: Scenario, index: number, now: number) {
|
|||
parent_span_id: null,
|
||||
span_id: spanId(0),
|
||||
spend: null,
|
||||
spend_log_request_id: null,
|
||||
spend_match: null,
|
||||
start_offset_ms: 0,
|
||||
status: "ok",
|
||||
type: "agent",
|
||||
|
|
@ -53,6 +55,7 @@ function makeTrace(scene: Scenario, index: number, now: number) {
|
|||
input_tokens: 520 + index * 41,
|
||||
output_tokens: 48 + index * 7,
|
||||
spend: 0.003 + index * 0.0002,
|
||||
spend_match: "matched",
|
||||
};
|
||||
const trace: Trace = {
|
||||
summary: {
|
||||
|
|
@ -71,6 +74,7 @@ function makeTrace(scene: Scenario, index: number, now: number) {
|
|||
service: "demo-agents",
|
||||
span_count: toolCount + 2,
|
||||
spend: model.spend,
|
||||
priced_calls: 1,
|
||||
start_time: iso(now - (index + 1) * 35 * 60_000),
|
||||
status: scene.failed ? "error" : "ok",
|
||||
tool_calls: toolCount,
|
||||
|
|
@ -85,6 +89,7 @@ function makeTrace(scene: Scenario, index: number, now: number) {
|
|||
llm_calls: 1,
|
||||
tool_calls: toolCount,
|
||||
spend: model.spend,
|
||||
priced_calls: 1,
|
||||
},
|
||||
],
|
||||
spans: [base, ...tools, model],
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export function withReleaseCases(run: { trace: Trace; details: SpanDetail[] }) {
|
|||
input_tokens: models.reduce((sum, span) => sum + span.input_tokens, 0),
|
||||
output_tokens: models.reduce((sum, span) => sum + span.output_tokens, 0),
|
||||
spend: models.reduce((sum, span) => sum + (span.spend ?? 0), 0),
|
||||
priced_calls: models.length,
|
||||
};
|
||||
const finalDetail = run.details.at(-1)!;
|
||||
const history = caseDetails.filter((_, index) => index % 3 === 2);
|
||||
|
|
@ -102,6 +103,7 @@ export function withReleaseCases(run: { trace: Trace; details: SpanDetail[] }) {
|
|||
llm_calls: caseCount,
|
||||
tool_calls: caseCount,
|
||||
spend: summary.spend - (final.spend ?? 0),
|
||||
priced_calls: caseCount,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { cn } from "@/lib/cva.config";
|
|||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
import type { TraceHandoff } from "../../api";
|
||||
import { runCost } from "../../list/AgentTracesTable";
|
||||
import { traceRefOf, traceShareUrl } from "../../routing";
|
||||
import { formatCost } from "../../list/AgentTracesTable";
|
||||
import { IdChip } from "../../ui/IdChip";
|
||||
import { SpanIcon } from "../../ui/SpanIcon";
|
||||
import { FrameworkLogo, traceFramework } from "../../ui/TraceFramework";
|
||||
|
|
@ -89,6 +89,7 @@ interface RunHeaderProps {
|
|||
export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps) {
|
||||
const { summary } = trace;
|
||||
const failed = summary.status === "error";
|
||||
const cost = runCost(summary);
|
||||
return (
|
||||
<header className="@container/run-header flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
|
||||
<div className="flex min-w-0 flex-col gap-2 @xl/run-header:flex-row @xl/run-header:items-center">
|
||||
|
|
@ -127,7 +128,10 @@ export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps)
|
|||
<Stat label="Duration" value={fmtMs(summary.duration_ms)} />
|
||||
<Stat label="Steps" value={summary.span_count.toLocaleString()} />
|
||||
<Stat label="Tokens" value={fmtTok(summary.input_tokens + summary.output_tokens)} />
|
||||
<Stat label="Cost" value={summary.spend == null ? "Not reported" : formatCost(summary.spend)} />
|
||||
<Stat
|
||||
label="Cost"
|
||||
value={cost ? [cost.label, cost.partial?.long].filter(Boolean).join(" · ") : "Not reported"}
|
||||
/>
|
||||
{summary.error_count > 0 && <Stat label="Step errors" value={summary.error_count.toLocaleString()} error />}
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,31 @@ import type { Span, SpanDetail, SpanErrorPage, Trace } from "../../types";
|
|||
vi.mock("../../../../networking", () => ({
|
||||
agentTraceSpanCall: vi.fn(),
|
||||
agentTraceSpanErrorCall: vi.fn(),
|
||||
uiSpendLogsCall: vi.fn(),
|
||||
getProxyBaseUrl: () => "http://proxy.test/",
|
||||
}));
|
||||
|
||||
import { agentTraceSpanCall, agentTraceSpanErrorCall } from "../../../../networking";
|
||||
vi.mock("../../../../logs/detail", () => ({
|
||||
LogDetailsDrawer: ({
|
||||
open,
|
||||
logEntry,
|
||||
onClose,
|
||||
backTo,
|
||||
}: {
|
||||
open: boolean;
|
||||
logEntry: { request_id: string } | null;
|
||||
onClose: () => void;
|
||||
backTo?: { label: string };
|
||||
}) =>
|
||||
open && logEntry ? (
|
||||
<div role="dialog" aria-label="Request log">
|
||||
{logEntry.request_id}
|
||||
{backTo && <button onClick={onClose}>Back to {backTo.label}</button>}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { agentTraceSpanCall, agentTraceSpanErrorCall, uiSpendLogsCall } from "../../../../networking";
|
||||
|
||||
type SpanFields = Partial<Span> & Pick<Span, "span_id">;
|
||||
|
||||
|
|
@ -37,6 +58,8 @@ const span = (overrides: SpanFields): Span => ({
|
|||
output_tokens: 0,
|
||||
litellm_request_id: null,
|
||||
spend: null,
|
||||
spend_log_request_id: null,
|
||||
spend_match: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
@ -49,6 +72,9 @@ const llmFields: SpanFields = {
|
|||
input_tokens: 659,
|
||||
output_tokens: 60,
|
||||
litellm_request_id: "chatcmpl-abc",
|
||||
spend: 0.0002,
|
||||
spend_log_request_id: "req_7f3a9c2e1b44",
|
||||
spend_match: "matched",
|
||||
};
|
||||
const failedToolFields: SpanFields = {
|
||||
span_id: "tool1",
|
||||
|
|
@ -244,13 +270,47 @@ describe("DetailPane", () => {
|
|||
expect(input).toHaveTextContent("acme-404");
|
||||
});
|
||||
|
||||
it("shows the LiteLLM request facts on the Request tab", async () => {
|
||||
it("links a priced LLM step to its spend log in the header and opens it by that request id", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({ data: [{ request_id: "req_7f3a9c2e1b44" }] });
|
||||
renderPane(spanRow(llm));
|
||||
const link = screen.getByRole("button", { name: "Open LiteLLM spend log req_7f3a9c2e1b44" });
|
||||
expect(link).toHaveTextContent("LiteLLM Spend Log$0.0002");
|
||||
await user.click(link);
|
||||
expect(await screen.findByRole("dialog", { name: "Request log" })).toHaveTextContent("req_7f3a9c2e1b44");
|
||||
expect(vi.mocked(uiSpendLogsCall).mock.calls[0][0].params).toEqual({ request_id: "req_7f3a9c2e1b44" });
|
||||
});
|
||||
|
||||
it("returns from the spend log to the same agent trace step", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({ data: [{ request_id: "req_7f3a9c2e1b44" }] });
|
||||
renderPane(spanRow(llm));
|
||||
await user.click(screen.getByRole("button", { name: "Open LiteLLM spend log req_7f3a9c2e1b44" }));
|
||||
const drawer = await screen.findByRole("dialog", { name: "Request log" });
|
||||
await user.click(within(drawer).getByRole("button", { name: "Back to Lens trace" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Request log" })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "Open LiteLLM spend log req_7f3a9c2e1b44" })).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ spend_match: "no_call_id" as const, reason: "This step records no usable call identifier or transport context" },
|
||||
{ spend_match: "no_spend_log" as const, reason: "No matching spend log belongs to this run" },
|
||||
{ spend_match: "incomplete_evidence" as const, reason: "This step does not account for every request in the call" },
|
||||
])("says why an unpriced LLM step has no cost ($spend_match)", async ({ spend_match, reason }) => {
|
||||
const user = userEvent.setup();
|
||||
const unpriced: SpanFields = { ...llmFields, spend: null, spend_log_request_id: null, spend_match };
|
||||
renderPane(spanRow(span(unpriced)));
|
||||
expect(screen.getByTitle(reason)).toHaveTextContent("Cost not matched");
|
||||
expect(screen.queryByRole("button", { name: /Open LiteLLM spend log/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Request" }));
|
||||
expect(await screen.findByText("chatcmpl-abc")).toBeInTheDocument();
|
||||
expect(screen.getByText("659")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Open request log/ })).toBeInTheDocument();
|
||||
expect(await screen.findByText(reason, { selector: "p" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Cost fact for a priced step that has no single spend log to link", () => {
|
||||
const pricedFromRetries: SpanFields = { ...llmFields, spend: 0.0005, spend_log_request_id: null };
|
||||
renderPane(spanRow(span(pricedFromRetries)));
|
||||
expect(screen.queryByRole("button", { name: /Open LiteLLM spend log/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Cost").parentElement).toHaveTextContent("Cost$0.0005");
|
||||
});
|
||||
|
||||
it("summarizes a ×N group with its failure pattern", () => {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ interface PaneHeaderProps {
|
|||
idValue?: string;
|
||||
facts: readonly (readonly [label: string, value: string])[];
|
||||
actions?: React.ReactNode;
|
||||
links?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Selected step identity: tile, name, id, then its time and usage on one quiet line. */
|
||||
export function PaneHeader({ type, model, failed, title, idValue, facts, actions, onClose }: PaneHeaderProps) {
|
||||
export function PaneHeader({ type, model, failed, title, idValue, facts, actions, links, onClose }: PaneHeaderProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-1.5 px-4 pt-3 pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
|
@ -40,15 +41,18 @@ export function PaneHeader({ type, model, failed, title, idValue, facts, actions
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{facts.length > 0 && (
|
||||
<dl className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-8 text-xs text-muted-foreground tabular-nums">
|
||||
{facts.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<dt>{label}</dt>
|
||||
<dd className="font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{(facts.length > 0 || links) && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-8">
|
||||
<dl className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground tabular-nums">
|
||||
{facts.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center gap-1">
|
||||
<dt>{label}</dt>
|
||||
<dd className="font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{links}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { LogDetailsDrawer } from "../../../../logs/detail";
|
||||
import { fieldEntries } from "../content/payload";
|
||||
import { formatCost } from "../../list/AgentTracesTable";
|
||||
import type { Span } from "../../types";
|
||||
import { fmtMs, fmtTok } from "../../utils";
|
||||
import { useSpanRequestLog } from "../useSpanRequestLog";
|
||||
import { FieldTree } from "../content/FieldTree";
|
||||
import { DetailGroup } from "./DetailGroup";
|
||||
import { SpendLogLink, unmatchedReason } from "./SpendLogLink";
|
||||
|
||||
interface RequestTabProps {
|
||||
span: Span;
|
||||
|
|
@ -22,17 +17,7 @@ interface RequestTabProps {
|
|||
traceStartMs: number;
|
||||
}
|
||||
|
||||
/** Usage and the linked LiteLLM request for an LLM span; "Open request log" opens the request drawer over the run. */
|
||||
export function RequestTab({ span, accessToken, traceStartMs }: RequestTabProps) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const logQuery = useSpanRequestLog(
|
||||
accessToken,
|
||||
span.litellm_request_id,
|
||||
traceStartMs + span.start_offset_ms,
|
||||
drawerOpen,
|
||||
);
|
||||
const logNotFound = drawerOpen && logQuery.isSuccess && logQuery.data === null;
|
||||
|
||||
if (span.type !== "llm") {
|
||||
return <div className="py-16 text-center text-sm text-muted-foreground">This span is not a model request.</div>;
|
||||
}
|
||||
|
|
@ -51,30 +36,22 @@ export function RequestTab({ span, accessToken, traceStartMs }: RequestTabProps)
|
|||
<DetailGroup title="Usage">
|
||||
<FieldTree entries={usage} />
|
||||
</DetailGroup>
|
||||
<DetailGroup title="LiteLLM request">
|
||||
{span.litellm_request_id ? (
|
||||
<div className="flex flex-col gap-2 py-1.5">
|
||||
<DetailGroup title="Spend log">
|
||||
<div className="flex flex-col gap-2 py-1.5">
|
||||
{span.spend_log_request_id && (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<FieldTree entries={fieldEntries([["request_id", span.litellm_request_id]])} mono />
|
||||
<FieldTree entries={fieldEntries([["request_id", span.spend_log_request_id]])} mono />
|
||||
</div>
|
||||
<CopyButton variant="action" value={span.litellm_request_id} label="Copy request ID" iconOnly />
|
||||
<CopyButton variant="action" value={span.spend_log_request_id} label="Copy request ID" iconOnly />
|
||||
</div>
|
||||
<Button variant="outline" size="xs" className="self-start" onClick={() => setDrawerOpen(true)}>
|
||||
Open request log <ArrowUpRight className="size-3.5" />
|
||||
</Button>
|
||||
{logNotFound && <p className="text-sm text-muted-foreground">No request log found for this call.</p>}
|
||||
)}
|
||||
<div className="self-start">
|
||||
<SpendLogLink span={span} accessToken={accessToken} traceStartMs={traceStartMs} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-1.5 text-sm text-muted-foreground">Not linked to a LiteLLM request</p>
|
||||
)}
|
||||
{unmatchedReason(span) && <p className="text-sm text-muted-foreground">{unmatchedReason(span)}</p>}
|
||||
</div>
|
||||
</DetailGroup>
|
||||
<LogDetailsDrawer
|
||||
open={drawerOpen && Boolean(logQuery.data)}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
logEntry={logQuery.data ?? null}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ContentTab, useSpanDetail } from "../content/ContentTab";
|
|||
import { AttributesTab } from "./AttributesTab";
|
||||
import { PaneHeader } from "./PaneHeader";
|
||||
import { RequestTab } from "./RequestTab";
|
||||
import { SpendLogLink } from "./SpendLogLink";
|
||||
|
||||
const TAB_LABELS: Record<SpanTab, string> = { content: "Content", request: "Request", attributes: "Attributes" };
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ const spanFacts = (span: Span): readonly Fact[] => {
|
|||
const tokens = span.input_tokens + span.output_tokens;
|
||||
const optional: readonly (Fact | null)[] = [
|
||||
tokens > 0 ? ["Tokens", fmtTok(tokens)] : null,
|
||||
span.spend != null ? ["Cost", formatCost(span.spend)] : null,
|
||||
span.spend != null && span.spend_log_request_id == null ? ["Cost", formatCost(span.spend)] : null,
|
||||
span.type === "llm" && span.model ? ["Step", span.name] : null,
|
||||
];
|
||||
return [["Duration", fmtMs(span.duration_ms)], ...optional.filter((fact): fact is Fact => fact !== null)];
|
||||
|
|
@ -42,6 +43,7 @@ export function SpanPane({
|
|||
}: SpanTabProps & { trace: Trace; span: Span; accessToken: string; onClose: () => void }) {
|
||||
const { trace_id: traceId, trace_ref: traceRef, start_time: startTime } = trace.summary;
|
||||
const handoff = useTracesApi(accessToken).handoff(traceId, span.span_id, traceRef);
|
||||
const traceStartMs = Date.parse(startTime);
|
||||
const detailQuery = useSpanDetail(accessToken, traceId, tab === "attributes" ? span.span_id : null, traceRef);
|
||||
return (
|
||||
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
|
||||
|
|
@ -52,6 +54,7 @@ export function SpanPane({
|
|||
title={span.type === "llm" ? span.model || span.name : span.name}
|
||||
idValue={span.span_id}
|
||||
facts={spanFacts(span)}
|
||||
links={<SpendLogLink span={span} accessToken={accessToken} traceStartMs={traceStartMs} />}
|
||||
actions={<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
|
@ -69,7 +72,7 @@ export function SpanPane({
|
|||
<ContentTab accessToken={accessToken} traceId={traceId} traceRef={traceRef} span={span} />
|
||||
</TabsContent>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
<RequestTab span={span} accessToken={accessToken} traceStartMs={Date.parse(startTime)} />
|
||||
<RequestTab span={span} accessToken={accessToken} traceStartMs={traceStartMs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="attributes" className="min-h-0 overflow-auto">
|
||||
<AttributesTab
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { Aperture, ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import litellmMonogram from "../../../../../../public/assets/logos/litellm_monogram.svg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { LogDetailsDrawer } from "../../../../logs/detail";
|
||||
import { formatCost } from "../../list/AgentTracesTable";
|
||||
import type { Span } from "../../types";
|
||||
import { useSpanRequestLog } from "../useSpanRequestLog";
|
||||
|
||||
const UNMATCHED_REASON: Record<Exclude<NonNullable<Span["spend_match"]>, "matched">, string> = {
|
||||
no_call_id: "This step records no usable call identifier or transport context",
|
||||
no_spend_log: "No matching spend log belongs to this run",
|
||||
incomplete_evidence: "This step does not account for every request in the call",
|
||||
ambiguous: "Recorded identifiers do not select one compatible spend log",
|
||||
};
|
||||
|
||||
const LENS_TRACE = { label: "Lens trace", icon: <Aperture aria-hidden /> };
|
||||
|
||||
export const unmatchedReason = (span: Span): string | null =>
|
||||
span.spend_match && span.spend_match !== "matched" ? UNMATCHED_REASON[span.spend_match] : null;
|
||||
|
||||
export function SpendLogLink({
|
||||
span,
|
||||
accessToken,
|
||||
traceStartMs,
|
||||
}: {
|
||||
span: Span;
|
||||
accessToken: string;
|
||||
traceStartMs: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const requestId = span.spend_log_request_id ?? null;
|
||||
const logQuery = useSpanRequestLog(accessToken, requestId, traceStartMs + span.start_offset_ms, open);
|
||||
const reason = unmatchedReason(span);
|
||||
|
||||
if (requestId == null) {
|
||||
if (!reason) return null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground" title={reason}>
|
||||
Cost <span className="font-medium text-foreground">not matched</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const missing = open && logQuery.isSuccess && logQuery.data === null;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setOpen(true)}
|
||||
title={requestId}
|
||||
aria-label={`Open LiteLLM spend log ${requestId}`}
|
||||
className="tabular-nums active:scale-[0.97] motion-reduce:transition-none"
|
||||
>
|
||||
<img src={litellmMonogram.src} alt="" aria-hidden className="size-3.5" />
|
||||
<span className="font-medium">LiteLLM Spend Log</span>
|
||||
{span.spend != null && <span className="text-muted-foreground">{formatCost(span.spend)}</span>}
|
||||
<ArrowUpRight data-icon="inline-end" />
|
||||
</Button>
|
||||
{missing && <span className="text-xs text-muted-foreground">Spend log not visible in this time range</span>}
|
||||
<LogDetailsDrawer
|
||||
open={open && Boolean(logQuery.data)}
|
||||
onClose={() => setOpen(false)}
|
||||
logEntry={logQuery.data ?? null}
|
||||
accessToken={accessToken}
|
||||
backTo={LENS_TRACE}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { MoreHorizontal } from "lucide-react";
|
|||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { formatCost } from "../../list/AgentTracesTable";
|
||||
import { FoldChevron } from "../../ui/Collapse";
|
||||
import { FAMILY_BAR, SPAN_FAMILY, SpanIcon } from "../../ui/SpanIcon";
|
||||
import type { GroupRowData, LoadMoreRowData, SpanRowData } from "../../tree";
|
||||
|
|
@ -177,6 +178,11 @@ export function SpanRow({ row, ctx }: { row: SpanRowData; ctx: RowContext }) {
|
|||
</span>
|
||||
)}
|
||||
{ctx.layout === "tree" && <span className={META}>{fmtMs(span.duration_ms)}</span>}
|
||||
{ctx.layout === "tree" && span.spend_match != null && (
|
||||
<span className={META} data-testid="step-cost">
|
||||
{span.spend_match === "matched" && span.spend != null ? formatCost(span.spend) : "—"}
|
||||
</span>
|
||||
)}
|
||||
{hint && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground" title={hint}>
|
||||
{hint}
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ describe("AgentTracesSection", () => {
|
|||
it("shows the spend returned for a run", async () => {
|
||||
vi.mocked(agentTraceListCall).mockResolvedValue({
|
||||
...(traceList as TracePage),
|
||||
data: [{ ...runs[0], spend: 0.025 }],
|
||||
data: [{ ...runs[0], spend: 0.025, priced_calls: runs[0].llm_calls }],
|
||||
});
|
||||
renderSection();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import { Inspector } from "@/components/shared/Inspector";
|
||||
import traceList from "../__fixtures__/trace_list.json";
|
||||
import { AgentTracesTable } from "./AgentTracesTable";
|
||||
import { AgentTracesTable, runCost } from "./AgentTracesTable";
|
||||
import { traceKey } from "../routing";
|
||||
import type { TracePage, TraceSummary } from "../types";
|
||||
|
||||
|
|
@ -113,6 +113,58 @@ describe("AgentTracesTable virtualization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runCost", () => {
|
||||
it.each([
|
||||
{ spend: 0.42, priced_calls: 20, llm_calls: 20, expected: { label: "$0.42", partial: null } },
|
||||
{
|
||||
spend: 0.38,
|
||||
priced_calls: 18,
|
||||
llm_calls: 20,
|
||||
expected: { label: "≥ $0.38", partial: { short: "18/20 priced", long: "18 of 20 calls priced" } },
|
||||
},
|
||||
{ spend: null, priced_calls: 0, llm_calls: 20, expected: null },
|
||||
{ spend: 0, priced_calls: 0, llm_calls: 0, expected: null },
|
||||
])("prices $priced_calls of $llm_calls calls", ({ expected, ...summary }) => {
|
||||
expect(runCost(summary)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable cost cell", () => {
|
||||
const template = (traceList as TracePage).data[0] as TraceSummary;
|
||||
const costCell = (run: Partial<TraceSummary>) => {
|
||||
renderWithProviders(
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={[{ ...template, llm_calls: 20, ...run }]}
|
||||
findings={new Map()}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const costColumn = screen.getAllByRole("columnheader").findIndex((header) => header.textContent === "Cost");
|
||||
return within(screen.getByTestId("agent-trace-row")).getAllByRole("cell")[costColumn];
|
||||
};
|
||||
|
||||
it("shows an exact cost when every call is priced", () => {
|
||||
expect(costCell({ spend: 0.42, priced_calls: 20 })).toHaveTextContent("$0.42");
|
||||
});
|
||||
|
||||
it("marks a partial cost as a lower bound and says how many calls were priced", () => {
|
||||
const cell = costCell({ spend: 0.38, priced_calls: 18 });
|
||||
expect(cell).toHaveTextContent("≥ $0.38");
|
||||
expect(cell).toHaveTextContent("18/20 priced");
|
||||
expect(within(cell).getByTitle("18 of 20 calls priced")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a dash when no call is priced", () => {
|
||||
expect(costCell({ spend: null, priced_calls: 0 })).toHaveTextContent("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable column picker", () => {
|
||||
const runs = (traceList as TracePage).data as TraceSummary[];
|
||||
const renderRuns = () =>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,33 @@ export const formatCost = (cost: number): string => {
|
|||
return `$${cost.toFixed(2)}`;
|
||||
};
|
||||
|
||||
type RunCost = { label: string; partial: { short: string; long: string } | null };
|
||||
|
||||
export const runCost = ({
|
||||
spend,
|
||||
priced_calls,
|
||||
llm_calls,
|
||||
}: Pick<TraceSummary, "spend" | "priced_calls" | "llm_calls">): RunCost | null => {
|
||||
if (spend == null || priced_calls === 0) return null;
|
||||
if (priced_calls >= llm_calls) return { label: formatCost(spend), partial: null };
|
||||
return {
|
||||
label: `≥ ${formatCost(spend)}`,
|
||||
partial: { short: `${priced_calls}/${llm_calls} priced`, long: `${priced_calls} of ${llm_calls} calls priced` },
|
||||
};
|
||||
};
|
||||
|
||||
function CostCell({ run }: { run: TraceSummary }) {
|
||||
const cost = runCost(run);
|
||||
if (!cost) return "—";
|
||||
if (!cost.partial) return cost.label;
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5" title={cost.partial.long}>
|
||||
{cost.label}
|
||||
<span className="text-xs text-muted-foreground">{cost.partial.short}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const firstLine = (text: string): string => text.split("\n")[0] ?? text;
|
||||
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
|
||||
|
||||
|
|
@ -158,7 +185,7 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
id: "cost",
|
||||
size: 80,
|
||||
header: "Cost",
|
||||
cell: ({ row }) => (row.original.spend == null ? "—" : formatCost(row.original.spend)),
|
||||
cell: ({ row }) => <CostCell run={row.original} />,
|
||||
meta: { numeric: true, className: NUM },
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const run = (overrides: Partial<TraceSummary>): TraceSummary => ({
|
|||
service: "svc",
|
||||
span_count: 1,
|
||||
spend: null,
|
||||
priced_calls: 0,
|
||||
start_time: "2026-10-01T00:00:00Z",
|
||||
status: "ok",
|
||||
tool_calls: 0,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "../../../../tests/test-utils";
|
||||
|
|
@ -82,3 +84,37 @@ describe("DrawerHeader sidebar toggle", () => {
|
|||
expect(within(row).getByText("170d64ea-69f0-431a-be72-332f8f78c18a")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DrawerHeader back link", () => {
|
||||
const renderWithBack = (backTo?: { label: string; icon: ReactNode }) => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<DrawerHeader
|
||||
log={logEntry({})}
|
||||
onClose={onClose}
|
||||
onPrevious={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
isSidebarCollapsed={false}
|
||||
onToggleSidebar={vi.fn()}
|
||||
statusLabel="Success"
|
||||
statusColor="success"
|
||||
environment="default"
|
||||
backTo={backTo}
|
||||
/>,
|
||||
);
|
||||
return onClose;
|
||||
};
|
||||
|
||||
it("names where the drawer was opened from and closes back to it", async () => {
|
||||
const onClose = renderWithBack({ label: "Lens trace", icon: <svg data-testid="lens-icon" /> });
|
||||
const back = screen.getByRole("button", { name: "Back to Lens trace" });
|
||||
expect(within(back).getByTestId("lens-icon")).toBeInTheDocument();
|
||||
await userEvent.click(back);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows no back link when opened from the logs page", () => {
|
||||
renderWithBack();
|
||||
expect(screen.queryByRole("button", { name: /^Back to/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -29,6 +29,12 @@ interface DrawerHeaderProps {
|
|||
environment: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
backTo?: DrawerBackTo;
|
||||
}
|
||||
|
||||
export interface DrawerBackTo {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -45,6 +51,7 @@ export function DrawerHeader({
|
|||
environment,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
backTo,
|
||||
}: DrawerHeaderProps) {
|
||||
const provider = log.custom_llm_provider || "";
|
||||
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
|
||||
|
|
@ -64,6 +71,13 @@ export function DrawerHeader({
|
|||
top: 0,
|
||||
}}
|
||||
>
|
||||
{backTo && (
|
||||
<Button variant="outline" size="sm" onClick={onClose} className="mb-3 max-w-full">
|
||||
<ArrowLeft data-icon="inline-start" />
|
||||
{backTo.icon}
|
||||
<span className="truncate">Back to {backTo.label}</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Row 0: Model + Provider with Logo */}
|
||||
<div className="flex items-center gap-2" style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
{showToggleWithProvider && <SidebarToggle isCollapsed onToggle={onToggleSidebar} />}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/t
|
|||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
|
||||
import { getEventDisplayName } from "./eventDisplayName";
|
||||
import { ClassifyTag } from "./ClassifyTag";
|
||||
import { DrawerHeader } from "./DrawerHeader";
|
||||
import { type DrawerBackTo, DrawerHeader } from "./DrawerHeader";
|
||||
import { SidebarToggle } from "./SidebarToggle";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent";
|
||||
|
|
@ -28,6 +28,7 @@ export interface LogDetailsDrawerProps {
|
|||
allLogs?: LogEntry[];
|
||||
onSelectLog?: (log: LogEntry) => void;
|
||||
startTime?: string;
|
||||
backTo?: DrawerBackTo;
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH_PX = 224;
|
||||
|
|
@ -119,6 +120,7 @@ export function LogDetailsDrawer({
|
|||
allLogs = [],
|
||||
onSelectLog,
|
||||
startTime,
|
||||
backTo,
|
||||
}: LogDetailsDrawerProps) {
|
||||
const isSessionMode = Boolean(sessionId);
|
||||
const [selectedSessionRequestId, setSelectedSessionRequestId] = useState<string | null>(null);
|
||||
|
|
@ -461,6 +463,7 @@ export function LogDetailsDrawer({
|
|||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
backTo={backTo}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25819,6 +25819,8 @@ export interface components {
|
|||
name: string;
|
||||
/** Parent Agent */
|
||||
parent_agent: string | null;
|
||||
/** Priced Calls */
|
||||
priced_calls: number;
|
||||
/** Spend */
|
||||
spend: number | null;
|
||||
/** Tool Calls */
|
||||
|
|
@ -45265,6 +45267,10 @@ export interface components {
|
|||
span_id: string;
|
||||
/** Spend */
|
||||
spend: number | null;
|
||||
/** Spend Log Request Id */
|
||||
spend_log_request_id: string | null;
|
||||
/** Spend Match */
|
||||
spend_match: ("matched" | "no_call_id" | "no_spend_log" | "ambiguous" | "incomplete_evidence") | null;
|
||||
/** Start Offset Ms */
|
||||
start_offset_ms: number;
|
||||
/**
|
||||
|
|
@ -47414,6 +47420,8 @@ export interface components {
|
|||
name: string;
|
||||
/** Output Tokens */
|
||||
output_tokens: number;
|
||||
/** Priced Calls */
|
||||
priced_calls: number;
|
||||
/** Resolution Limited */
|
||||
resolution_limited?: boolean;
|
||||
/** Service */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue