Skip to main content

sim_codec_chat/
parts.rs

1//! Shared chat content-part and token-usage builders.
2//!
3//! The chat message content-part shape `{type: text, text: <text>}` and the
4//! `numbers/f64` token-usage entries were independently re-grown by every crate
5//! that bridges a provider transcript (OpenAI server, Ollama codec, HTTP and
6//! process runners). These builders are the one home for that shape so the
7//! canonical spelling cannot drift.
8
9use sim_kernel::{Expr, NumberLiteral, Symbol};
10
11/// A chat text content part: the map `{type: text, text: <text>}`.
12pub fn text_part(text: &str) -> Expr {
13    Expr::Map(vec![
14        (
15            Expr::Symbol(Symbol::new("type")),
16            Expr::Symbol(Symbol::new("text")),
17        ),
18        (
19            Expr::Symbol(Symbol::new("text")),
20            Expr::String(text.to_owned()),
21        ),
22    ])
23}
24
25/// A `numbers/f64`-domain map entry `(name, value)` for usage and metric
26/// records.
27pub fn number_field(name: &str, value: u64) -> (Expr, Expr) {
28    (
29        Expr::Symbol(Symbol::new(name)),
30        Expr::Number(NumberLiteral {
31            domain: Symbol::qualified("numbers", "f64"),
32            canonical: value.to_string(),
33        }),
34    )
35}
36
37/// The canonical token-usage entries built from optional counts, in the fixed
38/// order `input-tokens`, `output-tokens`, `total-tokens`. Each present count
39/// becomes one [`number_field`]; absent counts are skipped. The caller decides
40/// how to wrap the result, because providers differ: some emit a usage map even
41/// when empty, others omit usage entirely when no counts are present.
42pub fn usage_record(
43    input: Option<u64>,
44    output: Option<u64>,
45    total: Option<u64>,
46) -> Vec<(Expr, Expr)> {
47    let mut fields = Vec::new();
48    if let Some(value) = input {
49        fields.push(number_field("input-tokens", value));
50    }
51    if let Some(value) = output {
52        fields.push(number_field("output-tokens", value));
53    }
54    if let Some(value) = total {
55        fields.push(number_field("total-tokens", value));
56    }
57    fields
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn text_part_shape_is_stable() {
66        assert_eq!(
67            text_part("hi"),
68            Expr::Map(vec![
69                (
70                    Expr::Symbol(Symbol::new("type")),
71                    Expr::Symbol(Symbol::new("text")),
72                ),
73                (
74                    Expr::Symbol(Symbol::new("text")),
75                    Expr::String("hi".to_owned()),
76                ),
77            ])
78        );
79    }
80
81    #[test]
82    fn number_field_uses_qualified_f64_domain() {
83        let (key, value) = number_field("input-tokens", 7);
84        assert_eq!(key, Expr::Symbol(Symbol::new("input-tokens")));
85        assert_eq!(
86            value,
87            Expr::Number(NumberLiteral {
88                domain: Symbol::qualified("numbers", "f64"),
89                canonical: "7".to_owned(),
90            })
91        );
92    }
93
94    #[test]
95    fn usage_record_skips_absent_counts_in_order() {
96        let fields = usage_record(Some(3), None, Some(5));
97        assert_eq!(fields.len(), 2);
98        assert_eq!(fields[0].0, Expr::Symbol(Symbol::new("input-tokens")));
99        assert_eq!(fields[1].0, Expr::Symbol(Symbol::new("total-tokens")));
100        assert!(usage_record(None, None, None).is_empty());
101    }
102}