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