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