runifold_model/
response.rs1use std::collections::BTreeMap;
2
3use runifold_core::Usage;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{ContentPart, ModelRef, ProviderData};
8
9#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(rename_all = "snake_case")]
12#[non_exhaustive]
13pub enum FinishReason {
14 Stop,
16 Length,
18 ToolCalls,
20 ContentFilter,
22 Cancelled,
24 Error,
26 Other(String),
28 #[default]
30 Unknown,
31}
32
33#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
35pub struct ModelUsage {
36 pub input_tokens: u64,
38 pub output_tokens: u64,
40 pub reasoning_tokens: u64,
42 pub cached_input_tokens: u64,
44 pub cache_write_tokens: u64,
46 pub cost_microusd: u64,
48}
49
50impl ModelUsage {
51 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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
90pub struct ModelWarning {
91 pub code: String,
93 pub message: String,
95 pub metadata: BTreeMap<String, Value>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
101pub struct ModelResponse {
102 pub id: Option<String>,
104 pub model: ModelRef,
106 pub content: Vec<ContentPart>,
108 pub finish_reason: FinishReason,
110 pub usage: ModelUsage,
112 pub warnings: Vec<ModelWarning>,
114 pub provider_metadata: BTreeMap<String, Value>,
116 pub provider_events: Vec<ProviderData>,
118}
119
120impl ModelResponse {
121 #[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 #[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}