use amberfork_model::{Payload, Step, StepKind, Warning, WarningCode};
use serde_json::{Map, Value};
const CONSUMED_KEYS: &[&str] = &[
"gen_ai.operation.name",
"gen_ai.tool.name",
"gen_ai.agent.name",
"gen_ai.input.messages",
"gen_ai.output.messages",
"gen_ai.tool.call.arguments",
"gen_ai.tool.call.result",
];
const KNOWN_PREFIXES: &[&str] = &["gen_ai.", "error.", "server."];
#[expect(
clippy::too_many_arguments,
reason = "the envelope-independent inputs of a span-to-step mapping; grouping them into a \
struct would only move the argument list, not shorten it"
)]
pub(crate) fn map_span_to_step(
idx: usize,
parent_idx: Option<usize>,
span_name: &str,
attrs: Map<String, Value>,
provenance: Vec<(&'static str, Value)>,
t_start: Option<String>,
t_end: Option<String>,
warnings: &mut Vec<Warning>,
) -> Step {
let kind = map_kind(attrs.get("gen_ai.operation.name").and_then(|v| v.as_str()));
let name_key = match kind {
StepKind::Tool => Some("gen_ai.tool.name"),
StepKind::Agent => Some("gen_ai.agent.name"),
StepKind::Llm | StepKind::Other => None,
};
let name = name_key
.and_then(|key| attrs.get(key))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map_or_else(|| span_name.to_string(), str::to_string);
let (inputs, outputs) = if kind == StepKind::Tool {
(
decode_content(attrs.get("gen_ai.tool.call.arguments")),
decode_content(attrs.get("gen_ai.tool.call.result")),
)
} else {
(
decode_content(attrs.get("gen_ai.input.messages")),
decode_content(attrs.get("gen_ai.output.messages")),
)
};
let mut out_attrs = Map::new();
let mut foreign = Vec::new();
for (key, value) in attrs {
if CONSUMED_KEYS.contains(&key.as_str()) {
continue;
}
if !is_known(&key) {
foreign.push(key.clone());
}
out_attrs.insert(key, value);
}
for (key, value) in provenance {
out_attrs.insert(key.to_string(), value);
}
if !foreign.is_empty() {
foreign.sort_unstable();
warnings.push(Warning {
code: WarningCode::UnmappedAttributes,
msg: format!(
"step {idx} ({name}): foreign attributes preserved to attrs: {}",
foreign.join(", ")
),
});
}
if inputs.is_none() && outputs.is_none() {
warnings.push(Warning {
code: WarningCode::ContentAbsent,
msg: format!("step {idx} ({name}): no input or output content captured"),
});
}
Step {
idx,
kind,
name,
inputs,
outputs,
attrs: out_attrs,
t_start,
t_end,
parent_idx,
}
}
pub(crate) fn map_kind(operation: Option<&str>) -> StepKind {
match operation {
Some("chat" | "text_completion" | "generate_content") => StepKind::Llm,
Some("execute_tool") => StepKind::Tool,
Some("create_agent" | "invoke_agent") => StepKind::Agent,
_ => StepKind::Other,
}
}
pub(crate) fn decode_content(value: Option<&Value>) -> Option<Payload> {
match value? {
Value::String(s) if s.is_empty() => None,
Value::String(s) => match serde_json::from_str::<Value>(s) {
Ok(parsed) => Some(payload_from_json(parsed)),
Err(_) => Some(Payload::Text(s.clone())),
},
Value::Array(arr) if arr.is_empty() => None,
Value::Object(map) if map.is_empty() => None,
Value::Null => None,
other => Some(payload_from_json(other.clone())),
}
}
fn payload_from_json(value: Value) -> Payload {
match value {
Value::String(s) => Payload::Text(s),
Value::Object(map) => Payload::Object(map),
Value::Array(items) => {
let mut map = Map::new();
map.insert("messages".to_string(), Value::Array(items));
Payload::Object(map)
}
other => Payload::Other(other),
}
}
fn is_known(key: &str) -> bool {
KNOWN_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
}