1pub mod openrouter;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolDef {
10 pub name: String,
11 pub description: String,
12 pub parameters: serde_json::Value,
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct ToolCall {
18 pub id: String,
19 pub name: String,
20 pub arguments: String,
22}
23
24#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
28pub enum BackendTag {
29 OpenRouter,
30 OpenAi,
31 OpencodeGo,
32 Codex,
33}
34
35impl BackendTag {
36 pub const fn name(self) -> &'static str {
38 match self {
39 Self::OpenRouter => "OpenRouter",
40 Self::OpenAi => "OpenAI",
41 Self::OpencodeGo => "OpenCode Go",
42 Self::Codex => "Codex",
43 }
44 }
45
46 pub const fn key_prefix(self) -> &'static str {
51 match self {
52 Self::OpenRouter => "",
53 Self::OpenAi => "openai:",
54 Self::OpencodeGo => "opencode:",
55 Self::Codex => "codex:",
56 }
57 }
58
59 pub const fn wire_prefix(self) -> &'static str {
62 match self {
63 Self::OpenRouter => "openrouter:",
64 Self::OpenAi => "openai:",
65 Self::OpencodeGo => "opencode:",
66 Self::Codex => "codex:",
67 }
68 }
69
70 pub const fn display_name(self) -> &'static str {
71 match self {
72 Self::OpenRouter => "OpenRouter",
73 Self::OpenAi => "OpenAI",
74 Self::OpencodeGo => "OpenCode Go",
75 Self::Codex => "Codex",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
86pub enum ReasoningEffort {
87 None,
88 Minimal,
89 Low,
90 Medium,
91 High,
92 XHigh,
93 Max,
94}
95
96impl ReasoningEffort {
97 pub const fn as_str(self) -> &'static str {
99 match self {
100 Self::None => "none",
101 Self::Minimal => "minimal",
102 Self::Low => "low",
103 Self::Medium => "medium",
104 Self::High => "high",
105 Self::XHigh => "xhigh",
106 Self::Max => "max",
107 }
108 }
109
110 pub const CYCLE_ORDER: &'static [Self] = &[
113 Self::Minimal,
114 Self::Low,
115 Self::Medium,
116 Self::High,
117 Self::XHigh,
118 Self::Max,
119 Self::None,
120 ];
121
122 pub const STANDARD: &'static [Self] = &[Self::Low, Self::Medium, Self::High];
124
125 pub const WITH_MINIMAL: &'static [Self] = &[Self::Minimal, Self::Low, Self::Medium, Self::High];
127
128 pub const WITH_XHIGH_AND_NONE: &'static [Self] =
130 &[Self::Low, Self::Medium, Self::High, Self::XHigh, Self::None];
131
132 pub const WITH_MAX_XHIGH_AND_NONE: &'static [Self] = &[
134 Self::Low,
135 Self::Medium,
136 Self::High,
137 Self::XHigh,
138 Self::Max,
139 Self::None,
140 ];
141
142 pub const HIGH_ONLY: &'static [Self] = &[Self::High];
144}
145
146#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct ModelPricing {
149 pub prompt: f64,
150 pub completion: f64,
151 pub cache_read: Option<f64>,
154 pub cache_write: Option<f64>,
156}
157
158#[derive(Debug, Clone)]
160pub struct Model {
161 pub id: String,
162 pub name: String,
163 pub reasoning_efforts: Vec<ReasoningEffort>,
166 pub context_length: Option<u64>,
168 pub supports_images: bool,
170 pub supports_image_generation: bool,
172 pub supports_video_generation: bool,
174 pub backend: BackendTag,
176 pub pricing: Option<ModelPricing>,
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct ChatParams {
184 pub reasoning_effort: Option<String>,
188 pub prompt_cache_key: Option<String>,
191 pub temperature: Option<f32>,
192 pub top_p: Option<f32>,
193 pub max_tokens: Option<u32>,
194}
195
196#[derive(Debug, Clone, Default)]
203pub struct ChatMessage {
204 pub role: String,
205 pub content: String,
206 pub reasoning_content: Option<String>,
209 pub tool_calls: Option<Vec<ToolCall>>,
210 pub tool_call_id: Option<String>,
211 pub images: Vec<String>,
212}
213
214impl ChatMessage {
215 pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
216 Self {
217 role: role.into(),
218 content: content.into(),
219 ..Default::default()
220 }
221 }
222}
223
224#[derive(Serialize)]
225struct Function<'a> {
226 name: &'a str,
227 arguments: &'a str,
228}
229
230#[derive(Serialize)]
231struct Wire<'a> {
232 id: &'a str,
233 r#type: &'static str,
234 function: Function<'a>,
235}
236
237impl Serialize for ChatMessage {
238 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
239 use serde::ser::SerializeMap;
240 let mut map = s.serialize_map(None)?;
241 map.serialize_entry("role", &self.role)?;
242 if self.images.is_empty() {
243 map.serialize_entry("content", &self.content)?;
244 } else {
245 let mut parts: Vec<serde_json::Value> = Vec::new();
247 if !self.content.is_empty() {
248 parts.push(serde_json::json!({ "type": "text", "text": self.content }));
249 }
250 for url in &self.images {
251 parts.push(serde_json::json!({ "type": "image_url", "image_url": { "url": url } }));
252 }
253 map.serialize_entry("content", &parts)?;
254 }
255 if let Some(reasoning) = &self.reasoning_content {
256 map.serialize_entry("reasoning_content", reasoning)?;
257 }
258 if let Some(calls) = &self.tool_calls {
259 let wire: Vec<Wire> = calls
260 .iter()
261 .map(|c| Wire {
262 id: &c.id,
263 r#type: "function",
264 function: Function {
265 name: &c.name,
266 arguments: &c.arguments,
267 },
268 })
269 .collect();
270 map.serialize_entry("tool_calls", &wire)?;
271 }
272 if let Some(id) = &self.tool_call_id {
273 map.serialize_entry("tool_call_id", id)?;
274 }
275 map.end()
276 }
277}
278
279#[derive(Debug, Clone, Copy)]
283#[allow(clippy::struct_field_names)]
285pub struct Usage {
286 pub prompt_tokens: u64,
287 pub completion_tokens: u64,
288 pub total_tokens: u64,
289 pub cache_read_tokens: u64,
291 pub cache_creation_tokens: u64,
293 pub cost: Option<f64>,
297}
298
299impl Usage {
300 #[allow(clippy::cast_precision_loss)] pub fn cache_hit_rate(&self) -> Option<f64> {
304 if self.prompt_tokens == 0 {
305 None
306 } else {
307 Some((self.cache_read_tokens as f64 / self.prompt_tokens as f64).clamp(0.0, 1.0))
308 }
309 }
310}
311
312#[derive(Debug, Clone)]
314pub struct Completion {
315 pub text: String,
317 pub usage: Option<Usage>,
319}
320
321pub fn seed_tool_result_dedup(
332 messages: &[ChatMessage],
333) -> std::collections::HashMap<(String, String), String> {
334 use std::collections::HashMap;
335 let mut seen: HashMap<(String, String), String> = HashMap::new();
336 let mut pending: std::collections::VecDeque<(String, String)> =
340 std::collections::VecDeque::new();
341 for msg in messages {
342 if let Some(calls) = &msg.tool_calls {
343 for call in calls {
344 pending.push_back((call.name.clone(), call.arguments.clone()));
345 }
346 } else if msg.role == "tool"
347 && let Some((name, args)) = pending.pop_front()
348 && !msg
349 .content
350 .starts_with(crate::tools::TOOL_RESULT_OMITTED_PREFIX)
351 {
352 seen.insert((name, args), msg.content.clone());
353 }
354 }
355 seen
356}
357
358#[derive(Debug, Clone)]
359pub enum StreamEvent {
360 Token(String),
362 Reasoning(String),
364 Usage(Usage),
366 Status(String),
369 ToolCall {
371 id: String,
374 reasoning: Option<String>,
377 assistant_content: Option<String>,
379 name: String,
380 arguments: String,
381 result: String,
382 },
383 Done,
384 Error(String),
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn seed_tool_result_dedup_reconstructs_replay_state() {
393 let pair = |id: &str, name: &str, args: &str, content: &str| {
396 vec![
397 ChatMessage {
398 role: "assistant".into(),
399 content: String::new(),
400 reasoning_content: None,
401 tool_calls: Some(vec![ToolCall {
402 id: id.into(),
403 name: name.into(),
404 arguments: args.into(),
405 }]),
406 tool_call_id: None,
407 images: Vec::new(),
408 },
409 ChatMessage {
410 role: "tool".into(),
411 content: content.into(),
412 reasoning_content: None,
413 tool_calls: None,
414 tool_call_id: Some(id.into()),
415 images: Vec::new(),
416 },
417 ]
418 };
419 let args = r#"{"name":"a.txt"}"#;
420 let mut msgs = pair("c0", "read_file", args, "v1");
421 msgs.extend(pair(
422 "c1",
423 "read_file",
424 args,
425 crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
426 ));
427 msgs.extend(pair("c2", "read_file", args, "v2"));
428 msgs.extend(pair(
429 "c3",
430 "read_file",
431 args,
432 crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
433 ));
434 let seen = seed_tool_result_dedup(&msgs);
435 assert_eq!(
437 seen.get(&("read_file".to_string(), args.to_string())),
438 Some(&"v2".to_string())
439 );
440 assert_eq!(seen.len(), 1);
441 }
442
443 #[test]
444 fn seed_tool_result_dedup_keeps_latest_full_per_call() {
445 let msgs = vec![
446 ChatMessage {
447 role: "assistant".into(),
448 content: String::new(),
449 reasoning_content: None,
450 tool_calls: Some(vec![ToolCall {
451 id: "a".into(),
452 name: "search".into(),
453 arguments: r#"{"query":"x"}"#.into(),
454 }]),
455 tool_call_id: None,
456 images: Vec::new(),
457 },
458 ChatMessage {
459 role: "tool".into(),
460 content: "hits-a".into(),
461 reasoning_content: None,
462 tool_calls: None,
463 tool_call_id: Some("a".into()),
464 images: Vec::new(),
465 },
466 ];
467 let seen = seed_tool_result_dedup(&msgs);
468 assert_eq!(
469 seen.get(&("search".to_string(), r#"{"query":"x"}"#.to_string())),
470 Some(&"hits-a".to_string())
471 );
472 }
473
474 #[test]
475 fn chat_message_serializes_string_content_when_no_images() {
476 let m = ChatMessage::text("user", "hi");
477 let v = serde_json::to_value(&m).unwrap();
478 assert_eq!(v["content"], "hi");
479 assert!(v.get("tool_calls").is_none());
480 }
481
482 #[test]
483 fn chat_message_serializes_reasoning_content_when_present() {
484 let mut m = ChatMessage::text("assistant", "");
485 m.reasoning_content = Some("check the tool result".into());
486 let v = serde_json::to_value(&m).unwrap();
487 assert_eq!(v["reasoning_content"], "check the tool result");
488 }
489
490 #[test]
491 fn chat_message_serializes_parts_when_images_present() {
492 let mut m = ChatMessage::text("user", "what is this?");
493 m.images = vec!["data:image/png;base64,AAAA".into()];
494 let v = serde_json::to_value(&m).unwrap();
495 assert_eq!(v["content"][0]["type"], "text");
496 assert_eq!(v["content"][0]["text"], "what is this?");
497 assert_eq!(v["content"][1]["type"], "image_url");
498 assert_eq!(
499 v["content"][1]["image_url"]["url"],
500 "data:image/png;base64,AAAA"
501 );
502 }
503
504 #[test]
505 fn chat_message_with_tool_calls_still_serializes_them() {
506 let m = ChatMessage {
507 role: "assistant".into(),
508 content: String::new(),
509 reasoning_content: None,
510 tool_calls: Some(vec![ToolCall {
511 id: "c1".into(),
512 name: "web_search".into(),
513 arguments: "{}".into(),
514 }]),
515 tool_call_id: None,
516 images: Vec::new(),
517 };
518 let v = serde_json::to_value(&m).unwrap();
519 assert_eq!(v["tool_calls"][0]["function"]["name"], "web_search");
520 assert_eq!(v["tool_calls"][0]["type"], "function");
521 }
522}