Skip to main content

lc_core/language_models/
chat.rs

1// src/core/language_models/chat.rs
2//! Chat model base trait.
3
4use super::BaseLanguageModel;
5use crate::tools::ToolDefinition;
6use crate::RunnableConfig;
7use async_trait::async_trait;
8use futures_util::Stream;
9use lc_schema::Message;
10use lc_shared::tools::ToolCall;
11use serde::{Deserialize, Serialize};
12use std::pin::Pin;
13
14/// LLM result containing response content and metadata.
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct LLMResult {
17    /// The generated response content.
18    #[serde(default)]
19    pub content: String,
20    /// The model identifier that produced the result.
21    #[serde(default)]
22    pub model: String,
23    /// Token usage statistics, if reported.
24    #[serde(default)]
25    pub token_usage: Option<TokenUsage>,
26    /// Tool calls requested by the model, if any.
27    #[serde(default)]
28    pub tool_calls: Option<Vec<ToolCall>>,
29    /// Model reasoning/thinking content, if present.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub thinking_content: Option<String>,
32}
33
34/// Token usage statistics.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TokenUsage {
37    /// Input token count.
38    pub prompt_tokens: usize,
39
40    /// Output token count.
41    pub completion_tokens: usize,
42
43    /// Total token count.
44    pub total_tokens: usize,
45}
46
47/// A single streaming chunk emitted by [`BaseChatModel::stream_chat`].
48///
49/// Replaces the bare `String` chunk so streaming paths can observe token
50/// usage without a separate non-streaming `invoke`. Most providers only
51/// populate [`StreamChunk::token_usage`] on the **final** chunk; intermediate
52/// chunks carry `text` with `token_usage: None`. Providers that do not report
53/// usage (Ollama, local, proxy passthrough) always yield `token_usage: None`.
54#[derive(Debug, Clone)]
55pub struct StreamChunk {
56    /// The text delta for this chunk.
57    pub text: String,
58    /// Token usage for the whole streaming call, typically only on the last
59    /// chunk (when the provider reports it).
60    pub token_usage: Option<TokenUsage>,
61}
62
63impl StreamChunk {
64    /// Creates a text-only chunk with no token usage.
65    pub fn new(text: impl Into<String>) -> Self {
66        Self {
67            text: text.into(),
68            token_usage: None,
69        }
70    }
71}
72
73/// Base trait for chat models.
74///
75/// Extends BaseLanguageModel for chat scenarios.
76/// Accepts message list as input, returns AI message.
77#[async_trait]
78pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
79    /// Chat with the model.
80    ///
81    /// # Arguments
82    /// * `messages` - Message list.
83    /// * `config` - Optional configuration.
84    ///
85    /// # Returns
86    /// LLM result.
87    async fn chat(
88        &self,
89        messages: Vec<Message>,
90        config: Option<RunnableConfig>,
91    ) -> Result<LLMResult, Self::Error>;
92
93    /// Stream chat with the model.
94    ///
95    /// # Arguments
96    /// * `messages` - Message list.
97    /// * `config` - Optional configuration.
98    ///
99    /// # Returns
100    /// Stream of [`StreamChunk`] items. Chunks carry the text delta; the final
101    /// chunk may additionally carry [`StreamChunk::token_usage`] when the
102    /// provider reports usage.
103    async fn stream_chat(
104        &self,
105        messages: Vec<Message>,
106        config: Option<RunnableConfig>,
107    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>;
108
109    /// Chat with system prompt.
110    ///
111    /// # Arguments
112    /// * `system` - System prompt.
113    /// * `messages` - Message list.
114    ///
115    /// # Returns
116    /// LLM result.
117    async fn chat_with_system(
118        &self,
119        system: String,
120        messages: Vec<Message>,
121    ) -> Result<LLMResult, Self::Error> {
122        let full_messages = vec![Message::system(system)]
123            .into_iter()
124            .chain(messages)
125            .collect();
126
127        self.chat(full_messages, None).await
128    }
129
130    /// Bind tool definitions for function calling.
131    ///
132    /// Returns `Some(model)` with the tools attached when the provider
133    /// supports tool calling; returns `None` when it does not. **The default
134    /// returns `None`, signalling a hard capability limit** — callers MUST
135    /// treat `None` as "this model cannot call tools" and branch accordingly
136    /// (e.g. fall back to text-only prompting). Providers that support
137    /// function calling (OpenAI, Ollama) override this.
138    ///
139    /// This is an explicit result, not a silent degrade: `None` is the honest
140    /// answer that tool-calling is unavailable on this model.
141    fn bind_tools(
142        &self,
143        _tools: Vec<ToolDefinition>,
144    ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
145        None
146    }
147}
148
149/// Error from [`predict_tools`].
150#[derive(Debug, thiserror::Error)]
151pub enum PredictToolsError<E>
152where
153    E: std::error::Error + Send + Sync + 'static,
154{
155    /// The model's `bind_tools` returned `None` — the provider cannot call
156    /// tools. Surfaced as an explicit error instead of silently degrading to a
157    /// plain-text prompt.
158    #[error("model does not support tool calling (bind_tools returned None); use a tool-capable model or call `chat` directly without tools")]
159    ToolsUnsupported,
160
161    /// Underlying chat model failure.
162    #[error("chat model error: {0}")]
163    Chat(#[source] E),
164}
165
166/// One-shot tool call: `bind_tools` + `chat` in a single entry point.
167///
168/// Binds `tools` to `llm`, sends `prompt` as a single human message, and returns
169/// the model response — including any `tool_calls`. This is a thin convenience
170/// for callers that want one turn with tools and plan to execute the tool calls
171/// themselves; it does **not** run an agent loop or auto-execute tools (that is
172/// `AgentExecutor`'s job).
173///
174/// # Errors
175///
176/// Returns [`PredictToolsError::ToolsUnsupported`] when the model's `bind_tools`
177/// returns `None`, instead of silently degrading to a tool-less prompt.
178pub async fn predict_tools<M>(
179    llm: &M,
180    prompt: impl Into<String>,
181    tools: Vec<ToolDefinition>,
182) -> Result<LLMResult, PredictToolsError<M::Error>>
183where
184    M: BaseChatModel + ?Sized,
185{
186    let Some(tool_llm) = llm.bind_tools(tools) else {
187        return Err(PredictToolsError::ToolsUnsupported);
188    };
189    let messages = vec![Message::human(prompt.into())];
190    tool_llm
191        .chat(messages, None)
192        .await
193        .map_err(PredictToolsError::Chat)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::runnables::Runnable;
200    use futures_util::Stream;
201    use std::pin::Pin;
202
203    /// Mock model whose `bind_tools` attaches tools and whose `chat` echoes
204    /// them back as `tool_calls` (tool-capable path).
205    #[derive(Debug, Clone)]
206    struct ToolCapableMock {
207        tools: Option<Vec<ToolDefinition>>,
208    }
209
210    impl ToolCapableMock {
211        fn new() -> Self {
212            Self { tools: None }
213        }
214    }
215
216    #[async_trait]
217    impl Runnable<Vec<Message>, LLMResult> for ToolCapableMock {
218        type Error = MockError;
219
220        async fn invoke(
221            &self,
222            _input: Vec<Message>,
223            _config: Option<RunnableConfig>,
224        ) -> Result<LLMResult, Self::Error> {
225            Ok(self.chat(_input, _config).await?)
226        }
227    }
228
229    #[async_trait]
230    impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolCapableMock {
231        fn model_name(&self) -> &str {
232            "mock-tool-capable"
233        }
234
235        fn get_num_tokens(&self, text: &str) -> usize {
236            text.len() / 4
237        }
238
239        fn with_temperature(self, _temp: f32) -> Self
240        where
241            Self: Sized,
242        {
243            self
244        }
245
246        fn with_max_tokens(self, _max: usize) -> Self
247        where
248            Self: Sized,
249        {
250            self
251        }
252    }
253
254    #[async_trait]
255    impl BaseChatModel for ToolCapableMock {
256        async fn chat(
257            &self,
258            _messages: Vec<Message>,
259            _config: Option<RunnableConfig>,
260        ) -> Result<LLMResult, Self::Error> {
261            let tool_calls = self.tools.as_ref().map(|tools| {
262                tools
263                    .iter()
264                    .enumerate()
265                    .map(|(i, t)| {
266                        ToolCall::builder(format!("call_{i}"))
267                            .name(t.function.name.clone())
268                            .arguments("{}".to_string())
269                            .build()
270                    })
271                    .collect()
272            });
273            Ok(LLMResult {
274                content: if tool_calls.is_some() {
275                    String::new()
276                } else {
277                    "plain reply".to_string()
278                },
279                model: "mock-tool-capable".to_string(),
280                token_usage: None,
281                tool_calls,
282                thinking_content: None,
283            })
284        }
285
286        async fn stream_chat(
287            &self,
288            _messages: Vec<Message>,
289            _config: Option<RunnableConfig>,
290        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
291        {
292            unreachable!("stream_chat not exercised in predict_tools tests")
293        }
294
295        fn bind_tools(
296            &self,
297            tools: Vec<ToolDefinition>,
298        ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
299            Some(Box::new(Self { tools: Some(tools) }))
300        }
301    }
302
303    /// Tool-capable model whose `chat` always fails (to exercise the `Chat` variant).
304    #[derive(Debug, Clone)]
305    struct FailingToolModel;
306
307    #[async_trait]
308    impl Runnable<Vec<Message>, LLMResult> for FailingToolModel {
309        type Error = MockError;
310
311        async fn invoke(
312            &self,
313            _input: Vec<Message>,
314            _config: Option<RunnableConfig>,
315        ) -> Result<LLMResult, Self::Error> {
316            Err(MockError("chat failed".to_string()))
317        }
318    }
319
320    #[async_trait]
321    impl BaseLanguageModel<Vec<Message>, LLMResult> for FailingToolModel {
322        fn model_name(&self) -> &str {
323            "mock-failing"
324        }
325
326        fn get_num_tokens(&self, text: &str) -> usize {
327            text.len() / 4
328        }
329
330        fn with_temperature(self, _temp: f32) -> Self
331        where
332            Self: Sized,
333        {
334            self
335        }
336
337        fn with_max_tokens(self, _max: usize) -> Self
338        where
339            Self: Sized,
340        {
341            self
342        }
343    }
344
345    #[async_trait]
346    impl BaseChatModel for FailingToolModel {
347        async fn chat(
348            &self,
349            _messages: Vec<Message>,
350            _config: Option<RunnableConfig>,
351        ) -> Result<LLMResult, Self::Error> {
352            Err(MockError("chat failed".to_string()))
353        }
354
355        async fn stream_chat(
356            &self,
357            _messages: Vec<Message>,
358            _config: Option<RunnableConfig>,
359        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
360        {
361            unreachable!("stream_chat not exercised in predict_tools tests")
362        }
363
364        fn bind_tools(
365            &self,
366            _tools: Vec<ToolDefinition>,
367        ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
368            Some(Box::new(Self))
369        }
370    }
371
372    /// Mock model using the default `bind_tools` (returns `None` — cannot call tools).
373    #[derive(Debug)]
374    struct ToolIncapableMock;
375
376    #[async_trait]
377    impl Runnable<Vec<Message>, LLMResult> for ToolIncapableMock {
378        type Error = MockError;
379
380        async fn invoke(
381            &self,
382            _input: Vec<Message>,
383            _config: Option<RunnableConfig>,
384        ) -> Result<LLMResult, Self::Error> {
385            Ok(LLMResult {
386                content: "plain reply".to_string(),
387                model: "mock-tool-incapable".to_string(),
388                token_usage: None,
389                tool_calls: None,
390                thinking_content: None,
391            })
392        }
393    }
394
395    #[async_trait]
396    impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolIncapableMock {
397        fn model_name(&self) -> &str {
398            "mock-tool-incapable"
399        }
400
401        fn get_num_tokens(&self, text: &str) -> usize {
402            text.len() / 4
403        }
404
405        fn with_temperature(self, _temp: f32) -> Self
406        where
407            Self: Sized,
408        {
409            self
410        }
411
412        fn with_max_tokens(self, _max: usize) -> Self
413        where
414            Self: Sized,
415        {
416            self
417        }
418    }
419
420    #[async_trait]
421    impl BaseChatModel for ToolIncapableMock {
422        async fn chat(
423            &self,
424            _messages: Vec<Message>,
425            _config: Option<RunnableConfig>,
426        ) -> Result<LLMResult, Self::Error> {
427            Ok(LLMResult {
428                content: "plain reply".to_string(),
429                model: "mock-tool-incapable".to_string(),
430                token_usage: None,
431                tool_calls: None,
432                thinking_content: None,
433            })
434        }
435
436        async fn stream_chat(
437            &self,
438            _messages: Vec<Message>,
439            _config: Option<RunnableConfig>,
440        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
441        {
442            let stream = futures_util::stream::once(async move { Ok(StreamChunk::new("plain")) });
443            Ok(Box::pin(stream))
444        }
445    }
446
447    #[derive(Debug)]
448    struct MockError(String);
449
450    impl std::fmt::Display for MockError {
451        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452            write!(f, "MockError: {}", self.0)
453        }
454    }
455
456    impl std::error::Error for MockError {}
457
458    #[tokio::test]
459    async fn predict_tools_binds_tools_and_returns_tool_calls() {
460        let llm = ToolCapableMock::new();
461        let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
462
463        let result = predict_tools(&llm, "weather in beijing?", tools)
464            .await
465            .unwrap();
466
467        let calls = result.tool_calls.expect("tool_calls should be present");
468        assert_eq!(calls.len(), 1);
469        assert_eq!(calls[0].name(), "get_weather");
470    }
471
472    #[tokio::test]
473    async fn predict_tools_returns_clear_error_when_model_cannot_bind() {
474        let llm = ToolIncapableMock;
475        let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
476
477        let err = predict_tools(&llm, "weather in beijing?", tools)
478            .await
479            .unwrap_err();
480
481        assert!(
482            matches!(err, PredictToolsError::ToolsUnsupported),
483            "expected ToolsUnsupported, got {err:?}"
484        );
485    }
486
487    #[tokio::test]
488    async fn predict_tools_propagates_chat_error() {
489        // Tool-capable model failing inside chat: the underlying error is
490        // surfaced via the `Chat` variant, not swallowed.
491        let llm = FailingToolModel;
492        let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
493
494        let err = predict_tools(&llm, "weather in beijing?", tools)
495            .await
496            .unwrap_err();
497
498        assert!(
499            matches!(err, PredictToolsError::Chat(ref e) if e.0 == "chat failed"),
500            "expected Chat(chat failed), got {err:?}"
501        );
502    }
503}