Skip to content
Open
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
12 changes: 11 additions & 1 deletion crates/rig-core/src/completion/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ where

/// Struct representing the token usage for a completion request.
/// If tokens used are `0`, then the provider failed to supply token usage metrics.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
pub struct Usage {
/// The number of input ("prompt") tokens used in a given request.
pub input_tokens: u64,
Expand All @@ -272,6 +272,9 @@ pub struct Usage {
/// The number of tokens spent on internal reasoning / "thoughts" by reasoning-capable
/// models (e.g. Gemini thinking, Anthropic extended thinking, OpenAI o-series).
pub reasoning_tokens: u64,
/// Provider-reported request cost (USD), if the provider supplies it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}

impl Usage {
Expand All @@ -285,6 +288,7 @@ impl Usage {
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
}
}

Expand All @@ -303,6 +307,8 @@ impl Default for Usage {
}
}

impl Eq for Usage {}

impl Add for Usage {
type Output = Self;

Expand All @@ -316,6 +322,7 @@ impl Add for Usage {
+ other.cache_creation_input_tokens,
tool_use_prompt_tokens: self.tool_use_prompt_tokens + other.tool_use_prompt_tokens,
reasoning_tokens: self.reasoning_tokens + other.reasoning_tokens,
cost: self.cost.or(other.cost),
}
}
}
Expand All @@ -329,6 +336,9 @@ impl AddAssign for Usage {
self.cache_creation_input_tokens += other.cache_creation_input_tokens;
self.tool_use_prompt_tokens += other.tool_use_prompt_tokens;
self.reasoning_tokens += other.reasoning_tokens;
if self.cost.is_none() {
self.cost = other.cost;
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/anthropic/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: response.usage.cache_creation_input_tokens.unwrap_or(0),
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
};

Ok(completion::CompletionResponse {
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/cohere/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
}
})
.unwrap_or_default();
Expand Down
5 changes: 4 additions & 1 deletion crates/rig-core/src/providers/copilot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@ impl TryFrom<ChatCompletionResponse> for completion::CompletionResponse<ChatComp
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
})
.unwrap_or_default();

Expand Down Expand Up @@ -1596,6 +1597,7 @@ impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
data.id,
data.model,
data.usage,
None,
&data.choices,
|choice| CompatibleChoiceData {
finish_reason: if choice.finish_reason == Some(ChatFinishReason::ToolCalls) {
Expand All @@ -1614,9 +1616,10 @@ impl CompatibleStreamProfile for CopilotChatCompatibleProfile {
))
}

fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
fn build_final_response(&self, usage: Self::Usage, cost: Option<f64>) -> Self::FinalResponse {
CopilotStreamingResponse::Chat(openai::completion::streaming::StreamingCompletionResponse {
usage,
cost,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/deepseek.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ impl GetTokenUsage for Usage {
.and_then(|details| details.reasoning_tokens)
.map(u64::from)
.unwrap_or(0),
cost: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/internal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ pub(crate) fn completion_usage(
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub(crate) struct CompatibleChunk<U, D> {
pub(crate) response_model: Option<String>,
pub(crate) choice: Option<CompatibleChoice<D>>,
pub(crate) usage: Option<U>,
pub(crate) cost: Option<f64>,
}

pub(crate) type NormalizedCompatibleChunk<U, D> =
Expand All @@ -128,6 +129,7 @@ pub(crate) fn normalize_first_choice_chunk<U, D, Choice, ToolCall, F>(
response_id: Option<String>,
response_model: Option<String>,
usage: Option<U>,
cost: Option<f64>,
choices: &[Choice],
map_choice: F,
) -> CompatibleChunk<U, D>
Expand All @@ -142,6 +144,7 @@ where
response_model,
choice,
usage,
cost,
}
}

Expand All @@ -162,7 +165,7 @@ pub(crate) trait CompatibleStreamProfile: WasmCompatSend {

fn normalize_chunk(&self, data: &str) -> NormalizedCompatibleChunk<Self::Usage, Self::Detail>;

fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse;
fn build_final_response(&self, usage: Self::Usage, cost: Option<f64>) -> Self::FinalResponse;

fn uses_distinct_tool_call_eviction(&self) -> bool {
false
Expand Down Expand Up @@ -231,6 +234,7 @@ where
let stream = stream! {
let mut tool_calls: HashMap<usize, RawStreamingToolCall> = HashMap::new();
let mut final_usage = None;
let mut final_cost = None;
let mut terminated_with_error = false;

while let Some(event_result) = event_source.next().await {
Expand Down Expand Up @@ -270,6 +274,10 @@ where
final_usage = Some(usage);
}

if let Some(cost) = chunk.cost {
final_cost = Some(cost);
}

let Some(choice) = chunk.choice else {
continue;
};
Expand Down Expand Up @@ -387,7 +395,7 @@ where
let final_usage = final_usage.unwrap_or_default();
record_usage(&span, &final_usage);
yield Ok(RawStreamingChoice::FinalResponse(
profile.build_final_response(final_usage),
profile.build_final_response(final_usage, final_cost),
));
}
.instrument(instrument_span);
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/mira.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
})
.unwrap_or_default();

Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/mistral/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
})
.unwrap_or_default();

Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/ollama.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
},
raw_response,
message_id: None,
Expand Down
88 changes: 85 additions & 3 deletions crates/rig-core/src/providers/openai/completion/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ struct StreamingCompletionChunk<U = Usage> {
model: Option<String>,
choices: Vec<StreamingChoice>,
usage: Option<U>,
/// Some gateways report cost as a string, others as a number.
#[serde(default, deserialize_with = "deserialize_cost")]
cost: Option<f64>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum CostValue {
Number(f64),
String(String),
}

fn deserialize_cost<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match Option::<CostValue>::deserialize(deserializer)? {
Some(CostValue::Number(n)) => Some(n),
Some(CostValue::String(s)) => s.trim().parse().ok(),
None => None,
})
}

