recall_echo/graph/llm.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Minimal LLM provider trait for knowledge graph operations.
6//!
7//! recall-graph defines its own trait to stay independent of pulse-system-types.
8//! Callers implement this to bridge their actual LLM backend.
9
10use serde::{Deserialize, Serialize};
11
12use super::error::GraphError;
13
14/// Tokens a provider *reported* for one call.
15///
16/// Measured, never inferred: this type is only ever built from numbers a
17/// provider printed. Where a provider says nothing, the caller estimates and
18/// says which it is doing — see [`crate::graph::types::IngestionReport`].
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TokenUsage {
21 /// Prompt tokens, as the provider counted them.
22 pub input_tokens: u64,
23 /// Completion tokens, as the provider counted them.
24 pub output_tokens: u64,
25}
26
27impl TokenUsage {
28 /// Usage from a pair of counts, or `None` when neither was reported.
29 ///
30 /// A provider that reports one side and not the other still measured
31 /// something, and half a real number beats a whole invented one.
32 #[must_use]
33 pub fn from_counts(input_tokens: Option<u64>, output_tokens: Option<u64>) -> Option<Self> {
34 match (input_tokens, output_tokens) {
35 (None, None) => None,
36 (input, output) => Some(Self {
37 input_tokens: input.unwrap_or(0),
38 output_tokens: output.unwrap_or(0),
39 }),
40 }
41 }
42
43 /// Total tokens billed for the call.
44 #[must_use]
45 pub fn total(self) -> u64 {
46 self.input_tokens + self.output_tokens
47 }
48}
49
50/// One completion, and what it cost if the provider was willing to say.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Completion {
53 /// The answer text — exactly what [`LlmProvider::complete`] returns.
54 pub text: String,
55 /// The provider's own token counts, when it reported any.
56 pub usage: Option<TokenUsage>,
57}
58
59impl Completion {
60 /// An answer from a provider that reported no usage.
61 #[must_use]
62 pub fn unmeasured(text: impl Into<String>) -> Self {
63 Self {
64 text: text.into(),
65 usage: None,
66 }
67 }
68
69 /// An answer with the provider's token counts attached.
70 #[must_use]
71 pub fn measured(text: impl Into<String>, usage: Option<TokenUsage>) -> Self {
72 Self {
73 text: text.into(),
74 usage,
75 }
76 }
77}
78
79/// Minimal LLM provider for extraction and deduplication.
80///
81/// Implementors bridge this to their actual LLM backend:
82/// - recall-echo bridges to `echo_system_types::LmProvider`
83/// - Standalone users can implement with any HTTP client
84#[async_trait::async_trait]
85pub trait LlmProvider: Send + Sync {
86 /// Send a system prompt and user message, get back a text response.
87 async fn complete(
88 &self,
89 system_prompt: &str,
90 user_message: &str,
91 max_tokens: u32,
92 ) -> Result<String, GraphError>;
93
94 /// The same call, plus whatever the provider reported about its cost.
95 ///
96 /// Defaulted to [`LlmProvider::complete`] with no usage, so an existing
97 /// implementor keeps working and simply goes on being estimated. Override
98 /// it wherever the backend prints real numbers — codex's
99 /// `turn.completed.usage`, grok's `usage`, the Anthropic and OpenAI
100 /// envelopes.
101 async fn complete_measured(
102 &self,
103 system_prompt: &str,
104 user_message: &str,
105 max_tokens: u32,
106 ) -> Result<Completion, GraphError> {
107 let text = self
108 .complete(system_prompt, user_message, max_tokens)
109 .await?;
110 Ok(Completion::unmeasured(text))
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn a_provider_that_reports_nothing_measures_nothing() {
120 assert_eq!(TokenUsage::from_counts(None, None), None);
121 }
122
123 #[test]
124 fn one_reported_side_is_still_a_measurement() {
125 assert_eq!(
126 TokenUsage::from_counts(None, Some(5)),
127 Some(TokenUsage {
128 input_tokens: 0,
129 output_tokens: 5,
130 })
131 );
132 }
133
134 #[test]
135 fn total_sums_both_sides() {
136 let usage = TokenUsage::from_counts(Some(13_658), Some(5)).unwrap();
137 assert_eq!(usage.total(), 13_663);
138 }
139}