Skip to main content

rust_llm/
message.rs

1//! Port of `message.rb`, `chunk.rb`, `tool_call.rb`, `thinking.rb`, `citation.rb`,
2//! `server_tool_call.rb`, and the usage `Entry` from `accounting/usage.rb`.
3
4use indexmap_lite::IndexMap;
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::attachment::Attachment;
9use crate::cost::{Cost, Tier};
10use crate::error::{Error, Result};
11use crate::model::Model;
12use crate::tokens::Tokens;
13
14/// Insertion-ordered map keyed by tool call id, like the Ruby Hash RubyLLM uses.
15pub mod indexmap_lite {
16    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
17    pub struct IndexMap<V>(pub Vec<(String, V)>);
18
19    impl<V> Default for IndexMap<V> {
20        fn default() -> Self {
21            IndexMap(Vec::new())
22        }
23    }
24
25    impl<V> IndexMap<V> {
26        pub fn new() -> Self {
27            IndexMap(Vec::new())
28        }
29        pub fn get(&self, key: &str) -> Option<&V> {
30            self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
31        }
32        pub fn get_mut(&mut self, key: &str) -> Option<&mut V> {
33            self.0.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
34        }
35        pub fn insert(&mut self, key: String, value: V) {
36            match self.get_mut(&key) {
37                Some(slot) => *slot = value,
38                None => self.0.push((key, value)),
39            }
40        }
41        pub fn contains_key(&self, key: &str) -> bool {
42            self.get(key).is_some()
43        }
44        pub fn values(&self) -> impl Iterator<Item = &V> {
45            self.0.iter().map(|(_, v)| v)
46        }
47        pub fn keys(&self) -> impl Iterator<Item = &String> {
48            self.0.iter().map(|(k, _)| k)
49        }
50        pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
51            self.0.iter().map(|(k, v)| (k, v))
52        }
53        pub fn len(&self) -> usize {
54            self.0.len()
55        }
56        pub fn is_empty(&self) -> bool {
57            self.0.is_empty()
58        }
59    }
60
61    impl<V> FromIterator<(String, V)> for IndexMap<V> {
62        fn from_iter<I: IntoIterator<Item = (String, V)>>(iter: I) -> Self {
63            let mut map = IndexMap::new();
64            for (k, v) in iter {
65                map.insert(k, v);
66            }
67            map
68        }
69    }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum Role {
75    System,
76    User,
77    Assistant,
78    Tool,
79}
80
81impl Role {
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            Role::System => "system",
85            Role::User => "user",
86            Role::Assistant => "assistant",
87            Role::Tool => "tool",
88        }
89    }
90
91    pub fn parse(s: &str) -> Result<Role> {
92        match s {
93            "system" => Ok(Role::System),
94            "user" => Ok(Role::User),
95            "assistant" => Ok(Role::Assistant),
96            "tool" => Ok(Role::Tool),
97            _ => Err(Error::Argument(
98                "Expected role to be one of: system, user, assistant, tool".into(),
99            )),
100        }
101    }
102}
103
104/// Normalized finish reasons (`FINISH_REASONS` in each protocol). Unknown reasons are kept verbatim.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum FinishReason {
108    Stop,
109    MaxTokens,
110    ToolCalls,
111    ContentFilter,
112    PauseTurn,
113    Other(String),
114}
115
116impl FinishReason {
117    pub fn from_symbol(s: &str) -> FinishReason {
118        match s {
119            "stop" => FinishReason::Stop,
120            "max_tokens" => FinishReason::MaxTokens,
121            "tool_calls" => FinishReason::ToolCalls,
122            "content_filter" => FinishReason::ContentFilter,
123            "pause_turn" => FinishReason::PauseTurn,
124            other => FinishReason::Other(other.to_string()),
125        }
126    }
127
128    pub fn as_str(&self) -> &str {
129        match self {
130            FinishReason::Stop => "stop",
131            FinishReason::MaxTokens => "max_tokens",
132            FinishReason::ToolCalls => "tool_calls",
133            FinishReason::ContentFilter => "content_filter",
134            FinishReason::PauseTurn => "pause_turn",
135            FinishReason::Other(s) => s,
136        }
137    }
138}
139
140/// Model deliberation (`Thinking`). The signature is opaque provider state that must be replayed.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct Thinking {
143    pub text: Option<String>,
144    pub signature: Option<String>,
145}
146
147impl Thinking {
148    /// `Thinking.build`: `None` when there is neither text nor a signature.
149    pub fn build(text: Option<String>, signature: Option<String>) -> Option<Thinking> {
150        let signature = signature.filter(|s| !s.is_empty());
151        let text = text.filter(|t| !(t.is_empty() && signature.is_none()));
152        if text.is_none() && signature.is_none() {
153            return None;
154        }
155        Some(Thinking { text, signature })
156    }
157}
158
159/// Arguments the model passed to a tool. While streaming they arrive as a JSON string in pieces.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161#[serde(untagged)]
162pub enum ToolArguments {
163    Parsed(Map<String, Value>),
164    Partial(String),
165}
166
167impl Default for ToolArguments {
168    fn default() -> Self {
169        ToolArguments::Parsed(Map::new())
170    }
171}
172
173impl ToolArguments {
174    /// The arguments as a map, keys ordered as [`canonical_arguments`] does. A streamed string is
175    /// parsed the way the finished call Ruby builds from it would be.
176    pub fn as_map(&self) -> Map<String, Value> {
177        let map = match self {
178            ToolArguments::Parsed(m) => m.clone(),
179            ToolArguments::Partial(s) => serde_json::from_str(s).unwrap_or_default(),
180        };
181        canonical_map(map)
182    }
183}
184
185/// `ToolCall#canonical_arguments`: object keys ordered the way PostgreSQL `jsonb` and MySQL
186/// `json` store them (shorter keys first, then bytewise), recursively, so a call reloaded from
187/// the database renders the same request bytes and keeps the provider's prompt cache.
188pub fn canonical_arguments(value: Value) -> Value {
189    match value {
190        Value::Object(map) => Value::Object(canonical_map(map)),
191        Value::Array(items) => Value::Array(items.into_iter().map(canonical_arguments).collect()),
192        other => other,
193    }
194}
195
196fn canonical_map(map: Map<String, Value>) -> Map<String, Value> {
197    let mut entries: Vec<(String, Value)> = map.into_iter().collect();
198    entries.sort_by(|(a, _), (b, _)| (a.len(), a.as_bytes()).cmp(&(b.len(), b.as_bytes())));
199    entries
200        .into_iter()
201        .map(|(k, v)| (k, canonical_arguments(v)))
202        .collect()
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct ToolCall {
207    pub id: String,
208    pub name: String,
209    pub arguments: ToolArguments,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub thought_signature: Option<String>,
212    #[serde(default)]
213    pub remote: bool,
214    /// Streaming only: whether this piece carried an id field at all. RubyLLM starts a new call
215    /// for any non-nil id (an empty one gets a UUID) and treats a nil id as an argument fragment.
216    #[serde(skip)]
217    pub(crate) starts: bool,
218}
219
220impl ToolCall {
221    pub fn new(
222        id: impl Into<String>,
223        name: impl Into<String>,
224        arguments: Map<String, Value>,
225    ) -> Self {
226        ToolCall {
227            id: id.into(),
228            name: name.into(),
229            arguments: ToolArguments::Parsed(canonical_map(arguments)),
230            thought_signature: None,
231            remote: false,
232            starts: true,
233        }
234    }
235
236    /// A streamed piece that opens a call (`id` present, possibly empty): Ruby's
237    /// `ToolCall.new(id: '', name:, arguments: '')` as a stream chunk carries it.
238    pub fn opening(id: String, name: String, arguments: String) -> ToolCall {
239        ToolCall {
240            id,
241            name,
242            arguments: ToolArguments::Partial(arguments),
243            thought_signature: None,
244            remote: false,
245            starts: true,
246        }
247    }
248
249    /// A streamed argument fragment for a call opened earlier: Ruby's
250    /// `ToolCall.new(id: nil, name: nil, arguments: '...')`. A `nil` fragment is an empty one.
251    pub fn fragment(arguments: String) -> ToolCall {
252        ToolCall {
253            id: String::new(),
254            name: String::new(),
255            arguments: ToolArguments::Partial(arguments),
256            thought_signature: None,
257            remote: false,
258            starts: false,
259        }
260    }
261
262    pub(crate) fn starts_call(&self) -> bool {
263        self.starts
264    }
265
266    pub fn arguments(&self) -> Map<String, Value> {
267        self.arguments.as_map()
268    }
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
272pub struct Citation {
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub url: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub title: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub cited_text: Option<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub text: Option<String>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub start_index: Option<i64>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub end_index: Option<i64>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub source_id: Option<String>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub source_index: Option<i64>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub start_page: Option<i64>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub end_page: Option<i64>,
293}
294
295/// A tool the provider ran itself (web search, code execution, ...).
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct ServerToolCall {
298    #[serde(rename = "type")]
299    pub kind: String,
300    pub name: Option<String>,
301    pub id: Option<String>,
302    pub input: Option<Value>,
303    pub result: Option<Value>,
304    pub raw: Value,
305    /// `#search_suggestions`: the HTML of the search suggestions the provider requires shown with
306    /// a grounded answer (Google's for Google Search grounding). Only the live response carries
307    /// them: `to_h` leaves them out, so a persisted chat never stores them.
308    #[serde(skip)]
309    pub search_suggestions: Option<String>,
310}
311
312/// The raw HTTP exchange behind a response (`message.raw`): status, headers, and body. Like
313/// RubyLLM 2.1's `release_request`, provider calls leave `request_body` empty: each request of a
314/// chat holds the whole conversation so far, so keeping one per reply grew a chat's memory
315/// quadratically. Read what a chat sends with `render` or a `before_request` hook instead.
316#[derive(Debug, Clone, Default, PartialEq)]
317pub struct RawResponse {
318    pub status: u16,
319    pub headers: Vec<(String, String)>,
320    pub body: Value,
321    pub request_body: std::sync::Arc<str>,
322}
323
324impl RawResponse {
325    /// The request as JSON (`Null` when there was no JSON body, e.g. a GET or multipart upload).
326    pub fn request_body_json(&self) -> Value {
327        serde_json::from_str(&self.request_body).unwrap_or(Value::Null)
328    }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum Operation {
334    Chat,
335    Embedding,
336    Moderation,
337    Image,
338    Speech,
339    Transcription,
340    Ocr,
341    Rerank,
342    Judgment,
343    /// A finished `VideoJob` (RubyLLM 2.1 `video_job.rb#record_usage`).
344    Video,
345    /// A finished research job (2.1 `Accounting::Usage::OPERATIONS`).
346    Research,
347}
348
349impl Operation {
350    pub fn as_str(&self) -> &'static str {
351        match self {
352            Operation::Chat => "chat",
353            Operation::Embedding => "embedding",
354            Operation::Moderation => "moderation",
355            Operation::Image => "image",
356            Operation::Speech => "speech",
357            Operation::Transcription => "transcription",
358            Operation::Ocr => "ocr",
359            Operation::Rerank => "rerank",
360            Operation::Judgment => "judgment",
361            Operation::Video => "video",
362            Operation::Research => "research",
363        }
364    }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case")]
369pub enum UsageStatus {
370    Pending,
371    Succeeded,
372    Failed,
373    Cancelled,
374}
375
376impl UsageStatus {
377    pub fn as_str(&self) -> &'static str {
378        match self {
379            UsageStatus::Pending => "pending",
380            UsageStatus::Succeeded => "succeeded",
381            UsageStatus::Failed => "failed",
382            UsageStatus::Cancelled => "cancelled",
383        }
384    }
385}
386
387/// `Accounting::Usage::Entry`: one billed attempt. Retries and fallbacks each get their own.
388/// `id` gives each attempt the identity Ruby gets from object identity, so persistence links the
389/// exact entry even when two attempts carry identical numbers.
390#[derive(Debug, Clone, PartialEq)]
391pub struct UsageEntry {
392    pub id: u64,
393    pub operation: Operation,
394    pub provider: String,
395    pub model: String,
396    pub status: UsageStatus,
397    pub tokens: Tokens,
398    pub cost: Cost,
399    /// `owner`: who the attempt is attributed to, from the operation's `owner:` or the enclosing
400    /// [`crate::accounting::with_usage_owner`] when the attempt started.
401    pub owner: Option<crate::accounting::UsageOwner>,
402}
403
404static NEXT_USAGE_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
405
406impl UsageEntry {
407    /// A fresh process-unique id for a new entry.
408    pub fn next_id() -> u64 {
409        NEXT_USAGE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
410    }
411
412    pub fn cost_available(&self) -> bool {
413        self.cost.total().is_some()
414    }
415}
416
417/// One message in a conversation (`RubyLLM::Message`). Streamed pieces use the same type
418/// (`Chunk < Message`).
419#[derive(Debug, Clone, PartialEq)]
420pub struct Message {
421    pub role: Role,
422    pub content: Option<String>,
423    pub attachments: Vec<Attachment>,
424    pub model: Option<String>,
425    pub tool_calls: Option<IndexMap<ToolCall>>,
426    pub tool_call_id: Option<String>,
427    pub tokens: Tokens,
428    pub thinking: Option<Thinking>,
429    pub citations: Vec<Citation>,
430    pub server_tool_calls: Vec<ServerToolCall>,
431    pub raw_content: Option<Value>,
432    pub raw_reasoning: Option<Value>,
433    pub finish_reason: Option<FinishReason>,
434    pub raw: Option<RawResponse>,
435    pub cache_until_here: bool,
436    /// `#cache_ttl`: this boundary's own cache lifetime (`"1h"`), or `None` for the chat's.
437    pub cache_ttl: Option<String>,
438    pub usage_entries: Vec<UsageEntry>,
439    /// `mcp_result`: the result of an MCP tool with a UI, kept on its tool result message so
440    /// your app can render the UI again.
441    pub mcp_result: Option<Box<crate::mcp::McpResult>>,
442    /// Primary key of the row this message is stored as, set by a persistence layer
443    /// (`rust_llm_loco`). `None` means not yet persisted.
444    pub record_id: Option<i64>,
445    pub(crate) model_info: Option<Model>,
446    /// `@supplied_cost`: an explicit `cost:` the message was built with (see [`Message::with_cost`]).
447    pub(crate) supplied_cost: Option<Cost>,
448}
449
450pub type Chunk = Message;
451
452impl Message {
453    pub fn new(role: Role, content: impl Into<Option<String>>) -> Message {
454        Message {
455            role,
456            content: content.into(),
457            attachments: Vec::new(),
458            model: None,
459            tool_calls: None,
460            tool_call_id: None,
461            tokens: Tokens::default(),
462            thinking: None,
463            citations: Vec::new(),
464            server_tool_calls: Vec::new(),
465            raw_content: None,
466            raw_reasoning: None,
467            finish_reason: None,
468            raw: None,
469            cache_until_here: false,
470            cache_ttl: None,
471            usage_entries: Vec::new(),
472            mcp_result: None,
473            record_id: None,
474            model_info: None,
475            supplied_cost: None,
476        }
477    }
478
479    /// `Message#cache_until_here(ttl:)`: marks this message as a prompt cache boundary, with its
480    /// own lifetime when `ttl` is given.
481    pub fn with_cache_until_here(mut self, ttl: Option<&str>) -> Message {
482        self.cache_until_here = true;
483        self.cache_ttl = ttl.map(str::to_string);
484        self
485    }
486
487    /// `Message.cache_boundary_options`: `true` (no lifetime) or `{ ttl: }`; `false`/`nil` is
488    /// no boundary. Anything else is an `ArgumentError`.
489    pub fn cache_boundary_options(value: &Value) -> Result<Option<Option<String>>> {
490        match value {
491            Value::Null | Value::Bool(false) => return Ok(None),
492            Value::Bool(true) => return Ok(Some(None)),
493            Value::Object(o) if o.keys().all(|k| k == "ttl") => {
494                return Ok(Some(
495                    o.get("ttl").and_then(Value::as_str).map(str::to_string),
496                ));
497            }
498            _ => {}
499        }
500        Err(Error::Argument(format!(
501            "cache_until_here accepts true, false, or ttl:, got {value}"
502        )))
503    }
504
505    pub fn system(content: impl Into<String>) -> Message {
506        Message::new(Role::System, Some(content.into()))
507    }
508
509    pub fn user(content: impl Into<String>) -> Message {
510        Message::new(Role::User, Some(content.into()))
511    }
512
513    pub fn assistant(content: impl Into<String>) -> Message {
514        Message::new(Role::Assistant, Some(content.into()))
515    }
516
517    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Message {
518        let mut m = Message::new(Role::Tool, Some(content.into()));
519        m.tool_call_id = Some(tool_call_id.into());
520        m
521    }
522
523    pub(crate) fn chunk() -> Message {
524        Message::new(Role::Assistant, None)
525    }
526
527    /// `normalize_content`: an assistant turn that only calls tools has content `""`.
528    pub(crate) fn normalized(mut self) -> Message {
529        if self.role == Role::Assistant && self.content.is_none() && self.is_tool_call() {
530            self.content = Some(String::new());
531        }
532        self
533    }
534
535    pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Message {
536        self.attachments = attachments;
537        self
538    }
539
540    pub fn content(&self) -> &str {
541        self.content.as_deref().unwrap_or("")
542    }
543
544    /// `Message#parsed`: the content as JSON, for structured output.
545    pub fn parsed(&self) -> Result<Option<Value>> {
546        match self.content.as_deref() {
547            None | Some("") => Ok(None),
548            Some(s) => Ok(Some(serde_json::from_str(s)?)),
549        }
550    }
551
552    pub fn is_tool_call(&self) -> bool {
553        self.tool_calls.as_ref().is_some_and(|t| !t.is_empty())
554    }
555
556    pub fn is_tool_result(&self) -> bool {
557        self.tool_call_id.as_ref().is_some_and(|id| !id.is_empty())
558    }
559
560    pub fn is_stopped(&self) -> bool {
561        self.finish_reason == Some(FinishReason::Stop) && !self.is_tool_call()
562    }
563
564    pub fn is_max_tokens(&self) -> bool {
565        self.finish_reason == Some(FinishReason::MaxTokens)
566    }
567
568    pub fn is_tool_call_stop(&self) -> bool {
569        self.finish_reason == Some(FinishReason::ToolCalls)
570            || (self.is_tool_call() && self.finish_reason == Some(FinishReason::Stop))
571    }
572
573    pub fn is_content_filtered(&self) -> bool {
574        self.finish_reason == Some(FinishReason::ContentFilter)
575    }
576
577    /// `Message#tokens`: the usage ledger when present, otherwise what the message was built with.
578    pub fn tokens(&self) -> Tokens {
579        if self.usage_entries.is_empty() {
580            self.tokens.clone()
581        } else {
582            Tokens::aggregate(self.usage_entries.iter().map(|e| &e.tokens))
583        }
584    }
585
586    /// `Message#cost`: the recorded attempt costs, else an explicitly supplied `cost:`, else
587    /// pricing from `model_info`. Pass a model to price against it instead.
588    pub fn cost(&self, model: Option<&Model>) -> Cost {
589        if model.is_none() && !self.usage_entries.is_empty() {
590            let complete = self.usage_entries.iter().all(UsageEntry::cost_available);
591            return Cost::aggregate(self.usage_entries.iter().map(|e| &e.cost), complete);
592        }
593        if let (None, Some(supplied)) = (model, &self.supplied_cost) {
594            return supplied.clone();
595        }
596        let info = model.cloned().or_else(|| self.model_info());
597        Cost::new(&self.tokens(), info.as_ref(), Tier::Standard)
598    }
599
600    /// Registry entry of the model that produced this message, if known.
601    pub fn model_info(&self) -> Option<Model> {
602        if let Some(m) = &self.model_info {
603            return Some(m.clone());
604        }
605        let registry = crate::models::models();
606        let entry = self
607            .usage_entries
608            .iter()
609            .rev()
610            .find(|e| e.status == UsageStatus::Succeeded);
611        match entry {
612            Some(e) => registry.find(&e.model, Some(&e.provider)).ok(),
613            None => self
614                .model
615                .as_deref()
616                .and_then(|m| registry.find(m, None).ok()),
617        }
618    }
619
620    /// The per-request view of a stored message: everything a protocol renders, without the
621    /// bookkeeping it never reads (the HTTP exchange, registry entry). Cloning those for every
622    /// message on every request made long chats quadratic. Of the usage ledger only the
623    /// producing entry stays: it is what names the producer (`Protocol#producing_entry`).
624    pub(crate) fn for_request(&self) -> Message {
625        Message {
626            role: self.role,
627            content: self.content.clone(),
628            attachments: self.attachments.clone(),
629            model: self.model.clone(),
630            tool_calls: self.tool_calls.clone(),
631            tool_call_id: self.tool_call_id.clone(),
632            tokens: Tokens::default(),
633            thinking: self.thinking.clone(),
634            citations: self.citations.clone(),
635            server_tool_calls: self.server_tool_calls.clone(),
636            raw_content: self.raw_content.clone(),
637            raw_reasoning: self.raw_reasoning.clone(),
638            finish_reason: self.finish_reason.clone(),
639            raw: None,
640            cache_until_here: self.cache_until_here,
641            cache_ttl: self.cache_ttl.clone(),
642            usage_entries: self.producing_entry().cloned().into_iter().collect(),
643            mcp_result: None,
644            record_id: self.record_id,
645            model_info: None,
646            supplied_cost: None,
647        }
648    }
649
650    /// `Protocol#producing_entry`: the last succeeded usage entry, the one record of which
651    /// provider and model produced this message.
652    pub(crate) fn producing_entry(&self) -> Option<&UsageEntry> {
653        self.usage_entries
654            .iter()
655            .rev()
656            .find(|e| e.status == UsageStatus::Succeeded)
657    }
658
659    /// `Protocol#own_signature`: the thinking signature, only when `provider` produced it. A
660    /// signature can be another provider's opaque blob, and a message without usage could come
661    /// from anyone.
662    pub(crate) fn own_signature(&self, provider: &str) -> Option<&str> {
663        let signature = self.thinking.as_ref()?.signature.as_deref()?;
664        (self.producing_entry()?.provider == provider).then_some(signature)
665    }
666
667    /// `Message#without_native_content(raw_content:)`: what gets replayed to a model that did
668    /// not produce the thinking, raw reasoning, raw content and call signatures.
669    pub(crate) fn without_native_content(&self, raw_content: Option<Value>) -> Message {
670        let mut m = self.clone();
671        m.thinking = None;
672        m.raw_reasoning = None;
673        m.raw_content = raw_content;
674        if let Some(calls) = &mut m.tool_calls {
675            for (_, call) in calls.0.iter_mut() {
676                call.thought_signature = None;
677            }
678        }
679        m
680    }
681
682    /// `Message.new(cost:)`: an explicitly supplied cost, kept through `to_h`/`from_h`.
683    /// Recorded attempt costs still win over it, and an explicit model reprices.
684    pub fn with_cost(mut self, cost: Cost) -> Message {
685        self.supplied_cost = Some(cost);
686        self
687    }
688
689    /// `Message#tool_results`: the tool result messages in `conversation` answering this
690    /// message's tool calls, or none when it made no calls. Ruby reads the chat through the
691    /// message's `conversation` back-link; pass the chat's messages (`chat.messages()`).
692    pub fn tool_results<'a>(&self, conversation: &'a [Message]) -> Vec<&'a Message> {
693        let Some(calls) = self.tool_calls.as_ref().filter(|c| !c.is_empty()) else {
694            return Vec::new();
695        };
696        conversation
697            .iter()
698            .filter(|m| {
699                m.is_tool_result()
700                    && m.tool_call_id
701                        .as_deref()
702                        .is_some_and(|id| calls.contains_key(id))
703            })
704            .collect()
705    }
706
707    /// `Message.new(message.to_h)`: rebuilds a message from its `to_h` attributes (string keys,
708    /// as after a JSON round-trip). Tool calls, thinking, citations, server tool calls, and the
709    /// supplied cost come back as value objects. Attachments are rebuilt from their `source`.
710    pub fn from_h(h: &Value) -> Result<Message> {
711        let str_of = |key: &str| h.get(key).and_then(Value::as_str).map(str::to_string);
712        let int_of = |key: &str| h.get(key).and_then(Value::as_i64);
713        let role = Role::parse(h.get("role").and_then(Value::as_str).unwrap_or_default())?;
714        let mut m = Message::new(role, str_of("content"));
715        m.model = str_of("model");
716        m.tool_call_id = str_of("tool_call_id");
717        if let Some(calls) = h.get("tool_calls").and_then(Value::as_object) {
718            m.tool_calls = Some(
719                calls
720                    .iter()
721                    .map(|(id, call)| {
722                        let mut tc = ToolCall::new(
723                            call.get("id")
724                                .and_then(Value::as_str)
725                                .unwrap_or(id.as_str()),
726                            call.get("name").and_then(Value::as_str).unwrap_or_default(),
727                            call.get("arguments")
728                                .and_then(Value::as_object)
729                                .cloned()
730                                .unwrap_or_default(),
731                        );
732                        tc.thought_signature = call
733                            .get("thought_signature")
734                            .and_then(Value::as_str)
735                            .map(str::to_string);
736                        tc.remote = call.get("remote").and_then(Value::as_bool).unwrap_or(false);
737                        (id.clone(), tc)
738                    })
739                    .collect(),
740            );
741        }
742        m.thinking = match h.get("thinking") {
743            Some(Value::Object(t)) => Thinking::build(
744                t.get("text").and_then(Value::as_str).map(str::to_string),
745                t.get("signature")
746                    .and_then(Value::as_str)
747                    .map(str::to_string),
748            ),
749            Some(Value::String(text)) => {
750                Thinking::build(Some(text.clone()), str_of("thinking_signature"))
751            }
752            // `coerce_thinking`: no thinking text still keeps a signature-only thinking.
753            _ => Thinking::build(None, str_of("thinking_signature")),
754        };
755        if let Some(citations) = h.get("citations") {
756            m.citations = serde_json::from_value(citations.clone())?;
757        }
758        for call in h
759            .get("server_tool_calls")
760            .and_then(Value::as_array)
761            .into_iter()
762            .flatten()
763        {
764            m.server_tool_calls.push(ServerToolCall {
765                kind: call
766                    .get("type")
767                    .and_then(Value::as_str)
768                    .unwrap_or_default()
769                    .to_string(),
770                name: call.get("name").and_then(Value::as_str).map(str::to_string),
771                id: call.get("id").and_then(Value::as_str).map(str::to_string),
772                input: call.get("input").cloned(),
773                result: call.get("result").cloned(),
774                raw: call.get("raw").cloned().unwrap_or(Value::Null),
775                search_suggestions: None,
776            });
777        }
778        for a in h
779            .get("attachments")
780            .and_then(Value::as_array)
781            .into_iter()
782            .flatten()
783        {
784            m.attachments.push(Attachment::from_h(a)?);
785        }
786        m.raw_content = h.get("raw_content").cloned();
787        m.raw_reasoning = h.get("raw_reasoning").cloned();
788        m.finish_reason = h
789            .get("finish_reason")
790            .and_then(Value::as_str)
791            .map(FinishReason::from_symbol);
792        m.cache_until_here = h
793            .get("cache_until_here")
794            .and_then(Value::as_bool)
795            .unwrap_or(false);
796        if m.cache_until_here {
797            m.cache_ttl = h
798                .get("cache_ttl")
799                .and_then(Value::as_str)
800                .map(str::to_string);
801        }
802        m.supplied_cost = h.get("cost").map(|c| Cost::from_h(c, None));
803        // `coerce_mcp_result`: a dumped result comes back as an `MCP::Result`.
804        m.mcp_result = h
805            .get("mcp_result")
806            .filter(|r| r.is_object())
807            .map(|r| Box::new(crate::mcp::McpResult::load(r)));
808        m.tokens = Tokens {
809            input: int_of("input_tokens"),
810            output: int_of("output_tokens"),
811            cache_read: int_of("cache_read_tokens"),
812            cache_write: int_of("cache_write_tokens"),
813            thinking: int_of("thinking_tokens"),
814            cache_write_by_ttl: h
815                .get("cache_write_tokens_by_ttl")
816                .and_then(Tokens::positive_counts),
817            server_tool_use: h.get("server_tool_use").and_then(Tokens::positive_counts),
818            reported_cost: h.get("reported_cost").and_then(Value::as_f64),
819        };
820        Ok(m.normalized())
821    }
822
823    /// `Message#to_h`: `nil` values and empty attachment, citation, and server tool call lists
824    /// are omitted; `cost` appears only when one was supplied.
825    pub fn to_h(&self) -> Value {
826        let mut h = Map::new();
827        h.insert("role".into(), self.role.as_str().into());
828        if let Some(c) = &self.content {
829            h.insert("content".into(), c.clone().into());
830        }
831        if !self.attachments.is_empty() {
832            h.insert(
833                "attachments".into(),
834                self.attachments.iter().map(attachment_h).collect(),
835            );
836        }
837        if let Some(m) = &self.model {
838            h.insert("model".into(), m.clone().into());
839        }
840        if self.supplied_cost.is_some() {
841            h.insert(
842                "cost".into(),
843                crate::instrumentation::cost_h(&self.cost(None)),
844            );
845        }
846        // `reported_cost: tokens.reported_cost`: the provider's own price, summed over attempts.
847        if let Some(reported) = self.tokens().reported_cost {
848            h.insert("reported_cost".into(), reported.into());
849        }
850        if let Some(calls) = &self.tool_calls {
851            let calls: Map<String, Value> = calls
852                .iter()
853                .map(|(k, v)| (k.clone(), tool_call_h(v)))
854                .collect();
855            h.insert("tool_calls".into(), calls.into());
856        }
857        if let Some(id) = &self.tool_call_id {
858            h.insert("tool_call_id".into(), id.clone().into());
859        }
860        if let Some(t) = &self.thinking {
861            if let Some(text) = &t.text {
862                h.insert("thinking".into(), text.clone().into());
863            }
864            if let Some(sig) = &t.signature {
865                h.insert("thinking_signature".into(), sig.clone().into());
866            }
867        }
868        if !self.citations.is_empty() {
869            h.insert(
870                "citations".into(),
871                serde_json::to_value(&self.citations).unwrap(),
872            ); // Citation always serializes
873        }
874        if !self.server_tool_calls.is_empty() {
875            h.insert(
876                "server_tool_calls".into(),
877                self.server_tool_calls
878                    .iter()
879                    .map(server_tool_call_h)
880                    .collect(),
881            );
882        }
883        if let Some(raw) = &self.raw_content {
884            h.insert("raw_content".into(), raw.clone());
885        }
886        if let Some(raw) = &self.raw_reasoning {
887            h.insert("raw_reasoning".into(), raw.clone());
888        }
889        if let Some(result) = &self.mcp_result {
890            h.insert("mcp_result".into(), result.dump());
891        }
892        if let Some(r) = &self.finish_reason {
893            h.insert("finish_reason".into(), r.as_str().into());
894        }
895        if self.cache_until_here {
896            h.insert("cache_until_here".into(), true.into());
897        }
898        if let Some(ttl) = &self.cache_ttl {
899            h.insert("cache_ttl".into(), ttl.clone().into());
900        }
901        let t = self.tokens();
902        for (k, v) in [
903            ("input_tokens", t.input),
904            ("output_tokens", t.output),
905            ("cache_read_tokens", t.cache_read),
906            ("cache_write_tokens", t.cache_write),
907            ("thinking_tokens", t.thinking),
908        ] {
909            if let Some(v) = v {
910                h.insert(k.into(), v.into());
911            }
912        }
913        if let Some(by_ttl) = &t.cache_write_by_ttl {
914            h.insert(
915                "cache_write_tokens_by_ttl".into(),
916                Value::Object(by_ttl.clone()),
917            );
918        }
919        if let Some(s) = &t.server_tool_use {
920            h.insert("server_tool_use".into(), Value::Object(s.clone()));
921        }
922        Value::Object(h)
923    }
924}
925
926/// `ToolCall#to_h`: `remote` only when true, `thought_signature` only when present.
927fn tool_call_h(call: &ToolCall) -> Value {
928    let mut h = Map::new();
929    h.insert("id".into(), call.id.clone().into());
930    h.insert("name".into(), call.name.clone().into());
931    h.insert("arguments".into(), Value::Object(call.arguments()));
932    if call.remote {
933        h.insert("remote".into(), true.into());
934    }
935    if let Some(sig) = &call.thought_signature {
936        h.insert("thought_signature".into(), sig.clone().into());
937    }
938    Value::Object(h)
939}
940
941/// `ServerToolCall#to_h`, omitting `nil` values.
942fn server_tool_call_h(call: &ServerToolCall) -> Value {
943    let mut h = Map::new();
944    h.insert("type".into(), call.kind.clone().into());
945    for (key, value) in [
946        ("name", call.name.clone().map(Value::from)),
947        ("id", call.id.clone().map(Value::from)),
948        ("input", call.input.clone()),
949        ("result", call.result.clone()),
950        ("raw", Some(call.raw.clone()).filter(|r| !r.is_null())),
951    ] {
952        if let Some(v) = value {
953            h.insert(key.into(), v);
954        }
955    }
956    Value::Object(h)
957}
958
959/// `Attachment#to_h`: `{ type:, source: }`. In-memory bytes have no source to record (`null`).
960fn attachment_h(a: &Attachment) -> Value {
961    a.to_h()
962}