use crate::events::ToolDefinitionSummary;
use crate::message::{ContentPart, Message, MessageRole};
use crate::tool_types::ToolCall;
use everruns_provider::reasoning::ReasoningText;
use serde_json::{Value, json};
pub mod gen_ai {
pub const OPERATION_NAME: &str = "gen_ai.operation.name";
pub const PROVIDER_NAME: &str = "gen_ai.provider.name";
pub const SYSTEM: &str = "gen_ai.system";
pub const REQUEST_MODEL: &str = "gen_ai.request.model";
pub const REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
pub const REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
pub const REQUEST_TOP_P: &str = "gen_ai.request.top_p";
pub const REQUEST_TOP_K: &str = "gen_ai.request.top_k";
pub const REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
pub const REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
pub const REQUEST_STOP_SEQUENCES: &str = "gen_ai.request.stop_sequences";
pub const REQUEST_SEED: &str = "gen_ai.request.seed";
pub const REQUEST_CHOICE_COUNT: &str = "gen_ai.request.choice.count";
pub const REQUEST_STREAM: &str = "gen_ai.request.stream";
pub const REQUEST_REASONING_LEVEL: &str = "gen_ai.request.reasoning.level";
pub const REQUEST_ENCODING_FORMATS: &str = "gen_ai.request.encoding_formats";
pub const RESPONSE_ID: &str = "gen_ai.response.id";
pub const RESPONSE_MODEL: &str = "gen_ai.response.model";
pub const RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
pub const RESPONSE_TIME_TO_FIRST_CHUNK: &str = "gen_ai.response.time_to_first_chunk";
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_INPUT_TOKENS: &str = "gen_ai.usage.cache_read.input_tokens";
pub const USAGE_CACHE_WRITE_INPUT_TOKENS: &str = "gen_ai.usage.cache_write.input_tokens";
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 TOOL_DEFINITIONS: &str = "gen_ai.tool.definitions";
pub const TOOL_NAME: &str = "gen_ai.tool.name";
pub const TOOL_TYPE: &str = "gen_ai.tool.type";
pub const TOOL_DESCRIPTION: &str = "gen_ai.tool.description";
pub const TOOL_CALL_ID: &str = "gen_ai.tool.call.id";
pub const TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments";
pub const TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result";
pub const CONVERSATION_ID: &str = "gen_ai.conversation.id";
pub const CONVERSATION_COMPACTED: &str = "gen_ai.conversation.compacted";
pub const EMBEDDINGS_DIMENSION_COUNT: &str = "gen_ai.embeddings.dimension.count";
pub const OUTPUT_TYPE: &str = "gen_ai.output.type";
pub const AGENT_ID: &str = "gen_ai.agent.id";
pub const AGENT_NAME: &str = "gen_ai.agent.name";
pub const AGENT_DESCRIPTION: &str = "gen_ai.agent.description";
pub const AGENT_VERSION: &str = "gen_ai.agent.version";
pub const SERVER_ADDRESS: &str = "server.address";
pub const SERVER_PORT: &str = "server.port";
pub const ERROR_TYPE: &str = "error.type";
pub const ERROR_TYPE_OTHER: &str = "_OTHER";
pub mod operation {
pub const CHAT: &str = "chat";
pub const EMBEDDINGS: &str = "embeddings";
pub const TEXT_COMPLETION: &str = "text_completion";
pub const GENERATE_CONTENT: &str = "generate_content";
pub const EXECUTE_TOOL: &str = "execute_tool";
pub const CREATE_AGENT: &str = "create_agent";
pub const INVOKE_AGENT: &str = "invoke_agent";
pub const INVOKE_WORKFLOW: &str = "invoke_workflow";
pub const PLAN: &str = "plan";
}
pub mod provider {
pub const OPENAI: &str = "openai";
pub const ANTHROPIC: &str = "anthropic";
pub const AZURE_OPENAI: &str = "azure.ai.openai";
pub const GEMINI: &str = "gcp.gemini";
pub const VERTEX_AI: &str = "gcp.vertex_ai";
pub const BEDROCK: &str = "aws.bedrock";
pub const MISTRAL_AI: &str = "mistral_ai";
pub const GROQ: &str = "groq";
pub const COHERE: &str = "cohere";
pub const DEEPSEEK: &str = "deepseek";
pub const PERPLEXITY: &str = "perplexity";
pub const X_AI: &str = "x_ai";
pub fn from_driver_id(driver_id: &str) -> &str {
match driver_id {
"openai" | "openai_completions" => OPENAI,
"anthropic" => ANTHROPIC,
"azure_openai" => AZURE_OPENAI,
"gemini" => GEMINI,
"vertex_ai" | "vertexai" => VERTEX_AI,
"bedrock" => BEDROCK,
"mistral" | "mistral_ai" => MISTRAL_AI,
"groq" => GROQ,
"cohere" => COHERE,
"deepseek" => DEEPSEEK,
"perplexity" => PERPLEXITY,
"xai" | "x_ai" => X_AI,
other => other,
}
}
}
pub mod tool_type {
pub const FUNCTION: &str = "function";
pub const EXTENSION: &str = "extension";
pub const DATASTORE: &str = "datastore";
}
pub mod output_type {
pub const TEXT: &str = "text";
pub const IMAGE: &str = "image";
pub const JSON: &str = "json";
pub const SPEECH: &str = "speech";
}
pub mod role {
pub const SYSTEM: &str = "system";
pub const USER: &str = "user";
pub const ASSISTANT: &str = "assistant";
pub const TOOL: &str = "tool";
}
pub mod part_type {
pub const TEXT: &str = "text";
pub const TOOL_CALL: &str = "tool_call";
pub const TOOL_CALL_RESPONSE: &str = "tool_call_response";
pub const REASONING: &str = "reasoning";
pub const URI: &str = "uri";
pub const BLOB: &str = "blob";
}
}
pub mod content {
use super::*;
pub fn system_instructions(messages: &[Message]) -> Option<Value> {
let parts: Vec<Value> = messages
.iter()
.filter(|m| m.role == MessageRole::System)
.flat_map(|m| m.content.iter().filter_map(part_json))
.collect();
if parts.is_empty() {
None
} else {
Some(Value::Array(parts))
}
}
pub fn input_messages(messages: &[Message]) -> Value {
Value::Array(
messages
.iter()
.filter(|m| m.role != MessageRole::System)
.map(message_json)
.collect(),
)
}
pub fn output_messages(
text: Option<&str>,
tool_calls: &[ToolCall],
reasoning: Option<&str>,
finish_reason: Option<&str>,
) -> Value {
let mut parts = Vec::new();
if let Some(reasoning) = reasoning.filter(|r| !r.is_empty()) {
parts.push(json!({
"type": gen_ai::part_type::REASONING,
"content": reasoning,
}));
}
if let Some(text) = text.filter(|t| !t.is_empty()) {
parts.push(json!({ "type": gen_ai::part_type::TEXT, "content": text }));
}
for call in tool_calls {
parts.push(json!({
"type": gen_ai::part_type::TOOL_CALL,
"id": call.id,
"name": call.name,
"arguments": call.arguments,
}));
}
let mut message = json!({ "role": gen_ai::role::ASSISTANT, "parts": parts });
if let Some(reason) = finish_reason {
message["finish_reason"] = Value::String(reason.to_string());
}
Value::Array(vec![message])
}
pub fn tool_definitions(tools: &[ToolDefinitionSummary]) -> Value {
Value::Array(
tools
.iter()
.map(|t| {
json!({
"type": gen_ai::tool_type::FUNCTION,
"name": t.name,
"description": t.description,
})
})
.collect(),
)
}
pub fn message_json(message: &Message) -> Value {
let parts: Vec<Value> = message.content.iter().filter_map(part_json).collect();
json!({ "role": role_name(&message.role), "parts": parts })
}
pub fn role_name(role: &MessageRole) -> &'static str {
match role {
MessageRole::System => gen_ai::role::SYSTEM,
MessageRole::User => gen_ai::role::USER,
MessageRole::Agent => gen_ai::role::ASSISTANT,
MessageRole::ToolResult => gen_ai::role::TOOL,
}
}
pub fn part_json(part: &ContentPart) -> Option<Value> {
match part {
ContentPart::Text(t) => Some(json!({
"type": gen_ai::part_type::TEXT,
"content": t.text,
})),
ContentPart::ToolCall(tc) => Some(json!({
"type": gen_ai::part_type::TOOL_CALL,
"id": tc.id,
"name": tc.name,
"arguments": tc.arguments,
})),
ContentPart::ToolResult(tr) => {
let response = match (&tr.error, &tr.result) {
(Some(error), _) => json!({ "error": error }),
(None, Some(result)) => result.clone(),
(None, None) => Value::Null,
};
Some(json!({
"type": gen_ai::part_type::TOOL_CALL_RESPONSE,
"id": tr.tool_call_id,
"response": response,
}))
}
ContentPart::Image(img) => {
if let Some(url) = &img.url {
Some(json!({
"type": gen_ai::part_type::URI,
"modality": "image",
"uri": url,
}))
} else if img.base64.is_some() {
Some(json!({
"type": gen_ai::part_type::BLOB,
"modality": "image",
"mime_type": img.media_type.as_deref().unwrap_or("image/png"),
}))
} else {
None
}
}
ContentPart::ImageFile(file) => Some(json!({
"type": gen_ai::part_type::URI,
"modality": "image",
"uri": format!("image_file:{}", file.image_id),
})),
ContentPart::File(file) => Some(json!({
"type": gen_ai::part_type::URI,
"modality": "file",
"uri": format!("file:{}", file.file_id),
})),
ContentPart::Reasoning(reasoning) => {
let text = match reasoning.text.as_ref()? {
ReasoningText::Plain { text } => text.clone(),
ReasoningText::Summary { parts } => parts.join("\n"),
ReasoningText::Redacted => return None,
};
Some(json!({
"type": gen_ai::part_type::REASONING,
"content": text,
}))
}
}
}
}
pub fn chat_span_name(model: &str) -> String {
format!("{} {}", gen_ai::operation::CHAT, model)
}
pub fn tool_span_name(tool_name: &str) -> String {
format!("{} {}", gen_ai::operation::EXECUTE_TOOL, tool_name)
}
pub fn text_completion_span_name(model: &str) -> String {
format!("{} {}", gen_ai::operation::TEXT_COMPLETION, model)
}
pub fn create_agent_span_name(agent_name: &str) -> String {
format!("{} {}", gen_ai::operation::CREATE_AGENT, agent_name)
}
pub fn invoke_agent_span_name(agent_name: Option<&str>) -> String {
match agent_name.map(str::trim).filter(|n| !n.is_empty()) {
Some(name) => format!("{} {}", gen_ai::operation::INVOKE_AGENT, name),
None => gen_ai::operation::INVOKE_AGENT.to_string(),
}
}
pub fn embeddings_span_name(model: &str) -> String {
format!("{} {}", gen_ai::operation::EMBEDDINGS, model)
}
pub fn error_type(code: Option<&str>, message: &str) -> String {
if let Some(code) = code.map(str::trim).filter(|c| !c.is_empty()) {
return code.to_string();
}
let lowered = message.to_ascii_lowercase();
if lowered.contains("timed out") || lowered.contains("timeout") {
return "timeout".to_string();
}
if let Some(status) = http_status_in(message) {
return status.to_string();
}
gen_ai::ERROR_TYPE_OTHER.to_string()
}
fn http_status_in(text: &str) -> Option<u16> {
let bytes = text.as_bytes();
let mut i = 0;
while i + 3 <= bytes.len() {
let window = &bytes[i..i + 3];
let standalone_before = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let standalone_after = i + 3 == bytes.len() || !bytes[i + 3].is_ascii_alphanumeric();
if standalone_before
&& standalone_after
&& window.iter().all(u8::is_ascii_digit)
&& matches!(window[0], b'4' | b'5')
{
return std::str::from_utf8(window).ok()?.parse().ok();
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operation_span_names_preserve_operation_and_subject() {
for (make_name, expected) in [
(chat_span_name as fn(&str) -> String, "chat subject/α"),
(tool_span_name, "execute_tool subject/α"),
(text_completion_span_name, "text_completion subject/α"),
(create_agent_span_name, "create_agent subject/α"),
(embeddings_span_name, "embeddings subject/α"),
] {
assert_eq!(make_name("subject/α"), expected);
}
}
#[test]
fn test_invoke_agent_span_name() {
assert_eq!(
invoke_agent_span_name(Some("customer_support")),
"invoke_agent customer_support"
);
assert_eq!(
invoke_agent_span_name(Some(" customer_support\n")),
"invoke_agent customer_support"
);
assert_eq!(invoke_agent_span_name(Some("")), "invoke_agent");
assert_eq!(invoke_agent_span_name(None), "invoke_agent");
assert_eq!(invoke_agent_span_name(Some(" ")), "invoke_agent");
}
#[test]
fn provider_names_follow_the_registry() {
for (driver, expected) in [
("openai", "openai"),
("openai_completions", "openai"),
("anthropic", "anthropic"),
("gemini", "gcp.gemini"),
("bedrock", "aws.bedrock"),
("azure_openai", "azure.ai.openai"),
("vertex_ai", "gcp.vertex_ai"),
("vertexai", "gcp.vertex_ai"),
("mistral", "mistral_ai"),
("mistral_ai", "mistral_ai"),
("xai", "x_ai"),
("x_ai", "x_ai"),
("openrouter", "openrouter"),
("llmsim", "llmsim"),
("custom-driver", "custom-driver"),
] {
assert_eq!(
gen_ai::provider::from_driver_id(driver),
expected,
"{driver}"
);
}
}
#[test]
fn error_classification_respects_precedence_and_status_boundaries() {
for (code, message, expected) in [
(
Some(" budget_exhausted "),
"timeout HTTP 503",
"budget_exhausted",
),
(Some(" "), "provider returned 503", "503"),
(None, "HTTP 503: request TIMED OUT", "timeout"),
(None, "TIMEOUT", "timeout"),
(None, "HTTP 429 Too Many Requests", "429"),
(None, "400", "400"),
(None, "599", "599"),
(None, "399 then 600", "_OTHER"),
(None, "A503 503B 1503 5030", "_OTHER"),
(None, "id 12345 not found", "_OTHER"),
(None, "失敗 (502), then 429", "502"),
(None, "something broke", "_OTHER"),
(None, "", "_OTHER"),
] {
assert_eq!(error_type(code, message), expected, "{code:?}: {message}");
}
}
#[test]
fn system_messages_go_to_instructions_not_history() {
let messages = vec![
Message::system("Be terse."),
Message::user("Hi"),
Message::assistant("Hello"),
Message::system("Use tools carefully."),
];
let instructions = content::system_instructions(&messages).unwrap();
assert_eq!(
instructions,
json!([
{ "type": "text", "content": "Be terse." },
{ "type": "text", "content": "Use tools carefully." }
])
);
let history = content::input_messages(&messages);
assert_eq!(
history,
json!([
{ "role": "user", "parts": [{ "type": "text", "content": "Hi" }] },
{ "role": "assistant", "parts": [{ "type": "text", "content": "Hello" }] },
])
);
assert!(content::system_instructions(&[Message::user("x")]).is_none());
}
#[test]
fn tool_calls_and_results_use_spec_part_types() {
let call = ToolCall {
id: "call_1".to_string(),
name: "get_weather".to_string(),
arguments: json!({ "city": "Paris" }),
};
let messages = vec![
Message::assistant_with_tools("Checking", vec![call.clone()]),
Message::tool_result("call_1", Some(json!({ "temp": 21 })), None),
];
let history = content::input_messages(&messages);
assert_eq!(
history,
json!([
{
"role": "assistant",
"parts": [
{ "type": "text", "content": "Checking" },
{ "type": "tool_call", "id": "call_1", "name": "get_weather",
"arguments": { "city": "Paris" } },
]
},
{
"role": "tool",
"parts": [
{ "type": "tool_call_response", "id": "call_1", "response": { "temp": 21 } },
]
},
])
);
let output = content::output_messages(
Some("Sunny"),
&[call],
Some("thinking..."),
Some("tool_calls"),
);
assert_eq!(
output,
json!([{
"role": "assistant",
"finish_reason": "tool_calls",
"parts": [
{ "type": "reasoning", "content": "thinking..." },
{ "type": "text", "content": "Sunny" },
{ "type": "tool_call", "id": "call_1", "name": "get_weather",
"arguments": { "city": "Paris" } },
]
}])
);
}
#[test]
fn tool_errors_override_results_and_missing_results_are_null() {
for (result, error, expected) in [
(None, Some("boom".to_string()), json!({"error": "boom"})),
(
Some(json!({"ignored": true})),
Some("boom".to_string()),
json!({"error": "boom"}),
),
(None, None, Value::Null),
] {
let history = content::input_messages(&[Message::tool_result("call_2", result, error)]);
assert_eq!(
history,
json!([{"role": "tool", "parts": [{
"type": "tool_call_response", "id": "call_2", "response": expected
}]}])
);
}
}
#[test]
fn absent_output_content_omits_optional_fields() {
for empty in [None, Some("")] {
assert_eq!(
content::output_messages(empty, &[], empty, None),
json!([{"role": "assistant", "parts": []}])
);
}
}
#[test]
fn captured_reasoning_omits_opaque_artifacts_and_redacted_parts() {
use everruns_provider::reasoning::ReasoningContentPart;
let artifact = || {
ReasoningContentPart::opaque("provider")
.with_signature("PRIVATE-SIGNATURE")
.with_encrypted("PRIVATE-ENCRYPTED")
.with_item_id("PRIVATE-ITEM")
.with_tokens(41)
};
let mut message = Message::assistant("");
message.content = vec![
ContentPart::Reasoning(artifact().with_text(ReasoningText::Plain {
text: "visible".into(),
})),
ContentPart::Reasoning(artifact().with_text(ReasoningText::Redacted)),
ContentPart::Reasoning(artifact()),
ContentPart::Reasoning(artifact().with_text(ReasoningText::Summary {
parts: vec!["first".into(), "second".into()],
})),
];
assert_eq!(
content::input_messages(&[message]),
json!([{"role": "assistant", "parts": [
{"type": "reasoning", "content": "visible"},
{"type": "reasoning", "content": "first\nsecond"}
]}])
);
}
#[test]
fn image_bytes_never_reach_telemetry() {
use crate::message::ImageContentPart;
let image_id = crate::typed_id::ImageId::new();
let mut message = Message::user("");
message.content = vec![
ContentPart::Image(ImageContentPart {
url: Some("https://example.com/a.png".into()),
base64: Some("PRIVATE-BYTES".into()),
media_type: None,
}),
ContentPart::Image(ImageContentPart {
url: None,
base64: None,
media_type: None,
}),
ContentPart::Image(ImageContentPart {
url: None,
base64: Some("PRIVATE-FALLBACK".into()),
media_type: None,
}),
ContentPart::Image(crate::message::ImageContentPart::from_base64(
"AAAA",
"image/jpeg",
)),
ContentPart::image_file(image_id),
];
let history = content::input_messages(&[message]);
assert_eq!(
history[0]["parts"],
json!([
{ "type": "uri", "modality": "image", "uri": "https://example.com/a.png" },
{ "type": "blob", "modality": "image", "mime_type": "image/png" },
{ "type": "blob", "modality": "image", "mime_type": "image/jpeg" },
{ "type": "uri", "modality": "image", "uri": format!("image_file:{image_id}") },
])
);
}
#[test]
fn tool_definitions_are_function_typed() {
assert_eq!(content::tool_definitions(&[]), json!([]));
let first = ToolDefinitionSummary {
name: "read_file".to_string(),
display_name: Some("Internal display".into()),
category: Some("Internal category".into()),
capability_id: Some("private-capability".into()),
capability_name: Some("Private capability".into()),
description: "Read a file".to_string(),
};
let second = ToolDefinitionSummary {
name: "write_file".into(),
description: "Write a file".into(),
..first.clone()
};
let tools = vec![first, second];
assert_eq!(
content::tool_definitions(&tools),
json!([
{ "type": "function", "name": "read_file", "description": "Read a file" },
{ "type": "function", "name": "write_file", "description": "Write a file" }
])
);
}
}