1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::fmt::Display;
4use time::OffsetDateTime;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct ModelInfo {
9 pub id: String,
10 pub provider: crate::ProviderId,
11 pub display_name: Option<String>,
12 pub description: Option<String>,
13 pub created_at: Option<OffsetDateTime>,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub context_window: Option<usize>,
25}
26
27impl ModelInfo {
28 pub fn new(id: impl Into<String>, provider: impl Into<crate::ProviderId>) -> Self {
29 Self {
30 id: id.into(),
31 provider: provider.into(),
32 display_name: None,
33 description: None,
34 created_at: None,
35 context_window: None,
36 }
37 }
38
39 pub fn with_context_window(mut self, context_window: usize) -> Self {
41 self.context_window = Some(context_window);
42 self
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ModelSelector {
49 Id(String),
50 NewestAvailable,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55pub struct TokenUsage {
56 pub input_tokens: Option<u64>,
57 pub output_tokens: Option<u64>,
58 pub total_tokens: Option<u64>,
59 pub cache_read_input_tokens: Option<u64>,
60 pub cache_creation_input_tokens: Option<u64>,
61 pub reasoning_tokens: Option<u64>,
62 pub thoughts_tokens: Option<u64>,
63 pub tool_input_tokens: Option<u64>,
64}
65
66impl TokenUsage {
67 pub fn is_empty(&self) -> bool {
68 self.input_tokens.is_none()
69 && self.output_tokens.is_none()
70 && self.total_tokens.is_none()
71 && self.cache_read_input_tokens.is_none()
72 && self.cache_creation_input_tokens.is_none()
73 && self.reasoning_tokens.is_none()
74 && self.thoughts_tokens.is_none()
75 && self.tool_input_tokens.is_none()
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Role {
83 User,
84 Assistant,
85 Unknown(String),
86}
87
88impl Display for Role {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 let value = match self {
91 Self::User => "user",
92 Self::Assistant => "assistant",
93 Self::Unknown(role) => role.as_str(),
94 };
95 f.write_str(value)
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub enum ImageSource {
102 Bytes { media_type: String, data: Vec<u8> },
103 Url { url: String },
104}
105
106impl ImageSource {
107 pub fn bytes(media_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
108 Self::Bytes {
109 media_type: media_type.into(),
110 data: data.into(),
111 }
112 }
113
114 pub fn url(url: impl Into<String>) -> Self {
115 Self::Url { url: url.into() }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(untagged)]
122pub enum ToolResultContent {
123 Text(String),
124 Structured(Value),
125}
126
127impl ToolResultContent {
128 pub fn text(value: impl Into<String>) -> Self {
129 Self::Text(value.into())
130 }
131
132 pub fn len(&self) -> usize {
133 match self {
134 Self::Text(text) => text.len(),
135 Self::Structured(value) => value.to_string().len(),
136 }
137 }
138
139 pub fn is_empty(&self) -> bool {
140 self.len() == 0
141 }
142
143 pub fn clear(&mut self) {
144 *self = Self::Text(String::new());
145 }
146
147 pub fn as_str(&self) -> &str {
148 match self {
149 Self::Text(text) => text.as_str(),
150 Self::Structured(_) => panic!("ToolResultContent::as_str requires text content"),
151 }
152 }
153
154 pub fn contains(&self, pattern: &str) -> bool {
155 match self {
156 Self::Text(text) => text.contains(pattern),
157 Self::Structured(value) => value.to_string().contains(pattern),
158 }
159 }
160
161 pub fn starts_with(&self, pattern: &str) -> bool {
162 match self {
163 Self::Text(text) => text.starts_with(pattern),
164 Self::Structured(value) => value.to_string().starts_with(pattern),
165 }
166 }
167
168 pub fn push_str(&mut self, value: &str) {
169 match self {
170 Self::Text(text) => text.push_str(value),
171 Self::Structured(existing) => {
172 let mut text = existing.to_string();
173 text.push_str(value);
174 *self = Self::Text(text);
175 }
176 }
177 }
178
179 pub fn to_display_string(&self) -> String {
180 match self {
181 Self::Text(text) => text.clone(),
182 Self::Structured(value) => value.to_string(),
183 }
184 }
185}
186
187impl Default for ToolResultContent {
188 fn default() -> Self {
189 Self::Text(String::new())
190 }
191}
192
193impl From<String> for ToolResultContent {
194 fn from(value: String) -> Self {
195 Self::Text(value)
196 }
197}
198
199impl Display for ToolResultContent {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.write_str(&self.to_display_string())
202 }
203}
204
205impl PartialEq<&str> for ToolResultContent {
206 fn eq(&self, other: &&str) -> bool {
207 self.to_display_string() == *other
208 }
209}
210
211impl PartialEq<str> for ToolResultContent {
212 fn eq(&self, other: &str) -> bool {
213 self.to_display_string() == other
214 }
215}
216
217impl PartialEq<ToolResultContent> for &str {
218 fn eq(&self, other: &ToolResultContent) -> bool {
219 *self == other.to_display_string()
220 }
221}
222
223impl PartialEq<ToolResultContent> for str {
224 fn eq(&self, other: &ToolResultContent) -> bool {
225 self == other.to_display_string()
226 }
227}
228
229impl From<&str> for ToolResultContent {
230 fn from(value: &str) -> Self {
231 Self::Text(value.to_string())
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct HostedToolSearchCall {
238 pub id: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub status: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub query: Option<String>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(tag = "type", rename_all = "snake_case")]
248pub enum WebSearchAction {
249 Search {
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 query: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 queries: Option<Vec<String>>,
254 },
255 OpenPage {
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 url: Option<String>,
258 },
259 FindInPage {
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 url: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pattern: Option<String>,
264 },
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct HostedWebSearchCall {
270 pub id: String,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub status: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub action: Option<WebSearchAction>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(tag = "type", rename_all = "snake_case")]
280pub enum ImageGenerationResult {
281 Image { source: ImageSource },
282 ArtifactRef { artifact_id: String },
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct ImageGenerationCall {
288 pub id: String,
289 pub status: String,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub revised_prompt: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub result: Option<ImageGenerationResult>,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum ReasoningFormat {
300 AnthropicSigned,
301 OpenAiEncrypted,
302 GeminiThought,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct ReasoningProvenance {
308 pub provider: crate::ProviderId,
309 pub model: String,
310 pub format: ReasoningFormat,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub enum ContentBlock {
316 Text {
317 text: String,
318 },
319 Thinking {
320 #[serde(default, skip_serializing_if = "String::is_empty")]
321 thinking: String,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 signature: Option<String>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 encrypted_content: Option<String>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 id: Option<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 provenance: Option<ReasoningProvenance>,
330 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
331 redacted: bool,
332 },
333 Image {
334 source: ImageSource,
335 },
336 ToolUse {
337 id: String,
338 name: String,
339 input: Value,
340 },
341 ToolResult {
342 tool_use_id: String,
343 content: ToolResultContent,
344 is_error: bool,
345 },
346 HostedToolSearch {
347 call: HostedToolSearchCall,
348 },
349 HostedWebSearch {
350 call: HostedWebSearchCall,
351 },
352 ImageGeneration {
353 call: ImageGenerationCall,
354 },
355}
356
357impl ContentBlock {
358 pub fn text(text: impl Into<String>) -> Self {
359 Self::Text { text: text.into() }
360 }
361
362 pub fn thinking(thinking: impl Into<String>) -> Self {
363 Self::Thinking {
364 thinking: thinking.into(),
365 signature: None,
366 encrypted_content: None,
367 id: None,
368 provenance: None,
369 redacted: false,
370 }
371 }
372
373 pub(crate) fn thinking_fallback_text(&self) -> Option<String> {
374 let Self::Thinking {
375 thinking, redacted, ..
376 } = self
377 else {
378 return None;
379 };
380
381 if !thinking.is_empty() {
382 Some(thinking.clone())
383 } else if *redacted {
384 Some("[redacted reasoning]".to_string())
385 } else {
386 Some("[reasoning unavailable]".to_string())
387 }
388 }
389
390 pub fn image_bytes(media_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
391 Self::Image {
392 source: ImageSource::bytes(media_type, data),
393 }
394 }
395
396 pub fn image_url(url: impl Into<String>) -> Self {
397 Self::Image {
398 source: ImageSource::url(url),
399 }
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn thinking_serde_is_externally_tagged_and_omits_empty_optional_fields() {
409 let block = ContentBlock::Thinking {
410 thinking: "private chain".to_string(),
411 signature: Some("opaque-signature".to_string()),
412 encrypted_content: None,
413 id: None,
414 provenance: Some(ReasoningProvenance {
415 provider: crate::ProviderId::new("anthropic-edge"),
416 model: "claude-test".to_string(),
417 format: ReasoningFormat::AnthropicSigned,
418 }),
419 redacted: false,
420 };
421
422 let json = serde_json::to_value(&block).expect("thinking block should serialize");
423 assert_eq!(json["Thinking"]["thinking"], "private chain");
424 assert_eq!(json["Thinking"]["signature"], "opaque-signature");
425 assert_eq!(json["Thinking"]["provenance"]["provider"], "anthropic-edge");
426 assert_eq!(json["Thinking"]["provenance"]["format"], "anthropic_signed");
427 assert!(json["Thinking"].get("encrypted_content").is_none());
428 assert!(json["Thinking"].get("id").is_none());
429 assert!(json["Thinking"].get("redacted").is_none());
430 assert_eq!(
431 serde_json::from_value::<ContentBlock>(json).expect("thinking block should load"),
432 block
433 );
434 }
435
436 #[test]
437 fn thinking_serde_defaults_omitted_payload_fields() {
438 let block: ContentBlock = serde_json::from_value(serde_json::json!({
439 "Thinking": {
440 "provenance": {
441 "provider": "anthropic",
442 "model": "claude-test",
443 "format": "anthropic_signed"
444 }
445 }
446 }))
447 .expect("omitted thinking payload fields should default");
448
449 assert_eq!(
450 block,
451 ContentBlock::Thinking {
452 thinking: String::new(),
453 signature: None,
454 encrypted_content: None,
455 id: None,
456 provenance: Some(ReasoningProvenance {
457 provider: crate::ProviderId::new("anthropic"),
458 model: "claude-test".to_string(),
459 format: ReasoningFormat::AnthropicSigned,
460 }),
461 redacted: false,
462 }
463 );
464 }
465
466 #[test]
467 fn pre_thinking_content_block_json_still_deserializes_unchanged() {
468 let json = serde_json::json!({"Text":{"text":"legacy"}});
469
470 assert_eq!(
471 serde_json::from_value::<ContentBlock>(json).expect("legacy block should load"),
472 ContentBlock::text("legacy")
473 );
474 }
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct Message {
480 pub role: Role,
481 pub content: Vec<ContentBlock>,
482}
483
484impl Message {
485 pub fn user(content: ContentBlock) -> Self {
486 Self {
487 role: Role::User,
488 content: vec![content],
489 }
490 }
491
492 pub fn assistant(content: ContentBlock) -> Self {
493 Self {
494 role: Role::Assistant,
495 content: vec![content],
496 }
497 }
498
499 pub fn unknown(role: impl Into<String>, content: ContentBlock) -> Self {
500 Self {
501 role: Role::Unknown(role.into()),
502 content: vec![content],
503 }
504 }
505
506 pub fn text(&self) -> String {
507 self.content
508 .iter()
509 .filter_map(|block| match block {
510 ContentBlock::Text { text } => Some(text.as_str()),
511 _ => None,
512 })
513 .collect::<Vec<_>>()
514 .join("")
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
520pub enum ToolChoice {
521 #[default]
522 Auto,
523 Any,
524 Tool {
525 name: String,
526 },
527}