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)]
63pub struct StreamChunk {
64 pub text: String,
66 pub token_usage: Option<TokenUsage>,
69 pub tool_calls: Option<Vec<ToolCall>>,
74}
75
76impl StreamChunk {
77 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#[async_trait]
92pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
93 async fn chat(
102 &self,
103 messages: Vec<Message>,
104 config: Option<RunnableConfig>,
105 ) -> Result<LLMResult, Self::Error>;
106
107 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 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 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#[derive(Debug, thiserror::Error)]
165pub enum PredictToolsError<E>
166where
167 E: std::error::Error + Send + Sync + 'static,
168{
169 #[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 #[error("chat model error: {0}")]
177 Chat(#[source] E),
178}
179
180pub 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 #[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 #[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 #[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 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}