Skip to main content

lc_chains/
llm_chain.rs

1// lc-chains/src/llm_chain.rs
2//! LLM Chain
3//!
4//! The most basic Chain, combining a Prompt and an LLM.
5
6use async_trait::async_trait;
7use futures_util::StreamExt;
8use lc_callbacks::{RunTree, RunType};
9use lc_core::runnables::RunnableConfig;
10use lc_core::BaseChatModel;
11use lc_providers::{wrap_chat_model, ProviderError};
12use lc_schema::Message;
13use regex::Regex;
14use serde_json::{json, Value};
15use std::collections::HashMap;
16use std::sync::LazyLock;
17
18use crate::base::{BaseChain, ChainError, ChainResult, ChainStream, StreamToken};
19use crate::BoxedChatModel;
20
21/// LLM Chain
22///
23/// Combines a Prompt template and an LLM. The most basic Chain.
24///
25/// # Examples
26/// ```ignore
27/// use lc_chains::LLMChain;
28///
29/// let chain = LLMChain::new(llm, "{question}");
30///
31/// let inputs = HashMap::from([("question".to_string(), "What is Rust?".into())]);
32/// let result = chain.invoke(inputs).await?;
33/// ```
34pub struct LLMChain {
35    /// LLM client.
36    llm: BoxedChatModel,
37
38    /// Prompt template.
39    prompt_template: String,
40
41    /// Input key name.
42    input_key: String,
43
44    /// Output key name.
45    output_key: String,
46
47    /// Chain name.
48    name: String,
49}
50
51/// Pre-compiled regex for detecting unreplaced template variables.
52static TEMPLATE_VAR_RE: LazyLock<Regex> =
53    LazyLock::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());
54
55impl LLMChain {
56    /// Create a new LLMChain.
57    ///
58    /// # Arguments
59    /// * `llm` - LLM client (any type implementing BaseChatModel)
60    /// * `prompt_template` - Prompt template string with {variable} placeholders
61    pub fn new<L>(llm: L, prompt_template: impl Into<String>) -> Self
62    where
63        L: BaseChatModel + Send + Sync + 'static,
64        L::Error: Into<ProviderError>,
65    {
66        Self::from_wrapped(wrap_chat_model(llm), prompt_template)
67    }
68
69    /// Construct from an already-wrapped model (internal builder path).
70    pub(crate) fn from_wrapped(llm: BoxedChatModel, prompt_template: impl Into<String>) -> Self {
71        Self {
72            llm,
73            prompt_template: prompt_template.into(),
74            input_key: "question".to_string(),
75            output_key: "text".to_string(),
76            name: "llm_chain".to_string(),
77        }
78    }
79
80    /// Set input key name.
81    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
82        self.input_key = key.into();
83        self
84    }
85
86    /// Set output key name.
87    pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
88        self.output_key = key.into();
89        self
90    }
91
92    /// Set chain name.
93    pub fn with_name(mut self, name: impl Into<String>) -> Self {
94        self.name = name.into();
95        self
96    }
97
98    /// Render the Prompt template.
99    ///
100    /// Validates that all {variable} placeholders in the template
101    /// have been replaced. Returns an error if any unreplaced placeholders remain.
102    fn render_prompt(&self, inputs: &HashMap<String, Value>) -> Result<String, ChainError> {
103        let mut prompt = self.prompt_template.clone();
104
105        for (key, value) in inputs {
106            let placeholder = format!("{{{}}}", key);
107            let value_str = match value {
108                Value::String(s) => s.clone(),
109                _ => value.to_string(),
110            };
111            prompt = prompt.replace(&placeholder, &value_str);
112        }
113
114        // Check for unreplaced {variable} placeholders
115        let unreplaced: Vec<&str> = TEMPLATE_VAR_RE
116            .captures_iter(&prompt)
117            .filter_map(|c| c.get(1).map(|m| m.as_str()))
118            .collect();
119
120        if !unreplaced.is_empty() {
121            return Err(ChainError::ExecutionError(format!(
122                "Prompt template has unreplaced variable(s): {}",
123                unreplaced.join(", ")
124            )));
125        }
126
127        Ok(prompt)
128    }
129}
130
131#[async_trait]
132impl BaseChain for LLMChain {
133    fn input_keys(&self) -> Vec<&str> {
134        vec![&self.input_key]
135    }
136
137    fn output_keys(&self) -> Vec<&str> {
138        vec![&self.output_key]
139    }
140
141    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
142        self.validate_inputs(&inputs)?;
143
144        let prompt = self.render_prompt(&inputs)?;
145
146        let messages = vec![Message::human(&prompt)];
147        let result = self
148            .llm
149            .invoke(messages, None)
150            .await
151            .map_err(|e| ChainError::ExecutionError(format!("LLM call failed: {}", e)))?;
152
153        let mut output = HashMap::new();
154        output.insert(self.output_key.clone(), Value::String(result.content));
155
156        Ok(output)
157    }
158
159    /// Execute the Chain with callback propagation.
160    ///
161    /// Fires `on_chain_start` → `on_llm_start` → LLM call → `on_llm_end` → `on_chain_end`.
162    /// On error, fires `on_llm_error` / `on_chain_error` instead.
163    async fn invoke_with_config(
164        &self,
165        inputs: HashMap<String, Value>,
166        config: Option<RunnableConfig>,
167    ) -> Result<ChainResult, ChainError> {
168        self.validate_inputs(&inputs)?;
169
170        let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
171
172        // Create root RunTree for this chain invocation
173        let mut run = RunTree::new(self.name(), RunType::Chain, json!({ "inputs": inputs }));
174
175        // on_chain_start
176        if let Some(ref cb) = callbacks {
177            cb.dispatch_chain_start(&run, &run.inputs).await;
178        }
179
180        let prompt = self.render_prompt(&inputs)?;
181        let messages = vec![Message::human(&prompt)];
182
183        // on_llm_start — single child run reused for both on_llm_end and
184        // on_llm_error, so the trace has exactly one LLM node per call
185        // (previously each callback created its own child, producing duplicate runs).
186        let mut llm_run = run.create_child(
187            format!("{}.llm", self.name()),
188            RunType::Llm,
189            json!({"messages_count": messages.len()}),
190        );
191        if let Some(ref cb) = callbacks {
192            cb.dispatch_llm_start(&llm_run, &messages).await;
193        }
194
195        // LLM call with config propagation
196        let llm_config = config.clone();
197        let result = self.llm.invoke(messages, llm_config).await;
198
199        match result {
200            Ok(llm_result) => {
201                // on_llm_end
202                llm_run.end(json!({"response": &llm_result.content}));
203                if let Some(ref cb) = callbacks {
204                    cb.dispatch_llm_end(&llm_run, &llm_result.content).await;
205                }
206
207                let mut output = HashMap::new();
208                output.insert(
209                    self.output_key.clone(),
210                    Value::String(llm_result.content.clone()),
211                );
212
213                run.end(json!({"output": &llm_result.content}));
214
215                // on_chain_end
216                if let Some(ref cb) = callbacks {
217                    cb.dispatch_chain_end(&run, &json!({"output": llm_result.content}))
218                        .await;
219                }
220
221                Ok(output)
222            }
223            Err(e) => {
224                let err_msg = e.to_string();
225
226                // on_llm_error
227                llm_run.end_with_error(err_msg.clone());
228                if let Some(ref cb) = callbacks {
229                    cb.dispatch_llm_error(&llm_run, &err_msg).await;
230                }
231
232                run.end_with_error(err_msg.clone());
233
234                // on_chain_error
235                if let Some(ref cb) = callbacks {
236                    cb.dispatch_chain_error(&run, &err_msg).await;
237                }
238
239                Err(ChainError::ExecutionError(format!(
240                    "LLM call failed: {}",
241                    err_msg
242                )))
243            }
244        }
245    }
246
247    /// Stream execution for LLMChain -- token by token output.
248    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
249        self.validate_inputs(&inputs)?;
250
251        let prompt = self.render_prompt(&inputs)?;
252
253        let messages = vec![Message::human(&prompt)];
254        let llm_stream = self
255            .llm
256            .stream_chat(messages, None)
257            .await
258            .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
259
260        let stream = llm_stream.map(move |result| match result {
261            Ok(chunk) => Ok(StreamToken {
262                token: chunk.text,
263                is_final: false,
264            }),
265            Err(e) => Err(ChainError::StreamError(format!(
266                "Stream token error: {}",
267                e
268            ))),
269        });
270
271        let final_stream = stream.chain(futures_util::stream::once(async move {
272            Ok(StreamToken {
273                token: String::new(),
274                is_final: true,
275            })
276        }));
277
278        Ok(Box::pin(final_stream))
279    }
280
281    fn name(&self) -> &str {
282        &self.name
283    }
284}
285
286/// LLMChain Builder.
287///
288/// Convenience builder for LLMChain.
289pub struct LLMChainBuilder {
290    llm: BoxedChatModel,
291    prompt_template: String,
292    input_key: Option<String>,
293    output_key: Option<String>,
294    name: Option<String>,
295}
296
297impl LLMChainBuilder {
298    /// Create a new [`LLMChainBuilder`] with the given LLM and prompt template.
299    pub fn new<L>(llm: L, prompt_template: impl Into<String>) -> Self
300    where
301        L: BaseChatModel + Send + Sync + 'static,
302        L::Error: Into<ProviderError>,
303    {
304        Self {
305            llm: wrap_chat_model(llm),
306            prompt_template: prompt_template.into(),
307            input_key: None,
308            output_key: None,
309            name: None,
310        }
311    }
312
313    /// Set the input key.
314    pub fn input_key(mut self, key: impl Into<String>) -> Self {
315        self.input_key = Some(key.into());
316        self
317    }
318
319    /// Set the output key.
320    pub fn output_key(mut self, key: impl Into<String>) -> Self {
321        self.output_key = Some(key.into());
322        self
323    }
324
325    /// Set the chain name.
326    pub fn name(mut self, name: impl Into<String>) -> Self {
327        self.name = Some(name.into());
328        self
329    }
330
331    /// Build the final [`LLMChain`].
332    pub fn build(self) -> LLMChain {
333        let mut chain = LLMChain::from_wrapped(self.llm, self.prompt_template);
334
335        if let Some(key) = self.input_key {
336            chain = chain.with_input_key(key);
337        }
338
339        if let Some(key) = self.output_key {
340            chain = chain.with_output_key(key);
341        }
342
343        if let Some(name) = self.name {
344            chain = chain.with_name(name);
345        }
346
347        chain
348    }
349}