Skip to main content

recursive/llm/
mod.rs

1//! LLM provider abstraction.
2//!
3//! A provider takes a transcript plus tool specs and returns either
4//! free-form content, structured tool calls, or both. The trait is the
5//! only thing the agent depends on; everything beyond it (HTTP, retries,
6//! mocking) lives in adapters.
7
8use 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
30/// Channel sender for streaming partial tokens during a streaming LLM call.
31/// Each `String` is a delta chunk (partial token) emitted by the provider.
32pub type StreamSender = mpsc::UnboundedSender<String>;
33
34// ── Shared retry policy ────────────────────────────────────────────────────
35
36/// Retry policy for transient LLM provider failures (network timeouts, 5xx).
37///
38/// Shared across all provider implementations to keep retry semantics
39/// consistent. Each provider stores one instance in its struct and calls
40/// [`RetryPolicy::backoff_for`] after every failed HTTP attempt.
41#[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    /// Returns `Some(backoff)` if the caller should sleep-and-retry, or `None`
60    /// to propagate the error. `attempt` is 0-indexed (0 = after the first failure).
61    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/// Token usage data from an LLM response.
80#[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    /// Saturating element-wise sum. Used to accumulate across LLM calls.
91    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/// Per-million-token pricing for one model. USD.
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct ModelPricing {
109    pub input_per_million: f64,
110    pub output_per_million: f64,
111    /// Price per million tokens for cache-hit prompts.
112    /// Defaults to 10% of input rate (DeepSeek's known discount).
113    pub cache_hit_input_per_million: f64,
114}
115
116impl ModelPricing {
117    /// USD cost for the given usage at this pricing.
118    pub fn cost_usd(&self, usage: TokenUsage) -> f64 {
119        let in_cost = if usage.cache_hit_tokens > 0 {
120            // Apply cache-hit discount: use cache_hit_input_per_million for cached tokens
121            let cache_hit =
122                usage.cache_hit_tokens as f64 * self.cache_hit_input_per_million / 1_000_000.0;
123            // Use full rate for cache-miss tokens (which is prompt - cache_hit)
124            // Note: cache_miss_tokens may not equal prompt - cache_hit due to rounding,
125            // but the difference is negligible for billing purposes
126            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
136/// Returns the context window size in tokens for the given model.
137///
138/// The value is looked up from the bundled `providers.toml` preset catalog.
139/// Unknown models (not listed in any preset) fall back to a conservative
140/// 128 K token default — the minimum window common to all current-generation
141/// frontier models.
142pub 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    // Conservative fallback for models not listed in providers.toml.
152    128_000
153}
154
155/// Compute the default compaction character-count threshold for a model.
156///
157/// Strategy (mirrors fake-cc `getAutoCompactThreshold`):
158/// 1. Start from the model's context window in tokens.
159/// 2. Reserve 20 000 tokens for the compaction summary output.
160/// 3. Take 80 % of the remainder as the trigger point (leaves a comfortable
161///    20 % buffer before the hard limit is hit).
162/// 4. Convert tokens → characters using a conservative 4 chars/token ratio.
163pub 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    // 80 % of effective window, then 4 chars per token.
168    (effective_tokens as f64 * 0.8 * 4.0) as usize
169}
170
171/// Returns pricing for a model by looking it up in the bundled `providers.toml`.
172/// Returns `None` if the model is not listed or has no pricing field.
173pub 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/// JSON-schema description of a tool, sent verbatim to the model.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ToolSpec {
187    pub name: String,
188    pub description: String,
189    /// JSON Schema object describing the tool's input.
190    pub parameters: Value,
191}
192
193/// A structured request to invoke one of the registered tools.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct ToolCall {
196    pub id: String,
197    pub name: String,
198    /// Raw JSON arguments as produced by the model.
199    pub arguments: Value,
200}
201
202/// Request for a structured JSON response conforming to a JSON schema.
203pub struct StructuredRequest {
204    pub messages: Vec<Message>,
205    /// JSON Schema describing the expected response shape.
206    pub schema: Value,
207    /// Name for the schema (sent to the provider as `schema_name`).
208    pub schema_name: String,
209}
210
211/// One step of model output.
212#[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    /// DeepSeek reasoning/thinking content. Stored in the transcript and
219    /// echoed back on subsequent requests to satisfy the API contract.
220    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    /// Variant that accepts a partition between eager and deferred
228    /// tools. Providers that support native deferred loading (e.g.
229    /// Anthropic via `defer_loading: true` + `tool_reference` content
230    /// blocks) override this. The default implementation concatenates
231    /// the two lists (dropping the hints) and calls `complete()` —
232    /// i.e., it ignores the partition and behaves identically to the
233    /// legacy interface.
234    ///
235    /// Providers that do NOT support deferred tool loading (e.g. the
236    /// OpenAI provider) inherit this default and see every tool as
237    /// eager. No code change is required in those providers.
238    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    /// Request a JSON response conforming to a caller-supplied schema.
253    /// Default impl returns an error. Providers that support structured
254    /// output (e.g. OpenAI-compatible) override this.
255    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    /// Stream a completion token-by-token.
262    ///
263    /// The default implementation delegates to [`LlmProvider::complete`] and emits the
264    /// entire content as a single delta via the channel (if configured).
265    /// Providers that support native SSE streaming should override this.
266    ///
267    /// The `stream_tx` channel receives partial-token deltas as they are
268    /// parsed. The returned `Completion` is the fully accumulated result.
269    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    /// Simple completion with a single user prompt.
285    ///
286    /// Wraps the prompt in a user [`Message`] and calls [`complete`](LlmProvider::complete)
287    /// with no tools. Providers that support temperature or other controls
288    /// should override this method. The default implementation ignores
289    /// `temperature`.
290    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        // MockProvider overrides complete_structured. When no structured
304        // responses are configured, it returns an error (triggering fallback).
305        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, // 10% discount
398        };
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        // 1M input tokens, 0 output
412        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        // 500K input + 250K output
431        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        // 0.5 * 1.0 + 0.25 * 2.0 = 0.5 + 0.5 = 1.0
440        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    /// Backward compat: cache_hit_tokens = 0 should return same as before.
485    #[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, // 10% discount
491        };
492        // No cache hits
493        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        // Old calculation: 1M * $1/M + 500K * $2/M = $1.00 + $1.00 = $2.00
501        let cost = pricing.cost_usd(usage);
502        assert!((cost - 2.0).abs() < 1e-9);
503    }
504
505    /// Cache hit tokens get discounted rate (DeepSeek 10% of input rate).
506    #[test]
507    fn cost_usd_with_cache_hit_applies_discount() {
508        // DeepSeek pricing: $0.27/M input, $0.027/M for cache hits
509        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        // 900 cache hit + 100 cache miss = 1000 prompt tokens
515        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        // Cache hit: 900 * 0.027/1M = 0.0000243
524        // Cache miss: 100 * 0.27/1M = 0.000027
525        // Output: 500 * 1.10/1M = 0.00055
526        // Total: 0.0000243 + 0.000027 + 0.00055 = 0.0006013
527        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    /// Verify known model has correct cache-hit pricing.
533    #[test]
534    fn pricing_for_deepseek_has_cache_discount() {
535        let pricing = pricing_for("deepseek-chat").expect("deepseek-chat should be known");
536        // Input: $0.27/M, cache hit should be 10% = $0.027/M
537        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    /// Unknown model returns None (cost won't be printed - conservative).
542    #[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    /// Verify accumulated TokenUsage preserves cache_hit_tokens sum.
549    #[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    // ── context_window_tokens_for_model / default_compact_threshold_chars ─────
572
573    #[test]
574    fn context_window_known_models() {
575        // Names must exactly match providers.toml entries.
576        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        // A model not listed in providers.toml → conservative 128 K default.
598        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        // deepseek-chat: 64 K tokens → threshold should be in the 50K–300K char range
607        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        // claude-sonnet-4-6: 200 K tokens → threshold should be much larger
612        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        // unknown model: threshold must be positive (falls back to 128K window)
617        let unk = default_compact_threshold_chars("unknown-model");
618        assert!(unk > 0);
619    }
620}