1use crate::cassette::{Body, Cassette, Exchange};
20use amberfork_model::{Payload, Run, SchemaVersion, Step, StepKind};
21use serde_json::{Map, Value};
22
23const UNNAMED_LLM: &str = "llm";
26
27pub fn normalize_str(s: &str) -> Result<Run, serde_json::Error> {
39 let cassette: Cassette = serde_json::from_str(s)?;
40 Ok(normalize(&cassette))
41}
42
43#[must_use]
50pub fn normalize(cassette: &Cassette) -> Run {
51 let steps = cassette
52 .exchanges
53 .iter()
54 .enumerate()
55 .map(|(idx, exchange)| step_from_exchange(idx, exchange))
56 .collect();
57 Run {
58 schema_version: SchemaVersion::current(),
59 id: cassette.id.clone(),
60 task: None,
61 outcome: None,
62 steps,
63 edges: None,
64 }
65}
66
67fn step_from_exchange(idx: usize, exchange: &Exchange) -> Step {
73 let mut attrs = Map::new();
74 attrs.insert("status".to_string(), exchange.response.status.into());
77 Step {
78 idx,
79 kind: StepKind::Llm,
80 name: model_name(&exchange.request.body),
81 inputs: Some(payload_from_body(&exchange.request.body)),
82 outputs: Some(payload_from_body(&exchange.response.body)),
83 attrs,
84 t_start: None,
85 t_end: None,
86 parent_idx: None,
87 }
88}
89
90fn model_name(body: &Body) -> String {
94 let Body::Json(Value::Object(map)) = body else {
95 return UNNAMED_LLM.to_string();
96 };
97 map.get("model")
98 .and_then(Value::as_str)
99 .unwrap_or(UNNAMED_LLM)
100 .to_string()
101}
102
103fn payload_from_body(body: &Body) -> Payload {
107 match body {
108 Body::Json(Value::Object(map)) => Payload::Object(map.clone()),
109 Body::Json(value) => Payload::Other(value.clone()),
110 Body::Text(text) => Payload::Text(text.clone()),
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::cassette::{CapturedRequest, CapturedResponse, CassetteVersion};
118 use serde_json::json;
119
120 fn exchange(idx: usize, request: Body, status: u16, response: Body) -> Exchange {
121 Exchange {
122 idx,
123 request: CapturedRequest {
124 method: "POST".to_string(),
125 path: "/v1/chat/completions".to_string(),
126 headers: Vec::new(),
127 body: request,
128 },
129 response: CapturedResponse {
130 status,
131 headers: Vec::new(),
132 body: response,
133 },
134 }
135 }
136
137 fn cassette(exchanges: Vec<Exchange>) -> Cassette {
138 Cassette {
139 cassette_version: CassetteVersion::current(),
140 id: "test-run".to_string(),
141 exchanges,
142 }
143 }
144
145 #[test]
146 fn each_exchange_becomes_one_llm_step_in_capture_order() {
147 let cass = cassette(vec![
148 exchange(
149 0,
150 Body::Json(json!({"model": "claude-sonnet-5"})),
151 200,
152 Body::Json(json!({"n": 1})),
153 ),
154 exchange(
155 1,
156 Body::Json(json!({"model": "claude-sonnet-5"})),
157 200,
158 Body::Json(json!({"n": 2})),
159 ),
160 ]);
161 let run = normalize(&cass);
162 assert_eq!(run.id, "test-run");
163 assert_eq!(run.steps.len(), 2);
164 assert_eq!(run.steps[0].idx, 0);
165 assert_eq!(run.steps[1].idx, 1);
166 assert!(run.steps.iter().all(|s| s.kind == StepKind::Llm));
167 assert!(run.edges.is_none());
169 assert!(run.steps.iter().all(|s| s.parent_idx.is_none()));
170 }
171
172 #[test]
173 fn every_recorded_step_carries_both_inputs_and_outputs() {
174 let cass = cassette(vec![exchange(
178 0,
179 Body::Json(
180 json!({"messages": [{"role": "user", "content": "Handle refund for 8841"}]}),
181 ),
182 200,
183 Body::Json(json!({"choices": [{"message": {"content": "Looking up the order."}}]})),
184 )]);
185 let run = normalize(&cass);
186 assert!(
187 run.steps
188 .iter()
189 .all(|s| s.inputs.is_some() && s.outputs.is_some())
190 );
191 }
192
193 #[test]
194 fn json_object_body_becomes_a_field_diffable_object_payload() {
195 let cass = cassette(vec![exchange(
196 0,
197 Body::Json(json!({"model": "claude-sonnet-5", "temperature": 0})),
198 200,
199 Body::Json(json!({"choices": []})),
200 )]);
201 let run = normalize(&cass);
202 let inputs = run.steps[0].inputs.as_ref().expect("inputs present");
203 let Payload::Object(map) = inputs else {
204 panic!("a JSON object request must map to Payload::Object, got {inputs:?}");
205 };
206 assert_eq!(map["temperature"], json!(0));
207 }
208
209 #[test]
210 fn non_json_response_degrades_to_text_not_silence() {
211 let cass = cassette(vec![exchange(
214 0,
215 Body::Json(json!({"model": "claude-sonnet-5"})),
216 502,
217 Body::Text("<html>502 Bad Gateway</html>".to_string()),
218 )]);
219 let run = normalize(&cass);
220 let outputs = run.steps[0].outputs.as_ref().expect("output present");
221 assert_eq!(
222 *outputs,
223 Payload::Text("<html>502 Bad Gateway</html>".to_string())
224 );
225 }
226
227 #[test]
228 fn step_name_is_the_model_when_the_request_names_one() {
229 let cass = cassette(vec![exchange(
232 0,
233 Body::Json(json!({"model": "claude-opus-4-8"})),
234 200,
235 Body::Json(json!({})),
236 )]);
237 assert_eq!(normalize(&cass).steps[0].name, "claude-opus-4-8");
238 }
239
240 #[test]
241 fn step_name_falls_back_when_no_model_is_named() {
242 let cass = cassette(vec![exchange(
243 0,
244 Body::Text("not json".to_string()),
245 200,
246 Body::Json(json!({})),
247 )]);
248 assert_eq!(normalize(&cass).steps[0].name, UNNAMED_LLM);
249 }
250
251 #[test]
252 fn normalize_str_parses_then_normalizes_a_well_formed_cassette() {
253 let json = r#"{
254 "cassette_version": "0.1",
255 "id": "from-str",
256 "exchanges": [
257 {
258 "idx": 0,
259 "request": { "method": "POST", "path": "/v1/messages",
260 "body": { "model": "claude-sonnet-5" } },
261 "response": { "status": 200, "body": { "ok": true } }
262 }
263 ]
264 }"#;
265 let run = normalize_str(json).expect("well-formed cassette normalizes");
266 assert_eq!(run.id, "from-str");
267 assert_eq!(run.steps.len(), 1);
268 assert_eq!(run.steps[0].name, "claude-sonnet-5");
269 }
270
271 #[test]
272 fn normalize_str_errors_on_a_broken_cassette_shape() {
273 let json = r#"{"cassette_version": "0.1", "id": "broken", "exchanges": "not-an-array"}"#;
276 assert!(normalize_str(json).is_err());
277 }
278
279 #[test]
280 fn response_status_is_kept_as_a_step_attribute() {
281 let cass = cassette(vec![exchange(
282 0,
283 Body::Json(json!({"model": "claude-sonnet-5"})),
284 429,
285 Body::Json(json!({"error": "rate_limited"})),
286 )]);
287 let run = normalize(&cass);
288 assert_eq!(run.steps[0].attrs["status"], json!(429));
289 }
290}