use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackendKind {
Gemini,
OpenAI,
Anthropic,
DeepSeek,
OpenRouter,
Ollama,
ZAI,
Moonshot,
HuggingFace,
Minimax,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub cached_prompt_tokens: Option<u32>,
pub cache_creation_tokens: Option<u32>,
pub cache_read_tokens: Option<u32>,
}
impl Usage {
#[inline]
fn has_cache_read_metric(&self) -> bool {
self.cache_read_tokens.is_some() || self.cached_prompt_tokens.is_some()
}
#[inline]
fn has_any_cache_metrics(&self) -> bool {
self.has_cache_read_metric() || self.cache_creation_tokens.is_some()
}
#[inline]
pub fn cache_read_tokens_or_fallback(&self) -> u32 {
self.cache_read_tokens
.or(self.cached_prompt_tokens)
.unwrap_or(0)
}
#[inline]
pub fn cache_creation_tokens_or_zero(&self) -> u32 {
self.cache_creation_tokens.unwrap_or(0)
}
#[inline]
pub fn cache_hit_rate(&self) -> Option<f64> {
if !self.has_any_cache_metrics() {
return None;
}
let read = self.cache_read_tokens_or_fallback() as f64;
let creation = self.cache_creation_tokens_or_zero() as f64;
let total = read + creation;
if total > 0.0 {
Some((read / total) * 100.0)
} else {
None
}
}
#[inline]
pub fn is_cache_hit(&self) -> Option<bool> {
self.has_any_cache_metrics()
.then(|| self.cache_read_tokens_or_fallback() > 0)
}
#[inline]
pub fn is_cache_miss(&self) -> Option<bool> {
self.has_any_cache_metrics().then(|| {
self.cache_creation_tokens_or_zero() > 0 && self.cache_read_tokens_or_fallback() == 0
})
}
#[inline]
pub fn total_cache_tokens(&self) -> u32 {
let read = self.cache_read_tokens_or_fallback();
let creation = self.cache_creation_tokens_or_zero();
read + creation
}
#[inline]
pub fn cache_savings_ratio(&self) -> Option<f64> {
if !self.has_cache_read_metric() {
return None;
}
let read = self.cache_read_tokens_or_fallback() as f64;
let prompt = self.prompt_tokens as f64;
if prompt > 0.0 {
Some(read / prompt)
} else {
None
}
}
}
#[cfg(test)]
mod usage_tests {
use super::Usage;
#[test]
fn cache_helpers_fall_back_to_cached_prompt_tokens() {
let usage = Usage {
prompt_tokens: 1_000,
completion_tokens: 200,
total_tokens: 1_200,
cached_prompt_tokens: Some(600),
cache_creation_tokens: Some(150),
cache_read_tokens: None,
};
assert_eq!(usage.cache_read_tokens_or_fallback(), 600);
assert_eq!(usage.cache_creation_tokens_or_zero(), 150);
assert_eq!(usage.total_cache_tokens(), 750);
assert_eq!(usage.is_cache_hit(), Some(true));
assert_eq!(usage.is_cache_miss(), Some(false));
assert_eq!(usage.cache_savings_ratio(), Some(0.6));
assert_eq!(usage.cache_hit_rate(), Some(80.0));
}
#[test]
fn cache_helpers_preserve_unknown_without_metrics() {
let usage = Usage {
prompt_tokens: 1_000,
completion_tokens: 200,
total_tokens: 1_200,
cached_prompt_tokens: None,
cache_creation_tokens: None,
cache_read_tokens: None,
};
assert_eq!(usage.total_cache_tokens(), 0);
assert_eq!(usage.is_cache_hit(), None);
assert_eq!(usage.is_cache_miss(), None);
assert_eq!(usage.cache_savings_ratio(), None);
assert_eq!(usage.cache_hit_rate(), None);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FinishReason {
#[default]
Stop,
Length,
ToolCalls,
ContentFilter,
Pause,
Refusal,
Error(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<FunctionCall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FunctionCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub name: String,
pub arguments: String,
}
impl ToolCall {
pub fn function(id: String, name: String, arguments: String) -> Self {
Self::function_with_namespace(id, None, name, arguments)
}
pub fn function_with_namespace(
id: String,
namespace: Option<String>,
name: String,
arguments: String,
) -> Self {
Self {
id,
call_type: "function".to_owned(),
function: Some(FunctionCall {
namespace,
name,
arguments,
}),
text: None,
thought_signature: None,
}
}
pub fn custom(id: String, name: String, text: String) -> Self {
Self {
id,
call_type: "custom".to_owned(),
function: Some(FunctionCall {
namespace: None,
name,
arguments: text.clone(),
}),
text: Some(text),
thought_signature: None,
}
}
pub fn is_custom(&self) -> bool {
self.call_type == "custom"
}
pub fn tool_name(&self) -> Option<&str> {
self.function
.as_ref()
.map(|function| function.name.as_str())
}
pub fn raw_input(&self) -> Option<&str> {
self.text.as_deref().or_else(|| {
self.function
.as_ref()
.map(|function| function.arguments.as_str())
})
}
pub fn parsed_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
if let Some(ref func) = self.function {
parse_tool_arguments(&func.arguments)
} else {
serde_json::from_str("")
}
}
pub fn execution_arguments(&self) -> Result<serde_json::Value, serde_json::Error> {
if self.is_custom() {
return Ok(serde_json::Value::String(
self.raw_input().unwrap_or_default().to_string(),
));
}
self.parsed_arguments()
}
pub fn validate(&self) -> Result<(), String> {
if self.id.is_empty() {
return Err("Tool call ID cannot be empty".to_owned());
}
match self.call_type.as_str() {
"function" => {
if let Some(func) = &self.function {
if func.name.is_empty() {
return Err("Function name cannot be empty".to_owned());
}
if let Err(e) = self.parsed_arguments() {
return Err(format!("Invalid JSON in function arguments: {}", e));
}
} else {
return Err("Function tool call missing function details".to_owned());
}
}
"custom" => {
if let Some(func) = &self.function {
if func.name.is_empty() {
return Err("Custom tool name cannot be empty".to_owned());
}
} else {
return Err("Custom tool call missing function details".to_owned());
}
}
_ => return Err(format!("Unsupported tool call type: {}", self.call_type)),
}
Ok(())
}
}
fn parse_tool_arguments(raw_arguments: &str) -> Result<serde_json::Value, serde_json::Error> {
let trimmed = raw_arguments.trim();
match serde_json::from_str(trimmed) {
Ok(parsed) => Ok(parsed),
Err(primary_error) => {
if let Some(candidate) = extract_balanced_json(trimmed)
&& let Ok(parsed) = serde_json::from_str(candidate)
{
return Ok(parsed);
}
if let Some(candidate) = repair_tag_polluted_json(trimmed)
&& let Ok(parsed) = serde_json::from_str(&candidate)
{
return Ok(parsed);
}
Err(primary_error)
}
}
}
fn extract_balanced_json(input: &str) -> Option<&str> {
let start = input.find(['{', '['])?;
let opening = input.as_bytes().get(start).copied()?;
let closing = match opening {
b'{' => b'}',
b'[' => b']',
_ => return None,
};
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, ch) in input[start..].char_indices() {
if in_string {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
_ if ch as u32 == opening as u32 => depth += 1,
_ if ch as u32 == closing as u32 => {
depth = depth.saturating_sub(1);
if depth == 0 {
let end = start + offset + ch.len_utf8();
return input.get(start..end);
}
}
_ => {}
}
}
None
}
fn repair_tag_polluted_json(input: &str) -> Option<String> {
let start = input.find(['{', '['])?;
let candidate = input.get(start..)?;
let boundary = find_provider_markup_boundary(candidate)?;
if boundary == 0 {
return None;
}
close_incomplete_json_prefix(candidate[..boundary].trim_end())
}
fn find_provider_markup_boundary(input: &str) -> Option<usize> {
const PROVIDER_MARKERS: &[&str] = &[
"<</",
"</parameter>",
"</invoke>",
"</minimax:tool_call>",
"<minimax:tool_call>",
"<parameter name=\"",
"<invoke name=\"",
"<tool_call>",
"</tool_call>",
];
input.char_indices().find_map(|(offset, _)| {
let rest = input.get(offset..)?;
PROVIDER_MARKERS
.iter()
.any(|marker| rest.starts_with(marker))
.then_some(offset)
})
}
fn close_incomplete_json_prefix(prefix: &str) -> Option<String> {
if prefix.is_empty() {
return None;
}
let mut repaired = String::with_capacity(prefix.len() + 8);
let mut expected_closers = Vec::new();
let mut in_string = false;
let mut escaped = false;
for ch in prefix.chars() {
repaired.push(ch);
if in_string {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'{' => expected_closers.push('}'),
'[' => expected_closers.push(']'),
'}' | ']' => {
if expected_closers.pop() != Some(ch) {
return None;
}
}
_ => {}
}
}
if in_string {
repaired.push('"');
}
while let Some(closer) = expected_closers.pop() {
repaired.push(closer);
}
Some(repaired)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct LLMResponse {
pub content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub model: String,
pub usage: Option<Usage>,
pub finish_reason: FinishReason,
pub reasoning: Option<String>,
pub reasoning_details: Option<Vec<String>>,
pub tool_references: Vec<String>,
pub request_id: Option<String>,
pub organization_id: Option<String>,
}
impl LLMResponse {
pub fn new(model: impl Into<String>, content: impl Into<String>) -> Self {
Self {
content: Some(content.into()),
tool_calls: None,
model: model.into(),
usage: None,
finish_reason: FinishReason::Stop,
reasoning: None,
reasoning_details: None,
tool_references: Vec::new(),
request_id: None,
organization_id: None,
}
}
pub fn content_text(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
pub fn content_string(&self) -> String {
self.content.clone().unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LLMErrorMetadata {
pub provider: Option<String>,
pub status: Option<u16>,
pub code: Option<String>,
pub request_id: Option<String>,
pub organization_id: Option<String>,
pub retry_after: Option<String>,
pub message: Option<String>,
}
impl LLMErrorMetadata {
pub fn new(
provider: impl Into<String>,
status: Option<u16>,
code: Option<String>,
request_id: Option<String>,
organization_id: Option<String>,
retry_after: Option<String>,
message: Option<String>,
) -> Box<Self> {
Box::new(Self {
provider: Some(provider.into()),
status,
code,
request_id,
organization_id,
retry_after,
message,
})
}
}
#[derive(Debug, thiserror::Error, Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LLMError {
#[error("Authentication failed: {message}")]
Authentication {
message: String,
metadata: Option<Box<LLMErrorMetadata>>,
},
#[error("Rate limit exceeded")]
RateLimit {
metadata: Option<Box<LLMErrorMetadata>>,
},
#[error("Invalid request: {message}")]
InvalidRequest {
message: String,
metadata: Option<Box<LLMErrorMetadata>>,
},
#[error("Network error: {message}")]
Network {
message: String,
metadata: Option<Box<LLMErrorMetadata>>,
},
#[error("Provider error: {message}")]
Provider {
message: String,
metadata: Option<Box<LLMErrorMetadata>>,
},
}
#[cfg(test)]
mod tests {
use super::ToolCall;
use serde_json::json;
#[test]
fn parsed_arguments_accepts_trailing_characters() {
let call = ToolCall::function(
"call_read".to_string(),
"read_file".to_string(),
r#"{"path":"src/main.rs"} trailing text"#.to_string(),
);
let parsed = call
.parsed_arguments()
.expect("arguments with trailing text should recover");
assert_eq!(parsed, json!({"path":"src/main.rs"}));
}
#[test]
fn parsed_arguments_accepts_code_fenced_json() {
let call = ToolCall::function(
"call_read".to_string(),
"read_file".to_string(),
"```json\n{\"path\":\"src/lib.rs\",\"limit\":25}\n```".to_string(),
);
let parsed = call
.parsed_arguments()
.expect("code-fenced arguments should recover");
assert_eq!(parsed, json!({"path":"src/lib.rs","limit":25}));
}
#[test]
fn parsed_arguments_rejects_incomplete_json() {
let call = ToolCall::function(
"call_read".to_string(),
"read_file".to_string(),
r#"{"path":"src/main.rs""#.to_string(),
);
assert!(call.parsed_arguments().is_err());
}
#[test]
fn parsed_arguments_recovers_truncated_minimax_markup() {
let call = ToolCall::function(
"call_search".to_string(),
"unified_search".to_string(),
"{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
);
let parsed = call
.parsed_arguments()
.expect("minimax markup spillover should recover");
assert_eq!(
parsed,
json!({
"action": "grep",
"pattern": "persistent_memory",
"path": "vtcode-core/src"
})
);
}
#[test]
fn function_call_serializes_optional_namespace() {
let call = ToolCall::function_with_namespace(
"call_read".to_string(),
Some("workspace".to_string()),
"read_file".to_string(),
r#"{"path":"src/main.rs"}"#.to_string(),
);
let json = serde_json::to_value(&call).expect("tool call should serialize");
assert_eq!(json["function"]["namespace"], "workspace");
assert_eq!(json["function"]["name"], "read_file");
}
#[test]
fn custom_tool_call_exposes_raw_execution_arguments() {
let patch = "*** Begin Patch\n*** End Patch\n".to_string();
let call = ToolCall::custom(
"call_patch".to_string(),
"apply_patch".to_string(),
patch.clone(),
);
assert!(call.is_custom());
assert_eq!(call.tool_name(), Some("apply_patch"));
assert_eq!(call.raw_input(), Some(patch.as_str()));
assert_eq!(
call.execution_arguments().expect("custom arguments"),
json!(patch)
);
assert!(
call.parsed_arguments().is_err(),
"custom tool payload should stay freeform rather than JSON"
);
}
}