use super::BaseLanguageModel;
use crate::tools::ToolDefinition;
use crate::RunnableConfig;
use async_trait::async_trait;
use futures_util::Stream;
use lc_schema::Message;
use lc_shared::tools::ToolCall;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LLMResult {
#[serde(default)]
pub content: String,
#[serde(default)]
pub model: String,
#[serde(default)]
pub token_usage: Option<TokenUsage>,
#[serde(default)]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: usize,
pub completion_tokens: usize,
pub total_tokens: usize,
}
#[derive(Debug, Clone)]
pub struct StreamChunk {
pub text: String,
pub token_usage: Option<TokenUsage>,
}
impl StreamChunk {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
token_usage: None,
}
}
}
#[async_trait]
pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error>;
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>;
async fn chat_with_system(
&self,
system: String,
messages: Vec<Message>,
) -> Result<LLMResult, Self::Error> {
let full_messages = vec![Message::system(system)]
.into_iter()
.chain(messages)
.collect();
self.chat(full_messages, None).await
}
fn bind_tools(
&self,
_tools: Vec<ToolDefinition>,
) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
None
}
}
#[derive(Debug, thiserror::Error)]
pub enum PredictToolsError<E>
where
E: std::error::Error + Send + Sync + 'static,
{
#[error("model does not support tool calling (bind_tools returned None); use a tool-capable model or call `chat` directly without tools")]
ToolsUnsupported,
#[error("chat model error: {0}")]
Chat(#[source] E),
}
pub async fn predict_tools<M>(
llm: &M,
prompt: impl Into<String>,
tools: Vec<ToolDefinition>,
) -> Result<LLMResult, PredictToolsError<M::Error>>
where
M: BaseChatModel + ?Sized,
{
let Some(tool_llm) = llm.bind_tools(tools) else {
return Err(PredictToolsError::ToolsUnsupported);
};
let messages = vec![Message::human(prompt.into())];
tool_llm
.chat(messages, None)
.await
.map_err(PredictToolsError::Chat)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runnables::Runnable;
use futures_util::Stream;
use std::pin::Pin;
#[derive(Debug, Clone)]
struct ToolCapableMock {
tools: Option<Vec<ToolDefinition>>,
}
impl ToolCapableMock {
fn new() -> Self {
Self { tools: None }
}
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for ToolCapableMock {
type Error = MockError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(self.chat(_input, _config).await?)
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolCapableMock {
fn model_name(&self) -> &str {
"mock-tool-capable"
}
fn get_num_tokens(&self, text: &str) -> usize {
text.len() / 4
}
fn with_temperature(self, _temp: f32) -> Self
where
Self: Sized,
{
self
}
fn with_max_tokens(self, _max: usize) -> Self
where
Self: Sized,
{
self
}
}
#[async_trait]
impl BaseChatModel for ToolCapableMock {
async fn chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
let tool_calls = self.tools.as_ref().map(|tools| {
tools
.iter()
.enumerate()
.map(|(i, t)| {
ToolCall::builder(format!("call_{i}"))
.name(t.function.name.clone())
.arguments("{}".to_string())
.build()
})
.collect()
});
Ok(LLMResult {
content: if tool_calls.is_some() {
String::new()
} else {
"plain reply".to_string()
},
model: "mock-tool-capable".to_string(),
token_usage: None,
tool_calls,
thinking_content: None,
})
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
unreachable!("stream_chat not exercised in predict_tools tests")
}
fn bind_tools(
&self,
tools: Vec<ToolDefinition>,
) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
Some(Box::new(Self { tools: Some(tools) }))
}
}
#[derive(Debug, Clone)]
struct FailingToolModel;
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for FailingToolModel {
type Error = MockError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Err(MockError("chat failed".to_string()))
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for FailingToolModel {
fn model_name(&self) -> &str {
"mock-failing"
}
fn get_num_tokens(&self, text: &str) -> usize {
text.len() / 4
}
fn with_temperature(self, _temp: f32) -> Self
where
Self: Sized,
{
self
}
fn with_max_tokens(self, _max: usize) -> Self
where
Self: Sized,
{
self
}
}
#[async_trait]
impl BaseChatModel for FailingToolModel {
async fn chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Err(MockError("chat failed".to_string()))
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
unreachable!("stream_chat not exercised in predict_tools tests")
}
fn bind_tools(
&self,
_tools: Vec<ToolDefinition>,
) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
Some(Box::new(Self))
}
}
#[derive(Debug)]
struct ToolIncapableMock;
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for ToolIncapableMock {
type Error = MockError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(LLMResult {
content: "plain reply".to_string(),
model: "mock-tool-incapable".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
})
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for ToolIncapableMock {
fn model_name(&self) -> &str {
"mock-tool-incapable"
}
fn get_num_tokens(&self, text: &str) -> usize {
text.len() / 4
}
fn with_temperature(self, _temp: f32) -> Self
where
Self: Sized,
{
self
}
fn with_max_tokens(self, _max: usize) -> Self
where
Self: Sized,
{
self
}
}
#[async_trait]
impl BaseChatModel for ToolIncapableMock {
async fn chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(LLMResult {
content: "plain reply".to_string(),
model: "mock-tool-incapable".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
})
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
let stream = futures_util::stream::once(async move { Ok(StreamChunk::new("plain")) });
Ok(Box::pin(stream))
}
}
#[derive(Debug)]
struct MockError(String);
impl std::fmt::Display for MockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MockError: {}", self.0)
}
}
impl std::error::Error for MockError {}
#[tokio::test]
async fn predict_tools_binds_tools_and_returns_tool_calls() {
let llm = ToolCapableMock::new();
let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
let result = predict_tools(&llm, "weather in beijing?", tools)
.await
.unwrap();
let calls = result.tool_calls.expect("tool_calls should be present");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name(), "get_weather");
}
#[tokio::test]
async fn predict_tools_returns_clear_error_when_model_cannot_bind() {
let llm = ToolIncapableMock;
let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
let err = predict_tools(&llm, "weather in beijing?", tools)
.await
.unwrap_err();
assert!(
matches!(err, PredictToolsError::ToolsUnsupported),
"expected ToolsUnsupported, got {err:?}"
);
}
#[tokio::test]
async fn predict_tools_propagates_chat_error() {
let llm = FailingToolModel;
let tools = vec![ToolDefinition::new("get_weather", "Get current weather")];
let err = predict_tools(&llm, "weather in beijing?", tools)
.await
.unwrap_err();
assert!(
matches!(err, PredictToolsError::Chat(ref e) if e.0 == "chat failed"),
"expected Chat(chat failed), got {err:?}"
);
}
}