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