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::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    /// Effective Vision support. Missing declarations default to enabled;
273    /// only an explicit false disables it. Never infer from model names.
274    async fn supports_vision(&self, model: &str) -> bool {
275        self.vision_support_override(model).await.unwrap_or(true)
276    }
277
278    /// Explicit per-model policy, used to distinguish a user's override from
279    /// their separately configured legacy image fallback. None is default-on.
280    async fn vision_support_override(&self, _model: &str) -> Option<bool> {
281        None
282    }
283
284    /// Select the provider's callable-catalog policy for one model request.
285    ///
286    /// Providers must opt in explicitly. The default preserves the complete
287    /// legacy function catalog for compatibility endpoints and unknown models.
288    async fn capability_loading_mode(
289        &self,
290        _model: &str,
291        _required_tool: Option<&str>,
292    ) -> CapabilityLoadingMode {
293        CapabilityLoadingMode::LegacyFullCatalog
294    }
295
296    /// Lower the tool definitions visible to the model at this request.
297    ///
298    /// The default matches Bamboo's OpenAI-compatible Chat wire, which is also
299    /// the legacy surface used by generic providers. Native adapters override
300    /// this when their schema shape or deferred-loading protocol differs.
301    async fn provider_visible_tool_footprint(
302        &self,
303        _ir: &PromptIR,
304        tools: &[ToolSchema],
305        _model: &str,
306        _required_tool: Option<&str>,
307    ) -> Result<ProviderVisibleToolFootprint> {
308        if tools.is_empty() {
309            return Ok(ProviderVisibleToolFootprint::default());
310        }
311        let projected = crate::providers::common::openai_compat::tools_to_openai_compat_json(tools);
312        Ok(ProviderVisibleToolFootprint {
313            segments: vec![ProviderVisibleToolSegment::from_serializable(
314                ProviderVisibleToolSegmentKind::InitialFullDefinition,
315                &projected,
316            )?],
317        })
318    }
319
320    /// Stream chat completion from the LLM
321    ///
322    /// This is the primary method for interacting with LLMs, returning
323    /// a stream of response chunks that can be processed incrementally.
324    ///
325    /// # Arguments
326    ///
327    /// * `messages` - Conversation history and current prompt
328    /// * `tools` - Available tools the LLM can call
329    /// * `max_output_tokens` - Optional limit on response length
330    /// * `model` - **Required** model identifier (e.g., "claude-sonnet-4-6")
331    ///
332    /// # Returns
333    ///
334    /// A stream of `LLMChunk` items containing partial responses
335    ///
336    /// # Errors
337    ///
338    /// Returns `LLMError` on network failures, API errors, or invalid requests
339    async fn chat_stream(
340        &self,
341        messages: &[Message],
342        tools: &[ToolSchema],
343        max_output_tokens: Option<u32>,
344        model: &str,
345    ) -> Result<LLMStream>;
346
347    /// Stream chat completion with optional request-level controls.
348    ///
349    /// Default implementation preserves backward compatibility by delegating to
350    /// [`LLMProvider::chat_stream`].
351    async fn chat_stream_with_options(
352        &self,
353        messages: &[Message],
354        tools: &[ToolSchema],
355        max_output_tokens: Option<u32>,
356        model: &str,
357        _options: Option<&LLMRequestOptions>,
358    ) -> Result<LLMStream> {
359        self.chat_stream(messages, tools, max_output_tokens, model)
360            .await
361    }
362
363    /// Stream from the canonical [`PromptIR`] — the single, rich, provider-agnostic
364    /// request the engine emits once per round.
365    ///
366    /// A provider renders the IR into its own wire format by calling the lowering
367    /// methods ([`PromptIR::system_field`], [`PromptIR::body_chat`],
368    /// [`PromptIR::responses_input`], [`PromptIR::continuation_delta`]). The IR
369    /// carries the stateful Responses continuation, so an adapter derives the
370    /// delta itself rather than the engine pre-baking it.
371    ///
372    /// The default implementation lowers the IR for BOTH wire families and
373    /// delegates to [`chat_stream_with_options`](Self::chat_stream_with_options):
374    /// - the flat message list (`continuation_delta` mid-tool-loop, else `flatten`)
375    ///   for the Chat-Completions path;
376    /// - the Responses-API view (`instructions` / `input_messages` /
377    ///   `previous_response_id`) derived via [`PromptIR::responses_request_options`]
378    ///   and merged onto the request POLICY, so a Responses provider works WITHOUT
379    ///   overriding this method (Chat-Completions providers ignore those options).
380    ///
381    /// This is byte-identical to the pre-IR request. Block-native providers (e.g.
382    /// Anthropic) still override this to consume `system_blocks` structurally.
383    async fn chat_stream_ir(
384        &self,
385        ir: &PromptIR,
386        tools: &[ToolSchema],
387        max_output_tokens: Option<u32>,
388        model: &str,
389        options: Option<&LLMRequestOptions>,
390    ) -> Result<LLMStream> {
391        crate::image_budget::validate_tool_image_budget(ir.body_chat_iter())
392            .map_err(LLMError::Api)?;
393        let messages = if ir.continuation.is_some() {
394            ir.continuation_delta()
395        } else {
396            ir.flatten()
397        };
398        let mut effective_options = options.cloned().unwrap_or_default();
399        effective_options.responses =
400            Some(ir.responses_request_options(effective_options.responses.as_ref()));
401        self.chat_stream_with_options(
402            &messages,
403            tools,
404            max_output_tokens,
405            model,
406            Some(&effective_options),
407        )
408        .await
409    }
410
411    /// Lists available models from this provider
412    ///
413    /// Returns a list of model identifiers that can be used with `chat_stream`.
414    /// Default implementation returns an empty list.
415    async fn list_models(&self) -> Result<Vec<String>> {
416        // Default implementation returns empty list
417        Ok(vec![])
418    }
419
420    /// Lists available models with optional token limit metadata.
421    ///
422    /// Default implementation preserves backward compatibility by adapting
423    /// `list_models()` output into metadata entries without limits.
424    async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
425        Ok(self
426            .list_models()
427            .await?
428            .into_iter()
429            .map(ProviderModelInfo::from_id)
430            .collect())
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use std::sync::{Arc, Mutex};
437
438    use async_trait::async_trait;
439    use futures::{stream, StreamExt};
440
441    use super::*;
442
443    #[tokio::test]
444    async fn chat_stream_ir_default_flattens_and_delegates() {
445        use crate::prompt_ir::{PromptIR, Segment, SegmentRole};
446
447        // A provider that captures the message list AND the options it is handed.
448        #[derive(Default)]
449        struct Capture {
450            seen: Arc<Mutex<Vec<Message>>>,
451            seen_responses: Arc<Mutex<Option<crate::provider::ResponsesRequestOptions>>>,
452        }
453        #[async_trait]
454        impl LLMProvider for Capture {
455            async fn chat_stream(
456                &self,
457                _m: &[Message],
458                _t: &[ToolSchema],
459                _mt: Option<u32>,
460                _model: &str,
461            ) -> Result<LLMStream> {
462                unreachable!("default chat_stream_ir must route via chat_stream_with_options")
463            }
464            async fn chat_stream_with_options(
465                &self,
466                messages: &[Message],
467                _t: &[ToolSchema],
468                _mt: Option<u32>,
469                _model: &str,
470                o: Option<&LLMRequestOptions>,
471            ) -> Result<LLMStream> {
472                *self.seen.lock().expect("seen lock") = messages.to_vec();
473                *self.seen_responses.lock().expect("resp lock") =
474                    o.and_then(|value| value.responses.clone());
475                Ok(Box::pin(stream::iter(Vec::<Result<LLMChunk>>::new())))
476            }
477        }
478
479        let cap = Capture::default();
480        assert_eq!(
481            cap.capability_loading_mode("any-model", None).await,
482            CapabilityLoadingMode::LegacyFullCatalog
483        );
484        let ir = PromptIR {
485            system_text: "sys".into(),
486            segments: vec![
487                Segment::new(SegmentRole::StablePrefix, vec![Message::user("guide")]),
488                Segment::new(SegmentRole::DynamicContext, vec![Message::user("dyn")]),
489                Segment::new(SegmentRole::Conversation, vec![Message::user("ask")]),
490            ],
491            ..PromptIR::default()
492        };
493        let _ = cap
494            .chat_stream_ir(&ir, &[], None, "m", None)
495            .await
496            .expect("ir stream");
497
498        let seen = cap.seen.lock().expect("seen lock").clone();
499        let expected = ir.flatten();
500        assert_eq!(seen.len(), expected.len(), "delegates the flattened IR");
501        for (got, want) in seen.iter().zip(expected.iter()) {
502            assert_eq!(got.role, want.role);
503            assert_eq!(got.content, want.content);
504        }
505        // system + guide + dyn + ask
506        assert_eq!(seen.len(), 4);
507        assert!(matches!(seen[0].role, bamboo_domain::Role::System));
508
509        // SAFETY NET: the default also derives the Responses-API view from the IR, so
510        // a Responses provider works without overriding `chat_stream_ir`. instructions
511        // = the (trimmed) system field; input_messages = the full responses_input view
512        // (system lifted out, so it does not lead with a system message).
513        let responses = cap
514            .seen_responses
515            .lock()
516            .expect("resp lock")
517            .clone()
518            .expect("default derives Responses options from the IR");
519        assert_eq!(responses.instructions.as_deref(), Some("sys"));
520        let input = responses.input_messages.expect("input_messages derived");
521        assert_eq!(
522            input.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
523            vec!["guide".to_string(), "dyn".to_string(), "ask".to_string()],
524            "input_messages is the responses_input view: NO leading system message"
525        );
526    }
527
528    #[derive(Clone, Default)]
529    struct RecordingProvider {
530        requested_models: Arc<Mutex<Vec<String>>>,
531        requested_max_tokens: Arc<Mutex<Vec<Option<u32>>>>,
532    }
533
534    #[tokio::test]
535    async fn default_tool_footprint_is_one_compact_openai_compat_position() {
536        let provider = RecordingProvider::default();
537        let tools = vec![ToolSchema {
538            schema_type: "function".to_string(),
539            function: bamboo_domain::FunctionSchema {
540                name: "lookup".to_string(),
541                description: "Look up a value".to_string(),
542                parameters: serde_json::json!({
543                    "type": "object",
544                    "properties": {"key": {"type": "string"}},
545                    "oneOf": [{"required": ["key"]}]
546                }),
547            },
548        }];
549
550        let footprint = provider
551            .provider_visible_tool_footprint(&PromptIR::default(), &tools, "model", None)
552            .await
553            .expect("footprint");
554
555        assert_eq!(footprint.segments.len(), 1);
556        assert_eq!(
557            footprint.segments[0].kind,
558            ProviderVisibleToolSegmentKind::InitialFullDefinition
559        );
560        let rendered: serde_json::Value =
561            serde_json::from_str(&footprint.segments[0].serialized).unwrap();
562        assert_eq!(
563            footprint.segments[0].serialized,
564            serde_json::to_string(&rendered).unwrap()
565        );
566        assert_eq!(rendered[0]["function"]["name"], "lookup");
567        assert!(rendered[0]["function"]["parameters"].get("oneOf").is_none());
568
569        assert_eq!(
570            provider
571                .provider_visible_tool_footprint(&PromptIR::default(), &[], "model", None)
572                .await
573                .unwrap(),
574            ProviderVisibleToolFootprint::default()
575        );
576    }
577
578    #[async_trait]
579    impl LLMProvider for RecordingProvider {
580        async fn chat_stream(
581            &self,
582            _messages: &[Message],
583            _tools: &[ToolSchema],
584            max_output_tokens: Option<u32>,
585            model: &str,
586        ) -> Result<LLMStream> {
587            if let Ok(mut models) = self.requested_models.lock() {
588                models.push(model.to_string());
589            }
590            if let Ok(mut max_tokens) = self.requested_max_tokens.lock() {
591                max_tokens.push(max_output_tokens);
592            }
593
594            Ok(Box::pin(stream::empty()))
595        }
596    }
597
598    #[tokio::test]
599    async fn chat_stream_with_options_delegates_to_chat_stream_with_same_model_and_tokens() {
600        let provider = RecordingProvider::default();
601        let options = LLMRequestOptions::default();
602
603        let mut stream = provider
604            .chat_stream_with_options(&[], &[], Some(512), "gpt-test", Some(&options))
605            .await
606            .expect("delegation should succeed");
607        assert!(stream.next().await.is_none());
608
609        assert_eq!(
610            provider
611                .requested_models
612                .lock()
613                .expect("lock poisoned")
614                .as_slice(),
615            ["gpt-test"]
616        );
617        assert_eq!(
618            provider
619                .requested_max_tokens
620                .lock()
621                .expect("lock poisoned")
622                .as_slice(),
623            [Some(512)]
624        );
625    }
626
627    #[tokio::test]
628    async fn list_models_returns_empty_by_default() {
629        let provider = RecordingProvider::default();
630        let models = provider
631            .list_models()
632            .await
633            .expect("default list_models should succeed");
634        assert!(models.is_empty());
635    }
636
637    #[test]
638    fn request_options_default_has_no_purpose() {
639        let opts = LLMRequestOptions::default();
640        assert!(opts.request_purpose.is_none());
641    }
642
643    #[test]
644    fn request_options_purpose_is_set_and_readable() {
645        let opts = LLMRequestOptions {
646            request_purpose: Some("title_generation".to_string()),
647            ..Default::default()
648        };
649        assert_eq!(opts.request_purpose.as_deref(), Some("title_generation"));
650    }
651}