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::ToolSchema;
10use bamboo_domain::{CapabilityLoadingMode, ReasoningEffort};
11use bamboo_domain::{Message, ModelContextResetReason};
12use futures::Stream;
13use serde::Serialize;
14use std::pin::Pin;
15use thiserror::Error;
16
17/// Errors that can occur when working with LLM providers
18#[derive(Error, Debug)]
19pub enum LLMError {
20 /// HTTP request/response errors
21 #[error("HTTP error: {0}")]
22 Http(#[from] reqwest::Error),
23
24 /// JSON serialization/deserialization errors
25 #[error("JSON error: {0}")]
26 Json(#[from] serde_json::Error),
27
28 /// Streaming response errors
29 #[error("Stream error: {0}")]
30 Stream(String),
31
32 /// LLM API errors (rate limits, invalid requests, etc.)
33 #[error("API error: {0}")]
34 Api(String),
35
36 /// Authentication/authorization errors
37 #[error("Authentication error: {0}")]
38 Auth(String),
39
40 /// Protocol conversion errors
41 #[error("Protocol conversion error: {0}")]
42 Protocol(#[from] crate::protocol::ProtocolError),
43}
44
45/// Convenient result type for LLM operations
46pub type Result<T> = std::result::Result<T, LLMError>;
47
48/// Type alias for boxed streaming LLM responses
49pub type LLMStream = Pin<Box<dyn Stream<Item = Result<LLMChunk>> + Send>>;
50
51/// Why one compact tool-schema segment is visible at its model position.
52///
53/// The footprint deliberately describes already-lowered JSON rather than
54/// estimating tokens in the provider crate. Adjacent top-level definitions are
55/// one segment; provider-inlined definitions later in history are separate
56/// segments because prompt text lies between those positions.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ProviderVisibleToolSegmentKind {
59 /// Complete definitions visible in the request's initial tools position.
60 InitialFullDefinition,
61 /// Name and description retained for one or more OpenAI hosted-search
62 /// functions while their parameter schemas remain deferred.
63 InitialDeferredDescriptor,
64 /// A complete Anthropic definition expanded at a validated tool reference.
65 AnthropicToolReferenceExpansion,
66 /// Empty marker following an initial search-enabled definition array. Later
67 /// definitions are bound by the provider or by transcript items rather than
68 /// duplicated at the initial position.
69 ProviderLateBound,
70}
71
72/// One compact serialized provider-visible schema position.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ProviderVisibleToolSegment {
75 pub kind: ProviderVisibleToolSegmentKind,
76 pub serialized: String,
77}
78
79impl ProviderVisibleToolSegment {
80 pub(crate) fn from_serializable<T: Serialize + ?Sized>(
81 kind: ProviderVisibleToolSegmentKind,
82 value: &T,
83 ) -> Result<Self> {
84 Ok(Self {
85 kind,
86 serialized: serde_json::to_string(value)?,
87 })
88 }
89
90 pub(crate) fn empty_marker(kind: ProviderVisibleToolSegmentKind) -> Self {
91 Self {
92 kind,
93 serialized: String::new(),
94 }
95 }
96}
97
98/// Ordered tool-schema material visible to a provider model for one request.
99///
100/// [`ProviderVisibleToolSegmentKind::ProviderLateBound`] explicitly marks
101/// provider-selected schema material that cannot be known before dispatch. It
102/// is not included in the known local token estimate; provider-reported usage
103/// remains authoritative after the response.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct ProviderVisibleToolFootprint {
106 pub segments: Vec<ProviderVisibleToolSegment>,
107}
108
109/// Metadata for a provider model returned by `list_model_info`.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ProviderModelInfo {
112 /// Model identifier.
113 pub id: String,
114 /// Maximum total context window (input + output) in tokens when known.
115 /// Provider adapters that receive an input-only limit must add the model's
116 /// output capacity before populating this field.
117 pub max_context_tokens: Option<u32>,
118 /// Maximum output/completion tokens when known.
119 pub max_output_tokens: Option<u32>,
120}
121
122impl ProviderModelInfo {
123 /// Create metadata with only model id (no token limits).
124 pub fn from_id(id: impl Into<String>) -> Self {
125 Self {
126 id: id.into(),
127 max_context_tokens: None,
128 max_output_tokens: None,
129 }
130 }
131}
132
133/// Optional request-time controls for provider calls.
134#[derive(Debug, Clone, Default)]
135pub struct ResponsesRequestOptions {
136 /// Optional top-level instructions for Responses API requests.
137 pub instructions: Option<String>,
138 /// Optional message list to serialize into the Responses API `input` array.
139 ///
140 /// When omitted, providers fall back to the generic `messages` slice passed
141 /// to `chat_stream_with_options`. This lets the engine provide a
142 /// Responses-specific input view (for example, without a duplicated stable
143 /// system message) while preserving backward compatibility for non-Responses
144 /// callers and providers.
145 pub input_messages: Option<Vec<Message>>,
146 /// Validated provider-native Responses items, grouped by the ordinary
147 /// assistant message they replace. Internal only; the OpenAI adapter lowers
148 /// these at their anchored `input` position.
149 pub provider_transcript_groups: Vec<bamboo_domain::ProviderTranscriptGroup>,
150 /// Optional reasoning summary control for Responses API requests
151 /// (e.g. "auto", "concise", "detailed").
152 pub reasoning_summary: Option<String>,
153 /// Optional include list for Responses API requests.
154 pub include: Option<Vec<String>>,
155 /// Whether Responses API should store the response server-side.
156 pub store: Option<bool>,
157 /// Optional continuation handle for stateful Responses API turns.
158 pub previous_response_id: Option<String>,
159 /// Optional truncation mode for Responses API requests
160 /// (e.g. "auto", "disabled").
161 pub truncation: Option<String>,
162 /// Optional text verbosity for Responses API requests
163 /// (e.g. "low", "medium", "high").
164 pub text_verbosity: Option<String>,
165 /// Stable affinity key for OpenAI prompt caching. Callers should provide a
166 /// privacy-preserving value. The agent loop supplies a domain-separated hash,
167 /// never its raw session identity, through this generic request DTO.
168 pub prompt_cache_key: Option<String>,
169 /// OpenAI request-wide cache policy (currently `mode` and optional `ttl`).
170 /// Kept as JSON so newly added official policy keys survive proxying.
171 pub prompt_cache_options: Option<serde_json::Value>,
172 /// Original Responses `input` retained by the compatibility endpoint when
173 /// it contains caller-authored explicit cache breakpoints.
174 ///
175 /// The OpenAI Responses adapter may use this instead of the provider-neutral
176 /// message rendering so supported `input_text`, `input_image`, and
177 /// `input_file` markers survive byte-for-byte. Agent/runtime calls leave it
178 /// unset.
179 pub raw_input_with_cache_breakpoints: Option<serde_json::Value>,
180 /// Internal model-context prefix epoch used only for safe wire-shape
181 /// diagnostics. It is never serialized into the upstream request.
182 pub prefix_epoch: Option<u64>,
183 /// Internal, secret-free reset reason paired with `prefix_epoch`.
184 pub prefix_reset_reason: Option<ModelContextResetReason>,
185 /// Retain raw Responses protocol events alongside provider-neutral chunks.
186 ///
187 /// This is an internal compatibility-endpoint control, not an upstream
188 /// request field. Agent/runtime calls leave it disabled to avoid cloning
189 /// every SSE payload when only normalized chunks are needed.
190 pub retain_protocol_events: bool,
191}
192
193/// Optional request-time controls for provider calls.
194#[derive(Debug, Clone, Default)]
195pub struct LLMRequestOptions {
196 /// Session identifier used for request-scoped logging correlation.
197 pub session_id: Option<String>,
198 /// Override reasoning effort for this request.
199 pub reasoning_effort: Option<ReasoningEffort>,
200 /// Request provider-side parallel tool call planning when supported.
201 ///
202 /// - OpenAI/Copilot: maps to `parallel_tool_calls`
203 /// - Anthropic: maps to `tool_choice.disable_parallel_tool_use` (inverse)
204 pub parallel_tool_calls: Option<bool>,
205 /// Require the model to issue this specific tool call when the provider
206 /// supports request-level tool choice. Providers translate this to their
207 /// native forced-function form; `None` preserves normal automatic choice.
208 pub required_tool: Option<String>,
209 /// Responses API specific overrides.
210 pub responses: Option<ResponsesRequestOptions>,
211 /// Purpose of this request for observability (e.g., "agent_loop", "task_evaluation").
212 pub request_purpose: Option<String>,
213 /// Provider-agnostic prompt-cache plan describing the stable, cacheable
214 /// prefix of this request. Providers render it in their own dialect
215 /// (Anthropic `cache_control`; GPT-5.6+ OpenAI Responses explicit content
216 /// breakpoints; automatic caching for providers without explicit support).
217 /// `None` means "no explicit cache hints".
218 pub cache: Option<crate::cache::PromptCachePlan>,
219}
220
221/// Resolve a forced named-tool request and fail before network I/O when the
222/// requested schema is not actually offered to the provider.
223pub(crate) fn required_tool_from_options<'a>(
224 options: Option<&'a LLMRequestOptions>,
225 tools: &[ToolSchema],
226) -> Result<Option<&'a str>> {
227 let Some(name) = options
228 .and_then(|options| options.required_tool.as_deref())
229 .map(str::trim)
230 .filter(|name| !name.is_empty())
231 else {
232 return Ok(None);
233 };
234 if tools.iter().any(|tool| tool.function.name == name) {
235 Ok(Some(name))
236 } else {
237 Err(LLMError::Api(format!(
238 "required tool schema '{name}' was not offered"
239 )))
240 }
241}
242
243/// Trait for LLM provider implementations
244///
245/// This trait defines the interface that all LLM providers must implement
246/// to work with Bamboo's agent system. Providers handle communication with
247/// specific LLM services (OpenAI, Anthropic, local models, etc.).
248///
249/// # Design Principle
250///
251/// The `model` parameter is **required** in `chat_stream`, not optional.
252/// This ensures that the calling code explicitly specifies which model to use,
253/// preventing accidental use of unintended models and making model selection
254/// explicit and auditable.
255///
256/// # Example
257///
258/// ```ignore
259/// use bamboo_agent::agent::llm::provider::LLMProvider;
260///
261/// async fn use_provider(provider: &dyn LLMProvider) {
262/// let stream = provider.chat_stream(
263/// &messages,
264/// &tools,
265/// Some(4096),
266/// "claude-sonnet-4-6", // Model is required
267/// ).await?;
268/// }
269/// ```
270#[async_trait]
271pub trait LLMProvider: Send + Sync {
272 /// Select the provider's callable-catalog policy for one model request.
273 ///
274 /// Providers must opt in explicitly. The default preserves the complete
275 /// legacy function catalog for compatibility endpoints and unknown models.
276 async fn capability_loading_mode(
277 &self,
278 _model: &str,
279 _required_tool: Option<&str>,
280 ) -> CapabilityLoadingMode {
281 CapabilityLoadingMode::LegacyFullCatalog
282 }
283
284 /// Lower the tool definitions visible to the model at this request.
285 ///
286 /// The default matches Bamboo's OpenAI-compatible Chat wire, which is also
287 /// the legacy surface used by generic providers. Native adapters override
288 /// this when their schema shape or deferred-loading protocol differs.
289 async fn provider_visible_tool_footprint(
290 &self,
291 _ir: &PromptIR,
292 tools: &[ToolSchema],
293 _model: &str,
294 _required_tool: Option<&str>,
295 ) -> Result<ProviderVisibleToolFootprint> {
296 if tools.is_empty() {
297 return Ok(ProviderVisibleToolFootprint::default());
298 }
299 let projected = crate::providers::common::openai_compat::tools_to_openai_compat_json(tools);
300 Ok(ProviderVisibleToolFootprint {
301 segments: vec![ProviderVisibleToolSegment::from_serializable(
302 ProviderVisibleToolSegmentKind::InitialFullDefinition,
303 &projected,
304 )?],
305 })
306 }
307
308 /// Stream chat completion from the LLM
309 ///
310 /// This is the primary method for interacting with LLMs, returning
311 /// a stream of response chunks that can be processed incrementally.
312 ///
313 /// # Arguments
314 ///
315 /// * `messages` - Conversation history and current prompt
316 /// * `tools` - Available tools the LLM can call
317 /// * `max_output_tokens` - Optional limit on response length
318 /// * `model` - **Required** model identifier (e.g., "claude-sonnet-4-6")
319 ///
320 /// # Returns
321 ///
322 /// A stream of `LLMChunk` items containing partial responses
323 ///
324 /// # Errors
325 ///
326 /// Returns `LLMError` on network failures, API errors, or invalid requests
327 async fn chat_stream(
328 &self,
329 messages: &[Message],
330 tools: &[ToolSchema],
331 max_output_tokens: Option<u32>,
332 model: &str,
333 ) -> Result<LLMStream>;
334
335 /// Stream chat completion with optional request-level controls.
336 ///
337 /// Default implementation preserves backward compatibility by delegating to
338 /// [`LLMProvider::chat_stream`].
339 async fn chat_stream_with_options(
340 &self,
341 messages: &[Message],
342 tools: &[ToolSchema],
343 max_output_tokens: Option<u32>,
344 model: &str,
345 _options: Option<&LLMRequestOptions>,
346 ) -> Result<LLMStream> {
347 self.chat_stream(messages, tools, max_output_tokens, model)
348 .await
349 }
350
351 /// Stream from the canonical [`PromptIR`] — the single, rich, provider-agnostic
352 /// request the engine emits once per round.
353 ///
354 /// A provider renders the IR into its own wire format by calling the lowering
355 /// methods ([`PromptIR::system_field`], [`PromptIR::body_chat`],
356 /// [`PromptIR::responses_input`], [`PromptIR::continuation_delta`]). The IR
357 /// carries the stateful Responses continuation, so an adapter derives the
358 /// delta itself rather than the engine pre-baking it.
359 ///
360 /// The default implementation lowers the IR for BOTH wire families and
361 /// delegates to [`chat_stream_with_options`](Self::chat_stream_with_options):
362 /// - the flat message list (`continuation_delta` mid-tool-loop, else `flatten`)
363 /// for the Chat-Completions path;
364 /// - the Responses-API view (`instructions` / `input_messages` /
365 /// `previous_response_id`) derived via [`PromptIR::responses_request_options`]
366 /// and merged onto the request POLICY, so a Responses provider works WITHOUT
367 /// overriding this method (Chat-Completions providers ignore those options).
368 ///
369 /// This is byte-identical to the pre-IR request. Block-native providers (e.g.
370 /// Anthropic) still override this to consume `system_blocks` structurally.
371 async fn chat_stream_ir(
372 &self,
373 ir: &PromptIR,
374 tools: &[ToolSchema],
375 max_output_tokens: Option<u32>,
376 model: &str,
377 options: Option<&LLMRequestOptions>,
378 ) -> Result<LLMStream> {
379 let messages = if ir.continuation.is_some() {
380 ir.continuation_delta()
381 } else {
382 ir.flatten()
383 };
384 let mut effective_options = options.cloned().unwrap_or_default();
385 effective_options.responses =
386 Some(ir.responses_request_options(effective_options.responses.as_ref()));
387 self.chat_stream_with_options(
388 &messages,
389 tools,
390 max_output_tokens,
391 model,
392 Some(&effective_options),
393 )
394 .await
395 }
396
397 /// Lists available models from this provider
398 ///
399 /// Returns a list of model identifiers that can be used with `chat_stream`.
400 /// Default implementation returns an empty list.
401 async fn list_models(&self) -> Result<Vec<String>> {
402 // Default implementation returns empty list
403 Ok(vec![])
404 }
405
406 /// Lists available models with optional token limit metadata.
407 ///
408 /// Default implementation preserves backward compatibility by adapting
409 /// `list_models()` output into metadata entries without limits.
410 async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
411 Ok(self
412 .list_models()
413 .await?
414 .into_iter()
415 .map(ProviderModelInfo::from_id)
416 .collect())
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use std::sync::{Arc, Mutex};
423
424 use async_trait::async_trait;
425 use futures::{stream, StreamExt};
426
427 use super::*;
428
429 #[tokio::test]
430 async fn chat_stream_ir_default_flattens_and_delegates() {
431 use crate::prompt_ir::{PromptIR, Segment, SegmentRole};
432
433 // A provider that captures the message list AND the options it is handed.
434 #[derive(Default)]
435 struct Capture {
436 seen: Arc<Mutex<Vec<Message>>>,
437 seen_responses: Arc<Mutex<Option<crate::provider::ResponsesRequestOptions>>>,
438 }
439 #[async_trait]
440 impl LLMProvider for Capture {
441 async fn chat_stream(
442 &self,
443 _m: &[Message],
444 _t: &[ToolSchema],
445 _mt: Option<u32>,
446 _model: &str,
447 ) -> Result<LLMStream> {
448 unreachable!("default chat_stream_ir must route via chat_stream_with_options")
449 }
450 async fn chat_stream_with_options(
451 &self,
452 messages: &[Message],
453 _t: &[ToolSchema],
454 _mt: Option<u32>,
455 _model: &str,
456 o: Option<&LLMRequestOptions>,
457 ) -> Result<LLMStream> {
458 *self.seen.lock().expect("seen lock") = messages.to_vec();
459 *self.seen_responses.lock().expect("resp lock") =
460 o.and_then(|value| value.responses.clone());
461 Ok(Box::pin(stream::iter(Vec::<Result<LLMChunk>>::new())))
462 }
463 }
464
465 let cap = Capture::default();
466 assert_eq!(
467 cap.capability_loading_mode("any-model", None).await,
468 CapabilityLoadingMode::LegacyFullCatalog
469 );
470 let ir = PromptIR {
471 system_text: "sys".into(),
472 segments: vec![
473 Segment::new(SegmentRole::StablePrefix, vec![Message::user("guide")]),
474 Segment::new(SegmentRole::DynamicContext, vec![Message::user("dyn")]),
475 Segment::new(SegmentRole::Conversation, vec![Message::user("ask")]),
476 ],
477 ..PromptIR::default()
478 };
479 let _ = cap
480 .chat_stream_ir(&ir, &[], None, "m", None)
481 .await
482 .expect("ir stream");
483
484 let seen = cap.seen.lock().expect("seen lock").clone();
485 let expected = ir.flatten();
486 assert_eq!(seen.len(), expected.len(), "delegates the flattened IR");
487 for (got, want) in seen.iter().zip(expected.iter()) {
488 assert_eq!(got.role, want.role);
489 assert_eq!(got.content, want.content);
490 }
491 // system + guide + dyn + ask
492 assert_eq!(seen.len(), 4);
493 assert!(matches!(seen[0].role, bamboo_domain::Role::System));
494
495 // SAFETY NET: the default also derives the Responses-API view from the IR, so
496 // a Responses provider works without overriding `chat_stream_ir`. instructions
497 // = the (trimmed) system field; input_messages = the full responses_input view
498 // (system lifted out, so it does not lead with a system message).
499 let responses = cap
500 .seen_responses
501 .lock()
502 .expect("resp lock")
503 .clone()
504 .expect("default derives Responses options from the IR");
505 assert_eq!(responses.instructions.as_deref(), Some("sys"));
506 let input = responses.input_messages.expect("input_messages derived");
507 assert_eq!(
508 input.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
509 vec!["guide".to_string(), "dyn".to_string(), "ask".to_string()],
510 "input_messages is the responses_input view: NO leading system message"
511 );
512 }
513
514 #[derive(Clone, Default)]
515 struct RecordingProvider {
516 requested_models: Arc<Mutex<Vec<String>>>,
517 requested_max_tokens: Arc<Mutex<Vec<Option<u32>>>>,
518 }
519
520 #[tokio::test]
521 async fn default_tool_footprint_is_one_compact_openai_compat_position() {
522 let provider = RecordingProvider::default();
523 let tools = vec![ToolSchema {
524 schema_type: "function".to_string(),
525 function: bamboo_domain::FunctionSchema {
526 name: "lookup".to_string(),
527 description: "Look up a value".to_string(),
528 parameters: serde_json::json!({
529 "type": "object",
530 "properties": {"key": {"type": "string"}},
531 "oneOf": [{"required": ["key"]}]
532 }),
533 },
534 }];
535
536 let footprint = provider
537 .provider_visible_tool_footprint(&PromptIR::default(), &tools, "model", None)
538 .await
539 .expect("footprint");
540
541 assert_eq!(footprint.segments.len(), 1);
542 assert_eq!(
543 footprint.segments[0].kind,
544 ProviderVisibleToolSegmentKind::InitialFullDefinition
545 );
546 let rendered: serde_json::Value =
547 serde_json::from_str(&footprint.segments[0].serialized).unwrap();
548 assert_eq!(
549 footprint.segments[0].serialized,
550 serde_json::to_string(&rendered).unwrap()
551 );
552 assert_eq!(rendered[0]["function"]["name"], "lookup");
553 assert!(rendered[0]["function"]["parameters"].get("oneOf").is_none());
554
555 assert_eq!(
556 provider
557 .provider_visible_tool_footprint(&PromptIR::default(), &[], "model", None)
558 .await
559 .unwrap(),
560 ProviderVisibleToolFootprint::default()
561 );
562 }
563
564 #[async_trait]
565 impl LLMProvider for RecordingProvider {
566 async fn chat_stream(
567 &self,
568 _messages: &[Message],
569 _tools: &[ToolSchema],
570 max_output_tokens: Option<u32>,
571 model: &str,
572 ) -> Result<LLMStream> {
573 if let Ok(mut models) = self.requested_models.lock() {
574 models.push(model.to_string());
575 }
576 if let Ok(mut max_tokens) = self.requested_max_tokens.lock() {
577 max_tokens.push(max_output_tokens);
578 }
579
580 Ok(Box::pin(stream::empty()))
581 }
582 }
583
584 #[tokio::test]
585 async fn chat_stream_with_options_delegates_to_chat_stream_with_same_model_and_tokens() {
586 let provider = RecordingProvider::default();
587 let options = LLMRequestOptions::default();
588
589 let mut stream = provider
590 .chat_stream_with_options(&[], &[], Some(512), "gpt-test", Some(&options))
591 .await
592 .expect("delegation should succeed");
593 assert!(stream.next().await.is_none());
594
595 assert_eq!(
596 provider
597 .requested_models
598 .lock()
599 .expect("lock poisoned")
600 .as_slice(),
601 ["gpt-test"]
602 );
603 assert_eq!(
604 provider
605 .requested_max_tokens
606 .lock()
607 .expect("lock poisoned")
608 .as_slice(),
609 [Some(512)]
610 );
611 }
612
613 #[tokio::test]
614 async fn list_models_returns_empty_by_default() {
615 let provider = RecordingProvider::default();
616 let models = provider
617 .list_models()
618 .await
619 .expect("default list_models should succeed");
620 assert!(models.is_empty());
621 }
622
623 #[test]
624 fn request_options_default_has_no_purpose() {
625 let opts = LLMRequestOptions::default();
626 assert!(opts.request_purpose.is_none());
627 }
628
629 #[test]
630 fn request_options_purpose_is_set_and_readable() {
631 let opts = LLMRequestOptions {
632 request_purpose: Some("title_generation".to_string()),
633 ..Default::default()
634 };
635 assert_eq!(opts.request_purpose.as_deref(), Some("title_generation"));
636 }
637}