Skip to main content

mecha_core/
message.rs

1//! Provider-agnostic conversation types.
2//!
3//! Every provider translates to and from these on the wire. Nothing in here
4//! knows about Anthropic, OpenAI, or any particular JSON shape.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Role {
12    User,
13    Assistant,
14}
15
16/// One piece of a message. A single assistant turn is often several blocks:
17/// thinking, then text, then one or more tool calls.
18///
19/// `PartialEq` because session recording decides between "append the new
20/// tail" and "the transcript was rewritten in place" by comparing the
21/// messages a run started from with what it left behind.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum Block {
25    Text {
26        text: String,
27    },
28    /// Reasoning. `signature` is opaque and must be echoed back unchanged when
29    /// continuing on the same model.
30    Thinking {
31        text: String,
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        signature: Option<String>,
34    },
35    ToolUse {
36        id: String,
37        name: String,
38        input: Value,
39    },
40    ToolResult {
41        tool_use_id: String,
42        content: String,
43        #[serde(default)]
44        is_error: bool,
45    },
46    /// An image the user put in front of the model.
47    ///
48    /// **User turns only, and that is a portability decision rather than a
49    /// simplification.** Anthropic accepts an image inside a `tool_result`;
50    /// the OpenAI dialect's `role: "tool"` messages carry a string and
51    /// nothing else, and llama-server is the same. A tool that returned
52    /// pixels would therefore work on one backend and silently lose them on
53    /// the other — the shape of failure this project keeps finding, in the
54    /// one place where the missing thing is what the whole turn was about.
55    /// So an image enters the conversation the way a person hands one over,
56    /// and `encode_message` renders it only on a user message.
57    ///
58    /// Not every model has eyes. `Provider::vision` says whether the one on
59    /// the other end does, and a backend that cannot see renders this block
60    /// as a line of text naming the file instead — so a run against a
61    /// text-only model behaves exactly as it did before this variant
62    /// existed, rather than failing on a request it cannot serve.
63    Image {
64        /// An IANA media type: `image/png`, `image/jpeg`, `image/gif`,
65        /// `image/webp`. Both providers require it and neither sniffs.
66        media_type: String,
67        /// Base64, with **no `data:` prefix**. The prefix is a rendering
68        /// detail of the OpenAI dialect — `anthropic.rs` wants the payload
69        /// bare — so it belongs to the backend that needs it and not to the
70        /// type every backend shares.
71        data: String,
72        /// What the file was called where it came from.
73        ///
74        /// Never sent to a provider. It exists because every *human*-facing
75        /// reader of a transcript — `mecha sessions`, the TUI, `recall` —
76        /// otherwise has a megabyte of base64 and no way to say what it was.
77        /// It is also what the text-only rendering names.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        source: Option<String>,
80    },
81}
82
83impl Block {
84    pub fn text(s: impl Into<String>) -> Self {
85        Block::Text { text: s.into() }
86    }
87
88    /// An image block from raw file bytes.
89    ///
90    /// One encoder, so the `data:` prefix question is answered once: what
91    /// goes in is bare base64, and the one dialect that wants a prefix adds
92    /// it at the wire.
93    pub fn image(media_type: impl Into<String>, bytes: &[u8], source: Option<String>) -> Self {
94        use base64::Engine as _;
95        Block::Image {
96            media_type: media_type.into(),
97            data: base64::engine::general_purpose::STANDARD.encode(bytes),
98            source,
99        }
100    }
101
102    /// How a person, or a model with no eyes, is told an image was here.
103    ///
104    /// Shared so the text-only provider rendering and every transcript
105    /// reader say the same thing. A reader that invented its own wording
106    /// would be a second answer to "what was in this turn".
107    pub fn image_placeholder(media_type: &str, source: Option<&str>) -> String {
108        match source {
109            Some(name) => format!("[image: {name} ({media_type})]"),
110            None => format!("[image: {media_type}]"),
111        }
112    }
113}
114
115/// The media type for a path, or `None` when it is not an image this system
116/// will send.
117///
118/// An allowlist keyed on extension, deliberately, and deliberately short: it
119/// is the intersection of what Anthropic accepts and what llama-server's
120/// mtmd stack decodes. Sniffing the bytes would be more general and would
121/// answer the wrong question — the point is not "is this an image" but "will
122/// the thing on the other end take it", and a TIFF is an image that neither
123/// backend will read.
124pub fn image_media_type(path: &std::path::Path) -> Option<&'static str> {
125    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
126    Some(match ext.as_str() {
127        "png" => "image/png",
128        "jpg" | "jpeg" => "image/jpeg",
129        "gif" => "image/gif",
130        "webp" => "image/webp",
131        _ => return None,
132    })
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct Message {
137    pub role: Role,
138    pub content: Vec<Block>,
139}
140
141impl Message {
142    pub fn user(text: impl Into<String>) -> Self {
143        Message {
144            role: Role::User,
145            content: vec![Block::text(text)],
146        }
147    }
148
149    pub fn assistant(content: Vec<Block>) -> Self {
150        Message {
151            role: Role::Assistant,
152            content,
153        }
154    }
155
156    /// Tool results always go back as a single user message — splitting them
157    /// across messages teaches the model to stop calling tools in parallel.
158    pub fn tool_results(results: Vec<Block>) -> Self {
159        Message {
160            role: Role::User,
161            content: results,
162        }
163    }
164
165    /// Concatenated text blocks, ignoring thinking and tool traffic.
166    pub fn text(&self) -> String {
167        self.content
168            .iter()
169            .filter_map(|b| match b {
170                Block::Text { text } => Some(text.as_str()),
171                _ => None,
172            })
173            .collect::<Vec<_>>()
174            .join("")
175    }
176
177    /// Concatenated thinking blocks. Deliberately separate from `text`: this
178    /// is deliberation, not an answer, and the two must never be confused —
179    /// `text` is what the loop grades a turn on and what a caller receives.
180    /// Read this only where the alternative is having nothing at all.
181    pub fn thinking(&self) -> String {
182        self.content
183            .iter()
184            .filter_map(|b| match b {
185                Block::Thinking { text, .. } => Some(text.as_str()),
186                _ => None,
187            })
188            .collect::<Vec<_>>()
189            .join("")
190    }
191
192    pub fn tool_uses(&self) -> Vec<(&str, &str, &Value)> {
193        self.content
194            .iter()
195            .filter_map(|b| match b {
196                Block::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
197                _ => None,
198            })
199            .collect()
200    }
201}
202
203/// Why the model stopped generating.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum StopReason {
207    /// Finished naturally.
208    EndTurn,
209    /// Wants one or more tools executed.
210    ToolUse,
211    /// Hit the output cap. Output is truncated.
212    MaxTokens,
213    /// Declined on safety grounds. `content` may be empty or partial.
214    Refusal,
215    /// Server-side tool loop paused; resend to continue.
216    PauseTurn,
217    Other,
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct Usage {
222    pub input_tokens: u64,
223    pub output_tokens: u64,
224    pub cache_creation_input_tokens: u64,
225    pub cache_read_input_tokens: u64,
226}
227
228impl Usage {
229    pub fn add(&mut self, other: &Usage) {
230        self.input_tokens += other.input_tokens;
231        self.output_tokens += other.output_tokens;
232        self.cache_creation_input_tokens += other.cache_creation_input_tokens;
233        self.cache_read_input_tokens += other.cache_read_input_tokens;
234    }
235
236    /// Total prompt size: the uncached remainder plus both cache tiers.
237    pub fn total_input(&self) -> u64 {
238        self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
239    }
240
241    /// What this cost, if the provider has prices configured.
242    ///
243    /// Cache reads and writes are billed at different multiples of the input
244    /// rate, so a run that looks cheap on raw token counts can be anything but.
245    pub fn cost_usd(&self, pricing: &Pricing) -> f64 {
246        let per_input = pricing.input_per_mtok / 1_000_000.0;
247        let per_output = pricing.output_per_mtok / 1_000_000.0;
248        self.input_tokens as f64 * per_input
249            + self.cache_creation_input_tokens as f64 * per_input * pricing.cache_write_multiplier
250            + self.cache_read_input_tokens as f64 * per_input * pricing.cache_read_multiplier
251            + self.output_tokens as f64 * per_output
252    }
253}
254
255/// Per-million-token prices. Configured, never guessed — hardcoding a price
256/// table guarantees it is wrong within a quarter.
257#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
258pub struct Pricing {
259    pub input_per_mtok: f64,
260    pub output_per_mtok: f64,
261    /// Cache writes usually cost more than plain input.
262    pub cache_write_multiplier: f64,
263    /// Cache reads usually cost far less.
264    pub cache_read_multiplier: f64,
265}
266
267impl Default for Pricing {
268    fn default() -> Self {
269        // The prevailing Anthropic ratios; override per provider in config.
270        Pricing {
271            input_per_mtok: 0.0,
272            output_per_mtok: 0.0,
273            cache_write_multiplier: 1.25,
274            cache_read_multiplier: 0.1,
275        }
276    }
277}
278
279/// A tool as the model sees it.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ToolSpec {
282    pub name: String,
283    pub description: String,
284    pub input_schema: Value,
285}
286
287/// How hard the model should work. Maps to Anthropic's `output_config.effort`;
288/// other providers approximate or ignore it.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "lowercase")]
291pub enum Effort {
292    Low,
293    Medium,
294    High,
295    XHigh,
296    Max,
297}
298
299impl Effort {
300    pub fn as_str(self) -> &'static str {
301        match self {
302            Effort::Low => "low",
303            Effort::Medium => "medium",
304            Effort::High => "high",
305            Effort::XHigh => "xhigh",
306            Effort::Max => "max",
307        }
308    }
309}
310
311impl std::str::FromStr for Effort {
312    type Err = String;
313    fn from_str(s: &str) -> Result<Self, Self::Err> {
314        match s.to_ascii_lowercase().as_str() {
315            "low" => Ok(Effort::Low),
316            "medium" | "med" => Ok(Effort::Medium),
317            "high" => Ok(Effort::High),
318            "xhigh" | "x-high" => Ok(Effort::XHigh),
319            "max" => Ok(Effort::Max),
320            other => Err(format!(
321                "unknown effort {other:?} (low|medium|high|xhigh|max)"
322            )),
323        }
324    }
325}
326
327/// One request to a provider. Stateless — the full history goes every time.
328#[derive(Debug, Clone)]
329pub struct CompletionRequest {
330    pub model: String,
331    pub system: Option<String>,
332    pub messages: Vec<Message>,
333    pub tools: Vec<ToolSpec>,
334    pub max_tokens: u32,
335    pub effort: Option<Effort>,
336    /// Ask the provider for a readable summary of the model's reasoning.
337    pub thinking: bool,
338    /// Mark the stable prefix (tools + system) as cacheable.
339    pub cache_prompt: bool,
340}
341
342#[derive(Debug, Clone)]
343pub struct CompletionResponse {
344    pub message: Message,
345    pub stop_reason: StopReason,
346    pub usage: Usage,
347    /// Populated on `StopReason::Refusal`.
348    pub refusal: Option<Refusal>,
349    /// The model that actually served the response.
350    pub model: String,
351    /// Tool calls whose arguments did not parse as JSON. The single most
352    /// useful reliability signal when comparing models: a model that is
353    /// smarter but malforms arguments is worse in a loop.
354    pub malformed_tool_args: u32,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct Refusal {
359    pub category: Option<String>,
360    pub explanation: Option<String>,
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367
368    #[test]
369    fn message_text_ignores_thinking_and_tool_traffic() {
370        // `text()` is what every grader, session summary and final answer reads.
371        // Letting reasoning leak into it would put the model's scratchpad in
372        // front of the user and into eval assertions.
373        let m = Message::assistant(vec![
374            Block::Thinking {
375                text: "let me think".into(),
376                signature: Some("sig".into()),
377            },
378            Block::text("the answer is "),
379            Block::ToolUse {
380                id: "t1".into(),
381                name: "echo".into(),
382                input: json!({}),
383            },
384            Block::text("42"),
385        ]);
386
387        assert_eq!(m.text(), "the answer is 42");
388    }
389
390    #[test]
391    fn tool_uses_reports_every_call_in_order() {
392        let m = Message::assistant(vec![
393            Block::ToolUse {
394                id: "t1".into(),
395                name: "fs_read".into(),
396                input: json!({"path": "a"}),
397            },
398            Block::text("and also"),
399            Block::ToolUse {
400                id: "t2".into(),
401                name: "shell".into(),
402                input: json!({"cmd": "ls"}),
403            },
404        ]);
405
406        let calls = m.tool_uses();
407        assert_eq!(calls.len(), 2);
408        assert_eq!((calls[0].0, calls[0].1), ("t1", "fs_read"));
409        assert_eq!((calls[1].0, calls[1].1), ("t2", "shell"));
410    }
411
412    #[test]
413    fn tool_results_travel_as_one_user_message() {
414        // Splitting them across messages teaches the model to stop calling
415        // tools in parallel, which is a behavioural regression no test of the
416        // wire format would catch.
417        let m = Message::tool_results(vec![
418            Block::ToolResult {
419                tool_use_id: "t1".into(),
420                content: "a".into(),
421                is_error: false,
422            },
423            Block::ToolResult {
424                tool_use_id: "t2".into(),
425                content: "b".into(),
426                is_error: true,
427            },
428        ]);
429
430        assert_eq!(m.role, Role::User);
431        assert_eq!(m.content.len(), 2);
432    }
433
434    #[test]
435    fn a_block_round_trips_through_the_session_format() {
436        // Transcripts are JSONL, so every block has to survive serialisation.
437        // A thinking block with no signature must not grow a null one: the API
438        // rejects reconstructed signatures, and `None` is how we know to drop
439        // it rather than replay it.
440        let blocks = vec![
441            Block::text("hello"),
442            Block::Thinking {
443                text: "hm".into(),
444                signature: None,
445            },
446            Block::Thinking {
447                text: "hm".into(),
448                signature: Some("sig".into()),
449            },
450            Block::ToolUse {
451                id: "t1".into(),
452                name: "echo".into(),
453                input: json!({"v": 1}),
454            },
455            Block::ToolResult {
456                tool_use_id: "t1".into(),
457                content: "1".into(),
458                is_error: true,
459            },
460        ];
461
462        let encoded = serde_json::to_string(&blocks).unwrap();
463        assert!(
464            !encoded.contains("\"signature\":null"),
465            "an absent signature was written out"
466        );
467
468        let decoded: Vec<Block> = serde_json::from_str(&encoded).unwrap();
469        assert_eq!(decoded.len(), blocks.len());
470        match &decoded[1] {
471            Block::Thinking { signature, .. } => assert!(signature.is_none()),
472            other => panic!("expected thinking, got {other:?}"),
473        }
474        match &decoded[4] {
475            Block::ToolResult { is_error, .. } => assert!(is_error),
476            other => panic!("expected a tool result, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn an_older_transcript_without_is_error_still_loads() {
482        // `is_error` is `#[serde(default)]` precisely so a transcript written
483        // before it existed still resumes.
484        let block: Block = serde_json::from_value(
485            json!({"type": "tool_result", "tool_use_id": "t1", "content": "x"}),
486        )
487        .unwrap();
488        match block {
489            Block::ToolResult { is_error, .. } => assert!(!is_error),
490            other => panic!("expected a tool result, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn total_input_counts_both_cache_tiers() {
496        // The compaction threshold reads the *reported* prompt size, so a
497        // total that forgot the cached tiers would let a session grow past the
498        // window while claiming to be small.
499        let usage = Usage {
500            input_tokens: 100,
501            output_tokens: 50,
502            cache_creation_input_tokens: 200,
503            cache_read_input_tokens: 3000,
504        };
505        assert_eq!(usage.total_input(), 3300);
506    }
507
508    #[test]
509    fn usage_accumulates_every_field() {
510        let mut a = Usage {
511            input_tokens: 1,
512            output_tokens: 2,
513            ..Usage::default()
514        };
515        a.add(&Usage {
516            input_tokens: 10,
517            output_tokens: 20,
518            cache_creation_input_tokens: 30,
519            cache_read_input_tokens: 40,
520        });
521
522        assert_eq!(a.input_tokens, 11);
523        assert_eq!(a.output_tokens, 22);
524        assert_eq!(a.cache_creation_input_tokens, 30);
525        assert_eq!(a.cache_read_input_tokens, 40);
526    }
527
528    #[test]
529    fn cache_reads_and_writes_are_priced_off_the_input_rate() {
530        // A run that looks cheap on raw token counts can be anything but, which
531        // is the whole reason the tiers are tracked separately.
532        let pricing = Pricing {
533            input_per_mtok: 1_000_000.0, // one dollar per token, to keep it readable
534            output_per_mtok: 2_000_000.0,
535            cache_write_multiplier: 1.25,
536            cache_read_multiplier: 0.1,
537        };
538        let usage = Usage {
539            input_tokens: 1,
540            output_tokens: 1,
541            cache_creation_input_tokens: 1,
542            cache_read_input_tokens: 1,
543        };
544
545        // 1 + 2 + 1.25 + 0.1
546        assert!((usage.cost_usd(&pricing) - 4.35).abs() < 1e-9);
547    }
548
549    #[test]
550    fn a_provider_with_no_prices_configured_costs_nothing_rather_than_guessing() {
551        // Hardcoding a price table guarantees it is wrong within a quarter, so
552        // the default has to be zero rather than a plausible number.
553        let usage = Usage {
554            input_tokens: 1_000_000,
555            output_tokens: 1_000_000,
556            ..Usage::default()
557        };
558        assert_eq!(usage.cost_usd(&Pricing::default()), 0.0);
559    }
560
561    #[test]
562    fn effort_parses_its_aliases_and_refuses_anything_else() {
563        use std::str::FromStr;
564
565        for (input, expected) in [
566            ("low", Effort::Low),
567            ("MEDIUM", Effort::Medium),
568            ("med", Effort::Medium),
569            ("high", Effort::High),
570            ("xhigh", Effort::XHigh),
571            ("x-high", Effort::XHigh),
572            ("max", Effort::Max),
573        ] {
574            assert_eq!(
575                Effort::from_str(input).unwrap(),
576                expected,
577                "parsing {input}"
578            );
579        }
580
581        let err = Effort::from_str("turbo").unwrap_err();
582        assert!(
583            err.contains("turbo") && err.contains("low|medium|high"),
584            "unhelpful: {err}"
585        );
586    }
587
588    #[test]
589    fn every_effort_round_trips_through_its_wire_name() {
590        use std::str::FromStr;
591        for effort in [
592            Effort::Low,
593            Effort::Medium,
594            Effort::High,
595            Effort::XHigh,
596            Effort::Max,
597        ] {
598            assert_eq!(Effort::from_str(effort.as_str()).unwrap(), effort);
599        }
600    }
601}