Skip to main content

lucy/
model.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{json, Value};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct ChatMessage {
6    pub role: String,
7    #[serde(default, skip_serializing_if = "Option::is_none")]
8    pub content: Option<String>,
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub reasoning_details: Option<Vec<Value>>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub name: Option<String>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub tool_call_id: Option<String>,
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub tool_calls: Vec<ChatToolCall>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct ChatToolCall {
21    pub id: String,
22    pub name: String,
23    pub arguments: String,
24}
25
26/// Reasoning detail formats whose `reasoning.text` entries are rejected by the
27/// upstream provider when they carry no signature.
28///
29/// Anthropic and Gemini verify the signature of every thinking block that is
30/// sent back. A streamed turn arrives as many `reasoning.text` fragments that
31/// share one `index`, and only the final fragment carries the signature, so
32/// replaying the fragments verbatim makes the provider reject the request with
33/// HTTP 400. Signed entries, unsigned entries in other formats, and every
34/// non-text entry stay untouched.
35///
36/// In practice the signed fragment of an Anthropic turn carries no `text`, so
37/// this deliberately stops replaying that turn's thinking text and keeps only
38/// the signed marker. Lucy does not reassemble the fragments: the concatenation
39/// rule is undocumented, whereas dropping unsigned fragments is what the
40/// upstream OpenRouter SDK does.
41const SIGNATURE_REQUIRING_REASONING_FORMATS: [&str; 2] =
42    ["anthropic-claude-v1", "google-gemini-v1"];
43
44fn is_sendable_reasoning_detail(detail: &Value) -> bool {
45    if detail.get("type").and_then(Value::as_str) != Some("reasoning.text") {
46        return true;
47    }
48    let Some(format) = detail.get("format").and_then(Value::as_str) else {
49        return true;
50    };
51    if !SIGNATURE_REQUIRING_REASONING_FORMATS.contains(&format) {
52        return true;
53    }
54    detail
55        .get("signature")
56        .and_then(Value::as_str)
57        .is_some_and(|signature| !signature.trim().is_empty())
58}
59
60fn sendable_reasoning_details(details: &[Value]) -> Vec<Value> {
61    details
62        .iter()
63        .filter(|detail| is_sendable_reasoning_detail(detail))
64        .cloned()
65        .collect()
66}
67
68/// Estimate the number of context tokens represented by provider messages.
69///
70/// Lucy supports arbitrary OpenAI-compatible providers and does not bundle a
71/// provider-specific tokenizer. Four UTF-8 bytes per token is therefore a
72/// deliberately conservative display estimate; the statusline should expose
73/// context pressure without pretending that every provider uses the same
74/// tokenizer.
75pub(crate) fn estimate_message_tokens(message: &ChatMessage) -> usize {
76    serde_json::to_vec(message)
77        .map(|encoded| encoded.len().div_ceil(4).max(1))
78        .unwrap_or(1)
79}
80
81pub(crate) fn estimate_context_tokens(messages: &[ChatMessage]) -> usize {
82    messages
83        .iter()
84        .map(estimate_message_tokens)
85        .sum::<usize>()
86        .max(1)
87}
88
89impl ChatMessage {
90    pub fn system(content: String) -> Self {
91        Self {
92            role: "system".to_owned(),
93            content: Some(content),
94            reasoning_details: None,
95            name: None,
96            tool_call_id: None,
97            tool_calls: Vec::new(),
98        }
99    }
100
101    pub fn user(content: String) -> Self {
102        Self {
103            role: "user".to_owned(),
104            content: Some(content),
105            reasoning_details: None,
106            name: None,
107            tool_call_id: None,
108            tool_calls: Vec::new(),
109        }
110    }
111
112    pub fn assistant(content: String, tool_calls: Vec<ChatToolCall>) -> Self {
113        Self {
114            role: "assistant".to_owned(),
115            content: (!content.is_empty()).then_some(content),
116            reasoning_details: None,
117            name: None,
118            tool_call_id: None,
119            tool_calls,
120        }
121    }
122
123    pub fn tool(tool_call_id: String, name: String, content: String) -> Self {
124        Self {
125            role: "tool".to_owned(),
126            content: Some(content),
127            reasoning_details: None,
128            name: Some(name),
129            tool_call_id: Some(tool_call_id),
130            tool_calls: Vec::new(),
131        }
132    }
133
134    pub fn to_openai_value(&self) -> Value {
135        let mut message = json!({
136            "role": self.role,
137            "content": self.content,
138        });
139        if self.role == "assistant" {
140            if let Some(reasoning_details) = &self.reasoning_details {
141                let sendable = sendable_reasoning_details(reasoning_details);
142                if !sendable.is_empty() {
143                    message["reasoning_details"] = Value::Array(sendable);
144                }
145            }
146        }
147        if let Some(name) = &self.name {
148            message["name"] = Value::String(name.clone());
149        }
150        if let Some(tool_call_id) = &self.tool_call_id {
151            message["tool_call_id"] = Value::String(tool_call_id.clone());
152        }
153        if !self.tool_calls.is_empty() {
154            message["tool_calls"] = Value::Array(
155                self.tool_calls
156                    .iter()
157                    .map(|tool_call| {
158                        json!({
159                            "id": tool_call.id,
160                            "type": "function",
161                            "function": {
162                                "name": tool_call.name,
163                                "arguments": tool_call.arguments,
164                            }
165                        })
166                    })
167                    .collect(),
168            );
169        }
170        message
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn context_token_estimate_is_nonzero_and_grows_with_messages() {
180        let empty = estimate_context_tokens(&[]);
181        let one = estimate_context_tokens(&[ChatMessage::user("hello".to_owned())]);
182
183        assert_eq!(empty, 1);
184        assert!(one > empty);
185        assert!(estimate_message_tokens(&ChatMessage::user("hello".to_owned())) > 0);
186    }
187
188    #[test]
189    fn tool_assistant_messages_have_openai_compatible_shape() {
190        let assistant = ChatMessage::assistant(
191            String::new(),
192            vec![ChatToolCall {
193                id: "call-1".to_owned(),
194                name: "cmd".to_owned(),
195                arguments: r#"{"command":"pwd"}"#.to_owned(),
196            }],
197        );
198        assert_eq!(assistant.to_openai_value()["content"], Value::Null);
199        assert_eq!(
200            assistant.to_openai_value()["tool_calls"][0]["type"],
201            "function"
202        );
203
204        let tool = ChatMessage::tool(
205            "call-1".to_owned(),
206            "cmd".to_owned(),
207            "{\"exit_code\":0}".to_owned(),
208        );
209        assert_eq!(tool.to_openai_value()["tool_call_id"], "call-1");
210        assert_eq!(tool.to_openai_value()["name"], "cmd");
211    }
212
213    #[test]
214    fn reasoning_details_are_optional_and_only_sent_for_assistant_messages() {
215        let details = vec![json!({
216            "type": "reasoning.text",
217            "text": "private reasoning"
218        })];
219        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
220        assistant.reasoning_details = Some(details.clone());
221        assert_eq!(
222            assistant.to_openai_value()["reasoning_details"],
223            json!(details)
224        );
225
226        let old_message: ChatMessage = serde_json::from_value(json!({
227            "role": "assistant",
228            "content": "old session"
229        }))
230        .expect("old message without reasoning details");
231        assert_eq!(old_message.reasoning_details, None);
232
233        let mut user = ChatMessage::user("question".to_owned());
234        user.reasoning_details = Some(vec![json!({"text": "must not be sent"})]);
235        assert!(user.to_openai_value().get("reasoning_details").is_none());
236    }
237
238    #[test]
239    fn unsigned_thinking_fragments_are_not_replayed_to_the_provider() {
240        let signed = json!({
241            "type": "reasoning.text",
242            "format": "anthropic-claude-v1",
243            "index": 0,
244            "signature": "CAIS0AIK"
245        });
246        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
247        assistant.reasoning_details = Some(vec![
248            json!({
249                "type": "reasoning.text",
250                "format": "anthropic-claude-v1",
251                "index": 0,
252                "text": "I"
253            }),
254            json!({
255                "type": "reasoning.text",
256                "format": "google-gemini-v1",
257                "index": 0,
258                "text": " need to inspect the repository."
259            }),
260            json!({
261                "type": "reasoning.text",
262                "format": "anthropic-claude-v1",
263                "index": 0,
264                "signature": ""
265            }),
266            json!({
267                "type": "reasoning.text",
268                "format": "anthropic-claude-v1",
269                "index": 0,
270                "signature": "   "
271            }),
272            json!({
273                "type": "reasoning.text",
274                "format": "some-other-provider-v1",
275                "text": "unsigned but not signature checked"
276            }),
277            json!({
278                "type": "reasoning.encrypted",
279                "id": "call-1",
280                "data": "opaque"
281            }),
282            signed.clone(),
283        ]);
284
285        assert_eq!(
286            assistant.to_openai_value()["reasoning_details"],
287            json!([
288                {
289                    "type": "reasoning.text",
290                    "format": "some-other-provider-v1",
291                    "text": "unsigned but not signature checked"
292                },
293                {
294                    "type": "reasoning.encrypted",
295                    "id": "call-1",
296                    "data": "opaque"
297                },
298                signed,
299            ])
300        );
301    }
302
303    #[test]
304    fn assistant_messages_omit_reasoning_details_when_every_entry_is_unsigned() {
305        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
306        assistant.reasoning_details = Some(vec![json!({
307            "type": "reasoning.text",
308            "format": "anthropic-claude-v1",
309            "index": 0,
310            "text": "only an unsigned fragment"
311        })]);
312
313        assert!(assistant
314            .to_openai_value()
315            .get("reasoning_details")
316            .is_none());
317    }
318}