1use async_trait::async_trait;
15use serde_json::Value;
16
17use crate::error::{Error, Result};
18use crate::message::Message;
19
20pub mod chat;
21pub mod pricing;
22pub mod search;
23
24#[cfg(feature = "anthropic")]
25pub mod anthropic;
26pub mod mock;
27pub mod openai;
28
29pub use chat::{
32 estimate_tokens, Completion, ContextBreakdown, StreamChunk, StreamSender, StructuredRequest,
33 TokenUsage, ToolCall, ToolSpec,
34};
35
36pub use pricing::{
39 context_window_tokens_for_model, context_window_tokens_for_model_effective,
40 default_compact_threshold_chars, default_compact_threshold_tokens, pricing_for, ModelPricing,
41 RetryPolicy,
42};
43
44pub const DEFAULT_MAX_TOKENS: u32 = 65_536;
62
63#[cfg(feature = "anthropic")]
66pub use anthropic::AnthropicProvider;
67#[cfg(any(test, feature = "test-utils"))]
68pub use mock::MockProvider;
69pub use openai::OpenAiProvider;
70
71#[async_trait]
80pub trait ChatProvider: Send + Sync {
81 async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion>;
82
83 fn supports_deferred_tools(&self) -> bool {
95 false
96 }
97
98 async fn complete_with_search(
110 &self,
111 messages: &[Message],
112 eager_tools: &[(ToolSpec, Option<String>)],
113 deferred_tools: &[(ToolSpec, Option<String>)],
114 ) -> Result<Completion> {
115 let all: Vec<ToolSpec> = eager_tools
116 .iter()
117 .chain(deferred_tools.iter())
118 .map(|(spec, _)| spec.clone())
119 .collect();
120 self.complete(messages, &all).await
121 }
122
123 async fn complete_structured(&self, _req: StructuredRequest) -> Result<Value> {
127 Err(Error::Config {
128 message: "provider does not support structured output".into(),
129 })
130 }
131
132 async fn stream(
146 &self,
147 messages: &[Message],
148 tools: &[ToolSpec],
149 stream_tx: Option<StreamSender>,
150 cancel_token: Option<tokio_util::sync::CancellationToken>,
151 ) -> Result<Completion> {
152 let _ = cancel_token; let completion = self.complete(messages, tools).await?;
154 if let Some(tx) = stream_tx {
155 if let Some(reasoning) = &completion.reasoning_content {
156 if !reasoning.is_empty() {
157 let _ = tx.send(StreamChunk::Reasoning(reasoning.clone()));
158 }
159 }
160 if !completion.content.is_empty() {
161 let _ = tx.send(StreamChunk::Text(completion.content.clone()));
162 }
163 }
164 Ok(completion)
165 }
166
167 async fn stream_with_search(
176 &self,
177 messages: &[Message],
178 eager_tools: &[(ToolSpec, Option<String>)],
179 deferred_tools: &[(ToolSpec, Option<String>)],
180 stream_tx: Option<StreamSender>,
181 cancel_token: Option<tokio_util::sync::CancellationToken>,
182 ) -> Result<Completion> {
183 let all: Vec<ToolSpec> = eager_tools
184 .iter()
185 .chain(deferred_tools.iter())
186 .map(|(spec, _)| spec.clone())
187 .collect();
188 self.stream(messages, &all, stream_tx, cancel_token).await
189 }
190
191 async fn complete_simple(&self, prompt: &str, _temperature: f32) -> Result<String> {
198 let messages = vec![Message::user(prompt.to_string())];
199 let completion = self.complete(&messages, &[]).await?;
200 Ok(completion.content)
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[tokio::test]
209 async fn mock_structured_returns_default_error() {
210 let provider = MockProvider::new(vec![]).with_structured_responses(vec![]);
213 let req = StructuredRequest {
214 messages: vec![Message::user("hi".to_string())],
215 schema: serde_json::json!({"type": "object", "properties": {"answer": {"type": "string"}}}),
216 schema_name: "test_schema".to_string(),
217 };
218 let result = provider.complete_structured(req).await;
219 assert!(result.is_err());
220 let err = result.unwrap_err();
221 let msg = err.to_string();
222 assert!(
223 msg.contains("no structured responses configured"),
224 "error should mention no structured responses: {msg}"
225 );
226 }
227
228 #[test]
229 fn token_usage_default_is_all_zeros() {
230 let u = TokenUsage::default();
231 assert_eq!(u.prompt_tokens, 0);
232 assert_eq!(u.completion_tokens, 0);
233 assert_eq!(u.total_tokens, 0);
234 }
235
236 #[test]
237 fn token_usage_accumulate_is_saturating() {
238 let u1 = TokenUsage {
239 reasoning_tokens: 0,
240 prompt_tokens: 10,
241 completion_tokens: 5,
242 total_tokens: 15,
243 cache_hit_tokens: 0,
244 cache_miss_tokens: 0,
245 };
246 let u2 = TokenUsage {
247 reasoning_tokens: 0,
248 prompt_tokens: 20,
249 completion_tokens: 30,
250 total_tokens: 50,
251 cache_hit_tokens: 0,
252 cache_miss_tokens: 0,
253 };
254 let acc = u1.accumulate(u2);
255 assert_eq!(acc.prompt_tokens, 30);
256 assert_eq!(acc.completion_tokens, 35);
257 assert_eq!(acc.total_tokens, 65);
258 }
259
260 #[test]
261 fn token_usage_accumulate_is_commutative() {
262 let u1 = TokenUsage {
263 reasoning_tokens: 0,
264 prompt_tokens: 10,
265 completion_tokens: 5,
266 total_tokens: 15,
267 cache_hit_tokens: 0,
268 cache_miss_tokens: 0,
269 };
270 let u2 = TokenUsage {
271 reasoning_tokens: 0,
272 prompt_tokens: 20,
273 completion_tokens: 30,
274 total_tokens: 50,
275 cache_hit_tokens: 0,
276 cache_miss_tokens: 0,
277 };
278 assert_eq!(u1.accumulate(u2), u2.accumulate(u1));
279 }
280
281 #[test]
282 fn token_usage_accumulate_saturates() {
283 let u1 = TokenUsage {
284 reasoning_tokens: 0,
285 prompt_tokens: u32::MAX,
286 completion_tokens: 1,
287 total_tokens: u32::MAX,
288 cache_hit_tokens: 0,
289 cache_miss_tokens: 0,
290 };
291 let u2 = TokenUsage {
292 reasoning_tokens: 0,
293 prompt_tokens: 1,
294 completion_tokens: u32::MAX,
295 total_tokens: u32::MAX,
296 cache_hit_tokens: 0,
297 cache_miss_tokens: 0,
298 };
299 let acc = u1.accumulate(u2);
300 assert_eq!(acc.prompt_tokens, u32::MAX);
301 assert_eq!(acc.completion_tokens, u32::MAX);
302 assert_eq!(acc.total_tokens, u32::MAX);
303 }
304
305 #[test]
306 fn cost_usd_handles_zero_usage() {
307 let pricing = ModelPricing {
308 input_per_million: 1.0,
309 output_per_million: 2.0,
310 cache_hit_input_per_million: 0.1,
311 };
312 let usage = TokenUsage::default();
313 let cost = pricing.cost_usd(usage);
314 assert!((cost - 0.0).abs() < 1e-9);
315 }
316
317 #[test]
318 fn cost_usd_computes_simple_case() {
319 let pricing = ModelPricing {
320 input_per_million: 1.0,
321 output_per_million: 1.0,
322 cache_hit_input_per_million: 0.1,
323 };
324 let usage = TokenUsage {
325 reasoning_tokens: 0,
326 prompt_tokens: 1_000_000,
327 completion_tokens: 0,
328 total_tokens: 1_000_000,
329 cache_hit_tokens: 0,
330 cache_miss_tokens: 0,
331 };
332 let cost = pricing.cost_usd(usage);
333 assert!((cost - 1.0).abs() < 1e-9);
334 }
335
336 #[test]
337 fn cost_usd_mixes_input_and_output() {
338 let pricing = ModelPricing {
339 input_per_million: 1.0,
340 output_per_million: 2.0,
341 cache_hit_input_per_million: 0.1,
342 };
343 let usage = TokenUsage {
344 reasoning_tokens: 0,
345 prompt_tokens: 500_000,
346 completion_tokens: 250_000,
347 total_tokens: 750_000,
348 cache_hit_tokens: 0,
349 cache_miss_tokens: 0,
350 };
351 let cost = pricing.cost_usd(usage);
352 assert!((cost - 1.0).abs() < 1e-9);
353 }
354
355 #[test]
356 fn pricing_for_known_models() {
357 let _home = tempfile::tempdir().unwrap();
362 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
363 let p1 = pricing_for("MiniMax-M3");
364 assert!(p1.is_some());
365 assert!((p1.unwrap().input_per_million - 0.30).abs() < 1e-9);
366
367 let p2 = pricing_for("deepseek-chat");
368 assert!(p2.is_some());
369 assert!((p2.unwrap().input_per_million - 0.14).abs() < 1e-9);
370 }
371
372 #[test]
373 fn pricing_for_unknown_returns_none() {
374 let p = pricing_for("unknown-model-xyz");
375 assert!(p.is_none());
376 }
377
378 #[test]
379 fn token_usage_accumulate_cache_fields() {
380 let u1 = TokenUsage {
381 reasoning_tokens: 0,
382 prompt_tokens: 100,
383 completion_tokens: 50,
384 total_tokens: 150,
385 cache_hit_tokens: 60,
386 cache_miss_tokens: 40,
387 };
388 let u2 = TokenUsage {
389 reasoning_tokens: 0,
390 prompt_tokens: 200,
391 completion_tokens: 100,
392 total_tokens: 300,
393 cache_hit_tokens: 120,
394 cache_miss_tokens: 80,
395 };
396 let acc = u1.accumulate(u2);
397 assert_eq!(acc.cache_hit_tokens, 180);
398 assert_eq!(acc.cache_miss_tokens, 120);
399 assert_eq!(acc.prompt_tokens, 300);
400 assert_eq!(acc.completion_tokens, 150);
401 assert_eq!(acc.total_tokens, 450);
402 }
403
404 #[test]
405 fn cost_usd_with_no_cache_hit_matches_old_behavior() {
406 let pricing = ModelPricing {
407 input_per_million: 1.0,
408 output_per_million: 2.0,
409 cache_hit_input_per_million: 0.1,
410 };
411 let usage = TokenUsage {
412 reasoning_tokens: 0,
413 prompt_tokens: 1_000_000,
414 completion_tokens: 500_000,
415 total_tokens: 1_500_000,
416 cache_hit_tokens: 0,
417 cache_miss_tokens: 1_000_000,
418 };
419 let cost = pricing.cost_usd(usage);
420 assert!((cost - 2.0).abs() < 1e-9);
421 }
422
423 #[test]
424 fn cost_usd_with_cache_hit_applies_discount() {
425 let pricing = ModelPricing {
426 input_per_million: 0.27,
427 output_per_million: 1.10,
428 cache_hit_input_per_million: 0.027,
429 };
430 let usage = TokenUsage {
431 reasoning_tokens: 0,
432 prompt_tokens: 1_000,
433 completion_tokens: 500,
434 total_tokens: 1_500,
435 cache_hit_tokens: 900,
436 cache_miss_tokens: 100,
437 };
438 let cost = pricing.cost_usd(usage);
439 let expected =
440 900.0 * 0.027 / 1_000_000.0 + 100.0 * 0.27 / 1_000_000.0 + 500.0 * 1.10 / 1_000_000.0;
441 assert!((cost - expected).abs() < 1e-9);
442 }
443
444 #[test]
445 fn pricing_for_deepseek_has_cache_discount() {
446 let _home = tempfile::tempdir().unwrap();
447 let _pin = crate::test_util::PinnedRecursiveHome::new(_home.path());
448 let pricing = pricing_for("deepseek-chat").expect("deepseek-chat should be known");
449 assert!((pricing.input_per_million - 0.14).abs() < 1e-9);
450 assert!((pricing.cache_hit_input_per_million - 0.0028).abs() < 1e-9);
451 }
452
453 #[test]
454 fn pricing_for_unknown_model_returns_none() {
455 let p = pricing_for("unknown-model-xyz");
456 assert!(p.is_none());
457 }
458
459 #[test]
460 fn token_usage_accumulate_preserves_cache_tokens() {
461 let u1 = TokenUsage {
462 reasoning_tokens: 0,
463 prompt_tokens: 1000,
464 completion_tokens: 100,
465 total_tokens: 1100,
466 cache_hit_tokens: 900,
467 cache_miss_tokens: 100,
468 };
469 let u2 = TokenUsage {
470 reasoning_tokens: 0,
471 prompt_tokens: 2000,
472 completion_tokens: 200,
473 total_tokens: 2200,
474 cache_hit_tokens: 1800,
475 cache_miss_tokens: 200,
476 };
477 let acc = u1.accumulate(u2);
478 assert_eq!(acc.cache_hit_tokens, 2700);
479 assert_eq!(acc.cache_miss_tokens, 300);
480 assert_eq!(acc.prompt_tokens, 3000);
481 }
482
483 #[test]
484 fn token_usage_accumulate_sums_reasoning() {
485 let a = TokenUsage {
486 reasoning_tokens: 100,
487 ..Default::default()
488 };
489 let b = TokenUsage {
490 reasoning_tokens: 250,
491 ..Default::default()
492 };
493 let c = a.accumulate(b);
494 assert_eq!(c.reasoning_tokens, 350);
495 }
496
497 #[test]
498 fn context_window_known_models() {
499 assert_eq!(
500 context_window_tokens_for_model("claude-sonnet-4-6"),
501 1_000_000
502 );
503 assert_eq!(
504 context_window_tokens_for_model("claude-opus-4-7"),
505 1_000_000
506 );
507 assert_eq!(context_window_tokens_for_model("MiniMax-M3"), 1_048_576);
508 assert_eq!(context_window_tokens_for_model("deepseek-chat"), 1_000_000);
509 assert_eq!(
510 context_window_tokens_for_model("deepseek-reasoner"),
511 1_000_000
512 );
513 assert_eq!(context_window_tokens_for_model("gpt-4o"), 128_000);
514 assert_eq!(context_window_tokens_for_model("gpt-4o-mini"), 128_000);
515 assert_eq!(context_window_tokens_for_model("moonshot-v1-8k"), 8_000);
516 assert_eq!(
517 context_window_tokens_for_model("doubao-1-5-pro-256k"),
518 256_000
519 );
520 assert_eq!(context_window_tokens_for_model("gemini-2.5-pro"), 1_048_576);
521 }
522
523 #[test]
524 fn context_window_unknown_model_fallback() {
525 assert_eq!(
526 context_window_tokens_for_model("some-future-model"),
527 128_000
528 );
529 }
530
531 #[test]
532 fn default_compact_threshold_is_reasonable() {
533 let ds = default_compact_threshold_chars("deepseek-chat");
534 assert!(ds > 500_000, "deepseek threshold too small: {ds}");
535 assert!(
536 ds < 4_000_000,
537 "deepseek threshold suspiciously large: {ds}"
538 );
539
540 let cl = default_compact_threshold_chars("claude-sonnet-4-6");
541 assert!(cl > 500_000, "claude threshold too small: {cl}");
542 assert!(cl < 4_000_000, "claude threshold suspiciously large: {cl}");
543
544 let unk = default_compact_threshold_chars("unknown-model");
545 assert!(unk > 0);
546 }
547
548 struct MinimalProvider {
553 response: String,
554 }
555
556 #[async_trait::async_trait]
557 impl ChatProvider for MinimalProvider {
558 async fn complete(&self, _messages: &[Message], _tools: &[ToolSpec]) -> Result<Completion> {
559 Ok(Completion {
560 content: self.response.clone(),
561 tool_calls: vec![],
562 reasoning_content: None,
563 usage: Some(TokenUsage::default()),
564 finish_reason: None,
565 })
566 }
567 }
568
569 #[test]
570 fn default_supports_deferred_tools_is_false() {
571 let provider = MinimalProvider {
573 response: "hi".into(),
574 };
575 assert!(
576 !provider.supports_deferred_tools(),
577 "default supports_deferred_tools must be false"
578 );
579 }
580
581 #[tokio::test]
582 async fn default_stream_sends_non_empty_content_to_channel() {
583 use tokio::sync::mpsc;
587 let provider = MinimalProvider {
588 response: "hello from stream".into(),
589 };
590 let (tx, mut rx) = mpsc::unbounded_channel::<StreamChunk>();
591
592 let completion = provider.stream(&[], &[], Some(tx), None).await.unwrap();
593 assert_eq!(completion.content, "hello from stream");
594
595 let chunk = rx.try_recv().expect("stream must emit at least one chunk");
597 match chunk {
598 StreamChunk::Text(t) => assert_eq!(t, "hello from stream"),
599 other => panic!("expected Text chunk, got {other:?}"),
600 }
601 }
602
603 #[tokio::test]
604 async fn default_stream_does_not_send_chunk_for_empty_content() {
605 use tokio::sync::mpsc;
607 let provider = MinimalProvider {
608 response: String::new(),
609 };
610 let (tx, mut rx) = mpsc::unbounded_channel::<StreamChunk>();
611
612 let completion = provider.stream(&[], &[], Some(tx), None).await.unwrap();
613 assert!(completion.content.is_empty());
614
615 assert!(
617 rx.try_recv().is_err(),
618 "empty content must not emit a Text chunk"
619 );
620 }
621}