Skip to main content

lucy/
model.rs

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