use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct GenAiSpanInfo {
pub system: Option<String>,
pub model: Option<String>,
pub response_model: Option<String>,
pub operation: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub cache_creation_tokens: Option<u64>,
pub cache_read_tokens: Option<u64>,
pub temperature: Option<f64>,
pub max_tokens: Option<u64>,
pub finish_reasons: Vec<String>,
pub is_genai: bool,
pub response_id: Option<String>,
pub tool_name: Option<String>,
pub tool_call_id: Option<String>,
pub tool_type: Option<String>,
pub top_p: Option<f64>,
pub seed: Option<u64>,
}
impl GenAiSpanInfo {
pub fn from_attributes(attrs: &HashMap<String, String>) -> Self {
let mut info = Self::default();
let has_genai_attrs = attrs.keys().any(|k| k.starts_with("gen_ai."));
if !has_genai_attrs {
return info;
}
info.is_genai = true;
info.system = attrs
.get("gen_ai.provider.name")
.or_else(|| attrs.get("gen_ai.system"))
.cloned();
info.model = attrs.get("gen_ai.request.model").cloned();
info.response_model = attrs.get("gen_ai.response.model").cloned();
info.operation = attrs.get("gen_ai.operation.name").cloned();
info.input_tokens = attrs
.get("gen_ai.usage.input_tokens")
.and_then(|s| s.parse().ok());
info.output_tokens = attrs
.get("gen_ai.usage.output_tokens")
.and_then(|s| s.parse().ok());
info.total_tokens = attrs
.get("gen_ai.usage.total_tokens")
.and_then(|s| s.parse().ok())
.or_else(|| match (info.input_tokens, info.output_tokens) {
(Some(input), Some(output)) => Some(input + output),
_ => None,
});
info.temperature = attrs
.get("gen_ai.request.temperature")
.and_then(|s| s.parse().ok());
info.max_tokens = attrs
.get("gen_ai.request.max_tokens")
.and_then(|s| s.parse().ok());
if let Some(reasons_str) = attrs.get("gen_ai.response.finish_reasons") {
info.finish_reasons = parse_finish_reasons(reasons_str);
}
info.cache_creation_tokens = attrs
.get("gen_ai.usage.cache_creation.input_tokens")
.and_then(|v| v.parse().ok());
info.cache_read_tokens = attrs
.get("gen_ai.usage.cache_read.input_tokens")
.and_then(|v| v.parse().ok());
info.response_id = attrs.get("gen_ai.response.id").cloned();
info.tool_name = attrs.get("gen_ai.tool.name").cloned();
info.tool_call_id = attrs.get("gen_ai.tool.call.id").cloned();
info.tool_type = attrs.get("gen_ai.tool.type").cloned();
info.top_p = attrs
.get("gen_ai.request.top_p")
.and_then(|s| s.parse().ok());
info.seed = attrs
.get("gen_ai.request.seed")
.and_then(|s| s.parse().ok());
info
}
pub fn is_tool_call(&self) -> bool {
self.operation.as_deref() == Some("execute_tool") || self.tool_name.is_some()
}
pub fn format_token_usage(&self) -> Option<String> {
match (self.input_tokens, self.output_tokens, self.total_tokens) {
(Some(input), Some(output), _) => {
let total = input + output;
Some(format!(
"Input: {} | Output: {} | Total: {}",
format_number(input),
format_number(output),
format_number(total)
))
},
(None, None, Some(total)) => Some(format!("Total: {}", format_number(total))),
_ => None,
}
}
pub fn format_token_summary(&self) -> Option<String> {
match (self.input_tokens, self.output_tokens, self.total_tokens) {
(Some(input), Some(output), _) => Some(format!("({}→{} tokens)", input, output)),
(None, None, Some(total)) => Some(format!("({} tokens)", total)),
_ => None,
}
}
pub fn system_display_name(&self) -> Option<String> {
self.system
.as_deref()
.map(GenAiSpanInfo::format_system_name)
}
pub fn format_system_name(s: &str) -> String {
match s {
"openai" => "OpenAI".to_string(),
"anthropic" => "Anthropic".to_string(),
"azure_openai" => "Azure OpenAI".to_string(),
"google" => "Google".to_string(),
"cohere" => "Cohere".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
},
}
}
}
fn parse_finish_reasons(s: &str) -> Vec<String> {
let trimmed = s.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if let Ok(parsed) = serde_json::from_str::<Vec<String>>(trimmed) {
return parsed;
}
}
trimmed
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (count, c) in s.chars().rev().enumerate() {
if count > 0 && count % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_openai_span() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.request.model".to_string(), "gpt-4".to_string());
attrs.insert("gen_ai.operation.name".to_string(), "chat".to_string());
attrs.insert("gen_ai.usage.input_tokens".to_string(), "1234".to_string());
attrs.insert("gen_ai.usage.output_tokens".to_string(), "567".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(info.is_genai);
assert_eq!(info.system, Some("openai".to_string()));
assert_eq!(info.model, Some("gpt-4".to_string()));
assert_eq!(info.operation, Some("chat".to_string()));
assert_eq!(info.input_tokens, Some(1234));
assert_eq!(info.output_tokens, Some(567));
assert_eq!(info.total_tokens, Some(1801));
}
#[test]
fn test_detect_anthropic_span() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "anthropic".to_string());
attrs.insert(
"gen_ai.request.model".to_string(),
"claude-sonnet-4-20250514".to_string(),
);
attrs.insert("gen_ai.operation.name".to_string(), "chat".to_string());
attrs.insert("gen_ai.usage.input_tokens".to_string(), "2000".to_string());
attrs.insert("gen_ai.usage.output_tokens".to_string(), "500".to_string());
attrs.insert("gen_ai.request.temperature".to_string(), "0.7".to_string());
attrs.insert(
"gen_ai.response.finish_reasons".to_string(),
"[\"stop\"]".to_string(),
);
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(info.is_genai);
assert_eq!(info.system, Some("anthropic".to_string()));
assert_eq!(info.model, Some("claude-sonnet-4-20250514".to_string()));
assert_eq!(info.operation, Some("chat".to_string()));
assert_eq!(info.input_tokens, Some(2000));
assert_eq!(info.output_tokens, Some(500));
assert_eq!(info.total_tokens, Some(2500));
assert_eq!(info.temperature, Some(0.7));
assert_eq!(info.finish_reasons, vec!["stop".to_string()]);
}
#[test]
fn test_no_genai_attributes() {
let mut attrs = HashMap::new();
attrs.insert("http.method".to_string(), "GET".to_string());
attrs.insert("http.status_code".to_string(), "200".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(!info.is_genai);
assert_eq!(info.system, None);
assert_eq!(info.model, None);
}
#[test]
fn test_partial_attributes() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(info.is_genai);
assert_eq!(info.system, Some("openai".to_string()));
assert_eq!(info.model, None);
assert_eq!(info.input_tokens, None);
}
#[test]
fn test_token_parsing() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.usage.input_tokens".to_string(), "1000".to_string());
attrs.insert("gen_ai.usage.output_tokens".to_string(), "500".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.input_tokens, Some(1000));
assert_eq!(info.output_tokens, Some(500));
assert_eq!(info.total_tokens, Some(1500));
}
#[test]
fn test_explicit_total_tokens() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.usage.total_tokens".to_string(), "2000".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.total_tokens, Some(2000));
assert_eq!(info.input_tokens, None);
assert_eq!(info.output_tokens, None);
}
#[test]
fn test_format_token_usage() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.usage.input_tokens".to_string(), "1234".to_string());
attrs.insert("gen_ai.usage.output_tokens".to_string(), "567".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
let formatted = info.format_token_usage();
assert_eq!(
formatted,
Some("Input: 1,234 | Output: 567 | Total: 1,801".to_string())
);
}
#[test]
fn test_format_token_summary() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.usage.input_tokens".to_string(), "1234".to_string());
attrs.insert("gen_ai.usage.output_tokens".to_string(), "567".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
let summary = info.format_token_summary();
assert_eq!(summary, Some("(1234→567 tokens)".to_string()));
}
#[test]
fn test_system_display_name() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.system_display_name(), Some("OpenAI".to_string()));
attrs.insert("gen_ai.system".to_string(), "anthropic".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.system_display_name(), Some("Anthropic".to_string()));
}
#[test]
fn test_parse_finish_reasons_json() {
let reasons = parse_finish_reasons("[\"stop\", \"length\"]");
assert_eq!(reasons, vec!["stop".to_string(), "length".to_string()]);
}
#[test]
fn test_parse_finish_reasons_comma_separated() {
let reasons = parse_finish_reasons("stop, length");
assert_eq!(reasons, vec!["stop".to_string(), "length".to_string()]);
}
#[test]
fn test_format_number() {
assert_eq!(format_number(1234), "1,234");
assert_eq!(format_number(1234567), "1,234,567");
assert_eq!(format_number(123), "123");
}
#[test]
fn test_response_id() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert(
"gen_ai.response.id".to_string(),
"chatcmpl-abc123".to_string(),
);
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.response_id, Some("chatcmpl-abc123".to_string()));
}
#[test]
fn test_tool_call_fields() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert(
"gen_ai.operation.name".to_string(),
"execute_tool".to_string(),
);
attrs.insert("gen_ai.tool.name".to_string(), "get_weather".to_string());
attrs.insert("gen_ai.tool.call.id".to_string(), "call_xyz789".to_string());
attrs.insert("gen_ai.tool.type".to_string(), "function".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.tool_name, Some("get_weather".to_string()));
assert_eq!(info.tool_call_id, Some("call_xyz789".to_string()));
assert_eq!(info.tool_type, Some("function".to_string()));
}
#[test]
fn test_is_tool_call_via_operation() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert(
"gen_ai.operation.name".to_string(),
"execute_tool".to_string(),
);
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(info.is_tool_call());
}
#[test]
fn test_is_tool_call_via_tool_name() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.tool.name".to_string(), "search_docs".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(info.is_tool_call());
}
#[test]
fn test_is_not_tool_call() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.operation.name".to_string(), "chat".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert!(!info.is_tool_call());
}
#[test]
fn test_top_p_and_seed() {
let mut attrs = HashMap::new();
attrs.insert("gen_ai.system".to_string(), "openai".to_string());
attrs.insert("gen_ai.request.top_p".to_string(), "0.9".to_string());
attrs.insert("gen_ai.request.seed".to_string(), "42".to_string());
let info = GenAiSpanInfo::from_attributes(&attrs);
assert_eq!(info.top_p, Some(0.9));
assert_eq!(info.seed, Some(42));
}
}