Skip to main content

sac/
types.rs

1use serde::{Deserialize, Serialize, Serializer};
2use serde_json::Value;
3
4fn serialize_nullable_content<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
5where
6    S: Serializer,
7{
8    match value {
9        Some(s) => serializer.serialize_str(s),
10        None => serializer.serialize_none(),
11    }
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "role", rename_all = "lowercase")]
16pub enum Message {
17    System {
18        content: String,
19    },
20    User {
21        content: String,
22    },
23    #[serde(rename = "assistant")]
24    Assistant {
25        #[serde(serialize_with = "serialize_nullable_content")]
26        content: Option<String>,
27        #[serde(
28            default,
29            alias = "reasoning_content",
30            skip_serializing_if = "Option::is_none"
31        )]
32        reasoning_text: Option<String>,
33        #[serde(default, skip_serializing_if = "Option::is_none")]
34        reasoning_details: Option<Value>,
35        #[serde(default, skip_serializing_if = "Option::is_none")]
36        tool_calls: Option<Vec<ToolCall>>,
37    },
38    #[serde(rename = "tool")]
39    Tool {
40        tool_call_id: String,
41        content: String,
42    },
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ToolCall {
47    pub id: String,
48    #[serde(rename = "type")]
49    pub call_type: String,
50    pub function: FunctionCall,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct FunctionCall {
55    pub name: String,
56    pub arguments: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ToolDefinition {
61    #[serde(rename = "type")]
62    pub def_type: String,
63    pub function: FunctionDef,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FunctionDef {
68    pub name: String,
69    pub description: String,
70    pub parameters: Value,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
74pub struct Usage {
75    pub prompt_tokens: Option<u32>,
76    pub completion_tokens: Option<u32>,
77    pub total_tokens: Option<u32>,
78    pub reasoning_tokens: Option<u32>,
79    /// Cached input tokens (prompt tokens served from cache).
80    /// Extracted from `prompt_tokens_details.cached_tokens` (Chat API)
81    /// or `input_tokens_details.cached_tokens` (Responses API).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub cached_tokens: Option<u32>,
84}
85
86impl Usage {
87    /// Accumulate another usage report into this one by summing each field.
88    pub fn accumulate(&mut self, other: &Usage) {
89        fn add_opt(a: &mut Option<u32>, b: Option<u32>) {
90            match (a.as_mut(), b) {
91                (Some(existing), Some(val)) => *existing = existing.saturating_add(val),
92                (None, Some(val)) => *a = Some(val),
93                _ => {}
94            }
95        }
96        add_opt(&mut self.prompt_tokens, other.prompt_tokens);
97        add_opt(&mut self.completion_tokens, other.completion_tokens);
98        add_opt(&mut self.total_tokens, other.total_tokens);
99        add_opt(&mut self.reasoning_tokens, other.reasoning_tokens);
100        add_opt(&mut self.cached_tokens, other.cached_tokens);
101    }
102
103    /// Compute the goal-accounting token delta following Codex's formula:
104    /// `(input_tokens - cached_input_tokens) + output_tokens`.
105    ///
106    /// Cached input tokens are subtracted because they represent prompt
107    /// tokens served from cache and therefore cost less.  Uses `max(0)`
108    /// on the subtraction to avoid negative deltas in the unlikely case
109    /// that cached tokens exceed prompt tokens (they are normally a
110    /// subset).
111    pub fn goal_token_delta(&self) -> i64 {
112        let prompt = self.prompt_tokens.unwrap_or(0) as i64;
113        let cached = self.cached_tokens.unwrap_or(0) as i64;
114        let completion = self.completion_tokens.unwrap_or(0) as i64;
115        (prompt - cached).max(0).saturating_add(completion)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_assistant_content_null() {
125        let msg = Message::Assistant {
126            content: None,
127            reasoning_text: Some("thinking".to_string()),
128            reasoning_details: Some(serde_json::json!([{"type": "reasoning"}])),
129            tool_calls: Some(vec![ToolCall {
130                id: "call_123".to_string(),
131                call_type: "function".to_string(),
132                function: FunctionCall {
133                    name: "read".to_string(),
134                    arguments: r#"{"path": "src/main.rs"}"#.to_string(),
135                },
136            }]),
137        };
138        let json = serde_json::to_string(&msg).unwrap();
139        assert!(
140            json.contains("\"content\":null"),
141            "Expected \"content\":null in JSON but got: {}",
142            json
143        );
144        assert!(
145            json.contains("tool_calls"),
146            "Expected tool_calls in JSON: {}",
147            json
148        );
149        assert!(
150            json.contains("\"reasoning_text\":\"thinking\""),
151            "Expected reasoning_text in JSON: {}",
152            json
153        );
154        assert!(
155            json.contains("\"reasoning_details\":[{\"type\":\"reasoning\"}]"),
156            "Expected reasoning_details in JSON: {}",
157            json
158        );
159    }
160
161    #[test]
162    fn test_assistant_reasoning_content_alias_deserializes() {
163        let json = r#"{
164            "role":"assistant",
165            "content":"hello",
166            "reasoning_content":"thinking",
167            "tool_calls":[]
168        }"#;
169        let parsed: Message = serde_json::from_str(json).unwrap();
170        match parsed {
171            Message::Assistant {
172                content,
173                reasoning_text,
174                reasoning_details,
175                tool_calls,
176            } => {
177                assert_eq!(content.as_deref(), Some("hello"));
178                assert_eq!(reasoning_text.as_deref(), Some("thinking"));
179                assert_eq!(reasoning_details, None);
180                assert_eq!(
181                    tool_calls.as_ref().map(std::vec::Vec::len),
182                    Some(0),
183                    "expected empty tool call list"
184                );
185            }
186            other => panic!("expected assistant message, got {:?}", other),
187        }
188    }
189
190    #[test]
191    fn test_message_role_serialization() {
192        let system = Message::System {
193            content: "hello".to_string(),
194        };
195        let json = serde_json::to_string(&system).unwrap();
196        assert!(json.contains("\"role\":\"system\""), "Got: {}", json);
197
198        let user = Message::User {
199            content: "hi".to_string(),
200        };
201        let json = serde_json::to_string(&user).unwrap();
202        assert!(json.contains("\"role\":\"user\""), "Got: {}", json);
203
204        let tool = Message::Tool {
205            tool_call_id: "call_abc".to_string(),
206            content: "result".to_string(),
207        };
208        let json = serde_json::to_string(&tool).unwrap();
209        assert!(json.contains("\"role\":\"tool\""), "Got: {}", json);
210        assert!(json.contains("tool_call_id"), "Got: {}", json);
211    }
212
213    #[test]
214    fn goal_token_delta_without_cached_tokens() {
215        let usage = Usage {
216            prompt_tokens: Some(100),
217            completion_tokens: Some(50),
218            total_tokens: Some(150),
219            reasoning_tokens: None,
220            cached_tokens: None,
221        };
222        // Without cached tokens, delta = prompt + completion
223        assert_eq!(usage.goal_token_delta(), 150);
224    }
225
226    #[test]
227    fn goal_token_delta_with_cached_tokens() {
228        let usage = Usage {
229            prompt_tokens: Some(1000),
230            completion_tokens: Some(200),
231            total_tokens: Some(1200),
232            reasoning_tokens: None,
233            cached_tokens: Some(800),
234        };
235        // (1000 - 800) + 200 = 400
236        assert_eq!(usage.goal_token_delta(), 400);
237    }
238
239    #[test]
240    fn goal_token_delta_cached_exceeds_prompt_clamps_to_zero() {
241        let usage = Usage {
242            prompt_tokens: Some(100),
243            completion_tokens: Some(50),
244            total_tokens: Some(150),
245            reasoning_tokens: None,
246            cached_tokens: Some(200),
247        };
248        // (100 - 200) clamps to 0, then + 50 = 50
249        assert_eq!(usage.goal_token_delta(), 50);
250    }
251
252    #[test]
253    fn goal_token_delta_all_none() {
254        let usage = Usage::default();
255        assert_eq!(usage.goal_token_delta(), 0);
256    }
257
258    #[test]
259    fn accumulate_includes_cached_tokens() {
260        let mut total = Usage {
261            prompt_tokens: Some(100),
262            completion_tokens: Some(50),
263            total_tokens: Some(150),
264            reasoning_tokens: None,
265            cached_tokens: Some(80),
266        };
267        let other = Usage {
268            prompt_tokens: Some(200),
269            completion_tokens: Some(60),
270            total_tokens: Some(260),
271            reasoning_tokens: None,
272            cached_tokens: Some(150),
273        };
274        total.accumulate(&other);
275        assert_eq!(total.prompt_tokens, Some(300));
276        assert_eq!(total.completion_tokens, Some(110));
277        assert_eq!(total.total_tokens, Some(410));
278        assert_eq!(total.cached_tokens, Some(230));
279    }
280
281    #[test]
282    fn accumulate_cached_tokens_none_plus_some() {
283        let mut total = Usage::default();
284        let other = Usage {
285            cached_tokens: Some(100),
286            ..Default::default()
287        };
288        total.accumulate(&other);
289        assert_eq!(total.cached_tokens, Some(100));
290    }
291}