use crate::cassette::{Body, Cassette, Exchange};
use amberfork_model::{Payload, Run, SchemaVersion, Step, StepKind};
use serde_json::{Map, Value};
const UNNAMED_LLM: &str = "llm";
pub fn normalize_str(s: &str) -> Result<Run, serde_json::Error> {
let cassette: Cassette = serde_json::from_str(s)?;
Ok(normalize(&cassette))
}
#[must_use]
pub fn normalize(cassette: &Cassette) -> Run {
let steps = cassette
.exchanges
.iter()
.enumerate()
.map(|(idx, exchange)| step_from_exchange(idx, exchange))
.collect();
Run {
schema_version: SchemaVersion::current(),
id: cassette.id.clone(),
task: None,
outcome: None,
steps,
edges: None,
}
}
fn step_from_exchange(idx: usize, exchange: &Exchange) -> Step {
let mut attrs = Map::new();
attrs.insert("status".to_string(), exchange.response.status.into());
Step {
idx,
kind: StepKind::Llm,
name: model_name(&exchange.request.body),
inputs: Some(payload_from_body(&exchange.request.body)),
outputs: Some(payload_from_body(&exchange.response.body)),
attrs,
t_start: None,
t_end: None,
parent_idx: None,
}
}
fn model_name(body: &Body) -> String {
let Body::Json(Value::Object(map)) = body else {
return UNNAMED_LLM.to_string();
};
map.get("model")
.and_then(Value::as_str)
.unwrap_or(UNNAMED_LLM)
.to_string()
}
fn payload_from_body(body: &Body) -> Payload {
match body {
Body::Json(Value::Object(map)) => Payload::Object(map.clone()),
Body::Json(value) => Payload::Other(value.clone()),
Body::Text(text) => Payload::Text(text.clone()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cassette::{CapturedRequest, CapturedResponse, CassetteVersion};
use serde_json::json;
fn exchange(idx: usize, request: Body, status: u16, response: Body) -> Exchange {
Exchange {
idx,
request: CapturedRequest {
method: "POST".to_string(),
path: "/v1/chat/completions".to_string(),
headers: Vec::new(),
body: request,
},
response: CapturedResponse {
status,
headers: Vec::new(),
body: response,
},
}
}
fn cassette(exchanges: Vec<Exchange>) -> Cassette {
Cassette {
cassette_version: CassetteVersion::current(),
id: "test-run".to_string(),
exchanges,
}
}
#[test]
fn each_exchange_becomes_one_llm_step_in_capture_order() {
let cass = cassette(vec![
exchange(
0,
Body::Json(json!({"model": "claude-sonnet-5"})),
200,
Body::Json(json!({"n": 1})),
),
exchange(
1,
Body::Json(json!({"model": "claude-sonnet-5"})),
200,
Body::Json(json!({"n": 2})),
),
]);
let run = normalize(&cass);
assert_eq!(run.id, "test-run");
assert_eq!(run.steps.len(), 2);
assert_eq!(run.steps[0].idx, 0);
assert_eq!(run.steps[1].idx, 1);
assert!(run.steps.iter().all(|s| s.kind == StepKind::Llm));
assert!(run.edges.is_none());
assert!(run.steps.iter().all(|s| s.parent_idx.is_none()));
}
#[test]
fn every_recorded_step_carries_both_inputs_and_outputs() {
let cass = cassette(vec![exchange(
0,
Body::Json(
json!({"messages": [{"role": "user", "content": "Handle refund for 8841"}]}),
),
200,
Body::Json(json!({"choices": [{"message": {"content": "Looking up the order."}}]})),
)]);
let run = normalize(&cass);
assert!(
run.steps
.iter()
.all(|s| s.inputs.is_some() && s.outputs.is_some())
);
}
#[test]
fn json_object_body_becomes_a_field_diffable_object_payload() {
let cass = cassette(vec![exchange(
0,
Body::Json(json!({"model": "claude-sonnet-5", "temperature": 0})),
200,
Body::Json(json!({"choices": []})),
)]);
let run = normalize(&cass);
let inputs = run.steps[0].inputs.as_ref().expect("inputs present");
let Payload::Object(map) = inputs else {
panic!("a JSON object request must map to Payload::Object, got {inputs:?}");
};
assert_eq!(map["temperature"], json!(0));
}
#[test]
fn non_json_response_degrades_to_text_not_silence() {
let cass = cassette(vec![exchange(
0,
Body::Json(json!({"model": "claude-sonnet-5"})),
502,
Body::Text("<html>502 Bad Gateway</html>".to_string()),
)]);
let run = normalize(&cass);
let outputs = run.steps[0].outputs.as_ref().expect("output present");
assert_eq!(
*outputs,
Payload::Text("<html>502 Bad Gateway</html>".to_string())
);
}
#[test]
fn step_name_is_the_model_when_the_request_names_one() {
let cass = cassette(vec![exchange(
0,
Body::Json(json!({"model": "claude-opus-4-8"})),
200,
Body::Json(json!({})),
)]);
assert_eq!(normalize(&cass).steps[0].name, "claude-opus-4-8");
}
#[test]
fn step_name_falls_back_when_no_model_is_named() {
let cass = cassette(vec![exchange(
0,
Body::Text("not json".to_string()),
200,
Body::Json(json!({})),
)]);
assert_eq!(normalize(&cass).steps[0].name, UNNAMED_LLM);
}
#[test]
fn normalize_str_parses_then_normalizes_a_well_formed_cassette() {
let json = r#"{
"cassette_version": "0.1",
"id": "from-str",
"exchanges": [
{
"idx": 0,
"request": { "method": "POST", "path": "/v1/messages",
"body": { "model": "claude-sonnet-5" } },
"response": { "status": 200, "body": { "ok": true } }
}
]
}"#;
let run = normalize_str(json).expect("well-formed cassette normalizes");
assert_eq!(run.id, "from-str");
assert_eq!(run.steps.len(), 1);
assert_eq!(run.steps[0].name, "claude-sonnet-5");
}
#[test]
fn normalize_str_errors_on_a_broken_cassette_shape() {
let json = r#"{"cassette_version": "0.1", "id": "broken", "exchanges": "not-an-array"}"#;
assert!(normalize_str(json).is_err());
}
#[test]
fn response_status_is_kept_as_a_step_attribute() {
let cass = cassette(vec![exchange(
0,
Body::Json(json!({"model": "claude-sonnet-5"})),
429,
Body::Json(json!({"error": "rate_limited"})),
)]);
let run = normalize(&cass);
assert_eq!(run.steps[0].attrs["status"], json!(429));
}
}