Skip to main content

bamboo_llm/
provider.rs

1//! LLM provider trait and types
2//!
3//! This module defines the interface for LLM (Large Language Model) providers,
4//! enabling support for multiple LLM backends through a common trait.
5
6use crate::prompt_ir::PromptIR;
7use crate::types::LLMChunk;
8use async_trait::async_trait;
9use bamboo_domain::Message;
10use bamboo_domain::ReasoningEffort;
11use bamboo_domain::ToolSchema;
12use futures::Stream;
13use std::pin::Pin;
14use thiserror::Error;
15
16/// Errors that can occur when working with LLM providers
17#[derive(Error, Debug)]
18pub enum LLMError {
19    /// HTTP request/response errors
20    #[error("HTTP error: {0}")]
21    Http(#[from] reqwest::Error),
22
23    /// JSON serialization/deserialization errors
24    #[error("JSON error: {0}")]
25    Json(#[from] serde_json::Error),
26
27    /// Streaming response errors
28    #[error("Stream error: {0}")]
29    Stream(String),
30
31    /// LLM API errors (rate limits, invalid requests, etc.)
32    #[error("API error: {0}")]
33    Api(String),
34
35    /// Authentication/authorization errors
36    #[error("Authentication error: {0}")]
37    Auth(String),
38
39    /// Protocol conversion errors
40    #[error("Protocol conversion error: {0}")]
41    Protocol(#[from] crate::protocol::ProtocolError),
42}
43
44/// Convenient result type for LLM operations
45pub type Result<T> = std::result::Result<T, LLMError>;
46
47/// Type alias for boxed streaming LLM responses
48pub type LLMStream = Pin<Box<dyn Stream<Item = Result<LLMChunk>> + Send>>;
49
50/// Metadata for a provider model returned by `list_model_info`.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ProviderModelInfo {
53    /// Model identifier.
54    pub id: String,
55    /// Maximum context window (input + output) in tokens when known.
56    pub max_context_tokens: Option<u32>,
57    /// Maximum output/completion tokens when known.
58    pub max_output_tokens: Option<u32>,
59}
60
61impl ProviderModelInfo {
62    /// Create metadata with only model id (no token limits).
63    pub fn from_id(id: impl Into<String>) -> Self {
64        Self {
65            id: id.into(),
66            max_context_tokens: None,
67            max_output_tokens: None,
68        }
69    }
70}
71
72/// Optional request-time controls for provider calls.
73#[derive(Debug, Clone, Default)]
74pub struct ResponsesRequestOptions {
75    /// Optional top-level instructions for Responses API requests.
76    pub instructions: Option<String>,
77    /// Optional message list to serialize into the Responses API `input` array.
78    ///
79    /// When omitted, providers fall back to the generic `messages` slice passed
80    /// to `chat_stream_with_options`. This lets the engine provide a
81    /// Responses-specific input view (for example, without a duplicated stable
82    /// system message) while preserving backward compatibility for non-Responses
83    /// callers and providers.
84    pub input_messages: Option<Vec<Message>>,
85    /// Optional reasoning summary control for Responses API requests
86    /// (e.g. "auto", "concise", "detailed").
87    pub reasoning_summary: Option<String>,
88    /// Optional include list for Responses API requests.
89    pub include: Option<Vec<String>>,
90    /// Whether Responses API should store the response server-side.
91    pub store: Option<bool>,
92    /// Optional continuation handle for stateful Responses API turns.
93    pub previous_response_id: Option<String>,
94    /// Optional truncation mode for Responses API requests
95    /// (e.g. "auto", "disabled").
96    pub truncation: Option<String>,
97    /// Optional text verbosity for Responses API requests
98    /// (e.g. "low", "medium", "high").
99    pub text_verbosity: Option<String>,
100}
101
102/// Optional request-time controls for provider calls.
103#[derive(Debug, Clone, Default)]
104pub struct LLMRequestOptions {
105    /// Session identifier used for request-scoped logging correlation.
106    pub session_id: Option<String>,
107    /// Override reasoning effort for this request.
108    pub reasoning_effort: Option<ReasoningEffort>,
109    /// Request provider-side parallel tool call planning when supported.
110    ///
111    /// - OpenAI/Copilot: maps to `parallel_tool_calls`
112    /// - Anthropic: maps to `tool_choice.disable_parallel_tool_use` (inverse)
113    pub parallel_tool_calls: Option<bool>,
114    /// Require the model to issue this specific tool call when the provider
115    /// supports request-level tool choice. Providers translate this to their
116    /// native forced-function form; `None` preserves normal automatic choice.
117    pub required_tool: Option<String>,
118    /// Responses API specific overrides.
119    pub responses: Option<ResponsesRequestOptions>,
120    /// Purpose of this request for observability (e.g., "agent_loop", "task_evaluation").
121    pub request_purpose: Option<String>,
122    /// Provider-agnostic prompt-cache plan describing the stable, cacheable
123    /// prefix of this request. Providers render it in their own dialect
124    /// (Anthropic `cache_control` breakpoints; OpenAI/Gemini rely on the stable
125    /// prefix automatically). `None` means "no explicit cache hints".
126    pub cache: Option<crate::cache::PromptCachePlan>,
127}
128
129/// Resolve a forced named-tool request and fail before network I/O when the
130/// requested schema is not actually offered to the provider.
131pub(crate) fn required_tool_from_options<'a>(
132    options: Option<&'a LLMRequestOptions>,
133    tools: &[ToolSchema],
134) -> Result<Option<&'a str>> {
135    let Some(name) = options
136        .and_then(|options| options.required_tool.as_deref())
137        .map(str::trim)
138        .filter(|name| !name.is_empty())
139    else {
140        return Ok(None);
141    };
142    if tools.iter().any(|tool| tool.function.name == name) {
143        Ok(Some(name))
144    } else {
145        Err(LLMError::Api(format!(
146            "required tool schema '{name}' was not offered"
147        )))
148    }
149}
150
151/// Trait for LLM provider implementations
152///
153/// This trait defines the interface that all LLM providers must implement
154/// to work with Bamboo's agent system. Providers handle communication with
155/// specific LLM services (OpenAI, Anthropic, local models, etc.).
156///
157/// # Design Principle
158///
159/// The `model` parameter is **required** in `chat_stream`, not optional.
160/// This ensures that the calling code explicitly specifies which model to use,
161/// preventing accidental use of unintended models and making model selection
162/// explicit and auditable.
163///
164/// # Example
165///
166/// ```ignore
167/// use bamboo_agent::agent::llm::provider::LLMProvider;
168///
169/// async fn use_provider(provider: &dyn LLMProvider) {
170///     let stream = provider.chat_stream(
171///         &messages,
172///         &tools,
173///         Some(4096),
174///         "claude-sonnet-4-6", // Model is required
175///     ).await?;
176/// }
177/// ```
178#[async_trait]
179pub trait LLMProvider: Send + Sync {
180    /// Stream chat completion from the LLM
181    ///
182    /// This is the primary method for interacting with LLMs, returning
183    /// a stream of response chunks that can be processed incrementally.
184    ///
185    /// # Arguments
186    ///
187    /// * `messages` - Conversation history and current prompt
188    /// * `tools` - Available tools the LLM can call
189    /// * `max_output_tokens` - Optional limit on response length
190    /// * `model` - **Required** model identifier (e.g., "claude-sonnet-4-6")
191    ///
192    /// # Returns
193    ///
194    /// A stream of `LLMChunk` items containing partial responses
195    ///
196    /// # Errors
197    ///
198    /// Returns `LLMError` on network failures, API errors, or invalid requests
199    async fn chat_stream(
200        &self,
201        messages: &[Message],
202        tools: &[ToolSchema],
203        max_output_tokens: Option<u32>,
204        model: &str,
205    ) -> Result<LLMStream>;
206
207    /// Stream chat completion with optional request-level controls.
208    ///
209    /// Default implementation preserves backward compatibility by delegating to
210    /// [`LLMProvider::chat_stream`].
211    async fn chat_stream_with_options(
212        &self,
213        messages: &[Message],
214        tools: &[ToolSchema],
215        max_output_tokens: Option<u32>,
216        model: &str,
217        _options: Option<&LLMRequestOptions>,
218    ) -> Result<LLMStream> {
219        self.chat_stream(messages, tools, max_output_tokens, model)
220            .await
221    }
222
223    /// Stream from the canonical [`PromptIR`] — the single, rich, provider-agnostic
224    /// request the engine emits once per round.
225    ///
226    /// A provider renders the IR into its own wire format by calling the lowering
227    /// methods ([`PromptIR::system_field`], [`PromptIR::body_chat`],
228    /// [`PromptIR::responses_input`], [`PromptIR::continuation_delta`]). The IR
229    /// carries the stateful Responses continuation, so an adapter derives the
230    /// delta itself rather than the engine pre-baking it.
231    ///
232    /// The default implementation lowers the IR for BOTH wire families and
233    /// delegates to [`chat_stream_with_options`](Self::chat_stream_with_options):
234    /// - the flat message list (`continuation_delta` mid-tool-loop, else `flatten`)
235    ///   for the Chat-Completions path;
236    /// - the Responses-API view (`instructions` / `input_messages` /
237    ///   `previous_response_id`) derived via [`PromptIR::responses_request_options`]
238    ///   and merged onto the request POLICY, so a Responses provider works WITHOUT
239    ///   overriding this method (Chat-Completions providers ignore those options).
240    ///
241    /// This is byte-identical to the pre-IR request. Block-native providers (e.g.
242    /// Anthropic) still override this to consume `system_blocks` structurally.
243    async fn chat_stream_ir(
244        &self,
245        ir: &PromptIR,
246        tools: &[ToolSchema],
247        max_output_tokens: Option<u32>,
248        model: &str,
249        options: Option<&LLMRequestOptions>,
250    ) -> Result<LLMStream> {
251        let messages = if ir.continuation.is_some() {
252            ir.continuation_delta()
253        } else {
254            ir.flatten()
255        };
256        let mut effective_options = options.cloned().unwrap_or_default();
257        effective_options.responses =
258            Some(ir.responses_request_options(effective_options.responses.as_ref()));
259        self.chat_stream_with_options(
260            &messages,
261            tools,
262            max_output_tokens,
263            model,
264            Some(&effective_options),
265        )
266        .await
267    }
268
269    /// Lists available models from this provider
270    ///
271    /// Returns a list of model identifiers that can be used with `chat_stream`.
272    /// Default implementation returns an empty list.
273    async fn list_models(&self) -> Result<Vec<String>> {
274        // Default implementation returns empty list
275        Ok(vec![])
276    }
277
278    /// Lists available models with optional token limit metadata.
279    ///
280    /// Default implementation preserves backward compatibility by adapting
281    /// `list_models()` output into metadata entries without limits.
282    async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
283        Ok(self
284            .list_models()
285            .await?
286            .into_iter()
287            .map(ProviderModelInfo::from_id)
288            .collect())
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::sync::{Arc, Mutex};
295
296    use async_trait::async_trait;
297    use futures::{stream, StreamExt};
298
299    use super::*;
300
301    #[tokio::test]
302    async fn chat_stream_ir_default_flattens_and_delegates() {
303        use crate::prompt_ir::{PromptIR, Segment, SegmentRole};
304
305        // A provider that captures the message list AND the options it is handed.
306        #[derive(Default)]
307        struct Capture {
308            seen: Arc<Mutex<Vec<Message>>>,
309            seen_responses: Arc<Mutex<Option<crate::provider::ResponsesRequestOptions>>>,
310        }
311        #[async_trait]
312        impl LLMProvider for Capture {
313            async fn chat_stream(
314                &self,
315                _m: &[Message],
316                _t: &[ToolSchema],
317                _mt: Option<u32>,
318                _model: &str,
319            ) -> Result<LLMStream> {
320                unreachable!("default chat_stream_ir must route via chat_stream_with_options")
321            }
322            async fn chat_stream_with_options(
323                &self,
324                messages: &[Message],
325                _t: &[ToolSchema],
326                _mt: Option<u32>,
327                _model: &str,
328                o: Option<&LLMRequestOptions>,
329            ) -> Result<LLMStream> {
330                *self.seen.lock().expect("seen lock") = messages.to_vec();
331                *self.seen_responses.lock().expect("resp lock") =
332                    o.and_then(|value| value.responses.clone());
333                Ok(Box::pin(stream::iter(Vec::<Result<LLMChunk>>::new())))
334            }
335        }
336
337        let cap = Capture::default();
338        let ir = PromptIR {
339            system_text: "sys".into(),
340            segments: vec![
341                Segment::new(SegmentRole::StablePrefix, vec![Message::user("guide")]),
342                Segment::new(SegmentRole::DynamicContext, vec![Message::user("dyn")]),
343                Segment::new(SegmentRole::Conversation, vec![Message::user("ask")]),
344            ],
345            ..PromptIR::default()
346        };
347        let _ = cap
348            .chat_stream_ir(&ir, &[], None, "m", None)
349            .await
350            .expect("ir stream");
351
352        let seen = cap.seen.lock().expect("seen lock").clone();
353        let expected = ir.flatten();
354        assert_eq!(seen.len(), expected.len(), "delegates the flattened IR");
355        for (got, want) in seen.iter().zip(expected.iter()) {
356            assert_eq!(got.role, want.role);
357            assert_eq!(got.content, want.content);
358        }
359        // system + guide + dyn + ask
360        assert_eq!(seen.len(), 4);
361        assert!(matches!(seen[0].role, bamboo_domain::Role::System));
362
363        // SAFETY NET: the default also derives the Responses-API view from the IR, so
364        // a Responses provider works without overriding `chat_stream_ir`. instructions
365        // = the (trimmed) system field; input_messages = the full responses_input view
366        // (system lifted out, so it does not lead with a system message).
367        let responses = cap
368            .seen_responses
369            .lock()
370            .expect("resp lock")
371            .clone()
372            .expect("default derives Responses options from the IR");
373        assert_eq!(responses.instructions.as_deref(), Some("sys"));
374        let input = responses.input_messages.expect("input_messages derived");
375        assert_eq!(
376            input.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
377            vec!["guide".to_string(), "dyn".to_string(), "ask".to_string()],
378            "input_messages is the responses_input view: NO leading system message"
379        );
380    }
381
382    #[derive(Clone, Default)]
383    struct RecordingProvider {
384        requested_models: Arc<Mutex<Vec<String>>>,
385        requested_max_tokens: Arc<Mutex<Vec<Option<u32>>>>,
386    }
387
388    #[async_trait]
389    impl LLMProvider for RecordingProvider {
390        async fn chat_stream(
391            &self,
392            _messages: &[Message],
393            _tools: &[ToolSchema],
394            max_output_tokens: Option<u32>,
395            model: &str,
396        ) -> Result<LLMStream> {
397            if let Ok(mut models) = self.requested_models.lock() {
398                models.push(model.to_string());
399            }
400            if let Ok(mut max_tokens) = self.requested_max_tokens.lock() {
401                max_tokens.push(max_output_tokens);
402            }
403
404            Ok(Box::pin(stream::empty()))
405        }
406    }
407
408    #[tokio::test]
409    async fn chat_stream_with_options_delegates_to_chat_stream_with_same_model_and_tokens() {
410        let provider = RecordingProvider::default();
411        let options = LLMRequestOptions::default();
412
413        let mut stream = provider
414            .chat_stream_with_options(&[], &[], Some(512), "gpt-test", Some(&options))
415            .await
416            .expect("delegation should succeed");
417        assert!(stream.next().await.is_none());
418
419        assert_eq!(
420            provider
421                .requested_models
422                .lock()
423                .expect("lock poisoned")
424                .as_slice(),
425            ["gpt-test"]
426        );
427        assert_eq!(
428            provider
429                .requested_max_tokens
430                .lock()
431                .expect("lock poisoned")
432                .as_slice(),
433            [Some(512)]
434        );
435    }
436
437    #[tokio::test]
438    async fn list_models_returns_empty_by_default() {
439        let provider = RecordingProvider::default();
440        let models = provider
441            .list_models()
442            .await
443            .expect("default list_models should succeed");
444        assert!(models.is_empty());
445    }
446
447    #[test]
448    fn request_options_default_has_no_purpose() {
449        let opts = LLMRequestOptions::default();
450        assert!(opts.request_purpose.is_none());
451    }
452
453    #[test]
454    fn request_options_purpose_is_set_and_readable() {
455        let opts = LLMRequestOptions {
456            request_purpose: Some("title_generation".to_string()),
457            ..Default::default()
458        };
459        assert_eq!(opts.request_purpose.as_deref(), Some("title_generation"));
460    }
461}