1use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::time::Duration;
12
13use crate::error::{Error, Result};
14use crate::message::Message;
15
16use tokio::sync::mpsc;
17
18#[cfg(feature = "anthropic")]
19pub mod anthropic;
20pub mod mock;
21pub mod openai;
22pub mod search;
23
24#[cfg(feature = "anthropic")]
25pub use anthropic::AnthropicProvider;
26#[cfg(any(test, feature = "test-utils"))]
27pub use mock::MockProvider;
28pub use openai::OpenAiProvider;
29
30pub type StreamSender = mpsc::UnboundedSender<String>;
33
34#[derive(Debug, Clone)]
42pub struct RetryPolicy {
43 pub max_retries: usize,
44 pub initial_backoff: Duration,
45 pub max_backoff: Duration,
46}
47
48impl Default for RetryPolicy {
49 fn default() -> Self {
50 Self {
51 max_retries: 2,
52 initial_backoff: Duration::from_secs(1),
53 max_backoff: Duration::from_secs(8),
54 }
55 }
56}
57
58impl RetryPolicy {
59 pub fn backoff_for(
62 &self,
63 attempt: usize,
64 status: Option<u16>,
65 is_network_error: bool,
66 ) -> Option<Duration> {
67 if attempt >= self.max_retries {
68 return None;
69 }
70 let is_transient = is_network_error || status.is_some_and(|s| (500..600).contains(&s));
71 if !is_transient {
72 return None;
73 }
74 let backoff = self.initial_backoff * 2u32.pow(attempt as u32);
75 Some(backoff.min(self.max_backoff))
76 }
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct TokenUsage {
82 pub prompt_tokens: u32,
83 pub completion_tokens: u32,
84 pub total_tokens: u32,
85 pub cache_hit_tokens: u32,
86 pub cache_miss_tokens: u32,
87}
88
89impl TokenUsage {
90 pub fn accumulate(self, other: TokenUsage) -> TokenUsage {
92 TokenUsage {
93 prompt_tokens: self.prompt_tokens.saturating_add(other.prompt_tokens),
94 completion_tokens: self
95 .completion_tokens
96 .saturating_add(other.completion_tokens),
97 total_tokens: self.total_tokens.saturating_add(other.total_tokens),
98 cache_hit_tokens: self.cache_hit_tokens.saturating_add(other.cache_hit_tokens),
99 cache_miss_tokens: self
100 .cache_miss_tokens
101 .saturating_add(other.cache_miss_tokens),
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct ModelPricing {
109 pub input_per_million: f64,
110 pub output_per_million: f64,
111 pub cache_hit_input_per_million: f64,
114}
115
116impl ModelPricing {
117 pub fn cost_usd(&self, usage: TokenUsage) -> f64 {
119 let in_cost = if usage.cache_hit_tokens > 0 {
120 let cache_hit =
122 usage.cache_hit_tokens as f64 * self.cache_hit_input_per_million / 1_000_000.0;
123 let cache_miss = usage.cache_miss_tokens as f64 * self.input_per_million / 1_000_000.0;
127 cache_hit + cache_miss
128 } else {
129 (usage.prompt_tokens as f64) * self.input_per_million / 1_000_000.0
130 };
131 let out_cost = (usage.completion_tokens as f64) * self.output_per_million / 1_000_000.0;
132 in_cost + out_cost
133 }
134}
135
136pub fn context_window_tokens_for_model(model: &str) -> usize {
143 use crate::providers::all_presets;
144 for preset in all_presets() {
145 for spec in &preset.models {
146 if spec.name == model {
147 return spec.context_window;
148 }
149 }
150 }
151 128_000
153}
154
155pub fn default_compact_threshold_chars(model: &str) -> usize {
164 let context_tokens = context_window_tokens_for_model(model);
165 let reserved_for_summary = 20_000_usize.min(context_tokens / 4);
166 let effective_tokens = context_tokens.saturating_sub(reserved_for_summary);
167 (effective_tokens as f64 * 0.8 * 4.0) as usize
169}
170
171pub fn pricing_for(model: &str) -> Option<ModelPricing> {
174 let spec = crate::providers::find_model_pricing(model)?;
175 Some(ModelPricing {
176 input_per_million: spec.input_per_million,
177 output_per_million: spec.output_per_million,
178 cache_hit_input_per_million: spec
179 .cache_hit_input_per_million
180 .unwrap_or(spec.input_per_million),
181 })
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ToolSpec {
187 pub name: String,
188 pub description: String,
189 pub parameters: Value,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct ToolCall {
196 pub id: String,
197 pub name: String,
198 pub arguments: Value,
200}
201
202pub struct StructuredRequest {
204 pub messages: Vec<Message>,
205 pub schema: Value,
207 pub schema_name: String,
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct Completion {
214 pub content: String,
215 pub tool_calls: Vec<ToolCall>,
216 pub finish_reason: Option<String>,
217 pub usage: Option<TokenUsage>,
218 pub reasoning_content: Option<String>,
221}
222
223#[async_trait]
224pub trait LlmProvider: Send + Sync {
225 async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion>;
226
227 async fn complete_with_search(
239 &self,
240 messages: &[Message],
241 eager_tools: &[(ToolSpec, Option<String>)],
242 deferred_tools: &[(ToolSpec, Option<String>)],
243 ) -> Result<Completion> {
244 let all: Vec<ToolSpec> = eager_tools
245 .iter()
246 .chain(deferred_tools.iter())
247 .map(|(spec, _)| spec.clone())
248 .collect();
249 self.complete(messages, &all).await
250 }
251
252 async fn complete_structured(&self, _req: StructuredRequest) -> Result<Value> {
256 Err(Error::Config {
257 message: "provider does not support structured output".into(),
258 })
259 }
260
261 async fn stream(
270 &self,
271 messages: &[Message],
272 tools: &[ToolSpec],
273 stream_tx: Option<StreamSender>,
274 ) -> Result<Completion> {
275 let completion = self.complete(messages, tools).await?;
276 if let Some(tx) = stream_tx {
277 if !completion.content.is_empty() {
278 let _ = tx.send(completion.content.clone());
279 }
280 }
281 Ok(completion)
282 }
283
284 async fn complete_simple(&self, prompt: &str, _temperature: f32) -> Result<String> {
291 let messages = vec![Message::user(prompt.to_string())];
292 let completion = self.complete(&messages, &[]).await?;
293 Ok(completion.content)
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[tokio::test]
302 async fn mock_structured_returns_default_error() {
303 let provider = MockProvider::new(vec![]).with_structured_responses(vec![]);
306 let req = StructuredRequest {
307 messages: vec![Message::user("hi".to_string())],
308 schema: serde_json::json!({"type": "object", "properties": {"answer": {"type": "string"}}}),
309 schema_name: "test_schema".to_string(),
310 };
311 let result = provider.complete_structured(req).await;
312 assert!(result.is_err());
313 let err = result.unwrap_err();
314 let msg = err.to_string();
315 assert!(
316 msg.contains("no structured responses configured"),
317 "error should mention no structured responses: {msg}"
318 );
319 }
320
321 #[test]
322 fn token_usage_default_is_all_zeros() {
323 let u = TokenUsage::default();
324 assert_eq!(u.prompt_tokens, 0);
325 assert_eq!(u.completion_tokens, 0);
326 assert_eq!(u.total_tokens, 0);
327 }
328
329 #[test]
330 fn token_usage_accumulate_is_saturating() {
331 let u1 = TokenUsage {
332 prompt_tokens: 10,
333 completion_tokens: 5,
334 total_tokens: 15,
335 cache_hit_tokens: 0,
336 cache_miss_tokens: 0,
337 };
338 let u2 = TokenUsage {
339 prompt_tokens: 20,
340 completion_tokens: 30,
341 total_tokens: 50,
342 cache_hit_tokens: 0,
343 cache_miss_tokens: 0,
344 };
345 let acc = u1.accumulate(u2);
346 assert_eq!(acc.prompt_tokens, 30);
347 assert_eq!(acc.completion_tokens, 35);
348 assert_eq!(acc.total_tokens, 65);
349 }
350
351 #[test]
352 fn token_usage_accumulate_is_commutative() {
353 let u1 = TokenUsage {
354 prompt_tokens: 10,
355 completion_tokens: 5,
356 total_tokens: 15,
357 cache_hit_tokens: 0,
358 cache_miss_tokens: 0,
359 };
360 let u2 = TokenUsage {
361 prompt_tokens: 20,
362 completion_tokens: 30,
363 total_tokens: 50,
364 cache_hit_tokens: 0,
365 cache_miss_tokens: 0,
366 };
367 assert_eq!(u1.accumulate(u2), u2.accumulate(u1));
368 }
369
370 #[test]
371 fn token_usage_accumulate_saturates() {
372 let u1 = TokenUsage {
373 prompt_tokens: u32::MAX,
374 completion_tokens: 1,
375 total_tokens: u32::MAX,
376 cache_hit_tokens: 0,
377 cache_miss_tokens: 0,
378 };
379 let u2 = TokenUsage {
380 prompt_tokens: 1,
381 completion_tokens: u32::MAX,
382 total_tokens: u32::MAX,
383 cache_hit_tokens: 0,
384 cache_miss_tokens: 0,
385 };
386 let acc = u1.accumulate(u2);
387 assert_eq!(acc.prompt_tokens, u32::MAX);
388 assert_eq!(acc.completion_tokens, u32::MAX);
389 assert_eq!(acc.total_tokens, u32::MAX);
390 }
391
392 #[test]
393 fn cost_usd_handles_zero_usage() {
394 let pricing = ModelPricing {
395 input_per_million: 1.0,
396 output_per_million: 2.0,
397 cache_hit_input_per_million: 0.1, };
399 let usage = TokenUsage::default();
400 let cost = pricing.cost_usd(usage);
401 assert!((cost - 0.0).abs() < 1e-9);
402 }
403
404 #[test]
405 fn cost_usd_computes_simple_case() {
406 let pricing = ModelPricing {
407 input_per_million: 1.0,
408 output_per_million: 1.0,
409 cache_hit_input_per_million: 0.1,
410 };
411 let usage = TokenUsage {
413 prompt_tokens: 1_000_000,
414 completion_tokens: 0,
415 total_tokens: 1_000_000,
416 cache_hit_tokens: 0,
417 cache_miss_tokens: 0,
418 };
419 let cost = pricing.cost_usd(usage);
420 assert!((cost - 1.0).abs() < 1e-9);
421 }
422
423 #[test]
424 fn cost_usd_mixes_input_and_output() {
425 let pricing = ModelPricing {
426 input_per_million: 1.0,
427 output_per_million: 2.0,
428 cache_hit_input_per_million: 0.1,
429 };
430 let usage = TokenUsage {
432 prompt_tokens: 500_000,
433 completion_tokens: 250_000,
434 total_tokens: 750_000,
435 cache_hit_tokens: 0,
436 cache_miss_tokens: 0,
437 };
438 let cost = pricing.cost_usd(usage);
439 assert!((cost - 1.0).abs() < 1e-9);
441 }
442
443 #[test]
444 fn pricing_for_known_models() {
445 let p1 = pricing_for("MiniMax-M3");
446 assert!(p1.is_some());
447 assert!((p1.unwrap().input_per_million - 0.30).abs() < 1e-9);
448
449 let p2 = pricing_for("deepseek-chat");
450 assert!(p2.is_some());
451 assert!((p2.unwrap().input_per_million - 0.27).abs() < 1e-9);
452 }
453
454 #[test]
455 fn pricing_for_unknown_returns_none() {
456 let p = pricing_for("unknown-model-xyz");
457 assert!(p.is_none());
458 }
459
460 #[test]
461 fn token_usage_accumulate_cache_fields() {
462 let u1 = TokenUsage {
463 prompt_tokens: 100,
464 completion_tokens: 50,
465 total_tokens: 150,
466 cache_hit_tokens: 60,
467 cache_miss_tokens: 40,
468 };
469 let u2 = TokenUsage {
470 prompt_tokens: 200,
471 completion_tokens: 100,
472 total_tokens: 300,
473 cache_hit_tokens: 120,
474 cache_miss_tokens: 80,
475 };
476 let acc = u1.accumulate(u2);
477 assert_eq!(acc.cache_hit_tokens, 180);
478 assert_eq!(acc.cache_miss_tokens, 120);
479 assert_eq!(acc.prompt_tokens, 300);
480 assert_eq!(acc.completion_tokens, 150);
481 assert_eq!(acc.total_tokens, 450);
482 }
483
484 #[test]
486 fn cost_usd_with_no_cache_hit_matches_old_behavior() {
487 let pricing = ModelPricing {
488 input_per_million: 1.0,
489 output_per_million: 2.0,
490 cache_hit_input_per_million: 0.1, };
492 let usage = TokenUsage {
494 prompt_tokens: 1_000_000,
495 completion_tokens: 500_000,
496 total_tokens: 1_500_000,
497 cache_hit_tokens: 0,
498 cache_miss_tokens: 1_000_000,
499 };
500 let cost = pricing.cost_usd(usage);
502 assert!((cost - 2.0).abs() < 1e-9);
503 }
504
505 #[test]
507 fn cost_usd_with_cache_hit_applies_discount() {
508 let pricing = ModelPricing {
510 input_per_million: 0.27,
511 output_per_million: 1.10,
512 cache_hit_input_per_million: 0.027,
513 };
514 let usage = TokenUsage {
516 prompt_tokens: 1_000,
517 completion_tokens: 500,
518 total_tokens: 1_500,
519 cache_hit_tokens: 900,
520 cache_miss_tokens: 100,
521 };
522 let cost = pricing.cost_usd(usage);
523 let expected =
528 900.0 * 0.027 / 1_000_000.0 + 100.0 * 0.27 / 1_000_000.0 + 500.0 * 1.10 / 1_000_000.0;
529 assert!((cost - expected).abs() < 1e-9);
530 }
531
532 #[test]
534 fn pricing_for_deepseek_has_cache_discount() {
535 let pricing = pricing_for("deepseek-chat").expect("deepseek-chat should be known");
536 assert!((pricing.input_per_million - 0.27).abs() < 1e-9);
538 assert!((pricing.cache_hit_input_per_million - 0.027).abs() < 1e-9);
539 }
540
541 #[test]
543 fn pricing_for_unknown_model_returns_none() {
544 let p = pricing_for("unknown-model-xyz");
545 assert!(p.is_none());
546 }
547
548 #[test]
550 fn token_usage_accumulate_preserves_cache_tokens() {
551 let u1 = TokenUsage {
552 prompt_tokens: 1000,
553 completion_tokens: 100,
554 total_tokens: 1100,
555 cache_hit_tokens: 900,
556 cache_miss_tokens: 100,
557 };
558 let u2 = TokenUsage {
559 prompt_tokens: 2000,
560 completion_tokens: 200,
561 total_tokens: 2200,
562 cache_hit_tokens: 1800,
563 cache_miss_tokens: 200,
564 };
565 let acc = u1.accumulate(u2);
566 assert_eq!(acc.cache_hit_tokens, 2700);
567 assert_eq!(acc.cache_miss_tokens, 300);
568 assert_eq!(acc.prompt_tokens, 3000);
569 }
570
571 #[test]
574 fn context_window_known_models() {
575 assert_eq!(
577 context_window_tokens_for_model("claude-sonnet-4-6"),
578 200_000
579 );
580 assert_eq!(context_window_tokens_for_model("claude-opus-4-7"), 200_000);
581 assert_eq!(context_window_tokens_for_model("MiniMax-M3"), 1_000_000);
582 assert_eq!(context_window_tokens_for_model("deepseek-chat"), 64_000);
583 assert_eq!(context_window_tokens_for_model("deepseek-reasoner"), 64_000);
584 assert_eq!(context_window_tokens_for_model("gpt-4o"), 128_000);
585 assert_eq!(context_window_tokens_for_model("gpt-4o-mini"), 128_000);
586 assert_eq!(context_window_tokens_for_model("glm-4-plus"), 128_000);
587 assert_eq!(context_window_tokens_for_model("moonshot-v1-8k"), 8_000);
588 assert_eq!(
589 context_window_tokens_for_model("doubao-1-5-pro-256k"),
590 256_000
591 );
592 assert_eq!(context_window_tokens_for_model("gemini-2.5-pro"), 1_048_576);
593 }
594
595 #[test]
596 fn context_window_unknown_model_fallback() {
597 assert_eq!(
599 context_window_tokens_for_model("some-future-model"),
600 128_000
601 );
602 }
603
604 #[test]
605 fn default_compact_threshold_is_reasonable() {
606 let ds = default_compact_threshold_chars("deepseek-chat");
608 assert!(ds > 50_000, "deepseek threshold too small: {ds}");
609 assert!(ds < 300_000, "deepseek threshold suspiciously large: {ds}");
610
611 let cl = default_compact_threshold_chars("claude-sonnet-4-6");
613 assert!(cl > 400_000, "claude threshold too small: {cl}");
614 assert!(cl < 1_000_000, "claude threshold suspiciously large: {cl}");
615
616 let unk = default_compact_threshold_chars("unknown-model");
618 assert!(unk > 0);
619 }
620}