pub mod openrouter;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum BackendTag {
OpenRouter,
OpenAi,
OpencodeGo,
Codex,
}
impl BackendTag {
pub const fn name(self) -> &'static str {
match self {
Self::OpenRouter => "OpenRouter",
Self::OpenAi => "OpenAI",
Self::OpencodeGo => "OpenCode Go",
Self::Codex => "Codex",
}
}
pub const fn key_prefix(self) -> &'static str {
match self {
Self::OpenRouter => "",
Self::OpenAi => "openai:",
Self::OpencodeGo => "opencode:",
Self::Codex => "codex:",
}
}
pub const fn display_name(self) -> &'static str {
match self {
Self::OpenRouter => "OpenRouter",
Self::OpenAi => "OpenAI",
Self::OpencodeGo => "OpenCode Go",
Self::Codex => "Codex",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
XHigh,
Max,
}
impl ReasoningEffort {
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
pub const CYCLE_ORDER: &'static [Self] = &[
Self::Minimal,
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
Self::Max,
Self::None,
];
pub const STANDARD: &'static [Self] = &[Self::Low, Self::Medium, Self::High];
pub const WITH_MINIMAL: &'static [Self] = &[Self::Minimal, Self::Low, Self::Medium, Self::High];
pub const WITH_XHIGH_AND_NONE: &'static [Self] =
&[Self::Low, Self::Medium, Self::High, Self::XHigh, Self::None];
pub const WITH_MAX_XHIGH_AND_NONE: &'static [Self] = &[
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
Self::Max,
Self::None,
];
pub const HIGH_ONLY: &'static [Self] = &[Self::High];
}
#[derive(Debug, Clone)]
pub struct Model {
pub id: String,
pub name: String,
pub reasoning_efforts: Vec<ReasoningEffort>,
pub context_length: Option<u64>,
pub supports_images: bool,
pub supports_image_generation: bool,
pub supports_video_generation: bool,
pub backend: BackendTag,
pub pricing: Option<(f64, f64)>,
}
#[derive(Debug, Clone, Default)]
pub struct ChatParams {
pub reasoning_effort: Option<String>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct ChatMessage {
pub role: String,
pub content: String,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
pub images: Vec<String>,
}
impl ChatMessage {
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: content.into(),
..Default::default()
}
}
}
#[derive(Serialize)]
struct Function<'a> {
name: &'a str,
arguments: &'a str,
}
#[derive(Serialize)]
struct Wire<'a> {
id: &'a str,
r#type: &'static str,
function: Function<'a>,
}
impl Serialize for ChatMessage {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = s.serialize_map(None)?;
map.serialize_entry("role", &self.role)?;
if self.images.is_empty() {
map.serialize_entry("content", &self.content)?;
} else {
let mut parts: Vec<serde_json::Value> = Vec::new();
if !self.content.is_empty() {
parts.push(serde_json::json!({ "type": "text", "text": self.content }));
}
for url in &self.images {
parts.push(serde_json::json!({ "type": "image_url", "image_url": { "url": url } }));
}
map.serialize_entry("content", &parts)?;
}
if let Some(calls) = &self.tool_calls {
let wire: Vec<Wire> = calls
.iter()
.map(|c| Wire {
id: &c.id,
r#type: "function",
function: Function {
name: &c.name,
arguments: &c.arguments,
},
})
.collect();
map.serialize_entry("tool_calls", &wire)?;
}
if let Some(id) = &self.tool_call_id {
map.serialize_entry("tool_call_id", id)?;
}
map.end()
}
}
#[derive(Debug, Clone, Copy)]
#[allow(clippy::struct_field_names)]
pub struct Usage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub cache_read_tokens: u64,
pub cache_creation_tokens: u64,
}
impl Usage {
pub fn cache_hit_rate(&self) -> Option<f64> {
if self.prompt_tokens == 0 {
None
} else {
Some((self.cache_read_tokens as f64 / self.prompt_tokens as f64).clamp(0.0, 1.0))
}
}
}
pub fn seed_tool_result_dedup(
messages: &[ChatMessage],
) -> std::collections::HashMap<(String, String), String> {
use std::collections::HashMap;
let mut seen: HashMap<(String, String), String> = HashMap::new();
let mut pending: std::collections::VecDeque<(String, String)> =
std::collections::VecDeque::new();
for msg in messages {
if let Some(calls) = &msg.tool_calls {
for call in calls {
pending.push_back((call.name.clone(), call.arguments.clone()));
}
} else if msg.role == "tool"
&& let Some((name, args)) = pending.pop_front()
&& !msg
.content
.starts_with(crate::tools::TOOL_RESULT_OMITTED_PREFIX)
{
seen.insert((name, args), msg.content.clone());
}
}
seen
}
#[derive(Debug)]
pub enum StreamEvent {
Token(String),
Reasoning(String),
Usage(Usage),
Status(String),
ToolCall {
name: String,
arguments: String,
result: String,
},
Done,
Error(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seed_tool_result_dedup_reconstructs_replay_state() {
let pair = |id: &str, name: &str, args: &str, content: &str| {
vec![
ChatMessage {
role: "assistant".into(),
content: String::new(),
tool_calls: Some(vec![ToolCall {
id: id.into(),
name: name.into(),
arguments: args.into(),
}]),
tool_call_id: None,
images: Vec::new(),
},
ChatMessage {
role: "tool".into(),
content: content.into(),
tool_calls: None,
tool_call_id: Some(id.into()),
images: Vec::new(),
},
]
};
let args = r#"{"name":"a.txt"}"#;
let mut msgs = pair("c0", "read_file", args, "v1");
msgs.extend(pair(
"c1",
"read_file",
args,
crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
));
msgs.extend(pair("c2", "read_file", args, "v2"));
msgs.extend(pair(
"c3",
"read_file",
args,
crate::tools::tool_result_unchanged_note("read_file", args).as_str(),
));
let seen = seed_tool_result_dedup(&msgs);
assert_eq!(
seen.get(&("read_file".to_string(), args.to_string())),
Some(&"v2".to_string())
);
assert_eq!(seen.len(), 1);
}
#[test]
fn seed_tool_result_dedup_keeps_latest_full_per_call() {
let msgs = vec![
ChatMessage {
role: "assistant".into(),
content: String::new(),
tool_calls: Some(vec![ToolCall {
id: "a".into(),
name: "search".into(),
arguments: r#"{"query":"x"}"#.into(),
}]),
tool_call_id: None,
images: Vec::new(),
},
ChatMessage {
role: "tool".into(),
content: "hits-a".into(),
tool_calls: None,
tool_call_id: Some("a".into()),
images: Vec::new(),
},
];
let seen = seed_tool_result_dedup(&msgs);
assert_eq!(
seen.get(&("search".to_string(), r#"{"query":"x"}"#.to_string())),
Some(&"hits-a".to_string())
);
}
#[test]
fn chat_message_serializes_string_content_when_no_images() {
let m = ChatMessage::text("user", "hi");
let v = serde_json::to_value(&m).unwrap();
assert_eq!(v["content"], "hi");
assert!(v.get("tool_calls").is_none());
}
#[test]
fn chat_message_serializes_parts_when_images_present() {
let mut m = ChatMessage::text("user", "what is this?");
m.images = vec!["data:image/png;base64,AAAA".into()];
let v = serde_json::to_value(&m).unwrap();
assert_eq!(v["content"][0]["type"], "text");
assert_eq!(v["content"][0]["text"], "what is this?");
assert_eq!(v["content"][1]["type"], "image_url");
assert_eq!(
v["content"][1]["image_url"]["url"],
"data:image/png;base64,AAAA"
);
}
#[test]
fn chat_message_with_tool_calls_still_serializes_them() {
let m = ChatMessage {
role: "assistant".into(),
content: "".into(),
tool_calls: Some(vec![ToolCall {
id: "c1".into(),
name: "web_search".into(),
arguments: "{}".into(),
}]),
tool_call_id: None,
images: Vec::new(),
};
let v = serde_json::to_value(&m).unwrap();
assert_eq!(v["tool_calls"][0]["function"]["name"], "web_search");
assert_eq!(v["tool_calls"][0]["type"], "function");
}
}