Skip to main content

kaizen/collect/
model_from_json.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Best-effort LLM model id from transcript JSONL lines or hook payloads.
3
4use serde_json::Value;
5
6fn non_empty(s: &str) -> Option<String> {
7    let t = s.trim();
8    if t.is_empty() {
9        None
10    } else {
11        Some(t.to_string())
12    }
13}
14
15/// Read model from a JSON object (transcript line or hook body).
16/// Tries `model`, then nested paths used by common agent formats.
17pub fn from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
18    if let Some(s) = obj
19        .get("model")
20        .and_then(|v| v.as_str())
21        .and_then(non_empty)
22    {
23        return Some(s);
24    }
25    for (parent, key) in [
26        ("message", "model"),
27        ("metadata", "model"),
28        ("config", "model"),
29    ] {
30        if let Some(s) = obj
31            .get(parent)
32            .and_then(|o| o.get(key))
33            .and_then(|v| v.as_str())
34            .and_then(non_empty)
35        {
36            return Some(s);
37        }
38    }
39    None
40}
41
42/// Parse a single JSONL line and return a model id when present.
43pub fn from_line(line: &str) -> Option<String> {
44    let v: Value = serde_json::from_str(line.trim()).ok()?;
45    v.as_object().and_then(from_object)
46}
47
48/// Extract model from a JSON value (e.g. hook `payload`).
49pub fn from_value(v: &Value) -> Option<String> {
50    v.as_object().and_then(from_object)
51}
52
53/// Extract channel from a JSON object (OpenClaw `origin.channel`, `channel`, or `source`).
54pub fn channel_from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
55    if let Some(s) = obj
56        .get("origin")
57        .and_then(|o| o.get("channel"))
58        .and_then(|v| v.as_str())
59        .and_then(non_empty)
60    {
61        return Some(s);
62    }
63    for key in ["channel", "source"] {
64        if let Some(s) = obj.get(key).and_then(|v| v.as_str()).and_then(non_empty) {
65            return Some(s);
66        }
67    }
68    None
69}
70
71/// Extract provider from a JSON object (OpenClaw `provider` or `message.provider`).
72pub fn provider_from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
73    if let Some(s) = obj
74        .get("provider")
75        .and_then(|v| v.as_str())
76        .and_then(non_empty)
77    {
78        return Some(s);
79    }
80    obj.get("message")
81        .and_then(|m| m.get("provider"))
82        .and_then(|v| v.as_str())
83        .and_then(non_empty)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn cursor_system_init() {
92        let j = r#"{"type":"system","subtype":"init","session_id":"s1","model":"Claude 4 Sonnet"}"#;
93        assert_eq!(from_line(j), Some("Claude 4 Sonnet".into()));
94    }
95
96    #[test]
97    fn openai_top_level_model() {
98        let j = r#"{"model":"gpt-4o","role":"assistant"}"#;
99        assert_eq!(from_line(j), Some("gpt-4o".into()));
100    }
101
102    #[test]
103    fn message_nested_model() {
104        let v = serde_json::json!({"message": {"model": "claude-3-5-sonnet-20241022"}});
105        assert_eq!(from_value(&v), Some("claude-3-5-sonnet-20241022".into()));
106    }
107
108    #[test]
109    fn empty_model_ignored() {
110        let v = serde_json::json!({"model": "  "});
111        assert_eq!(from_value(&v), None);
112    }
113}