use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use crate::error::{Error, Result};
use crate::message::Message;
use tokio::sync::mpsc;
#[cfg(feature = "anthropic")]
pub mod anthropic;
pub mod mock;
pub mod openai;
pub mod search;
#[cfg(feature = "anthropic")]
pub use anthropic::AnthropicProvider;
#[cfg(any(test, feature = "test-utils"))]
pub use mock::MockProvider;
pub use openai::OpenAiProvider;
pub type StreamSender = mpsc::UnboundedSender<String>;
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: usize,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 2,
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(8),
}
}
}
impl RetryPolicy {
pub fn backoff_for(
&self,
attempt: usize,
status: Option<u16>,
is_network_error: bool,
) -> Option<Duration> {
if attempt >= self.max_retries {
return None;
}
let is_transient = is_network_error || status.is_some_and(|s| (500..600).contains(&s));
if !is_transient {
return None;
}
let backoff = self.initial_backoff * 2u32.pow(attempt as u32);
Some(backoff.min(self.max_backoff))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub cache_hit_tokens: u32,
pub cache_miss_tokens: u32,
}
impl TokenUsage {
pub fn accumulate(self, other: TokenUsage) -> TokenUsage {
TokenUsage {
prompt_tokens: self.prompt_tokens.saturating_add(other.prompt_tokens),
completion_tokens: self
.completion_tokens
.saturating_add(other.completion_tokens),
total_tokens: self.total_tokens.saturating_add(other.total_tokens),
cache_hit_tokens: self.cache_hit_tokens.saturating_add(other.cache_hit_tokens),
cache_miss_tokens: self
.cache_miss_tokens
.saturating_add(other.cache_miss_tokens),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub input_per_million: f64,
pub output_per_million: f64,
pub cache_hit_input_per_million: f64,
}
impl ModelPricing {
pub fn cost_usd(&self, usage: TokenUsage) -> f64 {
let in_cost = if usage.cache_hit_tokens > 0 {
let cache_hit =
usage.cache_hit_tokens as f64 * self.cache_hit_input_per_million / 1_000_000.0;
let cache_miss = usage.cache_miss_tokens as f64 * self.input_per_million / 1_000_000.0;
cache_hit + cache_miss
} else {
(usage.prompt_tokens as f64) * self.input_per_million / 1_000_000.0
};
let out_cost = (usage.completion_tokens as f64) * self.output_per_million / 1_000_000.0;
in_cost + out_cost
}
}
pub fn context_window_tokens_for_model(model: &str) -> usize {
use crate::providers::all_presets;
for preset in all_presets() {
for spec in &preset.models {
if spec.name == model {
return spec.context_window;
}
}
}
128_000
}
pub fn default_compact_threshold_chars(model: &str) -> usize {
let context_tokens = context_window_tokens_for_model(model);
let reserved_for_summary = 20_000_usize.min(context_tokens / 4);
let effective_tokens = context_tokens.saturating_sub(reserved_for_summary);
(effective_tokens as f64 * 0.8 * 4.0) as usize
}
pub fn pricing_for(model: &str) -> Option<ModelPricing> {
let spec = crate::providers::find_model_pricing(model)?;
Some(ModelPricing {
input_per_million: spec.input_per_million,
output_per_million: spec.output_per_million,
cache_hit_input_per_million: spec
.cache_hit_input_per_million
.unwrap_or(spec.input_per_million),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
pub struct StructuredRequest {
pub messages: Vec<Message>,
pub schema: Value,
pub schema_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct Completion {
pub content: String,
pub tool_calls: Vec<ToolCall>,
pub finish_reason: Option<String>,
pub usage: Option<TokenUsage>,
pub reasoning_content: Option<String>,
}
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion>;
async fn complete_with_search(
&self,
messages: &[Message],
eager_tools: &[(ToolSpec, Option<String>)],
deferred_tools: &[(ToolSpec, Option<String>)],
) -> Result<Completion> {
let all: Vec<ToolSpec> = eager_tools
.iter()
.chain(deferred_tools.iter())
.map(|(spec, _)| spec.clone())
.collect();
self.complete(messages, &all).await
}
async fn complete_structured(&self, _req: StructuredRequest) -> Result<Value> {
Err(Error::Config {
message: "provider does not support structured output".into(),
})
}
async fn stream(
&self,
messages: &[Message],
tools: &[ToolSpec],
stream_tx: Option<StreamSender>,
) -> Result<Completion> {
let completion = self.complete(messages, tools).await?;
if let Some(tx) = stream_tx {
if !completion.content.is_empty() {
let _ = tx.send(completion.content.clone());
}
}
Ok(completion)
}
async fn complete_simple(&self, prompt: &str, _temperature: f32) -> Result<String> {
let messages = vec![Message::user(prompt.to_string())];
let completion = self.complete(&messages, &[]).await?;
Ok(completion.content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mock_structured_returns_default_error() {
let provider = MockProvider::new(vec![]).with_structured_responses(vec![]);
let req = StructuredRequest {
messages: vec![Message::user("hi".to_string())],
schema: serde_json::json!({"type": "object", "properties": {"answer": {"type": "string"}}}),
schema_name: "test_schema".to_string(),
};
let result = provider.complete_structured(req).await;
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no structured responses configured"),
"error should mention no structured responses: {msg}"
);
}
#[test]
fn token_usage_default_is_all_zeros() {
let u = TokenUsage::default();
assert_eq!(u.prompt_tokens, 0);
assert_eq!(u.completion_tokens, 0);
assert_eq!(u.total_tokens, 0);
}
#[test]
fn token_usage_accumulate_is_saturating() {
let u1 = TokenUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let u2 = TokenUsage {
prompt_tokens: 20,
completion_tokens: 30,
total_tokens: 50,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let acc = u1.accumulate(u2);
assert_eq!(acc.prompt_tokens, 30);
assert_eq!(acc.completion_tokens, 35);
assert_eq!(acc.total_tokens, 65);
}
#[test]
fn token_usage_accumulate_is_commutative() {
let u1 = TokenUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let u2 = TokenUsage {
prompt_tokens: 20,
completion_tokens: 30,
total_tokens: 50,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
assert_eq!(u1.accumulate(u2), u2.accumulate(u1));
}
#[test]
fn token_usage_accumulate_saturates() {
let u1 = TokenUsage {
prompt_tokens: u32::MAX,
completion_tokens: 1,
total_tokens: u32::MAX,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let u2 = TokenUsage {
prompt_tokens: 1,
completion_tokens: u32::MAX,
total_tokens: u32::MAX,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let acc = u1.accumulate(u2);
assert_eq!(acc.prompt_tokens, u32::MAX);
assert_eq!(acc.completion_tokens, u32::MAX);
assert_eq!(acc.total_tokens, u32::MAX);
}
#[test]
fn cost_usd_handles_zero_usage() {
let pricing = ModelPricing {
input_per_million: 1.0,
output_per_million: 2.0,
cache_hit_input_per_million: 0.1, };
let usage = TokenUsage::default();
let cost = pricing.cost_usd(usage);
assert!((cost - 0.0).abs() < 1e-9);
}
#[test]
fn cost_usd_computes_simple_case() {
let pricing = ModelPricing {
input_per_million: 1.0,
output_per_million: 1.0,
cache_hit_input_per_million: 0.1,
};
let usage = TokenUsage {
prompt_tokens: 1_000_000,
completion_tokens: 0,
total_tokens: 1_000_000,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let cost = pricing.cost_usd(usage);
assert!((cost - 1.0).abs() < 1e-9);
}
#[test]
fn cost_usd_mixes_input_and_output() {
let pricing = ModelPricing {
input_per_million: 1.0,
output_per_million: 2.0,
cache_hit_input_per_million: 0.1,
};
let usage = TokenUsage {
prompt_tokens: 500_000,
completion_tokens: 250_000,
total_tokens: 750_000,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
};
let cost = pricing.cost_usd(usage);
assert!((cost - 1.0).abs() < 1e-9);
}
#[test]
fn pricing_for_known_models() {
let p1 = pricing_for("MiniMax-M3");
assert!(p1.is_some());
assert!((p1.unwrap().input_per_million - 0.30).abs() < 1e-9);
let p2 = pricing_for("deepseek-chat");
assert!(p2.is_some());
assert!((p2.unwrap().input_per_million - 0.27).abs() < 1e-9);
}
#[test]
fn pricing_for_unknown_returns_none() {
let p = pricing_for("unknown-model-xyz");
assert!(p.is_none());
}
#[test]
fn token_usage_accumulate_cache_fields() {
let u1 = TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_hit_tokens: 60,
cache_miss_tokens: 40,
};
let u2 = TokenUsage {
prompt_tokens: 200,
completion_tokens: 100,
total_tokens: 300,
cache_hit_tokens: 120,
cache_miss_tokens: 80,
};
let acc = u1.accumulate(u2);
assert_eq!(acc.cache_hit_tokens, 180);
assert_eq!(acc.cache_miss_tokens, 120);
assert_eq!(acc.prompt_tokens, 300);
assert_eq!(acc.completion_tokens, 150);
assert_eq!(acc.total_tokens, 450);
}
#[test]
fn cost_usd_with_no_cache_hit_matches_old_behavior() {
let pricing = ModelPricing {
input_per_million: 1.0,
output_per_million: 2.0,
cache_hit_input_per_million: 0.1, };
let usage = TokenUsage {
prompt_tokens: 1_000_000,
completion_tokens: 500_000,
total_tokens: 1_500_000,
cache_hit_tokens: 0,
cache_miss_tokens: 1_000_000,
};
let cost = pricing.cost_usd(usage);
assert!((cost - 2.0).abs() < 1e-9);
}
#[test]
fn cost_usd_with_cache_hit_applies_discount() {
let pricing = ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
cache_hit_input_per_million: 0.027,
};
let usage = TokenUsage {
prompt_tokens: 1_000,
completion_tokens: 500,
total_tokens: 1_500,
cache_hit_tokens: 900,
cache_miss_tokens: 100,
};
let cost = pricing.cost_usd(usage);
let expected =
900.0 * 0.027 / 1_000_000.0 + 100.0 * 0.27 / 1_000_000.0 + 500.0 * 1.10 / 1_000_000.0;
assert!((cost - expected).abs() < 1e-9);
}
#[test]
fn pricing_for_deepseek_has_cache_discount() {
let pricing = pricing_for("deepseek-chat").expect("deepseek-chat should be known");
assert!((pricing.input_per_million - 0.27).abs() < 1e-9);
assert!((pricing.cache_hit_input_per_million - 0.027).abs() < 1e-9);
}
#[test]
fn pricing_for_unknown_model_returns_none() {
let p = pricing_for("unknown-model-xyz");
assert!(p.is_none());
}
#[test]
fn token_usage_accumulate_preserves_cache_tokens() {
let u1 = TokenUsage {
prompt_tokens: 1000,
completion_tokens: 100,
total_tokens: 1100,
cache_hit_tokens: 900,
cache_miss_tokens: 100,
};
let u2 = TokenUsage {
prompt_tokens: 2000,
completion_tokens: 200,
total_tokens: 2200,
cache_hit_tokens: 1800,
cache_miss_tokens: 200,
};
let acc = u1.accumulate(u2);
assert_eq!(acc.cache_hit_tokens, 2700);
assert_eq!(acc.cache_miss_tokens, 300);
assert_eq!(acc.prompt_tokens, 3000);
}
#[test]
fn context_window_known_models() {
assert_eq!(
context_window_tokens_for_model("claude-sonnet-4-6"),
200_000
);
assert_eq!(context_window_tokens_for_model("claude-opus-4-7"), 200_000);
assert_eq!(context_window_tokens_for_model("MiniMax-M3"), 1_000_000);
assert_eq!(context_window_tokens_for_model("deepseek-chat"), 64_000);
assert_eq!(context_window_tokens_for_model("deepseek-reasoner"), 64_000);
assert_eq!(context_window_tokens_for_model("gpt-4o"), 128_000);
assert_eq!(context_window_tokens_for_model("gpt-4o-mini"), 128_000);
assert_eq!(context_window_tokens_for_model("glm-4-plus"), 128_000);
assert_eq!(context_window_tokens_for_model("moonshot-v1-8k"), 8_000);
assert_eq!(
context_window_tokens_for_model("doubao-1-5-pro-256k"),
256_000
);
assert_eq!(context_window_tokens_for_model("gemini-2.5-pro"), 1_048_576);
}
#[test]
fn context_window_unknown_model_fallback() {
assert_eq!(
context_window_tokens_for_model("some-future-model"),
128_000
);
}
#[test]
fn default_compact_threshold_is_reasonable() {
let ds = default_compact_threshold_chars("deepseek-chat");
assert!(ds > 50_000, "deepseek threshold too small: {ds}");
assert!(ds < 300_000, "deepseek threshold suspiciously large: {ds}");
let cl = default_compact_threshold_chars("claude-sonnet-4-6");
assert!(cl > 400_000, "claude threshold too small: {cl}");
assert!(cl < 1_000_000, "claude threshold suspiciously large: {cl}");
let unk = default_compact_threshold_chars("unknown-model");
assert!(unk > 0);
}
}