1use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11pub enum FinishReason {
12 Stop,
14 ToolUse,
19 Truncated {
25 reason: Option<String>,
27 },
28 Other(String),
30}
31
32impl FinishReason {
33 pub fn from_openai(s: Option<&str>) -> Self {
35 match s {
36 Some("stop") => Self::Stop,
37 Some("length") => Self::Truncated {
38 reason: Some("length".into()),
39 },
40 Some(other) => Self::Other(other.to_string()),
42 None => Self::Stop, }
44 }
45
46 pub fn from_anthropic(s: Option<&str>) -> Self {
48 match s {
49 Some("end_turn") => Self::Stop,
50 Some("tool_use") => Self::ToolUse,
51 Some("max_tokens") => Self::Truncated {
52 reason: Some("max_tokens".into()),
53 },
54 Some(other) => Self::Other(other.to_string()),
55 None => Self::Stop,
56 }
57 }
58
59 pub fn from_responses(reason: Option<&str>) -> Self {
61 match reason {
62 None => Self::Stop,
63 Some("incomplete") => Self::Truncated { reason: None },
64 Some(other) => Self::Other(other.to_string()),
65 }
66 }
67
68 pub fn from_raw(s: Option<&str>) -> Self {
75 match s {
76 Some("stop") | Some("end_turn") => Self::Stop,
77 Some("tool_use") => Self::ToolUse,
78 Some("length") => Self::Truncated {
79 reason: Some("length".into()),
80 },
81 Some("max_tokens") => Self::Truncated {
82 reason: Some("max_tokens".into()),
83 },
84 Some("incomplete:max_output_tokens") => Self::Truncated {
87 reason: Some("incomplete:max_output_tokens".into()),
88 },
89 Some(s) if s.starts_with("incomplete:") => Self::Other(s.to_string()),
90 Some(other) => Self::Other(other.to_string()),
91 None => Self::Stop,
92 }
93 }
94
95 pub fn is_truncated(&self) -> bool {
97 matches!(self, Self::Truncated { .. })
98 }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub enum RunOutcome {
111 Completed,
112 Continuing,
113 Failed { error: String },
114 MaxTurnsExceeded { turns: u32 },
115 Cancelled,
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn openai_stop() {
124 assert_eq!(FinishReason::from_openai(Some("stop")), FinishReason::Stop);
125 }
126
127 #[test]
128 fn openai_length() {
129 assert_eq!(
130 FinishReason::from_openai(Some("length")),
131 FinishReason::Truncated {
132 reason: Some("length".into())
133 }
134 );
135 }
136
137 #[test]
138 fn openai_none() {
139 assert_eq!(FinishReason::from_openai(None), FinishReason::Stop);
140 }
141
142 #[test]
143 fn openai_other() {
144 assert_eq!(
145 FinishReason::from_openai(Some("content_filter")),
146 FinishReason::Other("content_filter".into())
147 );
148 }
149
150 #[test]
151 fn anthropic_end_turn() {
152 assert_eq!(
153 FinishReason::from_anthropic(Some("end_turn")),
154 FinishReason::Stop
155 );
156 }
157
158 #[test]
159 fn anthropic_tool_use() {
160 assert_eq!(
161 FinishReason::from_anthropic(Some("tool_use")),
162 FinishReason::ToolUse
163 );
164 }
165
166 #[test]
167 fn anthropic_max_tokens() {
168 assert_eq!(
169 FinishReason::from_anthropic(Some("max_tokens")),
170 FinishReason::Truncated {
171 reason: Some("max_tokens".into())
172 }
173 );
174 }
175
176 #[test]
177 fn responses_incomplete() {
178 assert_eq!(
179 FinishReason::from_responses(Some("incomplete")),
180 FinishReason::Truncated { reason: None }
181 );
182 }
183
184 #[test]
185 fn responses_none() {
186 assert_eq!(FinishReason::from_responses(None), FinishReason::Stop);
187 }
188
189 #[test]
190 fn is_truncated_true() {
191 assert!(FinishReason::Truncated { reason: None }.is_truncated());
192 assert!(
193 FinishReason::Truncated {
194 reason: Some("length".into())
195 }
196 .is_truncated()
197 );
198 }
199
200 #[test]
201 fn is_truncated_false() {
202 assert!(!FinishReason::Stop.is_truncated());
203 assert!(!FinishReason::ToolUse.is_truncated());
204 assert!(!FinishReason::Other("x".into()).is_truncated());
205 }
206
207 #[test]
208 fn from_raw_incomplete_prefix() {
209 assert_eq!(
210 FinishReason::from_raw(Some("incomplete:max_output_tokens")),
211 FinishReason::Truncated {
212 reason: Some("incomplete:max_output_tokens".into())
213 }
214 );
215 assert_eq!(
217 FinishReason::from_raw(Some("incomplete:content_filter")),
218 FinishReason::Other("incomplete:content_filter".into())
219 );
220 }
221}
222
223#[cfg(test)]
224mod proptest_tests {
225 use super::*;
226 use proptest::prelude::*;
227
228 proptest! {
229 #[test]
230 fn from_openai_never_panics(s in proptest::option::of(".*")) {
231 let result = FinishReason::from_openai(s.as_deref());
232 match result {
234 FinishReason::Stop | FinishReason::ToolUse |
235 FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
236 }
237 }
238
239 #[test]
240 fn from_anthropic_never_panics(s in proptest::option::of(".*")) {
241 let result = FinishReason::from_anthropic(s.as_deref());
242 match result {
243 FinishReason::Stop | FinishReason::ToolUse |
244 FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
245 }
246 }
247
248 #[test]
249 fn from_raw_never_panics(s in proptest::option::of(".*")) {
250 let result = FinishReason::from_raw(s.as_deref());
251 match result {
252 FinishReason::Stop | FinishReason::ToolUse |
253 FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
254 }
255 }
256
257 #[test]
258 fn from_responses_never_panics(s in proptest::option::of(".*")) {
259 let result = FinishReason::from_responses(s.as_deref());
260 match result {
261 FinishReason::Stop | FinishReason::ToolUse |
262 FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
263 }
264 }
265
266 #[test]
267 fn truncated_is_always_truncated(reason in proptest::option::of(".*")) {
268 let fr = FinishReason::Truncated { reason };
269 assert!(fr.is_truncated());
270 }
271 }
272}