use opentelemetry::trace::{Span, SpanKind, Status, Tracer};
use opentelemetry::{global, KeyValue};
use serde_json::{json, Value};
use super::interface::{
GenerateRequest, GenerateResponse, MessageRole, TokenUsage, ToolDefinition,
};
pub mod attributes {
pub const OPERATION_NAME: &str = "gen_ai.operation.name";
pub const PROVIDER_NAME: &str = "gen_ai.provider.name";
pub const REQUEST_MODEL: &str = "gen_ai.request.model";
pub const RESPONSE_MODEL: &str = "gen_ai.response.model";
pub const REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
pub const REQUEST_TOP_P: &str = "gen_ai.request.top_p";
pub const REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
pub const USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
pub const USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
pub const USAGE_CACHE_READ_TOKENS: &str = "gen_ai.usage.cache_read_input_tokens";
pub const USAGE_CACHE_CREATION_TOKENS: &str = "gen_ai.usage.cache_creation_input_tokens";
pub const USAGE_COST: &str = "gen_ai.usage.cost";
pub const USAGE_COST_CURRENCY: &str = "gen_ai.usage.cost.currency";
pub const RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
pub const INPUT_MESSAGES: &str = "gen_ai.input.messages";
pub const OUTPUT_MESSAGES: &str = "gen_ai.output.messages";
pub const SYSTEM_INSTRUCTIONS: &str = "gen_ai.system_instructions";
pub const REQUEST_DURATION_MS: &str = "gen_ai.request.duration_ms";
}
pub fn should_capture_content() -> bool {
std::env::var("AGNT5_LLM_CAPTURE_CONTENT")
.map(|v| v.to_lowercase() != "false" && v != "0")
.unwrap_or(true)
}
#[derive(Debug, Clone)]
pub struct ModelPricing {
pub input_per_1m: f64,
pub output_per_1m: f64,
}
pub fn get_model_pricing(provider: &str, model: &str) -> Option<ModelPricing> {
match (
provider.to_lowercase().as_str(),
model.to_lowercase().as_str(),
) {
("openai", m) if m.contains("gpt-4o") && !m.contains("mini") => Some(ModelPricing {
input_per_1m: 2.50,
output_per_1m: 10.00,
}),
("openai", m) if m.contains("gpt-4o-mini") => Some(ModelPricing {
input_per_1m: 0.150,
output_per_1m: 0.600,
}),
("openai", m)
if m.contains("gpt-4-turbo")
|| m == "gpt-4-1106-preview"
|| m == "gpt-4-0125-preview" =>
{
Some(ModelPricing {
input_per_1m: 10.00,
output_per_1m: 30.00,
})
}
("openai", m) if m.starts_with("gpt-4") && !m.contains("turbo") && !m.contains("o") => {
Some(ModelPricing {
input_per_1m: 30.00,
output_per_1m: 60.00,
})
}
("openai", m) if m.contains("gpt-3.5-turbo") => Some(ModelPricing {
input_per_1m: 0.50,
output_per_1m: 1.50,
}),
("openai", m) if m.contains("o1-preview") => Some(ModelPricing {
input_per_1m: 15.00,
output_per_1m: 60.00,
}),
("openai", m) if m.contains("o1-mini") => Some(ModelPricing {
input_per_1m: 3.00,
output_per_1m: 12.00,
}),
("openai", m) if m == "o1" => Some(ModelPricing {
input_per_1m: 15.00,
output_per_1m: 60.00,
}),
("openai", m) if m.contains("o3-mini") => Some(ModelPricing {
input_per_1m: 1.10,
output_per_1m: 4.40,
}),
("openai", m) if m == "o3" => Some(ModelPricing {
input_per_1m: 10.00,
output_per_1m: 40.00,
}),
("anthropic", m) if m.contains("claude-opus-4") || m.contains("claude-4-opus") => {
Some(ModelPricing {
input_per_1m: 15.00,
output_per_1m: 75.00,
})
}
("anthropic", m) if m.contains("claude-sonnet-4") || m.contains("claude-4-sonnet") => {
Some(ModelPricing {
input_per_1m: 3.00,
output_per_1m: 15.00,
})
}
("anthropic", m) if m.contains("claude-3-5-sonnet") => Some(ModelPricing {
input_per_1m: 3.00,
output_per_1m: 15.00,
}),
("anthropic", m) if m.contains("claude-3-5-haiku") => Some(ModelPricing {
input_per_1m: 0.80,
output_per_1m: 4.00,
}),
("anthropic", m) if m.contains("claude-3-opus") => Some(ModelPricing {
input_per_1m: 15.00,
output_per_1m: 75.00,
}),
("anthropic", m) if m.contains("claude-3-sonnet") && !m.contains("3-5") => {
Some(ModelPricing {
input_per_1m: 3.00,
output_per_1m: 15.00,
})
}
("anthropic", m) if m.contains("claude-3-haiku") && !m.contains("3-5") => {
Some(ModelPricing {
input_per_1m: 0.25,
output_per_1m: 1.25,
})
}
("google", m) if m.contains("gemini-2.0-flash") => Some(ModelPricing {
input_per_1m: 0.10,
output_per_1m: 0.40,
}),
("google", m) if m.contains("gemini-1.5-pro") => Some(ModelPricing {
input_per_1m: 1.25,
output_per_1m: 5.00,
}),
("google", m) if m.contains("gemini-1.5-flash") => Some(ModelPricing {
input_per_1m: 0.075,
output_per_1m: 0.30,
}),
("google", m) if m.contains("gemini-1.0-pro") || m.contains("gemini-pro") => {
Some(ModelPricing {
input_per_1m: 0.50,
output_per_1m: 1.50,
})
}
("deepseek", m) if m.contains("deepseek-chat") || m.contains("deepseek-v3") => {
Some(ModelPricing {
input_per_1m: 0.27,
output_per_1m: 1.10,
})
}
("deepseek", m) if m.contains("deepseek-reasoner") || m.contains("deepseek-r1") => {
Some(ModelPricing {
input_per_1m: 0.55,
output_per_1m: 2.19,
})
}
("deepseek", m) if m.contains("deepseek-coder") => Some(ModelPricing {
input_per_1m: 0.14,
output_per_1m: 0.28,
}),
("xai", m) if m.contains("grok-2") && !m.contains("mini") => Some(ModelPricing {
input_per_1m: 2.00,
output_per_1m: 10.00,
}),
("xai", m) if m.contains("grok-2-mini") => Some(ModelPricing {
input_per_1m: 0.20,
output_per_1m: 1.00,
}),
("xai", m) if m.contains("grok-beta") => Some(ModelPricing {
input_per_1m: 5.00,
output_per_1m: 15.00,
}),
("mistral", m) if m.contains("mistral-large") => Some(ModelPricing {
input_per_1m: 2.00,
output_per_1m: 6.00,
}),
("mistral", m) if m.contains("mistral-medium") => Some(ModelPricing {
input_per_1m: 2.70,
output_per_1m: 8.10,
}),
("mistral", m) if m.contains("mistral-small") => Some(ModelPricing {
input_per_1m: 0.20,
output_per_1m: 0.60,
}),
("mistral", m) if m.contains("codestral") => Some(ModelPricing {
input_per_1m: 0.20,
output_per_1m: 0.60,
}),
("mistral", m) if m.contains("ministral-8b") => Some(ModelPricing {
input_per_1m: 0.10,
output_per_1m: 0.10,
}),
("mistral", m) if m.contains("ministral-3b") => Some(ModelPricing {
input_per_1m: 0.04,
output_per_1m: 0.04,
}),
("mistral", m) if m.contains("pixtral-large") => Some(ModelPricing {
input_per_1m: 2.00,
output_per_1m: 6.00,
}),
("mistral", m) if m.contains("pixtral-12b") => Some(ModelPricing {
input_per_1m: 0.15,
output_per_1m: 0.15,
}),
("mistral", m) if m.contains("mixtral-8x22b") || m.contains("open-mixtral-8x22b") => {
Some(ModelPricing {
input_per_1m: 2.00,
output_per_1m: 6.00,
})
}
("mistral", m) if m.contains("mixtral-8x7b") || m.contains("open-mixtral-8x7b") => {
Some(ModelPricing {
input_per_1m: 0.70,
output_per_1m: 0.70,
})
}
("mistral", m) if m.contains("open-mistral-7b") || m.contains("mistral-7b") => {
Some(ModelPricing {
input_per_1m: 0.25,
output_per_1m: 0.25,
})
}
("groq", m) if m.contains("llama-3.3-70b") => Some(ModelPricing {
input_per_1m: 0.59,
output_per_1m: 0.79,
}),
("groq", m) if m.contains("llama-3.2-90b") => Some(ModelPricing {
input_per_1m: 0.90,
output_per_1m: 0.90,
}),
("groq", m) if m.contains("llama-3.2-11b") => Some(ModelPricing {
input_per_1m: 0.18,
output_per_1m: 0.18,
}),
("groq", m) if m.contains("llama-3.1-70b") => Some(ModelPricing {
input_per_1m: 0.59,
output_per_1m: 0.79,
}),
("groq", m) if m.contains("llama-3.1-8b") => Some(ModelPricing {
input_per_1m: 0.05,
output_per_1m: 0.08,
}),
("groq", m) if m.contains("llama3-70b") || m.contains("llama-3-70b") => {
Some(ModelPricing {
input_per_1m: 0.59,
output_per_1m: 0.79,
})
}
("groq", m) if m.contains("llama3-8b") || m.contains("llama-3-8b") => Some(ModelPricing {
input_per_1m: 0.05,
output_per_1m: 0.08,
}),
("groq", m) if m.contains("llama") => Some(ModelPricing {
input_per_1m: 0.10,
output_per_1m: 0.10,
}),
("groq", m) if m.contains("mixtral") => Some(ModelPricing {
input_per_1m: 0.24,
output_per_1m: 0.24,
}),
("groq", m) if m.contains("gemma2-9b") => Some(ModelPricing {
input_per_1m: 0.20,
output_per_1m: 0.20,
}),
("groq", m) if m.contains("gemma-7b") => Some(ModelPricing {
input_per_1m: 0.07,
output_per_1m: 0.07,
}),
("ollama", _) => Some(ModelPricing {
input_per_1m: 0.0,
output_per_1m: 0.0,
}),
_ => None,
}
}
pub fn calculate_cost(
provider: &str,
model: &str,
input_tokens: u32,
output_tokens: u32,
cached_tokens: Option<u32>,
) -> Option<f64> {
let pricing = get_model_pricing(provider, model)?;
let cached = cached_tokens.unwrap_or(0).min(input_tokens);
let non_cached_input = input_tokens - cached;
let input_cost = (non_cached_input as f64 / 1_000_000.0) * pricing.input_per_1m;
let cached_cost = (cached as f64 / 1_000_000.0) * pricing.input_per_1m * 0.1;
let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_1m;
Some(input_cost + cached_cost + output_cost)
}
pub fn set_cost_attributes(span: &mut impl Span, cost: f64) {
span.set_attribute(KeyValue::new(attributes::USAGE_COST, cost));
span.set_attribute(KeyValue::new(attributes::USAGE_COST_CURRENCY, "USD"));
}
pub fn create_gen_ai_span(
provider: &str,
model: &str,
parent_context: Option<opentelemetry::Context>,
) -> impl Span {
let tracer = global::tracer("agnt5-sdk-core");
let span_name = format!("chat {}", model);
let ctx = parent_context.unwrap_or_else(opentelemetry::Context::current);
let mut span = tracer
.span_builder(span_name)
.with_kind(SpanKind::Client)
.start_with_context(&tracer, &ctx);
span.set_attribute(KeyValue::new(attributes::OPERATION_NAME, "chat"));
span.set_attribute(KeyValue::new(
attributes::PROVIDER_NAME,
provider.to_string(),
));
span.set_attribute(KeyValue::new(attributes::REQUEST_MODEL, model.to_string()));
if let Some(pid) = crate::telemetry::get_project_id() {
span.set_attribute(KeyValue::new("agnt5.project.id", pid.to_string()));
}
if let Some(wid) = crate::telemetry::get_workspace_id() {
span.set_attribute(KeyValue::new("agnt5.workspace.id", wid.to_string()));
}
if let Some(did) = crate::telemetry::get_deployment_id() {
span.set_attribute(KeyValue::new("agnt5.deployment.id", did.to_string()));
}
span
}
pub fn set_request_attributes(span: &mut impl Span, request: &GenerateRequest) {
if let Some(temperature) = request.config.temperature {
span.set_attribute(KeyValue::new(
attributes::REQUEST_TEMPERATURE,
temperature as f64,
));
}
if let Some(top_p) = request.config.top_p {
span.set_attribute(KeyValue::new(attributes::REQUEST_TOP_P, top_p as f64));
}
if let Some(max_tokens) = request.config.max_output_tokens {
span.set_attribute(KeyValue::new(
attributes::REQUEST_MAX_TOKENS,
max_tokens as i64,
));
}
}
pub fn serialize_input_messages(request: &GenerateRequest) -> Value {
let mut messages_array = Vec::new();
for msg in &request.messages {
messages_array.push(json!({
"role": role_to_string(&msg.role),
"parts": [
{
"type": "text",
"content": truncate_content(&msg.content, 10_000)
}
]
}));
}
json!(messages_array)
}
pub fn serialize_system_instructions(system_prompt: &str) -> Value {
json!([
{
"type": "text",
"content": truncate_content(system_prompt, 10_000)
}
])
}
pub fn serialize_output_messages(response: &GenerateResponse) -> Value {
let mut parts = Vec::new();
if !response.text.is_empty() {
parts.push(json!({
"type": "text",
"content": &response.text
}));
}
if let Some(tool_calls) = &response.tool_calls {
for tool_call in tool_calls {
parts.push(json!({
"type": "tool_call",
"id": tool_call.id,
"name": tool_call.name,
"arguments": tool_call.arguments
}));
}
}
if parts.is_empty() {
parts.push(json!({
"type": "text",
"content": ""
}));
}
json!([
{
"role": "assistant",
"parts": parts
}
])
}
pub fn set_response_attributes(
span: &mut impl Span,
response: &GenerateResponse,
capture_content: bool,
) {
span.set_attribute(KeyValue::new(
attributes::RESPONSE_MODEL,
response.model.clone(),
));
if let Some(usage) = &response.usage {
set_token_usage_attributes(span, usage);
}
if let Some(finish_reason) = &response.finish_reason {
span.set_attribute(KeyValue::new(
attributes::RESPONSE_FINISH_REASONS,
format!("[\"{}\"]", finish_reason),
));
}
if capture_content {
let output_messages = serialize_output_messages(response);
span.set_attribute(KeyValue::new(
attributes::OUTPUT_MESSAGES,
output_messages.to_string(),
));
}
span.set_status(Status::Ok);
}
pub fn set_token_usage_attributes(span: &mut impl Span, usage: &TokenUsage) {
if let Some(prompt_tokens) = usage.prompt_tokens {
span.set_attribute(KeyValue::new(
attributes::USAGE_INPUT_TOKENS,
prompt_tokens as i64,
));
}
if let Some(completion_tokens) = usage.completion_tokens {
span.set_attribute(KeyValue::new(
attributes::USAGE_OUTPUT_TOKENS,
completion_tokens as i64,
));
}
if let Some(cached_tokens) = usage.cached_tokens {
span.set_attribute(KeyValue::new(
attributes::USAGE_CACHE_READ_TOKENS,
cached_tokens as i64,
));
}
if let Some(cache_creation_tokens) = usage.cache_creation_tokens {
span.set_attribute(KeyValue::new(
attributes::USAGE_CACHE_CREATION_TOKENS,
cache_creation_tokens as i64,
));
}
}
pub fn set_error_status(span: &mut impl Span, error: &str) {
span.set_status(Status::error(error.to_string()));
}
pub fn set_duration(span: &mut impl Span, duration_ms: u128) {
span.set_attribute(KeyValue::new(
attributes::REQUEST_DURATION_MS,
duration_ms as i64,
));
}
fn truncate_content(content: &str, max_bytes: usize) -> String {
if content.len() <= max_bytes {
content.to_string()
} else {
format!(
"{}... [truncated {} bytes]",
&content[..max_bytes],
content.len() - max_bytes
)
}
}
fn role_to_string(role: &MessageRole) -> &'static str {
match role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
}
}
pub fn serialize_tool_definitions(tools: &[ToolDefinition]) -> Value {
let tools_array: Vec<Value> = tools
.iter()
.map(|tool| {
let mut tool_obj = json!({
"name": &tool.name,
});
if let Some(description) = &tool.description {
tool_obj["description"] = json!(truncate_content(description, 500));
}
if let Some(parameters) = &tool.parameters {
let params_str = parameters.to_string();
tool_obj["parameters"] = if params_str.len() > 2000 {
json!(format!("{}... [truncated]", ¶ms_str[..2000]))
} else {
parameters.clone()
};
}
tool_obj
})
.collect();
json!(tools_array)
}
pub fn set_tool_request_attributes(
span: &mut impl Span,
request: &GenerateRequest,
capture_content: bool,
) {
if !request.tools.is_empty() {
span.set_attribute(KeyValue::new(
"gen_ai.request.tools_count",
request.tools.len() as i64,
));
if capture_content {
let tools_json = serialize_tool_definitions(&request.tools);
span.set_attribute(KeyValue::new(
"gen_ai.request.tools",
tools_json.to_string(),
));
}
}
if let Some(tool_choice) = &request.tool_choice {
let choice_str = format!("{:?}", tool_choice);
span.set_attribute(KeyValue::new("gen_ai.request.tool_choice", choice_str));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_capture_content_default_true() {
std::env::remove_var("AGNT5_LLM_CAPTURE_CONTENT");
assert!(should_capture_content());
}
#[test]
fn test_calculate_cost_discounts_cached_tokens() {
let full = calculate_cost("openai", "gpt-4o", 1000, 0, None).unwrap();
assert!((full - 0.0025).abs() < 1e-9, "full cost was {full}");
let cached = calculate_cost("openai", "gpt-4o", 1000, 0, Some(500)).unwrap();
assert!((cached - 0.001375).abs() < 1e-9, "cached cost was {cached}");
assert!(cached < full, "caching should never increase cost");
}
#[test]
fn test_calculate_cost_clamps_cached_over_input() {
let cost = calculate_cost("openai", "gpt-4o", 1000, 0, Some(5000)).unwrap();
assert!((cost - 0.00025).abs() < 1e-9, "clamped cost was {cost}");
}
#[test]
fn test_truncate_content_short() {
let content = "Hello, world!";
assert_eq!(truncate_content(content, 100), "Hello, world!");
}
#[test]
fn test_truncate_content_long() {
let content = "x".repeat(20000);
let truncated = truncate_content(&content, 10000);
assert!(truncated.contains("[truncated"));
assert!(truncated.len() < content.len());
}
#[test]
fn test_serialize_input_messages_excludes_system_prompt() {
let request = GenerateRequest::new("gpt-4")
.system_prompt("You are helpful")
.user_message("Hello");
let serialized = serialize_input_messages(&request);
assert!(serialized.is_array());
let array = serialized.as_array().unwrap();
assert_eq!(array.len(), 1);
assert_eq!(array[0]["role"], "user");
let system = serialize_system_instructions("You are helpful");
assert!(system.is_array());
assert_eq!(system.as_array().unwrap().len(), 1);
assert_eq!(system[0]["content"], "You are helpful");
}
}