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