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 serde_json::{json, Value};
14use std::collections::HashMap;
15
16use crate::base::{
17    stream_chain_with_callbacks, substitute_template, BaseChain, ChainError, ChainResult,
18    ChainStream, StreamToken,
19};
20use crate::BoxedChatModel;
21
22/// LLM Chain
23///
24/// Combines a Prompt template and an LLM. The most basic Chain.
25///
26/// # Examples
27/// ```ignore
28/// use lc_chains::LLMChain;
29///
30/// let chain = LLMChain::new(llm, "{question}");
31///
32/// let inputs = HashMap::from([("question".to_string(), "What is Rust?".into())]);
33/// let result = chain.invoke(inputs).await?;
34/// ```
35pub struct LLMChain {
36    /// LLM client.
37    llm: BoxedChatModel,
38
39    /// Prompt template.
40    prompt_template: String,
41
42    /// Input key name.
43    input_key: String,
44
45    /// Output key name.
46    output_key: String,
47
48    /// Chain name.
49    name: String,
50}
51
52impl LLMChain {
53    /// Shared streaming body used by `stream` (config-less) and
54    /// `stream_with_config` (config threaded into the LLM stream).
55    async fn stream_body(
56        &self,
57        inputs: HashMap<String, Value>,
58        config: Option<RunnableConfig>,
59    ) -> Result<ChainStream, ChainError> {
60        self.validate_inputs(&inputs)?;
61        if config.as_ref().is_some_and(|c| c.is_cancelled()) {
62            return Err(ChainError::StreamError("Operation cancelled".to_string()));
63        }
64        let prompt = self.render_prompt(&inputs)?;
65        let messages = vec![Message::human(&prompt)];
66        let llm_stream = self
67            .llm
68            .stream_chat(messages, config)
69            .await
70            .map_err(|e| ChainError::StreamError(format!("LLM stream failed: {}", e)))?;
71        let stream = llm_stream.map(move |result| match result {
72            Ok(chunk) => Ok(StreamToken {
73                token: chunk.text,
74                is_final: false,
75            }),
76            Err(e) => Err(ChainError::StreamError(format!(
77                "Stream token error: {}",
78                e
79            ))),
80        });
81        let final_stream = stream.chain(futures_util::stream::once(async move {
82            Ok(StreamToken {
83                token: String::new(),
84                is_final: true,
85            })
86        }));
87        Ok(Box::pin(final_stream))
88    }
89
90    /// Create a new LLMChain.
91    ///
92    /// # Arguments
93    /// * `llm` - LLM client (any type implementing BaseChatModel)
94    /// * `prompt_template` - Prompt template string with {variable} placeholders
95    pub fn new<L>(llm: L, prompt_template: impl Into<String>) -> Self
96    where
97        L: BaseChatModel + Send + Sync + 'static,
98        L::Error: Into<ProviderError>,
99    {
100        Self::from_wrapped(wrap_chat_model(llm), prompt_template)
101    }
102
103    /// Construct from an already-wrapped model (internal builder path).
104    pub(crate) fn from_wrapped(llm: BoxedChatModel, prompt_template: impl Into<String>) -> Self {
105        Self {
106            llm,
107            prompt_template: prompt_template.into(),
108            input_key: "question".to_string(),
109            output_key: "text".to_string(),
110            name: "llm_chain".to_string(),
111        }
112    }
113
114    /// Set input key name.
115    pub fn with_input_key(mut self, key: impl Into<String>) -> Self {
116        self.input_key = key.into();
117        self
118    }
119
120    /// Set output key name.
121    pub fn with_output_key(mut self, key: impl Into<String>) -> Self {
122        self.output_key = key.into();
123        self
124    }
125
126    /// Set chain name.
127    pub fn with_name(mut self, name: impl Into<String>) -> Self {
128        self.name = name.into();
129        self
130    }
131
132    /// Render the Prompt template.
133    ///
134    /// 0.22.0 audit fix (H-C1): single-pass tokenized replacement mirroring
135    /// `lc-prompts` semantics — values are never rescanned (no re-replacement
136    /// injection), CJK variable names are recognized, and `{{`/`}}` escape to
137    /// literal braces. Missing variables are an error (like lc-prompts), and
138    /// ALL of them are reported in one message.
139    fn render_prompt(&self, inputs: &HashMap<String, Value>) -> Result<String, ChainError> {
140        let mut vars = HashMap::with_capacity(inputs.len());
141        for (key, value) in inputs {
142            let value_str = match value {
143                Value::String(s) => s.clone(),
144                _ => value.to_string(),
145            };
146            vars.insert(key.clone(), value_str);
147        }
148
149        let (prompt, missing) = substitute_template(&self.prompt_template, &vars);
150
151        if !missing.is_empty() {
152            return Err(ChainError::ExecutionError(format!(
153                "Prompt template has unreplaced variable(s): {}",
154                missing.join(", ")
155            )));
156        }
157
158        Ok(prompt)
159    }
160}
161
162#[async_trait]
163impl BaseChain for LLMChain {
164    fn input_keys(&self) -> Vec<&str> {
165        vec![&self.input_key]
166    }
167
168    fn output_keys(&self) -> Vec<&str> {
169        vec![&self.output_key]
170    }
171
172    async fn invoke(&self, inputs: HashMap<String, Value>) -> Result<ChainResult, ChainError> {
173        self.validate_inputs(&inputs)?;
174
175        let prompt = self.render_prompt(&inputs)?;
176
177        let messages = vec![Message::human(&prompt)];
178        let result = self
179            .llm
180            .invoke(messages, None)
181            .await
182            .map_err(|e| ChainError::ExecutionError(format!("LLM call failed: {}", e)))?;
183
184        let mut output = HashMap::new();
185        output.insert(self.output_key.clone(), Value::String(result.content));
186
187        Ok(output)
188    }
189
190    /// Execute the Chain with callback propagation.
191    ///
192    /// Fires `on_chain_start` → `on_llm_start` → LLM call → `on_llm_end` → `on_chain_end`.
193    /// On error, fires `on_llm_error` / `on_chain_error` instead.
194    async fn invoke_with_config(
195        &self,
196        inputs: HashMap<String, Value>,
197        config: Option<RunnableConfig>,
198    ) -> Result<ChainResult, ChainError> {
199        self.validate_inputs(&inputs)?;
200
201        let callbacks = config.as_ref().and_then(|c| c.callbacks.clone());
202
203        // Create root RunTree for this chain invocation
204        let mut run = RunTree::new(self.name(), RunType::Chain, json!({ "inputs": inputs }));
205
206        // on_chain_start
207        if let Some(ref cb) = callbacks {
208            cb.dispatch_chain_start(&run, &run.inputs).await;
209        }
210
211        // 0.22.0 audit fix (H-C5): a render_prompt failure used to `?`-return
212        // without ending the run or firing on_chain_error, leaking the run in
213        // the observability run tree. End the run and dispatch the error
214        // callback, matching the other error paths below.
215        let prompt = match self.render_prompt(&inputs) {
216            Ok(p) => p,
217            Err(e) => {
218                let msg = e.to_string();
219                run.end_with_error(msg.clone());
220                if let Some(ref cb) = callbacks {
221                    cb.dispatch_chain_error(&run, &msg).await;
222                }
223                return Err(e);
224            }
225        };
226        let messages = vec![Message::human(&prompt)];
227
228        // on_llm_start — single child run reused for both on_llm_end and
229        // on_llm_error, so the trace has exactly one LLM node per call
230        // (previously each callback created its own child, producing duplicate runs).
231        let mut llm_run = run.create_child(
232            format!("{}.llm", self.name()),
233            RunType::Llm,
234            json!({"messages_count": messages.len()}),
235        );
236        if let Some(ref cb) = callbacks {
237            cb.dispatch_llm_start(&llm_run, &messages).await;
238        }
239
240        // LLM call with config propagation
241        let llm_config = config.clone();
242        let result = self.llm.invoke(messages, llm_config).await;
243
244        match result {
245            Ok(llm_result) => {
246                // on_llm_end
247                llm_run.end(json!({"response": &llm_result.content}));
248                if let Some(ref cb) = callbacks {
249                    cb.dispatch_llm_end(&llm_run, &llm_result.content).await;
250                }
251
252                let mut output = HashMap::new();
253                output.insert(
254                    self.output_key.clone(),
255                    Value::String(llm_result.content.clone()),
256                );
257
258                run.end(json!({"output": &llm_result.content}));
259
260                // on_chain_end
261                if let Some(ref cb) = callbacks {
262                    cb.dispatch_chain_end(&run, &json!({"output": llm_result.content}))
263                        .await;
264                }
265
266                Ok(output)
267            }
268            Err(e) => {
269                let err_msg = e.to_string();
270
271                // on_llm_error
272                llm_run.end_with_error(err_msg.clone());
273                if let Some(ref cb) = callbacks {
274                    cb.dispatch_llm_error(&llm_run, &err_msg).await;
275                }
276
277                run.end_with_error(err_msg.clone());
278
279                // on_chain_error
280                if let Some(ref cb) = callbacks {
281                    cb.dispatch_chain_error(&run, &err_msg).await;
282                }
283
284                Err(ChainError::ExecutionError(format!(
285                    "LLM call failed: {}",
286                    err_msg
287                )))
288            }
289        }
290    }
291
292    /// Stream execution for LLMChain -- token by token output.
293    async fn stream(&self, inputs: HashMap<String, Value>) -> Result<ChainStream, ChainError> {
294        self.stream_body(inputs, None).await
295    }
296
297    /// Stream with config propagation.
298    ///
299    /// 0.22.0 audit fix (H-C3): the chain's `RunnableConfig` is threaded into
300    /// `stream_chat`, so sampling overrides / cancellation token / callbacks
301    /// reach the provider (providers consume config via `apply_overrides`)
302    /// instead of every streaming call being hardwired to `None`.
303    async fn stream_with_config(
304        &self,
305        inputs: HashMap<String, Value>,
306        config: Option<RunnableConfig>,
307    ) -> Result<ChainStream, ChainError> {
308        let output_key = Some(self.output_key.clone());
309        stream_chain_with_callbacks(
310            self.name(),
311            inputs,
312            config.clone(),
313            output_key,
314            |inputs| async move { self.stream_body(inputs, config).await },
315        )
316        .await
317    }
318
319    fn name(&self) -> &str {
320        &self.name
321    }
322}
323
324/// LLMChain Builder.
325///
326/// Convenience builder for LLMChain.
327pub struct LLMChainBuilder {
328    llm: BoxedChatModel,
329    prompt_template: String,
330    input_key: Option<String>,
331    output_key: Option<String>,
332    name: Option<String>,
333}
334
335impl LLMChainBuilder {
336    /// Create a new [`LLMChainBuilder`] with the given LLM and prompt template.
337    pub fn new<L>(llm: L, prompt_template: impl Into<String>) -> Self
338    where
339        L: BaseChatModel + Send + Sync + 'static,
340        L::Error: Into<ProviderError>,
341    {
342        Self {
343            llm: wrap_chat_model(llm),
344            prompt_template: prompt_template.into(),
345            input_key: None,
346            output_key: None,
347            name: None,
348        }
349    }
350
351    /// Set the input key.
352    pub fn input_key(mut self, key: impl Into<String>) -> Self {
353        self.input_key = Some(key.into());
354        self
355    }
356
357    /// Set the output key.
358    pub fn output_key(mut self, key: impl Into<String>) -> Self {
359        self.output_key = Some(key.into());
360        self
361    }
362
363    /// Set the chain name.
364    pub fn name(mut self, name: impl Into<String>) -> Self {
365        self.name = Some(name.into());
366        self
367    }
368
369    /// Build the final [`LLMChain`].
370    pub fn build(self) -> LLMChain {
371        let mut chain = LLMChain::from_wrapped(self.llm, self.prompt_template);
372
373        if let Some(key) = self.input_key {
374            chain = chain.with_input_key(key);
375        }
376
377        if let Some(key) = self.output_key {
378            chain = chain.with_output_key(key);
379        }
380
381        if let Some(name) = self.name {
382            chain = chain.with_name(name);
383        }
384
385        chain
386    }
387}