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 temperature: Option<f32>,
189 pub top_p: Option<f32>,
190 pub max_tokens: Option<u32>,
191}
192
193#[derive(Debug, Clone, Default)]
200pub struct ChatMessage {
201 pub role: String,
202 pub content: String,
203 pub tool_calls: Option<Vec<ToolCall>>,
204 pub tool_call_id: Option<String>,
205 pub images: Vec<String>,
206}
207
208impl ChatMessage {
209 pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
210 Self {
211 role: role.into(),
212 content: content.into(),
213 ..Default::default()
214 }
215 }
216}
217
218#[derive(Serialize)]
219struct Function<'a> {
220 name: &'a str,
221 arguments: &'a str,
222}
223
224#[derive(Serialize)]
225struct Wire<'a> {
226 id: &'a str,
227 r#type: &'static str,
228 function: Function<'a>,
229}
230
231impl Serialize for ChatMessage {
232 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
233 use serde::ser::SerializeMap;
234 let mut map = s.serialize_map(None)?;
235 map.serialize_entry("role", &self.role)?;
236 if self.images.is_empty() {
237 map.serialize_entry("content", &self.content)?;
238 } else {
239 let mut parts: Vec<serde_json::Value> = Vec::new();
241 if !self.content.is_empty() {
242 parts.push(serde_json::json!({ "type": "text", "text": self.content }));
243 }
244 for url in &self.images {
245 parts.push(serde_json::json!({ "type": "image_url", "image_url": { "url": url } }));
246 }
247 map.serialize_entry("content", &parts)?;
248 }
249 if let Some(calls) = &self.tool_calls {
250 let wire: Vec<Wire> = calls
251 .iter()
252 .map(|c| Wire {
253 id: &c.id,
254 r#type: "function",
255 function: Function {
256 name: &c.name,
257 arguments: &c.arguments,
258 },
259 })
260 .collect();
261 map.serialize_entry("tool_calls", &wire)?;
262 }
263 if let Some(id) = &self.tool_call_id {
264 map.serialize_entry("tool_call_id", id)?;
265 }
266 map.end()
267 }
268}
269
270#[derive(Debug, Clone, Copy)]
274#[allow(clippy::struct_field_names)]
276pub struct Usage {
277 pub prompt_tokens: u64,
278 pub completion_tokens: u64,
279 pub total_tokens: u64,
280 pub cache_read_tokens: u64,
282 pub cache_creation_tokens: u64,
284 pub cost: Option<f64>,
288}
289
290impl Usage {
291 #[allow(clippy::cast_precision_loss)] pub fn cache_hit_rate(&self) -> Option<f64> {
295 if self.prompt_tokens == 0 {
296 None
297 } else {
298 Some((self.cache_read_tokens as f64 / self.prompt_tokens as f64).clamp(0.0, 1.0))
299 }
300 }
301}
302
303pub fn seed_tool_result_dedup(
314 messages: &[ChatMessage],
315) -> std::collections::HashMap<(String, String), String> {
316 use std::collections::HashMap;
317 let mut seen: HashMap<(String, String), String> = HashMap::new();
318 let mut pending: std::collections::VecDeque<(String, String)> =
322 std::collections::VecDeque::new();
323 for msg in messages {
324 if let Some(calls) = &msg.tool_calls {
325 for call in calls {
326 pending.push_back((call.name.clone(), call.arguments.clone()));
327 }
328 } else if msg.role == "tool"
329 && let Some((name, args)) = pending.pop_front()
330 && !msg
331 .content
332 .starts_with(crate::tools::TOOL_RESULT_OMITTED_PREFIX)
333 {
334 seen.insert((name, args), msg.content.clone());
335 }
336 }
337 seen
338}
339
340#[derive(Debug, Clone)]
341pub enum StreamEvent {
342 Token(String),
344 Reasoning(String),
346 Usage(Usage),
348 Status(String),
351 ToolCall {
353 name: String,
354 arguments: String,
355 result: String,
356 },
357 Done,
358 Error(String),
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn seed_tool_result_dedup_reconstructs_replay_state() {
367 let pair = |id: &str, name: &str, args: &str, content: &str| {
370 vec![
371 ChatMessage {
372 role: "assistant".into(),
373 content: String::new(),
374 tool_calls: Some(vec![ToolCall {
375 id: id.into(),
376 name: name.into(),
377 arguments: args.into(),
378 }]),
379 tool_call_id: None,
380 images: Vec::new(),
381 },
382 ChatMessage {
383 role: "tool".into(),
384 content: content.into(),
385 tool_calls: None,
386 tool_call_id: Some(id.into()),
387 images: Vec::new(),
388 },
389 ]
390 };
391 let args = r#"{"name":"a.txt"}"#;
392 let mut msgs = pair("c0", "read_file", args, "v1");
393 msgs.extend(pair(
394 "c1",
395 "read_file",
396 args,
397 crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
398 ));
399 msgs.extend(pair("c2", "read_file", args, "v2"));
400 msgs.extend(pair(
401 "c3",
402 "read_file",
403 args,
404 crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
405 ));
406 let seen = seed_tool_result_dedup(&msgs);
407 assert_eq!(
409 seen.get(&("read_file".to_string(), args.to_string())),
410 Some(&"v2".to_string())
411 );
412 assert_eq!(seen.len(), 1);
413 }
414
415 #[test]
416 fn seed_tool_result_dedup_keeps_latest_full_per_call() {
417 let msgs = vec![
418 ChatMessage {
419 role: "assistant".into(),
420 content: String::new(),
421 tool_calls: Some(vec![ToolCall {
422 id: "a".into(),
423 name: "search".into(),
424 arguments: r#"{"query":"x"}"#.into(),
425 }]),
426 tool_call_id: None,
427 images: Vec::new(),
428 },
429 ChatMessage {
430 role: "tool".into(),
431 content: "hits-a".into(),
432 tool_calls: None,
433 tool_call_id: Some("a".into()),
434 images: Vec::new(),
435 },
436 ];
437 let seen = seed_tool_result_dedup(&msgs);
438 assert_eq!(
439 seen.get(&("search".to_string(), r#"{"query":"x"}"#.to_string())),
440 Some(&"hits-a".to_string())
441 );
442 }
443
444 #[test]
445 fn chat_message_serializes_string_content_when_no_images() {
446 let m = ChatMessage::text("user", "hi");
447 let v = serde_json::to_value(&m).unwrap();
448 assert_eq!(v["content"], "hi");
449 assert!(v.get("tool_calls").is_none());
450 }
451
452 #[test]
453 fn chat_message_serializes_parts_when_images_present() {
454 let mut m = ChatMessage::text("user", "what is this?");
455 m.images = vec!["data:image/png;base64,AAAA".into()];
456 let v = serde_json::to_value(&m).unwrap();
457 assert_eq!(v["content"][0]["type"], "text");
458 assert_eq!(v["content"][0]["text"], "what is this?");
459 assert_eq!(v["content"][1]["type"], "image_url");
460 assert_eq!(
461 v["content"][1]["image_url"]["url"],
462 "data:image/png;base64,AAAA"
463 );
464 }
465
466 #[test]
467 fn chat_message_with_tool_calls_still_serializes_them() {
468 let m = ChatMessage {
469 role: "assistant".into(),
470 content: String::new(),
471 tool_calls: Some(vec![ToolCall {
472 id: "c1".into(),
473 name: "web_search".into(),
474 arguments: "{}".into(),
475 }]),
476 tool_call_id: None,
477 images: Vec::new(),
478 };
479 let v = serde_json::to_value(&m).unwrap();
480 assert_eq!(v["tool_calls"][0]["function"]["name"], "web_search");
481 assert_eq!(v["tool_calls"][0]["type"], "function");
482 }
483}