1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct LLMResult {
17 #[serde(default)]
19 pub content: String,
20 #[serde(default)]
22 pub model: String,
23 #[serde(default)]
25 pub token_usage: Option<TokenUsage>,
26 #[serde(default)]
28 pub tool_calls: Option<Vec<ToolCall>>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub thinking_content: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct TokenUsage {
37 pub prompt_tokens: usize,
39
40 pub completion_tokens: usize,
42
43 pub total_tokens: usize,
45}
46
47#[derive(Debug, Clone, Default)]
63pub struct StreamChunk {
64 pub text: String,
66 pub thinking_content: Option<String>,
72 pub token_usage: Option<TokenUsage>,
75 pub tool_calls: Option<Vec<ToolCall>>,
80}
81
82impl StreamChunk {
83 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#[async_trait]
99pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
100 async fn chat(
109 &self,
110 messages: Vec<Message>,
111 config: Option<RunnableConfig>,
112 ) -> Result<LLMResult, Self::Error>;
113
114 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 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 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#[derive(Debug, thiserror::Error)]
172pub enum PredictToolsError<E>
173where
174 E: std::error::Error + Send + Sync + 'static,
175{
176 #[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 #[error("chat model error: {0}")]
184 Chat(#[source] E),
185}
186
187pub 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 #[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 #[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 #[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 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}