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