Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ members = [
"crates/switchyard-runner",
"crates/switchyard-desktop",
"crates/switchyard-menubar",
"crates/switchyard-costs",
"crates/switchyard-server",
"crates/switchyard-skill-distillation",
"crates/switchyard-soak",
Expand Down Expand Up @@ -49,6 +50,7 @@ regex = "1"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
switchyard-costs = { path = "crates/switchyard-costs", version = "0.3.0" }
switchyard-libsy = { path = "crates/libsy", version = "0.3.0" }
switchyard-llm-client = { path = "crates/libsy-llm-client", version = "0.3.0" }
switchyard-protocol = { path = "crates/protocol", version = "0.3.0" }
Expand Down
10 changes: 9 additions & 1 deletion crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ impl TranslatingLlmClient {
})?;
// Map a provider's failed generation to 502, even under HTTP 200.
// Redact forwarded credentials before returning the provider error.
let agg =
let mut agg =
observed_translation::decode_response(&body, wire_format).map_err(|error| {
match error {
TranslationError::UpstreamFailure { error } => {
Expand All @@ -676,6 +676,14 @@ impl TranslatingLlmClient {
error => LlmClientError::ResponseTranslation(error.to_string()),
}
})?;
// The gateway may report a buffered charge only in its response header.
if agg.usage.provider_cost.is_none() {
agg.usage.provider_cost = upstream_headers
.get("x-litellm-response-cost")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<f64>().ok())
.filter(|usd| usd.is_finite() && *usd >= 0.0);
}
(LlmResponse::Agg(agg), upstream_headers)
}
};
Expand Down
18 changes: 12 additions & 6 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use switchyard_libsy::{
use switchyard_protocol::{
AggLlmResponse, LlmClientError, LlmResponse, LlmResponseChunk, LlmResponseStream, Message,
ModelId, Request, Response, ResponseAccumulator, RoutedDecisionClient, RoutedLlmClient,
RoutingFallbackReason, WireFormat,
RoutingFallbackReason, Usage, WireFormat,
};
use switchyard_translation::prepare_request_for_target;

Expand Down Expand Up @@ -376,10 +376,13 @@ async fn call_one(
// the provider's, so it belongs in the routing overhead.
let client = clients.route(model_id);
let started = Instant::now();
let mut routing_usage = None;
let result = async {
let mut response = client?.call(request).await?;
if is_routing && let LlmResponse::Stream(chunks) = response.llm_response {
response.llm_response = LlmResponse::Stream(buffer_routing_stream(chunks).await?);
let (chunks, usage) = buffer_routing_stream(chunks).await?;
routing_usage = usage;
response.llm_response = LlmResponse::Stream(chunks);
}
Ok(response)
}
Expand All @@ -405,19 +408,22 @@ async fn call_one(
.as_ref()
.ok()
.and_then(|response| response.llm_response.as_agg())
.map(|response| response.usage.clone()),
.map(|response| response.usage.clone())
.or(routing_usage),
});
result
}

// Preserve signed provider events while checking the complete routing response.
// Routing logs need the final usage, but replay must keep signed provider events intact.
async fn buffer_routing_stream(
mut chunks: LlmResponseStream,
) -> std::result::Result<LlmResponseStream, LlmClientError> {
) -> std::result::Result<(LlmResponseStream, Option<Usage>), LlmClientError> {
let mut events = Vec::new();
let mut usage = None;
while let Some(event) = chunks.try_next().await? {
for chunk in event.normalized() {
match chunk {
LlmResponseChunk::Usage(latest) => usage = Some(latest.clone()),
LlmResponseChunk::DecodeError { message } => {
return Err(LlmClientError::ResponseTranslation(message.clone()));
}
Expand All @@ -432,7 +438,7 @@ async fn buffer_routing_stream(
}
events.push(event);
}
Ok(stream::iter(events.into_iter().map(Ok)).boxed())
Ok((stream::iter(events.into_iter().map(Ok)).boxed(), usage))
}

/// Whether a failed candidate is worth routing around.
Expand Down
1 change: 1 addition & 0 deletions crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,7 @@ async fn successful_run_records_metrics_spans_and_outcome_metadata() -> switchya
total_tokens: Some(25),
reasoning_tokens: Some(2),
cache: Usage::cache_details(Some(3), Some(4)),
provider_cost: None,
},
}) as Arc<dyn RoutedLlmClient>;
let mut request = request_with_metadata("obs-session-1", "obs-corr-1");
Expand Down
9 changes: 8 additions & 1 deletion crates/protocol/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,13 +559,16 @@ pub struct LlmRequest {
/// Normalized token usage counts.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Usage {
/// The upstream provider reports this monetary charge in USD.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_cost: Option<f64>,
/// Non-cached input tokens. Provider codecs normalize aggregate OpenAI
/// input counts by subtracting the cache detail fields.
pub input_tokens: Option<u64>,
/// Cache-read and cache-creation token detail, when reported.
#[serde(flatten)]
pub cache: Option<Box<InputCacheUsage>>,
/// Generated output tokens, excluding reasoning detail when the provider reports it separately.
/// The output count includes reasoning tokens when the provider counts them.
pub output_tokens: Option<u64>,
/// Provider-reported or codec-computed total token count.
///
Expand All @@ -579,6 +582,9 @@ pub struct Usage {
/// Optional cache-token detail kept out of the common, cache-free usage allocation.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct InputCacheUsage {
/// The provider reports these cache-write tokens at the one-hour rate.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_1h_input_tokens: Option<u64>,
/// Input tokens read from a provider cache.
pub cached_input_tokens: Option<u64>,
/// Input tokens written into a provider cache.
Expand All @@ -595,6 +601,7 @@ impl Usage {
return None;
}
Some(Box::new(InputCacheUsage {
cache_creation_1h_input_tokens: None,
cached_input_tokens,
cache_creation_input_tokens,
}))
Expand Down
19 changes: 19 additions & 0 deletions crates/switchyard-costs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[package]
name = "switchyard-costs"
version.workspace = true
description = "Shared token pricing and recorded cost totals for Switchyard"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

[dependencies]
chrono = { version = "0.4", default-features = false, features = ["std"] }
switchyard-protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
toml = "0.9"
27 changes: 27 additions & 0 deletions crates/switchyard-costs/src/anthropic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Anthropic prices cache writes at separate rates for five-minute and one-hour lifetimes.

use switchyard_protocol::Usage;

/// The caller divides this weighted token sum by one million to obtain USD.
pub(super) fn cache_write_cost(
usage: &Usage,
short_rate: f64,
long_rate: Option<f64>,
) -> Option<f64> {
let written = usage.cache_creation_input_tokens().unwrap_or(0);
let one_hour = usage
.cache
.as_ref()
.and_then(|cache| cache.cache_creation_1h_input_tokens)
.unwrap_or(0);
let five_minute = written.checked_sub(one_hour)?;
let long_cost = if one_hour > 0 {
one_hour as f64 * long_rate?
} else {
0.0
};
Some(five_minute as f64 * short_rate + long_cost)
}
Loading
Loading