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