/// Final streaming response. `U` is the provider's streaming usage payload
Expand All @@ -118,14 +139,19 @@ struct StreamingCompletionChunk<U = Usage> {
#[derive(Clone, Serialize, Deserialize)]
pub struct StreamingCompletionResponse<U = Usage> {
pub usage: U,
/// Provider-reported request cost (USD), when the provider supplies it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}

impl<U> GetTokenUsage for StreamingCompletionResponse<U>
where
U: GetTokenUsage,
{
fn token_usage(&self) -> crate::completion::Usage {
self.usage.token_usage()
let mut usage = self.usage.token_usage();
usage.cost = self.cost;
usage
}
}

Expand Down Expand Up @@ -271,6 +297,7 @@ where
data.id,
data.model,
data.usage,
data.cost,
&data.choices,
|choice| CompatibleChoiceData {
// `function_call` is the deprecated pre-tools finish reason
Expand All @@ -297,8 +324,8 @@ where
))
}

fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
StreamingCompletionResponse { usage }
fn build_final_response(&self, usage: Self::Usage, cost: Option<f64>) -> Self::FinalResponse {
StreamingCompletionResponse { usage, cost }
}

fn decorate_tool_call(
Expand Down Expand Up @@ -579,6 +606,61 @@ mod tests {
);
}

#[tokio::test]
async fn test_streaming_captures_provider_reported_cost() {
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;

// OpenCode Go emits a final usage chunk followed by a dedicated
// `inference-cost` chunk carrying `cost` as a string.
let client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
"{\"choices\":[{\"delta\":{\"content\":\"Hello\",\"tool_calls\":[]}}],\"usage\":null}",
"{\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}",
"{\"choices\":[],\"cost\":\"0.00006972\"}",
"[DONE]",
]),
};

let req = http::Request::builder()
.method("POST")
.uri("http://localhost/v1/chat/completions")
.body(Vec::new())
.unwrap();

let mut stream = send_compatible_streaming_request(client, req)
.await
.unwrap();

let mut final_response = None;
while let Some(chunk) = stream.next().await {
if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
final_response = Some(res);
break;
}
}

let response = final_response.expect("expected a final streaming response");
assert_eq!(response.cost, Some(0.00006972));
assert_eq!(response.token_usage().cost, Some(0.00006972));
assert_eq!(response.token_usage().input_tokens, 10);
}

#[test]
fn test_streaming_chunk_parses_cost_string_or_number() {
let chunk: StreamingCompletionChunk =
serde_json::from_str(r#"{"choices":[],"cost":"0.00006972"}"#).unwrap();
assert_eq!(chunk.cost, Some(0.00006972));

let chunk: StreamingCompletionChunk =
serde_json::from_str(r#"{"choices":[],"cost":0.001}"#).unwrap();
assert_eq!(chunk.cost, Some(0.001));

let chunk: StreamingCompletionChunk =
serde_json::from_str(r#"{"choices":[],"usage":null}"#).unwrap();
assert_eq!(chunk.cost, None);
}

#[tokio::test]
async fn test_streaming_usage_only_chunk_is_not_ignored() {
use crate::test_utils::MockStreamingClient;
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/openai/embedding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ where
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
},
None if Ext::REQUIRES_USAGE => {
return Err(EmbeddingError::MissingUsage {
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/openai/responses_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ impl GetTokenUsage for ResponsesUsage {
.as_ref()
.map(|details| details.reasoning_tokens)
.unwrap_or(0),
cost: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/openrouter/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ impl GetTokenUsage for Usage {
cache_creation_input_tokens: cache_creation,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/providers/openrouter/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ impl TryFrom<CompletionResponse> for completion::CompletionResponse<CompletionRe
cache_creation_input_tokens: cache_creation,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
}
})
.unwrap_or_default();
Expand Down
2 changes: 2 additions & 0 deletions crates/rig-core/src/providers/voyageai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ where
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
cost: None,
};

let embeddings = response
Expand Down Expand Up @@ -412,6 +413,7 @@ where
cache_creation_input_tokens: 0,
reasoning_tokens: 0,
tool_use_prompt_tokens: 0,
cost: None,
};

let results = response
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/telemetry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,7 @@ mod tests {
cache_creation_input_tokens: 4,
tool_use_prompt_tokens: 12,
reasoning_tokens: 5,
cost: None,
});

// Scoped-subscriber tests must not run concurrently; see
Expand Down
Loading