1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Role {
12 User,
13 Assistant,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum Block {
25 Text {
26 text: String,
27 },
28 Thinking {
31 text: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 signature: Option<String>,
34 },
35 ToolUse {
36 id: String,
37 name: String,
38 input: Value,
39 },
40 ToolResult {
41 tool_use_id: String,
42 content: String,
43 #[serde(default)]
44 is_error: bool,
45 },
46 Image {
64 media_type: String,
67 data: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
79 source: Option<String>,
80 },
81}
82
83impl Block {
84 pub fn text(s: impl Into<String>) -> Self {
85 Block::Text { text: s.into() }
86 }
87
88 pub fn image(media_type: impl Into<String>, bytes: &[u8], source: Option<String>) -> Self {
94 use base64::Engine as _;
95 Block::Image {
96 media_type: media_type.into(),
97 data: base64::engine::general_purpose::STANDARD.encode(bytes),
98 source,
99 }
100 }
101
102 pub fn image_placeholder(media_type: &str, source: Option<&str>) -> String {
108 match source {
109 Some(name) => format!("[image: {name} ({media_type})]"),
110 None => format!("[image: {media_type}]"),
111 }
112 }
113}
114
115pub fn image_media_type(path: &std::path::Path) -> Option<&'static str> {
125 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
126 Some(match ext.as_str() {
127 "png" => "image/png",
128 "jpg" | "jpeg" => "image/jpeg",
129 "gif" => "image/gif",
130 "webp" => "image/webp",
131 _ => return None,
132 })
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct Message {
137 pub role: Role,
138 pub content: Vec<Block>,
139}
140
141impl Message {
142 pub fn user(text: impl Into<String>) -> Self {
143 Message {
144 role: Role::User,
145 content: vec![Block::text(text)],
146 }
147 }
148
149 pub fn assistant(content: Vec<Block>) -> Self {
150 Message {
151 role: Role::Assistant,
152 content,
153 }
154 }
155
156 pub fn tool_results(results: Vec<Block>) -> Self {
159 Message {
160 role: Role::User,
161 content: results,
162 }
163 }
164
165 pub fn text(&self) -> String {
167 self.content
168 .iter()
169 .filter_map(|b| match b {
170 Block::Text { text } => Some(text.as_str()),
171 _ => None,
172 })
173 .collect::<Vec<_>>()
174 .join("")
175 }
176
177 pub fn thinking(&self) -> String {
182 self.content
183 .iter()
184 .filter_map(|b| match b {
185 Block::Thinking { text, .. } => Some(text.as_str()),
186 _ => None,
187 })
188 .collect::<Vec<_>>()
189 .join("")
190 }
191
192 pub fn tool_uses(&self) -> Vec<(&str, &str, &Value)> {
193 self.content
194 .iter()
195 .filter_map(|b| match b {
196 Block::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
197 _ => None,
198 })
199 .collect()
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum StopReason {
207 EndTurn,
209 ToolUse,
211 MaxTokens,
213 Refusal,
215 PauseTurn,
217 Other,
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct Usage {
222 pub input_tokens: u64,
223 pub output_tokens: u64,
224 pub cache_creation_input_tokens: u64,
225 pub cache_read_input_tokens: u64,
226}
227
228impl Usage {
229 pub fn add(&mut self, other: &Usage) {
230 self.input_tokens += other.input_tokens;
231 self.output_tokens += other.output_tokens;
232 self.cache_creation_input_tokens += other.cache_creation_input_tokens;
233 self.cache_read_input_tokens += other.cache_read_input_tokens;
234 }
235
236 pub fn total_input(&self) -> u64 {
238 self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens
239 }
240
241 pub fn cost_usd(&self, pricing: &Pricing) -> f64 {
246 let per_input = pricing.input_per_mtok / 1_000_000.0;
247 let per_output = pricing.output_per_mtok / 1_000_000.0;
248 self.input_tokens as f64 * per_input
249 + self.cache_creation_input_tokens as f64 * per_input * pricing.cache_write_multiplier
250 + self.cache_read_input_tokens as f64 * per_input * pricing.cache_read_multiplier
251 + self.output_tokens as f64 * per_output
252 }
253}
254
255#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
258pub struct Pricing {
259 pub input_per_mtok: f64,
260 pub output_per_mtok: f64,
261 pub cache_write_multiplier: f64,
263 pub cache_read_multiplier: f64,
265}
266
267impl Default for Pricing {
268 fn default() -> Self {
269 Pricing {
271 input_per_mtok: 0.0,
272 output_per_mtok: 0.0,
273 cache_write_multiplier: 1.25,
274 cache_read_multiplier: 0.1,
275 }
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ToolSpec {
282 pub name: String,
283 pub description: String,
284 pub input_schema: Value,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "lowercase")]
291pub enum Effort {
292 Low,
293 Medium,
294 High,
295 XHigh,
296 Max,
297}
298
299impl Effort {
300 pub fn as_str(self) -> &'static str {
301 match self {
302 Effort::Low => "low",
303 Effort::Medium => "medium",
304 Effort::High => "high",
305 Effort::XHigh => "xhigh",
306 Effort::Max => "max",
307 }
308 }
309}
310
311impl std::str::FromStr for Effort {
312 type Err = String;
313 fn from_str(s: &str) -> Result<Self, Self::Err> {
314 match s.to_ascii_lowercase().as_str() {
315 "low" => Ok(Effort::Low),
316 "medium" | "med" => Ok(Effort::Medium),
317 "high" => Ok(Effort::High),
318 "xhigh" | "x-high" => Ok(Effort::XHigh),
319 "max" => Ok(Effort::Max),
320 other => Err(format!(
321 "unknown effort {other:?} (low|medium|high|xhigh|max)"
322 )),
323 }
324 }
325}
326
327#[derive(Debug, Clone)]
329pub struct CompletionRequest {
330 pub model: String,
331 pub system: Option<String>,
332 pub messages: Vec<Message>,
333 pub tools: Vec<ToolSpec>,
334 pub max_tokens: u32,
335 pub effort: Option<Effort>,
336 pub thinking: bool,
338 pub cache_prompt: bool,
340}
341
342#[derive(Debug, Clone)]
343pub struct CompletionResponse {
344 pub message: Message,
345 pub stop_reason: StopReason,
346 pub usage: Usage,
347 pub refusal: Option<Refusal>,
349 pub model: String,
351 pub malformed_tool_args: u32,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct Refusal {
359 pub category: Option<String>,
360 pub explanation: Option<String>,
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use serde_json::json;
367
368 #[test]
369 fn message_text_ignores_thinking_and_tool_traffic() {
370 let m = Message::assistant(vec![
374 Block::Thinking {
375 text: "let me think".into(),
376 signature: Some("sig".into()),
377 },
378 Block::text("the answer is "),
379 Block::ToolUse {
380 id: "t1".into(),
381 name: "echo".into(),
382 input: json!({}),
383 },
384 Block::text("42"),
385 ]);
386
387 assert_eq!(m.text(), "the answer is 42");
388 }
389
390 #[test]
391 fn tool_uses_reports_every_call_in_order() {
392 let m = Message::assistant(vec![
393 Block::ToolUse {
394 id: "t1".into(),
395 name: "fs_read".into(),
396 input: json!({"path": "a"}),
397 },
398 Block::text("and also"),
399 Block::ToolUse {
400 id: "t2".into(),
401 name: "shell".into(),
402 input: json!({"cmd": "ls"}),
403 },
404 ]);
405
406 let calls = m.tool_uses();
407 assert_eq!(calls.len(), 2);
408 assert_eq!((calls[0].0, calls[0].1), ("t1", "fs_read"));
409 assert_eq!((calls[1].0, calls[1].1), ("t2", "shell"));
410 }
411
412 #[test]
413 fn tool_results_travel_as_one_user_message() {
414 let m = Message::tool_results(vec![
418 Block::ToolResult {
419 tool_use_id: "t1".into(),
420 content: "a".into(),
421 is_error: false,
422 },
423 Block::ToolResult {
424 tool_use_id: "t2".into(),
425 content: "b".into(),
426 is_error: true,
427 },
428 ]);
429
430 assert_eq!(m.role, Role::User);
431 assert_eq!(m.content.len(), 2);
432 }
433
434 #[test]
435 fn a_block_round_trips_through_the_session_format() {
436 let blocks = vec![
441 Block::text("hello"),
442 Block::Thinking {
443 text: "hm".into(),
444 signature: None,
445 },
446 Block::Thinking {
447 text: "hm".into(),
448 signature: Some("sig".into()),
449 },
450 Block::ToolUse {
451 id: "t1".into(),
452 name: "echo".into(),
453 input: json!({"v": 1}),
454 },
455 Block::ToolResult {
456 tool_use_id: "t1".into(),
457 content: "1".into(),
458 is_error: true,
459 },
460 ];
461
462 let encoded = serde_json::to_string(&blocks).unwrap();
463 assert!(
464 !encoded.contains("\"signature\":null"),
465 "an absent signature was written out"
466 );
467
468 let decoded: Vec<Block> = serde_json::from_str(&encoded).unwrap();
469 assert_eq!(decoded.len(), blocks.len());
470 match &decoded[1] {
471 Block::Thinking { signature, .. } => assert!(signature.is_none()),
472 other => panic!("expected thinking, got {other:?}"),
473 }
474 match &decoded[4] {
475 Block::ToolResult { is_error, .. } => assert!(is_error),
476 other => panic!("expected a tool result, got {other:?}"),
477 }
478 }
479
480 #[test]
481 fn an_older_transcript_without_is_error_still_loads() {
482 let block: Block = serde_json::from_value(
485 json!({"type": "tool_result", "tool_use_id": "t1", "content": "x"}),
486 )
487 .unwrap();
488 match block {
489 Block::ToolResult { is_error, .. } => assert!(!is_error),
490 other => panic!("expected a tool result, got {other:?}"),
491 }
492 }
493
494 #[test]
495 fn total_input_counts_both_cache_tiers() {
496 let usage = Usage {
500 input_tokens: 100,
501 output_tokens: 50,
502 cache_creation_input_tokens: 200,
503 cache_read_input_tokens: 3000,
504 };
505 assert_eq!(usage.total_input(), 3300);
506 }
507
508 #[test]
509 fn usage_accumulates_every_field() {
510 let mut a = Usage {
511 input_tokens: 1,
512 output_tokens: 2,
513 ..Usage::default()
514 };
515 a.add(&Usage {
516 input_tokens: 10,
517 output_tokens: 20,
518 cache_creation_input_tokens: 30,
519 cache_read_input_tokens: 40,
520 });
521
522 assert_eq!(a.input_tokens, 11);
523 assert_eq!(a.output_tokens, 22);
524 assert_eq!(a.cache_creation_input_tokens, 30);
525 assert_eq!(a.cache_read_input_tokens, 40);
526 }
527
528 #[test]
529 fn cache_reads_and_writes_are_priced_off_the_input_rate() {
530 let pricing = Pricing {
533 input_per_mtok: 1_000_000.0, output_per_mtok: 2_000_000.0,
535 cache_write_multiplier: 1.25,
536 cache_read_multiplier: 0.1,
537 };
538 let usage = Usage {
539 input_tokens: 1,
540 output_tokens: 1,
541 cache_creation_input_tokens: 1,
542 cache_read_input_tokens: 1,
543 };
544
545 assert!((usage.cost_usd(&pricing) - 4.35).abs() < 1e-9);
547 }
548
549 #[test]
550 fn a_provider_with_no_prices_configured_costs_nothing_rather_than_guessing() {
551 let usage = Usage {
554 input_tokens: 1_000_000,
555 output_tokens: 1_000_000,
556 ..Usage::default()
557 };
558 assert_eq!(usage.cost_usd(&Pricing::default()), 0.0);
559 }
560
561 #[test]
562 fn effort_parses_its_aliases_and_refuses_anything_else() {
563 use std::str::FromStr;
564
565 for (input, expected) in [
566 ("low", Effort::Low),
567 ("MEDIUM", Effort::Medium),
568 ("med", Effort::Medium),
569 ("high", Effort::High),
570 ("xhigh", Effort::XHigh),
571 ("x-high", Effort::XHigh),
572 ("max", Effort::Max),
573 ] {
574 assert_eq!(
575 Effort::from_str(input).unwrap(),
576 expected,
577 "parsing {input}"
578 );
579 }
580
581 let err = Effort::from_str("turbo").unwrap_err();
582 assert!(
583 err.contains("turbo") && err.contains("low|medium|high"),
584 "unhelpful: {err}"
585 );
586 }
587
588 #[test]
589 fn every_effort_round_trips_through_its_wire_name() {
590 use std::str::FromStr;
591 for effort in [
592 Effort::Low,
593 Effort::Medium,
594 Effort::High,
595 Effort::XHigh,
596 Effort::Max,
597 ] {
598 assert_eq!(Effort::from_str(effort.as_str()).unwrap(), effort);
599 }
600 }
601}