Skip to main content

runifold_model/
response.rs

1use std::collections::BTreeMap;
2
3use runifold_core::Usage;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{ContentPart, ModelRef, ProviderData};
8
9/// Why a model stopped producing output.
10#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(rename_all = "snake_case")]
12#[non_exhaustive]
13pub enum FinishReason {
14    /// Natural completion.
15    Stop,
16    /// Output-token or context limit.
17    Length,
18    /// The model requested one or more tools.
19    ToolCalls,
20    /// Provider safety or content filter.
21    ContentFilter,
22    /// The operation was cancelled.
23    Cancelled,
24    /// Provider reported an error as a terminal reason.
25    Error,
26    /// Provider-specific reason retained as text.
27    Other(String),
28    /// No reliable reason was provided.
29    #[default]
30    Unknown,
31}
32
33/// Detailed usage reported by a model provider.
34#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
35pub struct ModelUsage {
36    /// Input tokens.
37    pub input_tokens: u64,
38    /// Output tokens.
39    pub output_tokens: u64,
40    /// Reasoning tokens, when reported separately.
41    pub reasoning_tokens: u64,
42    /// Input tokens served from provider cache.
43    pub cached_input_tokens: u64,
44    /// Input tokens written to provider cache.
45    pub cache_write_tokens: u64,
46    /// Estimated or reported cost in micro-US-dollars.
47    pub cost_microusd: u64,
48}
49
50impl ModelUsage {
51    /// Returns total model tokens without double-counting usage details.
52    ///
53    /// Reasoning tokens are normally a subset of output tokens, just as cached
54    /// tokens are a subset of input tokens.
55    pub fn total_tokens(self) -> u64 {
56        self.input_tokens.saturating_add(self.output_tokens)
57    }
58}
59
60impl From<ModelUsage> for Usage {
61    fn from(value: ModelUsage) -> Self {
62        Self {
63            tokens: value.total_tokens(),
64            cost_microusd: value.cost_microusd,
65            ..Self::default()
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::ModelUsage;
73
74    #[test]
75    fn token_totals_do_not_double_count_reasoning_details() {
76        let usage = ModelUsage {
77            input_tokens: 10,
78            output_tokens: 8,
79            reasoning_tokens: 3,
80            cached_input_tokens: 4,
81            ..ModelUsage::default()
82        };
83
84        assert_eq!(usage.total_tokens(), 18);
85    }
86}
87
88/// A visible feature degradation or translation warning.
89#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
90pub struct ModelWarning {
91    /// Stable warning code.
92    pub code: String,
93    /// Safe explanation.
94    pub message: String,
95    /// Namespaced details.
96    pub metadata: BTreeMap<String, Value>,
97}
98
99/// A complete canonical model response.
100#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
101pub struct ModelResponse {
102    /// Provider response identity.
103    pub id: Option<String>,
104    /// Actual model that produced the response.
105    pub model: ModelRef,
106    /// Ordered output content.
107    pub content: Vec<ContentPart>,
108    /// Normalized terminal reason.
109    pub finish_reason: FinishReason,
110    /// Detailed model usage.
111    pub usage: ModelUsage,
112    /// Explicit degradation and compatibility warnings.
113    pub warnings: Vec<ModelWarning>,
114    /// Namespaced response metadata.
115    pub provider_metadata: BTreeMap<String, Value>,
116    /// Provider stream events retained without normalization.
117    pub provider_events: Vec<ProviderData>,
118}
119
120impl ModelResponse {
121    /// Collects model-visible text in canonical content order.
122    ///
123    /// Reasoning, refusals, Tool calls, citations, and provider-specific
124    /// payloads remain available through [`Self::content`] and are not mixed
125    /// into the returned text.
126    #[must_use]
127    pub fn text(&self) -> String {
128        self.content
129            .iter()
130            .filter_map(|part| match part {
131                ContentPart::Text { text } => Some(text.as_str()),
132                _ => None,
133            })
134            .collect()
135    }
136
137    /// Consumes the response and collects model-visible text in canonical
138    /// content order.
139    ///
140    /// Use this when the remaining response metadata and provider events are
141    /// no longer needed.
142    #[must_use]
143    pub fn into_text(self) -> String {
144        self.content
145            .into_iter()
146            .filter_map(|part| match part {
147                ContentPart::Text { text } => Some(text),
148                _ => None,
149            })
150            .collect()
151    }
152}
153
154#[cfg(test)]
155mod response_tests {
156    use super::{FinishReason, ModelResponse, ModelUsage};
157    use crate::{ContentPart, ModelRef};
158    use std::collections::BTreeMap;
159
160    fn response(content: Vec<ContentPart>) -> ModelResponse {
161        ModelResponse {
162            id: Some("response-1".into()),
163            model: ModelRef::new("test", "scripted"),
164            content,
165            finish_reason: FinishReason::Stop,
166            usage: ModelUsage::default(),
167            warnings: Vec::new(),
168            provider_metadata: BTreeMap::new(),
169            provider_events: Vec::new(),
170        }
171    }
172
173    #[test]
174    fn text_collects_only_model_visible_text_in_order() {
175        let response = response(vec![
176            ContentPart::text("hello"),
177            ContentPart::Refusal {
178                text: "not included".into(),
179            },
180            ContentPart::text(" world"),
181        ]);
182
183        assert_eq!(response.text(), "hello world");
184        assert_eq!(response.into_text(), "hello world");
185    }
186}