orion_core/backend.rs
1use std::sync::atomic::AtomicBool;
2use std::sync::Arc;
3
4use crate::error::CoreResult;
5#[cfg(feature = "chat-backend")]
6use crate::messages::Message;
7
8/// Token callback invoked for each generated token.
9/// Receives the token text, tokens generated so far, and current tokens/sec.
10pub type TokenCallback = Box<dyn FnMut(&str, u32, f64) + Send>;
11
12/// Inference parameters for a single generation request.
13#[derive(Debug, Clone)]
14pub struct InferenceParams {
15 /// Maximum number of tokens to generate in the response.
16 pub max_tokens: u32,
17 /// Sampling temperature (0.0 = deterministic, higher = more random).
18 pub temperature: f32,
19 /// Context window size in tokens to allocate for this request.
20 pub context_size: u32,
21 /// Number of CPU threads to use for inference.
22 pub n_threads: u32,
23}
24
25impl Default for InferenceParams {
26 fn default() -> Self {
27 let default_threads = std::thread::available_parallelism()
28 .map(|n| (n.get() as u32).saturating_sub(2).max(1))
29 .unwrap_or(4);
30 Self {
31 max_tokens: 2048,
32 temperature: 0.7,
33 context_size: 4096,
34 n_threads: default_threads,
35 }
36 }
37}
38
39/// Result of a completed generation.
40#[derive(Debug, Clone)]
41pub struct GenerationResult {
42 /// The full generated text.
43 pub text: String,
44 /// Number of tokens generated in the response.
45 pub tokens_generated: u32,
46 /// Number of tokens in the (formatted) prompt that was fed in.
47 pub prompt_tokens: u32,
48 /// Average generation speed in tokens per second.
49 pub tokens_per_sec: f64,
50 /// Time from request start to the first emitted token, in milliseconds.
51 pub time_to_first_token_ms: f64,
52 /// Total generation time, in milliseconds.
53 pub generation_time_ms: f64,
54}
55
56/// Trait for LLM backends (llama.cpp, MLX, cloud APIs, etc.).
57///
58/// The agent loop is backend-agnostic. OrionPod implements this
59/// with llama.cpp; other backends can be swapped in freely.
60///
61/// `generate` runs synchronously on a blocking thread. The agent
62/// loop handles the async orchestration around it.
63///
64/// ```no_run
65/// use orion_core::{LlmBackend, InferenceParams, GenerationResult, TokenCallback, CoreResult};
66/// use std::sync::atomic::AtomicBool;
67/// use std::sync::Arc;
68///
69/// struct MyBackend; // your engine state
70///
71/// impl LlmBackend for MyBackend {
72/// fn generate(
73/// &self,
74/// prompt: &str, // fully formatted (chat template applied)
75/// params: &InferenceParams, // max_tokens, temperature, context_size, n_threads
76/// abort: Arc<AtomicBool>, // check each token to support cancellation
77/// on_token: TokenCallback, // call with (token_text, count, tokens_per_sec)
78/// ) -> CoreResult<GenerationResult> {
79/// // Feed prompt, sample tokens, call on_token per token, return stats.
80/// todo!()
81/// }
82///
83/// fn tokenize_count(&self, text: &str) -> CoreResult<u32> {
84/// // Count tokens without running inference (used for budgeting).
85/// todo!()
86/// }
87///
88/// fn is_ready(&self) -> bool {
89/// // Whether a model is loaded and ready.
90/// todo!()
91/// }
92/// }
93/// ```
94pub trait LlmBackend: Send + Sync {
95 /// Run inference on a formatted prompt string.
96 ///
97 /// The prompt is already fully formatted (chat template applied).
98 /// The backend just needs to feed it and generate tokens.
99 fn generate(
100 &self,
101 prompt: &str,
102 params: &InferenceParams,
103 abort: Arc<AtomicBool>,
104 on_token: TokenCallback,
105 ) -> CoreResult<GenerationResult>;
106
107 /// Count tokens in a string without running inference.
108 ///
109 /// Defaults to the usual four-characters-a-token approximation, which is what a
110 /// backend with no local tokenizer can honestly offer. Override it wherever the real
111 /// count is cheap: context budgeting is only as good as this answer.
112 fn tokenize_count(&self, text: &str) -> CoreResult<u32> {
113 Ok(estimate_tokens(text))
114 }
115
116 /// Whether a model is currently loaded and ready.
117 ///
118 /// Defaults to true, which is the right answer for a backend with nothing to load.
119 fn is_ready(&self) -> bool {
120 true
121 }
122}
123
124/// Tokens in a string, approximated at four characters each.
125///
126/// A remote endpoint exposes no cheap tokenizer, so this is what stands in for one when
127/// the budget has to be decided before the request goes out. Real counts come back with
128/// the response and are reported in [`GenerationResult`].
129pub fn estimate_tokens(text: &str) -> u32 {
130 (text.chars().count() as u32 / 4).max(1)
131}
132
133/// A backend that takes the conversation as messages rather than as a formatted prompt.
134///
135/// [`LlmBackend`] hands over a string that has already had a [`ChatTemplate`] applied,
136/// which is what a local engine wants and what a hosted chat API does not: those take a
137/// structured message list and apply the model's own template themselves. Sending a
138/// formatted prompt to one means either templating twice or collapsing every turn into a
139/// single user message, and both change what the model sees.
140///
141/// So a hosted provider implements this instead, and the agent skips its own templating
142/// for it. Everything else - pruning, the tool loop, the event stream - is unchanged.
143///
144/// Unlike `generate` this is async, because the work is I/O rather than compute and there
145/// is no reason to hold a blocking thread for it.
146///
147/// [`ChatTemplate`]: crate::ChatTemplate
148#[cfg(feature = "chat-backend")]
149#[async_trait::async_trait]
150pub trait ChatBackend: Send + Sync {
151 /// Run one turn against a conversation.
152 ///
153 /// `system` is the system prompt with any tool instructions already folded in, kept
154 /// apart from `messages` because that is how every chat API takes it. `messages` are
155 /// the turns that survived pruning, in order, and carry no system message of their own.
156 async fn chat(
157 &self,
158 system: &str,
159 messages: &[Message],
160 params: &InferenceParams,
161 abort: Arc<AtomicBool>,
162 on_token: TokenCallback,
163 ) -> CoreResult<GenerationResult>;
164
165 /// Count tokens in a string, for context budgeting. See
166 /// [`LlmBackend::tokenize_count`]; the same approximation applies.
167 fn tokenize_count(&self, text: &str) -> u32 {
168 estimate_tokens(text)
169 }
170
171 /// Whether the backend can be asked to run. Defaults to true.
172 fn is_ready(&self) -> bool {
173 true
174 }
175}
176
177/// Whichever kind of backend a turn is being run against.
178///
179/// Callers do not name this: `Arc<dyn LlmBackend>` and `Arc<dyn ChatBackend>` both convert
180/// into it, so [`Agent::prompt`](crate::Agent::prompt) takes either and every existing
181/// caller keeps compiling.
182#[derive(Clone)]
183pub enum Backend {
184 /// A backend fed a formatted prompt string.
185 Prompt(Arc<dyn LlmBackend>),
186 /// A backend fed structured messages.
187 #[cfg(feature = "chat-backend")]
188 Chat(Arc<dyn ChatBackend>),
189}
190
191impl Backend {
192 /// Whether the backend can be asked to run.
193 pub fn is_ready(&self) -> bool {
194 match self {
195 Self::Prompt(backend) => backend.is_ready(),
196 #[cfg(feature = "chat-backend")]
197 Self::Chat(backend) => backend.is_ready(),
198 }
199 }
200
201 /// Tokens in a string, however this backend counts them.
202 pub fn tokenize_count(&self, text: &str) -> u32 {
203 match self {
204 Self::Prompt(backend) => backend.tokenize_count(text).unwrap_or(0),
205 #[cfg(feature = "chat-backend")]
206 Self::Chat(backend) => backend.tokenize_count(text),
207 }
208 }
209}
210
211impl From<Arc<dyn LlmBackend>> for Backend {
212 fn from(backend: Arc<dyn LlmBackend>) -> Self {
213 Self::Prompt(backend)
214 }
215}
216
217#[cfg(feature = "chat-backend")]
218impl From<Arc<dyn ChatBackend>> for Backend {
219 fn from(backend: Arc<dyn ChatBackend>) -> Self {
220 Self::Chat(backend)
221 }
222}