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)]
55pub struct StreamChunk {
56 pub text: String,
58 pub token_usage: Option<TokenUsage>,
61}
62
63impl StreamChunk {
64 pub fn new(text: impl Into<String>) -> Self {
66 Self {
67 text: text.into(),
68 token_usage: None,
69 }
70 }
71}
72
73#[async_trait]
78pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
79 async fn chat(
88 &self,
89 messages: Vec<Message>,
90 config: Option<RunnableConfig>,
91 ) -> Result<LLMResult, Self::Error>;
92
93 async fn stream_chat(
104 &self,
105 messages: Vec<Message>,
106 config: Option<RunnableConfig>,
107 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>;
108
109 async fn chat_with_system(
118 &self,
119 system: String,
120 messages: Vec<Message>,
121 ) -> Result<LLMResult, Self::Error> {
122 let full_messages = vec![Message::system(system)]
123 .into_iter()
124 .chain(messages)
125 .collect();
126
127 self.chat(full_messages, None).await
128 }
129
130 fn bind_tools(
142 &self,
143 _tools: Vec<ToolDefinition>,
144 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
145 None
146 }
147}
148
149#[derive(Debug, thiserror::Error)]
151pub enum PredictToolsError<E>
152where
153 E: std::error::Error + Send + Sync + 'static,
154{
155 #[error("model does not support tool calling (bind_tools returned None); use a tool-capable model or call `chat` directly without tools")]
159 ToolsUnsupported,
160
161 #[error("chat model error: {0}")]
163 Chat(#[source] E),
164}
165
166pub async fn predict_tools<M>(
179 llm: &M,
180 prompt: impl Into<String>,
181 tools: Vec<ToolDefinition>,
182) -> Result<LLMResult, PredictToolsError<M::Error>>
183where
184 M: BaseChatModel + ?Sized,
185{
186 let Some(tool_llm) = llm.bind_tools(tools) else {
187 return Err(PredictToolsError::ToolsUnsupported);
188 };
189 let messages = vec![Message::human(prompt.into())];
190 tool_llm
191 .chat(messages, None)
192 .await
193 .map_err(PredictToolsError::Chat)
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::runnables::Runnable;
200 use futures_util::Stream;
201 use std::pin::Pin;
202
203 #[derive(Debug, Clone)]
206 struct ToolCapableMock {
207 tools: Option<Vec<ToolDefinition>>,
208 }
209
210 impl ToolCapableMock {
211 fn new() -> Self {
212 Self { tools: None }
213 }
214 }
215
216 #[async_trait]
217 impl Runnable<Vec<Message>, LLMResult> for ToolCapableMock {
218 type Error = MockError;
219
220 async fn invoke(
221 &self,
222 _input: Vec<Message>,
223 _config: Option<RunnableConfig>,
224 ) -> Result<LLMResult, Self::Error> {
225 Ok(self.chat(_input, _config).await?)
226 }
227 }
228
229 #[async_trait]
230 impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolCapableMock {
231 fn model_name(&self) -> &str {
232 "mock-tool-capable"
233 }
234
235 fn get_num_tokens(&self, text: &str) -> usize {
236 text.len() / 4
237 }
238
239 fn with_temperature(self, _temp: f32) -> Self
240 where
241 Self: Sized,
242 {
243 self
244 }
245
246 fn with_max_tokens(self, _max: usize) -> Self
247 where
248 Self: Sized,
249 {
250 self
251 }
252 }
253
254 #[async_trait]
255 impl BaseChatModel for ToolCapableMock {
256 async fn chat(
257 &self,
258 _messages: Vec<Message>,
259 _config: Option<RunnableConfig>,
260 ) -> Result<LLMResult, Self::Error> {
261 let tool_calls = self.tools.as_ref().map(|tools| {
262 tools
263 .iter()
264 .enumerate()
265 .map(|(i, t)| {
266 ToolCall::builder(format!("call_{i}"))
267 .name(t.function.name.clone())
268 .arguments("{}".to_string())
269 .build()
270 })
271 .collect()
272 });
273 Ok(LLMResult {
274 content: if tool_calls.is_some() {
275 String::new()
276 } else {
277 "plain reply".to_string()
278 },
279 model: "mock-tool-capable".to_string(),
280 token_usage: None,
281 tool_calls,
282 thinking_content: None,
283 })
284 }
285
286 async fn stream_chat(
287 &self,
288 _messages: Vec<Message>,
289 _config: Option<RunnableConfig>,
290 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
291 {
292 unreachable!("stream_chat not exercised in predict_tools tests")
293 }
294
295 fn bind_tools(
296 &self,
297 tools: Vec<ToolDefinition>,
298 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
299 Some(Box::new(Self { tools: Some(tools) }))
300 }
301 }
302
303 #[derive(Debug, Clone)]
305 struct FailingToolModel;
306
307 #[async_trait]
308 impl Runnable<Vec<Message>, LLMResult> for FailingToolModel {
309 type Error = MockError;
310
311 async fn invoke(
312 &self,
313 _input: Vec<Message>,
314 _config: Option<RunnableConfig>,
315 ) -> Result<LLMResult, Self::Error> {
316 Err(MockError("chat failed".to_string()))
317 }
318 }
319
320 #[async_trait]
321 impl BaseLanguageModel<Vec<Message>, LLMResult> for FailingToolModel {
322 fn model_name(&self) -> &str {
323 "mock-failing"
324 }
325
326 fn get_num_tokens(&self, text: &str) -> usize {
327 text.len() / 4
328 }
329
330 fn with_temperature(self, _temp: f32) -> Self
331 where
332 Self: Sized,
333 {
334 self
335 }
336
337 fn with_max_tokens(self, _max: usize) -> Self
338 where
339 Self: Sized,
340 {
341 self
342 }
343 }
344
345 #[async_trait]
346 impl BaseChatModel for FailingToolModel {
347 async fn chat(
348 &self,
349 _messages: Vec<Message>,
350 _config: Option<RunnableConfig>,
351 ) -> Result<LLMResult, Self::Error> {
352 Err(MockError("chat failed".to_string()))
353 }
354
355 async fn stream_chat(
356 &self,
357 _messages: Vec<Message>,
358 _config: Option<RunnableConfig>,
359 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
360 {
361 unreachable!("stream_chat not exercised in predict_tools tests")
362 }
363
364 fn bind_tools(
365 &self,
366 _tools: Vec<ToolDefinition>,
367 ) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
368 Some(Box::new(Self))
369 }
370 }
371
372 #[derive(Debug)]
374 struct ToolIncapableMock;
375
376 #[async_trait]
377 impl Runnable<Vec<Message>, LLMResult> for ToolIncapableMock {
378 type Error = MockError;
379
380 async fn invoke(
381 &self,
382 _input: Vec<Message>,
383 _config: Option<RunnableConfig>,
384 ) -> Result<LLMResult, Self::Error> {
385 Ok(LLMResult {
386 content: "plain reply".to_string(),
387 model: "mock-tool-incapable".to_string(),
388 token_usage: None,
389 tool_calls: None,
390 thinking_content: None,
391 })
392 }
393 }
394
395 #[async_trait]
396 impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolIncapableMock {
397 fn model_name(&self) -> &str {
398 "mock-tool-incapable"
399 }
400
401 fn get_num_tokens(&self, text: &str) -> usize {
402 text.len() / 4
403 }
404
405 fn with_temperature(self, _temp: f32) -> Self
406 where
407 Self: Sized,
408 {
409 self
410 }
411
412 fn with_max_tokens(self, _max: usize) -> Self
413 where
414 Self: Sized,
415 {
416 self
417 }
418 }
419
420 #[async_trait]
421 impl BaseChatModel for ToolIncapableMock {
422 async fn chat(
423 &self,
424 _messages: Vec<Message>,
425 _config: Option<RunnableConfig>,
426 ) -> Result<LLMResult, Self::Error> {
427 Ok(LLMResult {
428 content: "plain reply".to_string(),
429 model: "mock-tool-incapable".to_string(),
430 token_usage: None,
431 tool_calls: None,
432 thinking_content: None,
433 })
434 }
435
436 async fn stream_chat(
437 &self,
438 _messages: Vec<Message>,
439 _config: Option<RunnableConfig>,
440 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
441 {
442 let stream = futures_util::stream::once(async move { Ok(StreamChunk::new("plain")) });
443 Ok(Box::pin(stream))
444 }
445 }
446
447 #[derive(Debug)]
448 struct MockError(String);
449
450 impl std::fmt::Display for MockError {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 write!(f, "MockError: {}", self.0)
453 }
454 }
455
456 impl std::error::Error for MockError {}
457
458 #[tokio::test]
459 async fn predict_tools_binds_tools_and_returns_tool_calls() {
460 let llm = ToolCapableMock::new();
461 let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
462
463 let result = predict_tools(&llm, "weather in beijing?", tools)
464 .await
465 .unwrap();
466
467 let calls = result.tool_calls.expect("tool_calls should be present");
468 assert_eq!(calls.len(), 1);
469 assert_eq!(calls[0].name(), "get_weather");
470 }
471
472 #[tokio::test]
473 async fn predict_tools_returns_clear_error_when_model_cannot_bind() {
474 let llm = ToolIncapableMock;
475 let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
476
477 let err = predict_tools(&llm, "weather in beijing?", tools)
478 .await
479 .unwrap_err();
480
481 assert!(
482 matches!(err, PredictToolsError::ToolsUnsupported),
483 "expected ToolsUnsupported, got {err:?}"
484 );
485 }
486
487 #[tokio::test]
488 async fn predict_tools_propagates_chat_error() {
489 let llm = FailingToolModel;
492 let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
493
494 let err = predict_tools(&llm, "weather in beijing?", tools)
495 .await
496 .unwrap_err();
497
498 assert!(
499 matches!(err, PredictToolsError::Chat(ref e) if e.0 == "chat failed"),
500 "expected Chat(chat failed), got {err:?}"
501 );
502 }
503}