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}