1use crate::{ModelBid, ModelCard, ModelEvent, ModelRequest, ModelResponse, ModelUsage};
2use sim_codec_chat::{
3 is_model_request_expr, model_card_expr, model_request_messages_expr, model_response_expr,
4 validate_chat_transcript,
5};
6use sim_kernel::{Error, Expr, Result, Symbol};
7
8impl From<ModelRequest> for Expr {
9 fn from(value: ModelRequest) -> Self {
10 let mut entries = vec![
11 key_bool("model-request", true),
12 key_expr("task", value.task),
13 key_expr("messages", Expr::List(value.messages)),
14 ];
15 entries.extend(value.extra);
16 Expr::Map(entries)
17 }
18}
19
20impl TryFrom<Expr> for ModelRequest {
21 type Error = Error;
22
23 fn try_from(value: Expr) -> Result<Self> {
24 if !is_model_request_expr(&value) {
25 return Err(Error::Eval(
26 "model request transcript must set model-request true".to_owned(),
27 ));
28 }
29 validate_chat_transcript(&value)?;
30 Ok(Self {
31 task: require_field(&value, "task")?.clone(),
32 messages: model_request_messages_expr(&value)?.to_vec(),
33 extra: extra_fields(&value, &["model-request", "task", "messages"]),
34 })
35 }
36}
37
38impl From<ModelResponse> for Expr {
39 fn from(value: ModelResponse) -> Self {
40 let mut expr = match model_response_expr(
41 value.runner,
42 value.model,
43 value.content,
44 value.stop_reason,
45 ) {
46 Expr::Map(entries) => entries,
47 _ => unreachable!("model_response_expr always returns a map"),
48 };
49 if let Some(usage) = value.usage {
50 expr.push(key_expr("usage", usage.into()));
51 }
52 expr.extend(value.extra);
53 Expr::Map(expr)
54 }
55}
56
57impl TryFrom<Expr> for ModelResponse {
58 type Error = Error;
59
60 fn try_from(value: Expr) -> Result<Self> {
61 validate_chat_transcript(&value)?;
62 expect_marker(&value, "model-response")?;
63 Ok(Self {
64 runner: require_symbol_field(&value, "runner")?.clone(),
65 model: require_string_field(&value, "model")?.to_owned(),
66 content: require_list_field(&value, "content")?.to_vec(),
67 stop_reason: require_symbol_field(&value, "stop-reason")?.clone(),
68 usage: field(&value, "usage")
69 .map(ModelUsage::from_expr)
70 .transpose()?,
71 extra: extra_fields(
72 &value,
73 &[
74 "model-response",
75 "runner",
76 "model",
77 "content",
78 "stop-reason",
79 "usage",
80 ],
81 ),
82 })
83 }
84}
85
86impl From<ModelUsage> for Expr {
87 fn from(value: ModelUsage) -> Self {
88 let mut entries = Vec::new();
89 if let Some(tokens) = value.input_tokens {
90 entries.push(key_expr("input-tokens", number_expr(tokens)));
91 }
92 if let Some(tokens) = value.output_tokens {
93 entries.push(key_expr("output-tokens", number_expr(tokens)));
94 }
95 if let Some(latency) = value.latency_ms {
96 entries.push(key_expr("latency-ms", number_expr(latency)));
97 }
98 if let Some(cost) = value.cost_usd {
99 entries.push(key_expr("cost-usd", float_expr(cost)));
100 }
101 entries.extend(value.extra);
102 Expr::Map(entries)
103 }
104}
105
106impl ModelUsage {
107 fn from_expr(value: &Expr) -> Result<Self> {
108 let Expr::Map(_) = value else {
109 return Err(Error::Eval("model usage must be a map".to_owned()));
110 };
111 Ok(Self {
112 input_tokens: field(value, "input-tokens").map(parse_u64).transpose()?,
113 output_tokens: field(value, "output-tokens").map(parse_u64).transpose()?,
114 latency_ms: field(value, "latency-ms").map(parse_u64).transpose()?,
115 cost_usd: field(value, "cost-usd").map(parse_f64).transpose()?,
116 extra: extra_fields(
117 value,
118 &["input-tokens", "output-tokens", "latency-ms", "cost-usd"],
119 ),
120 })
121 }
122}
123
124impl From<ModelEvent> for Expr {
125 fn from(value: ModelEvent) -> Self {
126 let mut entries = vec![
127 key_bool("model-event", true),
128 key_expr("event", Expr::Symbol(value.event)),
129 key_expr("runner", Expr::Symbol(value.runner)),
130 key_expr("model", Expr::String(value.model)),
131 key_expr("span-id", value.span_id),
132 ];
133 if let Some(response) = value.response {
134 entries.push(key_expr("response", response.into()));
135 }
136 entries.extend(value.extra);
137 Expr::Map(entries)
138 }
139}
140
141impl TryFrom<Expr> for ModelEvent {
142 type Error = Error;
143
144 fn try_from(value: Expr) -> Result<Self> {
145 validate_chat_transcript(&value)?;
146 expect_marker(&value, "model-event")?;
147 Ok(Self {
148 event: require_symbol_field(&value, "event")?.clone(),
149 runner: require_symbol_field(&value, "runner")?.clone(),
150 model: require_string_field(&value, "model")?.to_owned(),
151 span_id: require_field(&value, "span-id")?.clone(),
152 response: field(&value, "response")
153 .cloned()
154 .map(ModelResponse::try_from)
155 .transpose()?,
156 extra: extra_fields(
157 &value,
158 &[
159 "model-event",
160 "event",
161 "runner",
162 "model",
163 "span-id",
164 "response",
165 ],
166 ),
167 })
168 }
169}
170
171impl From<ModelCard> for Expr {
172 fn from(value: ModelCard) -> Self {
173 let mut expr =
174 match model_card_expr(value.runner, value.model, value.provider, value.locality) {
175 Expr::Map(entries) => entries,
176 _ => unreachable!("model_card_expr always returns a map"),
177 };
178 expr.extend(value.extra);
179 Expr::Map(expr)
180 }
181}
182
183impl TryFrom<Expr> for ModelCard {
184 type Error = Error;
185
186 fn try_from(value: Expr) -> Result<Self> {
187 validate_chat_transcript(&value)?;
188 expect_marker(&value, "model-card")?;
189 Ok(Self {
190 runner: require_symbol_field(&value, "runner")?.clone(),
191 model: require_string_field(&value, "model")?.to_owned(),
192 provider: require_symbol_field(&value, "provider")?.clone(),
193 locality: require_symbol_field(&value, "locality")?.clone(),
194 extra: extra_fields(
195 &value,
196 &["model-card", "runner", "model", "provider", "locality"],
197 ),
198 })
199 }
200}
201
202impl From<ModelBid> for Expr {
203 fn from(value: ModelBid) -> Self {
204 let mut entries = vec![key_bool("model-bid", value.available)];
205 if let Some(reason) = value.reason {
206 entries.push(key_expr("reason", Expr::String(reason)));
207 }
208 if let Some(score) = value.score {
209 entries.push(key_expr("score", float_expr(score)));
210 }
211 if let Some(model) = value.model {
212 entries.push(key_expr("model", Expr::String(model)));
213 }
214 entries.extend(value.extra);
215 Expr::Map(entries)
216 }
217}
218
219impl TryFrom<Expr> for ModelBid {
220 type Error = Error;
221
222 fn try_from(value: Expr) -> Result<Self> {
223 Ok(Self {
224 available: match field(&value, "model-bid") {
225 Some(Expr::Bool(flag)) => *flag,
226 _ => {
227 return Err(Error::Eval(
228 "chat transcript must set model-bid to a bool".to_owned(),
229 ));
230 }
231 },
232 reason: field(&value, "reason")
233 .map(require_string)
234 .transpose()?
235 .map(str::to_owned),
236 score: field(&value, "score").map(parse_f64).transpose()?,
237 model: field(&value, "model")
238 .map(require_string)
239 .transpose()?
240 .map(str::to_owned),
241 extra: extra_fields(&value, &["model-bid", "reason", "score", "model"]),
242 })
243 }
244}
245
246use sim_value::access::field;
247
248fn require_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Expr> {
249 field(expr, name).ok_or_else(|| Error::Eval(format!("chat transcript missing {name} field")))
250}
251
252fn require_list_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a [Expr]> {
253 match require_field(expr, name)? {
254 Expr::List(items) => Ok(items),
255 _ => Err(Error::Eval(format!(
256 "chat transcript {name} field must be a list"
257 ))),
258 }
259}
260
261fn require_symbol_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Symbol> {
262 match require_field(expr, name)? {
263 Expr::Symbol(symbol) => Ok(symbol),
264 _ => Err(Error::Eval(format!(
265 "chat transcript {name} field must be a symbol"
266 ))),
267 }
268}
269
270fn require_string_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a str> {
271 require_string(require_field(expr, name)?)
272}
273
274fn require_string(expr: &Expr) -> Result<&str> {
275 match expr {
276 Expr::String(text) => Ok(text),
277 _ => Err(Error::Eval(
278 "chat transcript field must be a string".to_owned(),
279 )),
280 }
281}
282
283fn expect_marker(expr: &Expr, name: &str) -> Result<()> {
284 if matches!(field(expr, name), Some(Expr::Bool(true))) {
285 Ok(())
286 } else {
287 Err(Error::Eval(format!("chat transcript must set {name} true")))
288 }
289}
290
291fn extra_fields(expr: &Expr, known: &[&str]) -> Vec<(Expr, Expr)> {
292 let Expr::Map(entries) = expr else {
293 return Vec::new();
294 };
295 entries
296 .iter()
297 .filter(|(key, _)| !is_known_field(key, known))
298 .cloned()
299 .collect()
300}
301
302fn is_known_field(key: &Expr, known: &[&str]) -> bool {
303 match key {
304 Expr::Symbol(symbol) if symbol.namespace.is_none() => {
305 known.iter().any(|name| symbol.name.as_ref() == *name)
306 }
307 _ => false,
308 }
309}
310
311fn key_bool(name: &str, value: bool) -> (Expr, Expr) {
312 key_expr(name, Expr::Bool(value))
313}
314
315use sim_value::build::entry as key_expr;
316
317fn number_expr(value: u64) -> Expr {
318 sim_value::build::num_q(Some("numbers"), "f64", &value.to_string())
319}
320
321fn float_expr(value: f64) -> Expr {
322 sim_value::build::num_q(Some("numbers"), "f64", &value.to_string())
323}
324
325fn parse_u64(expr: &Expr) -> Result<u64> {
326 match expr {
327 Expr::Number(number) => number
328 .canonical
329 .parse()
330 .map_err(|_| Error::Eval("numeric transcript field must be an integer".to_owned())),
331 _ => Err(Error::Eval(
332 "numeric transcript field must be a number literal".to_owned(),
333 )),
334 }
335}
336
337fn parse_f64(expr: &Expr) -> Result<f64> {
338 match expr {
339 Expr::Number(number) => number
340 .canonical
341 .parse()
342 .map_err(|_| Error::Eval("numeric transcript field must be numeric".to_owned())),
343 _ => Err(Error::Eval(
344 "numeric transcript field must be a number literal".to_owned(),
345 )),
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use crate::{ModelBid, ModelCard, ModelEvent, ModelRequest, ModelResponse, ModelUsage};
352 use sim_codec_chat::validate_chat_transcript;
353 use sim_kernel::{Expr, Symbol};
354
355 #[test]
356 fn model_request_round_trips() {
357 let mut request = ModelRequest::new(
358 Expr::String("summarize".to_owned()),
359 vec![Expr::Map(vec![
360 (
361 Expr::Symbol(Symbol::new("role")),
362 Expr::Symbol(Symbol::new("user")),
363 ),
364 (
365 Expr::Symbol(Symbol::new("content")),
366 Expr::List(vec![Expr::Map(vec![
367 (
368 Expr::Symbol(Symbol::new("type")),
369 Expr::Symbol(Symbol::new("text")),
370 ),
371 (
372 Expr::Symbol(Symbol::new("text")),
373 Expr::String("hello".to_owned()),
374 ),
375 ])]),
376 ),
377 ])],
378 );
379 request.extra.push((
380 Expr::Symbol(Symbol::new("metadata")),
381 Expr::Map(vec![(
382 Expr::Symbol(Symbol::new("ticket")),
383 Expr::String("A5".to_owned()),
384 )]),
385 ));
386
387 let expr: Expr = request.clone().into();
388 validate_chat_transcript(&expr).unwrap();
389 let decoded = ModelRequest::try_from(expr).unwrap();
390 assert_eq!(decoded, request);
391 }
392
393 #[test]
394 fn model_response_usage_round_trips() {
395 let mut response = ModelResponse::new(
396 Symbol::new("runner"),
397 "fake/model",
398 vec![Expr::Map(vec![
399 (
400 Expr::Symbol(Symbol::new("type")),
401 Expr::Symbol(Symbol::new("text")),
402 ),
403 (
404 Expr::Symbol(Symbol::new("text")),
405 Expr::String("done".to_owned()),
406 ),
407 ])],
408 Symbol::new("stop"),
409 );
410 response.usage = Some(ModelUsage {
411 input_tokens: Some(10),
412 output_tokens: Some(2),
413 latency_ms: Some(5),
414 cost_usd: Some(0.0),
415 extra: Vec::new(),
416 });
417 response
418 .extra
419 .push((Expr::Symbol(Symbol::new("shape-ok")), Expr::Bool(true)));
420
421 let expr: Expr = response.clone().into();
422 validate_chat_transcript(&expr).unwrap();
423 let decoded = ModelResponse::try_from(expr).unwrap();
424 assert_eq!(decoded, response);
425 }
426
427 #[test]
428 fn model_event_card_and_bid_round_trip() {
429 let mut event = ModelEvent::new(
430 Symbol::new("usage"),
431 Symbol::new("runner"),
432 "fake/model",
433 Expr::String("span-1".to_owned()),
434 );
435 event.extra.push((
436 Expr::Symbol(Symbol::new("detail")),
437 Expr::String("tracked".to_owned()),
438 ));
439 let expr: Expr = event.clone().into();
440 validate_chat_transcript(&expr).unwrap();
441 assert_eq!(ModelEvent::try_from(expr).unwrap(), event);
442
443 let mut card = ModelCard::new(
444 Symbol::new("runner"),
445 "fake/model",
446 Symbol::new("fake"),
447 Symbol::new("local"),
448 );
449 card.extra.push((
450 Expr::Symbol(Symbol::new("supports-shape")),
451 Expr::Bool(true),
452 ));
453 let expr: Expr = card.into();
454 validate_chat_transcript(&expr).unwrap();
455 let decoded = ModelCard::try_from(expr).unwrap();
456 assert_eq!(decoded.runner, Symbol::new("runner"));
457 assert_eq!(decoded.model, "fake/model");
458 assert!(decoded.extra.iter().any(|(key, value)| {
459 *key == Expr::Symbol(Symbol::new("supports-shape")) && *value == Expr::Bool(true)
460 }));
461
462 let bid = ModelBid::unavailable("busy");
463 let expr: Expr = bid.clone().into();
464 assert_eq!(ModelBid::try_from(expr).unwrap().reason, bid.reason);
465 }
466}