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