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 [`ChatProvider`]
5//! trait is the only thing the agent depends on; everything beyond it
6//! (HTTP, retries, mocking) lives in adapters.
7//!
8//! ## Sub-modules
9//!
10//! - [`chat`] — data types: `Completion`, `ToolSpec`, `ToolCall`, `TokenUsage`, …
11//! - [`pricing`] — `ModelPricing`, `RetryPolicy`, and catalog lookup helpers
12//! - `openai` / `anthropic` / `mock` — concrete provider implementations
13
14use 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
29// ── Re-exports: chat types ────────────────────────────────────────────────────
30
31pub use chat::{
32    estimate_tokens, Completion, ContextBreakdown, StreamChunk, StreamSender, StructuredRequest,
33    TokenUsage, ToolCall, ToolSpec,
34};
35
36// ── Re-exports: pricing ───────────────────────────────────────────────────────
37
38pub 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
44// ── Max-output-tokens default ────────────────────────────────────────────────
45
46/// Default per-response output token cap used when neither the
47/// `RECURSIVE_MAX_TOKENS` env var, the active provider preset's
48/// `ModelSpec.max_tokens`, nor the config-file `agent.max_tokens` is set.
49///
50/// Chosen to align with the escalation ceiling other coding agents use
51/// (e.g. Claude Code's `ESCALATED_MAX_TOKENS = 64_000`). 64K is generous
52/// for reasoning-heavy turns (the failure mode that motivated this: a
53/// 16_384 cap truncated a ~60K-char reasoning turn mid-plan, producing an
54/// empty tool-call message and `provider_stop:length`) yet still far below
55/// the real ceiling of modern providers (DeepSeek V4 = 384K, MiniMax M3
56/// ~1M context), so it neither truncates useful work nor over-reserves
57/// slot capacity.
58///
59/// Providers seed their struct default from this; `Config::from_env` uses
60/// it as the final fallback. Override per-run with `RECURSIVE_MAX_TOKENS`.
61pub const DEFAULT_MAX_TOKENS: u32 = 65_536;
62
63// ── Re-exports: provider implementations ─────────────────────────────────────
64
65#[cfg(feature = "anthropic")]
66pub use anthropic::AnthropicProvider;
67#[cfg(any(test, feature = "test-utils"))]
68pub use mock::MockProvider;
69pub use openai::OpenAiProvider;
70
71// ── ChatProvider trait ────────────────────────────────────────────────────────
72
73/// Core trait for LLM chat-completion providers.
74///
75/// Implementors must provide [`ChatProvider::complete`]. All other methods
76/// have default implementations that delegate to `complete` or return
77/// appropriate defaults, so adding a new provider only requires implementing
78/// one `async fn`.
79#[async_trait]
80pub trait ChatProvider: Send + Sync {
81    async fn complete(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Completion>;
82
83    /// Whether this provider supports deferred tool loading via
84    /// `tool_reference` content blocks (Anthropic API feature).
85    ///
86    /// When `true`, `run_core` will:
87    /// - Strip deferred tools from the `tools` array sent to the LLM
88    /// - Inject `<available-deferred-tools>` into the messages
89    /// - Rely on `serialize_messages_anthropic` to convert ToolSearchTool
90    ///   results into `tool_reference` blocks for schema expansion.
91    ///
92    /// When `false` (default), all tools are sent eagerly and
93    /// `ToolSearchTool` is NOT registered in the registry.
94    fn supports_deferred_tools(&self) -> bool {
95        false
96    }
97
98    /// Variant that accepts a partition between eager and deferred
99    /// tools. Providers that support native deferred loading (e.g.
100    /// Anthropic via `defer_loading: true` + `tool_reference` content
101    /// blocks) override this. The default implementation concatenates
102    /// the two lists (dropping the hints) and calls `complete()` —
103    /// i.e., it ignores the partition and behaves identically to the
104    /// legacy interface.
105    ///
106    /// Providers that do NOT support deferred tool loading (e.g. the
107    /// OpenAI provider) inherit this default and see every tool as
108    /// eager. No code change is required in those providers.
109    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    /// Request a JSON response conforming to a caller-supplied schema.
124    /// Default impl returns an error. Providers that support structured
125    /// output (e.g. OpenAI-compatible) override this.
126    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    /// Stream a completion token-by-token.
133    ///
134    /// The default implementation delegates to [`ChatProvider::complete`] and emits the
135    /// entire content as a single delta via the channel (if configured).
136    /// Providers that support native SSE streaming should override this.
137    ///
138    /// The `stream_tx` channel receives partial-token deltas as they are
139    /// parsed. The returned `Completion` is the fully accumulated result.
140    ///
141    /// The optional `cancel_token`, when set, enables cooperative
142    /// cancellation of the underlying SSE read loop via `tokio::select!`.
143    /// When the token fires, the provider drops the HTTP response (closing
144    /// the TCP connection) and returns `Err(Error::Cancelled)`.
145    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; // used by providers that override this
153        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    /// Search-aware streaming: model sees eager tools + `ToolSearchTool`;
168    /// deferred tools become available only after the model requests them.
169    ///
170    /// The default implementation merges both lists and delegates to
171    /// [`ChatProvider::stream`] — i.e., behaves like the legacy eager-only path.
172    /// Providers that support deferred tool loading (e.g. Anthropic) override
173    /// this to inject `ToolSearchTool` into the eager set and handle the
174    /// search loop across multiple streaming rounds.
175    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    /// Simple completion with a single user prompt.
192    ///
193    /// Wraps the prompt in a user [`Message`] and calls [`complete`](ChatProvider::complete)
194    /// with no tools. Providers that support temperature or other controls
195    /// should override this method. The default implementation ignores
196    /// `temperature`.
197    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        // MockProvider overrides complete_structured. When no structured
211        // responses are configured, it returns an error (triggering fallback).
212        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        // Pin RECURSIVE_HOME so the effective catalog (remote cache +
358        // bundled) collapses to bundled pricing — a stray
359        // ~/.recursive/providers_cache.json on the developer's machine
360        // must not change the asserted bundled prices.
361        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    // ── ChatProvider default method tests ─────────────────────────────────────
549
550    /// A minimal provider that only overrides `complete()`.
551    /// All other ChatProvider methods use their trait defaults.
552    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        // kills `replace ChatProvider::supports_deferred_tools -> bool with true`
572        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        // kills `delete ! in ChatProvider::stream` at line 132
584        // With the mutant, content is only sent when IS empty — so this test
585        // would receive no chunk and the assertion would fail.
586        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        // The channel should have received a Text chunk with the content.
596        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        // Verifies the positive case: empty content → no Text chunk sent
606        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        // No Text chunk should have been sent for empty content.
616        assert!(
617            rx.try_recv().is_err(),
618            "empty content must not emit a Text chunk"
619        );
620    }
621}