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::future::Future;
15use std::pin::Pin;
16use std::sync::Arc;
17use thiserror::Error;
18
19/// Observes the concrete provider selected for one scoped main dispatch.
20/// Observation does not authorize the provider or prove a managed origin.
21#[doc(hidden)]
22pub type MainDispatchObserver = Arc<dyn Fn(Arc<dyn LLMProvider>) + Send + Sync>;
23
24tokio::task_local! {
25    static MAIN_DISPATCH_OBSERVER: MainDispatchObserver;
26}
27
28/// Scope one actual main dispatch attempt. The observer is not inherited by
29/// spawned tasks; a Host caller must reject a missing receipt rather than
30/// inferring an unmanaged route. Nested scopes restore their outer observer.
31#[doc(hidden)]
32pub async fn scope_main_dispatch<F: Future>(
33    observer: MainDispatchObserver,
34    future: F,
35) -> F::Output {
36    MAIN_DISPATCH_OBSERVER.scope(observer, future).await
37}
38
39/// Report the exact Arc selected by a forwarding boundary. Returns false if
40/// no observer was scoped in the current task; ordinary SDK calls keep their
41/// existing behavior and the Host owns the missing-receipt failure policy.
42#[doc(hidden)]
43pub fn observe_main_dispatch(selected: Arc<dyn LLMProvider>) -> bool {
44    MAIN_DISPATCH_OBSERVER
45        .try_with(|observer| observer(selected))
46        .is_ok()
47}
48
49/// Allocation identity for pairing delayed requests. Dynamic credentials
50/// inside an existing provider remain the provider owner’s responsibility.
51#[doc(hidden)]
52#[derive(Clone)]
53pub enum QueuedRouteIdentity {
54    Stable(Arc<dyn LLMProvider>),
55    Unmanaged,
56}
57
58/// Errors that can occur when working with LLM providers
59#[derive(Error, Debug)]
60pub enum LLMError {
61    /// HTTP request/response errors
62    #[error("HTTP error: {0}")]
63    Http(#[from] reqwest::Error),
64
65    /// JSON serialization/deserialization errors
66    #[error("JSON error: {0}")]
67    Json(#[from] serde_json::Error),
68
69    /// Streaming response errors
70    #[error("Stream error: {0}")]
71    Stream(String),
72
73    /// LLM API errors (rate limits, invalid requests, etc.)
74    #[error("API error: {0}")]
75    Api(String),
76
77    /// Authentication/authorization errors
78    #[error("Authentication error: {0}")]
79    Auth(String),
80
81    /// The provider already applied its bounded request policy. Callers must
82    /// retain this terminal boundary instead of retrying the entire agent turn.
83    #[error("{0}")]
84    RequestFailed(String),
85
86    /// Protocol conversion errors
87    #[error("Protocol conversion error: {0}")]
88    Protocol(#[from] crate::protocol::ProtocolError),
89}
90
91/// Convenient result type for LLM operations
92pub type Result<T> = std::result::Result<T, LLMError>;
93
94/// Type alias for boxed streaming LLM responses
95pub type LLMStream = Pin<Box<dyn Stream<Item = Result<LLMChunk>> + Send>>;
96
97/// Why one compact tool-schema segment is visible at its model position.
98///
99/// The footprint deliberately describes already-lowered JSON rather than
100/// estimating tokens in the provider crate. Adjacent top-level definitions are
101/// one segment; provider-inlined definitions later in history are separate
102/// segments because prompt text lies between those positions.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum ProviderVisibleToolSegmentKind {
105    /// Complete definitions visible in the request's initial tools position.
106    InitialFullDefinition,
107    /// Name and description retained for one or more OpenAI hosted-search
108    /// functions while their parameter schemas remain deferred.
109    InitialDeferredDescriptor,
110    /// A complete Anthropic definition expanded at a validated tool reference.
111    AnthropicToolReferenceExpansion,
112    /// Empty marker following an initial search-enabled definition array. Later
113    /// definitions are bound by the provider or by transcript items rather than
114    /// duplicated at the initial position.
115    ProviderLateBound,
116}
117
118/// One compact serialized provider-visible schema position.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ProviderVisibleToolSegment {
121    pub kind: ProviderVisibleToolSegmentKind,
122    pub serialized: String,
123}
124
125impl ProviderVisibleToolSegment {
126    pub(crate) fn from_serializable<T: Serialize + ?Sized>(
127        kind: ProviderVisibleToolSegmentKind,
128        value: &T,
129    ) -> Result<Self> {
130        Ok(Self {
131            kind,
132            serialized: serde_json::to_string(value)?,
133        })
134    }
135
136    pub(crate) fn empty_marker(kind: ProviderVisibleToolSegmentKind) -> Self {
137        Self {
138            kind,
139            serialized: String::new(),
140        }
141    }
142}
143
144/// Ordered tool-schema material visible to a provider model for one request.
145///
146/// [`ProviderVisibleToolSegmentKind::ProviderLateBound`] explicitly marks
147/// provider-selected schema material that cannot be known before dispatch. It
148/// is not included in the known local token estimate; provider-reported usage
149/// remains authoritative after the response.
150#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct ProviderVisibleToolFootprint {
152    pub segments: Vec<ProviderVisibleToolSegment>,
153}
154
155/// Metadata for a provider model returned by `list_model_info`.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ProviderModelInfo {
158    /// Model identifier.
159    pub id: String,
160    /// Maximum total context window (input + output) in tokens when known.
161    /// Provider adapters that receive an input-only limit must add the model's
162    /// output capacity before populating this field.
163    pub max_context_tokens: Option<u32>,
164    /// Maximum output/completion tokens when known.
165    pub max_output_tokens: Option<u32>,
166}
167
168impl ProviderModelInfo {
169    /// Create metadata with only model id (no token limits).
170    pub fn from_id(id: impl Into<String>) -> Self {
171        Self {
172            id: id.into(),
173            max_context_tokens: None,
174            max_output_tokens: None,
175        }
176    }
177}
178
179/// Optional request-time controls for provider calls.
180#[derive(Debug, Clone, Default)]
181pub struct ResponsesRequestOptions {
182    /// Optional top-level instructions for Responses API requests.
183    pub instructions: Option<String>,
184    /// Optional message list to serialize into the Responses API `input` array.
185    ///
186    /// When omitted, providers fall back to the generic `messages` slice passed
187    /// to `chat_stream_with_options`. This lets the engine provide a
188    /// Responses-specific input view (for example, without a duplicated stable
189    /// system message) while preserving backward compatibility for non-Responses
190    /// callers and providers.
191    pub input_messages: Option<Vec<Message>>,
192    /// Validated provider-native Responses items, grouped by the ordinary
193    /// assistant message they replace. Internal only; the OpenAI adapter lowers
194    /// these at their anchored `input` position.
195    pub provider_transcript_groups: Vec<bamboo_domain::ProviderTranscriptGroup>,
196    /// Optional reasoning summary control for Responses API requests
197    /// (e.g. "auto", "concise", "detailed").
198    pub reasoning_summary: Option<String>,
199    /// Optional include list for Responses API requests.
200    pub include: Option<Vec<String>>,
201    /// Whether Responses API should store the response server-side.
202    pub store: Option<bool>,
203    /// Optional continuation handle for stateful Responses API turns.
204    pub previous_response_id: Option<String>,
205    /// Optional truncation mode for Responses API requests
206    /// (e.g. "auto", "disabled").
207    pub truncation: Option<String>,
208    /// Optional text verbosity for Responses API requests
209    /// (e.g. "low", "medium", "high").
210    pub text_verbosity: Option<String>,
211    /// Stable affinity key for OpenAI prompt caching. Callers should provide a
212    /// privacy-preserving value. The agent loop supplies a domain-separated hash,
213    /// never its raw session identity, through this generic request DTO.
214    pub prompt_cache_key: Option<String>,
215    /// OpenAI request-wide cache policy (currently `mode` and optional `ttl`).
216    /// Kept as JSON so newly added official policy keys survive proxying.
217    pub prompt_cache_options: Option<serde_json::Value>,
218    /// Original Responses `input` retained by the compatibility endpoint when
219    /// it contains caller-authored explicit cache breakpoints.
220    ///
221    /// The OpenAI Responses adapter may use this instead of the provider-neutral
222    /// message rendering so supported `input_text`, `input_image`, and
223    /// `input_file` markers survive byte-for-byte. Agent/runtime calls leave it
224    /// unset.
225    pub raw_input_with_cache_breakpoints: Option<serde_json::Value>,
226    /// Internal model-context prefix epoch used only for safe wire-shape
227    /// diagnostics. It is never serialized into the upstream request.
228    pub prefix_epoch: Option<u64>,
229    /// Internal, secret-free reset reason paired with `prefix_epoch`.
230    pub prefix_reset_reason: Option<ModelContextResetReason>,
231    /// Retain raw Responses protocol events alongside provider-neutral chunks.
232    ///
233    /// This is an internal compatibility-endpoint control, not an upstream
234    /// request field. Agent/runtime calls leave it disabled to avoid cloning
235    /// every SSE payload when only normalized chunks are needed.
236    pub retain_protocol_events: bool,
237}
238
239/// Optional request-time controls for provider calls.
240#[derive(Debug, Clone, Default)]
241pub struct LLMRequestOptions {
242    /// Session identifier used for request-scoped logging correlation.
243    pub session_id: Option<String>,
244    /// Override reasoning effort for this request.
245    pub reasoning_effort: Option<ReasoningEffort>,
246    /// Request provider-side parallel tool call planning when supported.
247    ///
248    /// - OpenAI/Copilot: maps to `parallel_tool_calls`
249    /// - Anthropic: maps to `tool_choice.disable_parallel_tool_use` (inverse)
250    pub parallel_tool_calls: Option<bool>,
251    /// Require the model to issue this specific tool call when the provider
252    /// supports request-level tool choice. Providers translate this to their
253    /// native forced-function form; `None` preserves normal automatic choice.
254    pub required_tool: Option<String>,
255    /// Responses API specific overrides.
256    pub responses: Option<ResponsesRequestOptions>,
257    /// Purpose of this request for observability (e.g., "agent_loop", "task_evaluation").
258    pub request_purpose: Option<String>,
259    /// Provider-agnostic prompt-cache plan describing the stable, cacheable
260    /// prefix of this request. Providers render it in their own dialect
261    /// (Anthropic `cache_control`; GPT-5.6+ OpenAI Responses explicit content
262    /// breakpoints; automatic caching for providers without explicit support).
263    /// `None` means "no explicit cache hints".
264    pub cache: Option<crate::cache::PromptCachePlan>,
265}
266
267/// Resolve a forced named-tool request and fail before network I/O when the
268/// requested schema is not actually offered to the provider.
269pub(crate) fn required_tool_from_options<'a>(
270    options: Option<&'a LLMRequestOptions>,
271    tools: &[ToolSchema],
272) -> Result<Option<&'a str>> {
273    let Some(name) = options
274        .and_then(|options| options.required_tool.as_deref())
275        .map(str::trim)
276        .filter(|name| !name.is_empty())
277    else {
278        return Ok(None);
279    };
280    if tools.iter().any(|tool| tool.function.name == name) {
281        Ok(Some(name))
282    } else {
283        Err(LLMError::Api(format!(
284            "required tool schema '{name}' was not offered"
285        )))
286    }
287}
288
289/// Trait for LLM provider implementations
290///
291/// This trait defines the interface that all LLM providers must implement
292/// to work with Bamboo's agent system. Providers handle communication with
293/// specific LLM services (OpenAI, Anthropic, local models, etc.).
294///
295/// # Design Principle
296///
297/// The `model` parameter is **required** in `chat_stream`, not optional.
298/// This ensures that the calling code explicitly specifies which model to use,
299/// preventing accidental use of unintended models and making model selection
300/// explicit and auditable.
301///
302/// # Example
303///
304/// ```ignore
305/// use bamboo_agent::agent::llm::provider::LLMProvider;
306///
307/// async fn use_provider(provider: &dyn LLMProvider) {
308///     let stream = provider.chat_stream(
309///         &messages,
310///         &tools,
311///         Some(4096),
312///         "claude-sonnet-4-6", // Model is required
313///     ).await?;
314/// }
315/// ```
316#[async_trait]
317pub trait LLMProvider: Send + Sync {
318    /// An immutable downstream handle through which this wrapper sends every
319    /// chat request. This is separate from queue and allocation identity.
320    ///
321    /// Return `Some` only when every chat and IR method forwards the exact
322    /// unchanged model to this same handle, or refuses locally before any
323    /// dispatch, for the wrapper's entire lifetime. It must not replace the
324    /// model, bypass the handle, or asynchronously change the destination.
325    /// A mutable router or provider that can choose a different account must
326    /// return `None`.
327    /// Implementations are trusted in-process adapters: this is a behavior
328    /// contract, not a security boundary against dishonest implementations.
329    #[doc(hidden)]
330    fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn LLMProvider>> {
331        None
332    }
333
334    /// Return the fixed provider explicitly injected into a Host while it is
335    /// still current. Mutable routers must never identify an inferred provider
336    /// as an injection; the default preserves existing SDK implementations.
337    #[doc(hidden)]
338    fn legacy_injected_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
339        Ok(None)
340    }
341
342    /// An already-known local routing refusal, when no request may be sent.
343    /// This query must be nonblocking and must not initiate a request.
344    /// The default preserves existing providers and SDK implementations.
345    /// Transparent provider wrappers should forward this to their inner handle.
346    #[doc(hidden)]
347    fn known_pre_dispatch_error(&self) -> Option<LLMError> {
348        None
349    }
350
351    /// Whether this single-use target refused its request locally.
352    /// This is for evaluation lifecycle classification, never accounting or
353    /// authorization evidence. Implementations must not infer it from later state.
354    #[doc(hidden)]
355    fn local_dispatch_refused(&self) -> bool {
356        false
357    }
358
359    /// Transparent Host decorators retain the original provider allocation's
360    /// process-wide concurrency identity. This is never a model/client ID.
361    fn resource_allocation_identity(&self) -> Option<usize> {
362        None
363    }
364
365    /// True only for a Host route whose managed registry admitted the target.
366    /// An opaque SDK provider defaults to retaining its original Arc. This
367    /// cannot freeze credentials or other mutable state inside that provider;
368    /// SDK owners must keep those stable or implement the snapshot hook.
369    #[doc(hidden)]
370    fn queue_requires_current_route(&self) -> bool {
371        false
372    }
373
374    /// Underlying allocation used for pairing a delayed model and provider.
375    /// Transparent decorators should forward this unchanged. This identity
376    /// does not establish that dynamic credentials inside a provider are fixed.
377    #[doc(hidden)]
378    fn queued_route_identity(&self) -> QueuedRouteIdentity {
379        QueuedRouteIdentity::Unmanaged
380    }
381
382    /// Freeze only transparent mutable handles for delayed dispatch. An SDK
383    /// provider defaults to its existing Arc; it owns changes behind that Arc.
384    #[doc(hidden)]
385    fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
386        Ok(None)
387    }
388
389    /// Effective Vision support. Missing declarations default to enabled;
390    /// only an explicit false disables it. Never infer from model names.
391    async fn supports_vision(&self, model: &str) -> bool {
392        self.vision_support_override(model).await.unwrap_or(true)
393    }
394
395    /// Explicit per-model policy, used to distinguish a user's override from
396    /// their separately configured legacy image fallback. None is default-on.
397    async fn vision_support_override(&self, _model: &str) -> Option<bool> {
398        None
399    }
400
401    /// Select the provider's callable-catalog policy for one model request.
402    ///
403    /// Providers must opt in explicitly. The default preserves the complete
404    /// legacy function catalog for compatibility endpoints and unknown models.
405    async fn capability_loading_mode(
406        &self,
407        _model: &str,
408        _required_tool: Option<&str>,
409    ) -> CapabilityLoadingMode {
410        CapabilityLoadingMode::LegacyFullCatalog
411    }
412
413    /// Lower the tool definitions visible to the model at this request.
414    ///
415    /// The default matches Bamboo's OpenAI-compatible Chat wire, which is also
416    /// the legacy surface used by generic providers. Native adapters override
417    /// this when their schema shape or deferred-loading protocol differs.
418    async fn provider_visible_tool_footprint(
419        &self,
420        _ir: &PromptIR,
421        tools: &[ToolSchema],
422        _model: &str,
423        _required_tool: Option<&str>,
424    ) -> Result<ProviderVisibleToolFootprint> {
425        if tools.is_empty() {
426            return Ok(ProviderVisibleToolFootprint::default());
427        }
428        let projected = crate::providers::common::openai_compat::tools_to_openai_compat_json(tools);
429        Ok(ProviderVisibleToolFootprint {
430            segments: vec![ProviderVisibleToolSegment::from_serializable(
431                ProviderVisibleToolSegmentKind::InitialFullDefinition,
432                &projected,
433            )?],
434        })
435    }
436
437    /// Stream chat completion from the LLM
438    ///
439    /// This is the primary method for interacting with LLMs, returning
440    /// a stream of response chunks that can be processed incrementally.
441    ///
442    /// # Arguments
443    ///
444    /// * `messages` - Conversation history and current prompt
445    /// * `tools` - Available tools the LLM can call
446    /// * `max_output_tokens` - Optional limit on response length
447    /// * `model` - **Required** model identifier (e.g., "claude-sonnet-4-6")
448    ///
449    /// # Returns
450    ///
451    /// A stream of `LLMChunk` items containing partial responses
452    ///
453    /// # Errors
454    ///
455    /// Returns `LLMError` on network failures, API errors, or invalid requests
456    async fn chat_stream(
457        &self,
458        messages: &[Message],
459        tools: &[ToolSchema],
460        max_output_tokens: Option<u32>,
461        model: &str,
462    ) -> Result<LLMStream>;
463
464    /// Stream chat completion with optional request-level controls.
465    ///
466    /// Default implementation preserves backward compatibility by delegating to
467    /// [`LLMProvider::chat_stream`].
468    async fn chat_stream_with_options(
469        &self,
470        messages: &[Message],
471        tools: &[ToolSchema],
472        max_output_tokens: Option<u32>,
473        model: &str,
474        _options: Option<&LLMRequestOptions>,
475    ) -> Result<LLMStream> {
476        self.chat_stream(messages, tools, max_output_tokens, model)
477            .await
478    }
479
480    /// Stream from the canonical [`PromptIR`] — the single, rich, provider-agnostic
481    /// request the engine emits once per round.
482    ///
483    /// A provider renders the IR into its own wire format by calling the lowering
484    /// methods ([`PromptIR::system_field`], [`PromptIR::body_chat`],
485    /// [`PromptIR::responses_input`], [`PromptIR::continuation_delta`]). The IR
486    /// carries the stateful Responses continuation, so an adapter derives the
487    /// delta itself rather than the engine pre-baking it.
488    ///
489    /// The default implementation lowers the IR for BOTH wire families and
490    /// delegates to [`chat_stream_with_options`](Self::chat_stream_with_options):
491    /// - the flat message list (`continuation_delta` mid-tool-loop, else `flatten`)
492    ///   for the Chat-Completions path;
493    /// - the Responses-API view (`instructions` / `input_messages` /
494    ///   `previous_response_id`) derived via [`PromptIR::responses_request_options`]
495    ///   and merged onto the request POLICY, so a Responses provider works WITHOUT
496    ///   overriding this method (Chat-Completions providers ignore those options).
497    ///
498    /// This is byte-identical to the pre-IR request. Block-native providers (e.g.
499    /// Anthropic) still override this to consume `system_blocks` structurally.
500    async fn chat_stream_ir(
501        &self,
502        ir: &PromptIR,
503        tools: &[ToolSchema],
504        max_output_tokens: Option<u32>,
505        model: &str,
506        options: Option<&LLMRequestOptions>,
507    ) -> Result<LLMStream> {
508        crate::image_budget::validate_tool_image_budget(ir.body_chat_iter())
509            .map_err(LLMError::Api)?;
510        let messages = if ir.continuation.is_some() {
511            ir.continuation_delta()
512        } else {
513            ir.flatten()
514        };
515        let mut effective_options = options.cloned().unwrap_or_default();
516        effective_options.responses =
517            Some(ir.responses_request_options(effective_options.responses.as_ref()));
518        self.chat_stream_with_options(
519            &messages,
520            tools,
521            max_output_tokens,
522            model,
523            Some(&effective_options),
524        )
525        .await
526    }
527
528    /// Lists available models from this provider
529    ///
530    /// Returns a list of model identifiers that can be used with `chat_stream`.
531    /// Default implementation returns an empty list.
532    async fn list_models(&self) -> Result<Vec<String>> {
533        // Default implementation returns empty list
534        Ok(vec![])
535    }
536
537    /// Lists available models with optional token limit metadata.
538    ///
539    /// Default implementation preserves backward compatibility by adapting
540    /// `list_models()` output into metadata entries without limits.
541    async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
542        Ok(self
543            .list_models()
544            .await?
545            .into_iter()
546            .map(ProviderModelInfo::from_id)
547            .collect())
548    }
549}
550
551/// Produce a stable, process-local identity for a known wrapper's inner Arc.
552#[doc(hidden)]
553pub fn queued_identity_for(provider: &Arc<dyn LLMProvider>) -> QueuedRouteIdentity {
554    match provider.queued_route_identity() {
555        QueuedRouteIdentity::Unmanaged => QueuedRouteIdentity::Stable(provider.clone()),
556        identity => identity,
557    }
558}
559
560/// Resolve a transparent mutable wrapper once, retaining the original outer
561/// Arc and its existing process-wide auxiliary-budget identity.
562#[doc(hidden)]
563pub fn queued_snapshot_for(provider: Arc<dyn LLMProvider>) -> Result<Arc<dyn LLMProvider>> {
564    match provider.queued_provider_snapshot()? {
565        Some(inner) => Ok(Arc::new(QueuedBudgetProvider {
566            inner,
567            require_current: provider.queue_requires_current_route(),
568            original: provider,
569            fixed_inner: false,
570        })),
571        None => Ok(provider),
572    }
573}
574
575/// Mark only a route admitted from a Host-managed registry snapshot.
576/// The original handle and its concurrency identity remain unchanged.
577#[doc(hidden)]
578pub fn mark_host_queued_route(provider: Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> {
579    Arc::new(QueuedBudgetProvider {
580        inner: provider.clone(),
581        original: provider,
582        require_current: true,
583        fixed_inner: false,
584    })
585}
586
587/// Retain the provider selected by a dispatch while using the original outer
588/// provider's process-wide auxiliary semaphore identity. This function does
589/// not prove origin or admission: callers must use an independent Host guard.
590/// It freezes the selected Arc, not mutable state or credentials inside it.
591#[doc(hidden)]
592pub fn fixed_provider_with_identity(
593    selected: Arc<dyn LLMProvider>,
594    original: Arc<dyn LLMProvider>,
595) -> Arc<dyn LLMProvider> {
596    Arc::new(QueuedBudgetProvider {
597        inner: selected,
598        original,
599        require_current: true,
600        fixed_inner: true,
601    })
602}
603
604/// Freeze the selected Arc for an explicitly injected provider while keeping
605/// its own queue policy and the original outer semaphore identity. This is not
606/// evidence of injection: the Host must have independently classified the
607/// exact selected Arc as injected before using this helper.
608#[doc(hidden)]
609pub fn fixed_injected_provider_with_identity(
610    selected: Arc<dyn LLMProvider>,
611    original: Arc<dyn LLMProvider>,
612) -> Arc<dyn LLMProvider> {
613    Arc::new(QueuedBudgetProvider {
614        inner: selected,
615        original,
616        require_current: false,
617        fixed_inner: true,
618    })
619}
620
621struct QueuedBudgetProvider {
622    inner: Arc<dyn LLMProvider>,
623    original: Arc<dyn LLMProvider>,
624    require_current: bool,
625    // A receipt-bound delegate must not later resnapshot a mutable wrapper.
626    fixed_inner: bool,
627}
628
629#[async_trait]
630impl LLMProvider for QueuedBudgetProvider {
631    fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn LLMProvider>> {
632        Some(self.inner.clone())
633    }
634
635    fn local_dispatch_refused(&self) -> bool {
636        self.inner.local_dispatch_refused()
637    }
638
639    fn queue_requires_current_route(&self) -> bool {
640        self.require_current || self.inner.queue_requires_current_route()
641    }
642    fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
643        if self.fixed_inner {
644            return Ok(None);
645        }
646        let Some(inner) = self.inner.queued_provider_snapshot()? else {
647            return Ok(None);
648        };
649        Ok(Some(Arc::new(Self {
650            inner,
651            original: self.original.clone(),
652            require_current: self.require_current,
653            fixed_inner: false,
654        })))
655    }
656    fn queued_route_identity(&self) -> QueuedRouteIdentity {
657        queued_identity_for(&self.inner)
658    }
659    fn known_pre_dispatch_error(&self) -> Option<LLMError> {
660        self.inner.known_pre_dispatch_error()
661    }
662    fn resource_allocation_identity(&self) -> Option<usize> {
663        Some(
664            self.original
665                .resource_allocation_identity()
666                .unwrap_or(Arc::as_ptr(&self.original) as *const () as usize),
667        )
668    }
669    async fn supports_vision(&self, model: &str) -> bool {
670        self.inner.supports_vision(model).await
671    }
672    async fn vision_support_override(&self, model: &str) -> Option<bool> {
673        self.inner.vision_support_override(model).await
674    }
675    async fn capability_loading_mode(
676        &self,
677        model: &str,
678        required_tool: Option<&str>,
679    ) -> CapabilityLoadingMode {
680        self.inner
681            .capability_loading_mode(model, required_tool)
682            .await
683    }
684    async fn provider_visible_tool_footprint(
685        &self,
686        ir: &PromptIR,
687        tools: &[ToolSchema],
688        model: &str,
689        required_tool: Option<&str>,
690    ) -> Result<ProviderVisibleToolFootprint> {
691        self.inner
692            .provider_visible_tool_footprint(ir, tools, model, required_tool)
693            .await
694    }
695    async fn chat_stream(
696        &self,
697        messages: &[Message],
698        tools: &[ToolSchema],
699        max_output_tokens: Option<u32>,
700        model: &str,
701    ) -> Result<LLMStream> {
702        self.inner
703            .chat_stream(messages, tools, max_output_tokens, model)
704            .await
705    }
706    async fn chat_stream_with_options(
707        &self,
708        messages: &[Message],
709        tools: &[ToolSchema],
710        max_output_tokens: Option<u32>,
711        model: &str,
712        options: Option<&LLMRequestOptions>,
713    ) -> Result<LLMStream> {
714        self.inner
715            .chat_stream_with_options(messages, tools, max_output_tokens, model, options)
716            .await
717    }
718    async fn chat_stream_ir(
719        &self,
720        ir: &PromptIR,
721        tools: &[ToolSchema],
722        max_output_tokens: Option<u32>,
723        model: &str,
724        options: Option<&LLMRequestOptions>,
725    ) -> Result<LLMStream> {
726        self.inner
727            .chat_stream_ir(ir, tools, max_output_tokens, model, options)
728            .await
729    }
730    async fn list_models(&self) -> Result<Vec<String>> {
731        self.inner.list_models().await
732    }
733    async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
734        self.inner.list_model_info().await
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use std::sync::{Arc, Mutex};
741
742    use async_trait::async_trait;
743    use futures::{stream, StreamExt};
744
745    use super::*;
746
747    #[tokio::test]
748    async fn chat_stream_ir_default_flattens_and_delegates() {
749        use crate::prompt_ir::{PromptIR, Segment, SegmentRole};
750
751        // A provider that captures the message list AND the options it is handed.
752        #[derive(Default)]
753        struct Capture {
754            seen: Arc<Mutex<Vec<Message>>>,
755            seen_responses: Arc<Mutex<Option<crate::provider::ResponsesRequestOptions>>>,
756        }
757        #[async_trait]
758        impl LLMProvider for Capture {
759            async fn chat_stream(
760                &self,
761                _m: &[Message],
762                _t: &[ToolSchema],
763                _mt: Option<u32>,
764                _model: &str,
765            ) -> Result<LLMStream> {
766                unreachable!("default chat_stream_ir must route via chat_stream_with_options")
767            }
768            async fn chat_stream_with_options(
769                &self,
770                messages: &[Message],
771                _t: &[ToolSchema],
772                _mt: Option<u32>,
773                _model: &str,
774                o: Option<&LLMRequestOptions>,
775            ) -> Result<LLMStream> {
776                *self.seen.lock().expect("seen lock") = messages.to_vec();
777                *self.seen_responses.lock().expect("resp lock") =
778                    o.and_then(|value| value.responses.clone());
779                Ok(Box::pin(stream::iter(Vec::<Result<LLMChunk>>::new())))
780            }
781        }
782
783        let cap = Capture::default();
784        assert_eq!(
785            cap.capability_loading_mode("any-model", None).await,
786            CapabilityLoadingMode::LegacyFullCatalog
787        );
788        let ir = PromptIR {
789            system_text: "sys".into(),
790            segments: vec![
791                Segment::new(SegmentRole::StablePrefix, vec![Message::user("guide")]),
792                Segment::new(SegmentRole::DynamicContext, vec![Message::user("dyn")]),
793                Segment::new(SegmentRole::Conversation, vec![Message::user("ask")]),
794            ],
795            ..PromptIR::default()
796        };
797        let _ = cap
798            .chat_stream_ir(&ir, &[], None, "m", None)
799            .await
800            .expect("ir stream");
801
802        let seen = cap.seen.lock().expect("seen lock").clone();
803        let expected = ir.flatten();
804        assert_eq!(seen.len(), expected.len(), "delegates the flattened IR");
805        for (got, want) in seen.iter().zip(expected.iter()) {
806            assert_eq!(got.role, want.role);
807            assert_eq!(got.content, want.content);
808        }
809        // system + guide + dyn + ask
810        assert_eq!(seen.len(), 4);
811        assert!(matches!(seen[0].role, bamboo_domain::Role::System));
812
813        // SAFETY NET: the default also derives the Responses-API view from the IR, so
814        // a Responses provider works without overriding `chat_stream_ir`. instructions
815        // = the (trimmed) system field; input_messages = the full responses_input view
816        // (system lifted out, so it does not lead with a system message).
817        let responses = cap
818            .seen_responses
819            .lock()
820            .expect("resp lock")
821            .clone()
822            .expect("default derives Responses options from the IR");
823        assert_eq!(responses.instructions.as_deref(), Some("sys"));
824        let input = responses.input_messages.expect("input_messages derived");
825        assert_eq!(
826            input.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
827            vec!["guide".to_string(), "dyn".to_string(), "ask".to_string()],
828            "input_messages is the responses_input view: NO leading system message"
829        );
830    }
831
832    #[derive(Clone, Default)]
833    struct RecordingProvider {
834        requested_models: Arc<Mutex<Vec<String>>>,
835        requested_max_tokens: Arc<Mutex<Vec<Option<u32>>>>,
836    }
837
838    #[tokio::test]
839    async fn default_tool_footprint_is_one_compact_openai_compat_position() {
840        let provider = RecordingProvider::default();
841        let tools = vec![ToolSchema {
842            schema_type: "function".to_string(),
843            function: bamboo_domain::FunctionSchema {
844                name: "lookup".to_string(),
845                description: "Look up a value".to_string(),
846                parameters: serde_json::json!({
847                    "type": "object",
848                    "properties": {"key": {"type": "string"}},
849                    "oneOf": [{"required": ["key"]}]
850                }),
851            },
852        }];
853
854        let footprint = provider
855            .provider_visible_tool_footprint(&PromptIR::default(), &tools, "model", None)
856            .await
857            .expect("footprint");
858
859        assert_eq!(footprint.segments.len(), 1);
860        assert_eq!(
861            footprint.segments[0].kind,
862            ProviderVisibleToolSegmentKind::InitialFullDefinition
863        );
864        let rendered: serde_json::Value =
865            serde_json::from_str(&footprint.segments[0].serialized).unwrap();
866        assert_eq!(
867            footprint.segments[0].serialized,
868            serde_json::to_string(&rendered).unwrap()
869        );
870        assert_eq!(rendered[0]["function"]["name"], "lookup");
871        assert!(rendered[0]["function"]["parameters"].get("oneOf").is_none());
872
873        assert_eq!(
874            provider
875                .provider_visible_tool_footprint(&PromptIR::default(), &[], "model", None)
876                .await
877                .unwrap(),
878            ProviderVisibleToolFootprint::default()
879        );
880    }
881
882    #[async_trait]
883    impl LLMProvider for RecordingProvider {
884        async fn chat_stream(
885            &self,
886            _messages: &[Message],
887            _tools: &[ToolSchema],
888            max_output_tokens: Option<u32>,
889            model: &str,
890        ) -> Result<LLMStream> {
891            if let Ok(mut models) = self.requested_models.lock() {
892                models.push(model.to_string());
893            }
894            if let Ok(mut max_tokens) = self.requested_max_tokens.lock() {
895                max_tokens.push(max_output_tokens);
896            }
897
898            Ok(Box::pin(stream::empty()))
899        }
900    }
901
902    #[tokio::test]
903    async fn main_dispatch_scope_is_attempt_local_and_not_inherited_by_spawned_tasks() {
904        let a: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
905        let b: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
906        let outer_seen = Arc::new(Mutex::new(Vec::<Arc<dyn LLMProvider>>::new()));
907        let inner_seen = Arc::new(Mutex::new(Vec::<Arc<dyn LLMProvider>>::new()));
908        let outer_copy = outer_seen.clone();
909        let inner_copy = inner_seen.clone();
910        let outer: MainDispatchObserver =
911            Arc::new(move |provider| outer_copy.lock().unwrap().push(provider));
912        let inner: MainDispatchObserver =
913            Arc::new(move |provider| inner_copy.lock().unwrap().push(provider));
914
915        assert!(!observe_main_dispatch(a.clone()));
916        scope_main_dispatch(outer, async {
917            assert!(observe_main_dispatch(a.clone()));
918            let spawned_b = b.clone();
919            assert!(
920                !tokio::spawn(async move { observe_main_dispatch(spawned_b) })
921                    .await
922                    .unwrap(),
923                "a new task must not silently inherit an attempt receipt"
924            );
925            scope_main_dispatch(inner, async {
926                assert!(observe_main_dispatch(b.clone()));
927            })
928            .await;
929            assert!(observe_main_dispatch(a.clone()), "outer scope is restored");
930        })
931        .await;
932        assert!(
933            !observe_main_dispatch(b.clone()),
934            "scope must not leak after completion"
935        );
936        let outer = outer_seen.lock().unwrap();
937        assert_eq!(outer.len(), 2);
938        assert!(outer.iter().all(|provider| Arc::ptr_eq(provider, &a)));
939        let inner = inner_seen.lock().unwrap();
940        assert_eq!(inner.len(), 1);
941        assert!(Arc::ptr_eq(&inner[0], &b));
942    }
943
944    #[tokio::test]
945    async fn cancelling_scoped_main_attempt_does_not_install_a_later_observer() {
946        let selected: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
947        let observed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
948        let observed_copy = observed.clone();
949        let observer: MainDispatchObserver = Arc::new(move |_| {
950            observed_copy.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
951        });
952        let (started, ready) = tokio::sync::oneshot::channel();
953        let run = tokio::spawn(scope_main_dispatch(observer, async move {
954            started.send(()).unwrap();
955            std::future::pending::<()>().await;
956        }));
957        ready.await.unwrap();
958        run.abort();
959        assert!(run.await.unwrap_err().is_cancelled());
960        assert!(!observe_main_dispatch(selected));
961        assert_eq!(observed.load(std::sync::atomic::Ordering::SeqCst), 0);
962    }
963
964    struct ResnapshottingProvider {
965        alternate: Arc<dyn LLMProvider>,
966        calls: std::sync::atomic::AtomicUsize,
967        require_current: bool,
968    }
969
970    #[async_trait]
971    impl LLMProvider for ResnapshottingProvider {
972        fn queue_requires_current_route(&self) -> bool {
973            self.require_current
974        }
975
976        fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
977            Ok(Some(self.alternate.clone()))
978        }
979
980        async fn chat_stream(
981            &self,
982            _: &[Message],
983            _: &[ToolSchema],
984            _: Option<u32>,
985            _: &str,
986        ) -> Result<LLMStream> {
987            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
988            Ok(Box::pin(stream::empty()))
989        }
990    }
991
992    #[tokio::test]
993    async fn fixed_dispatch_delegate_keeps_selected_arc_and_outer_budget_identity() {
994        let b = Arc::new(RecordingProvider::default());
995        let a = Arc::new(ResnapshottingProvider {
996            alternate: b.clone(),
997            calls: std::sync::atomic::AtomicUsize::new(0),
998            require_current: false,
999        });
1000        let selected: Arc<dyn LLMProvider> = a.clone();
1001        let original: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
1002        let expected_identity = Arc::as_ptr(&original) as *const () as usize;
1003        let fixed = fixed_provider_with_identity(selected, original);
1004        assert_eq!(
1005            fixed.resource_allocation_identity(),
1006            Some(expected_identity)
1007        );
1008        assert!(fixed.queue_requires_current_route());
1009        let queued = queued_snapshot_for(fixed.clone()).unwrap();
1010        assert!(
1011            Arc::ptr_eq(&queued, &fixed),
1012            "receipt-bound Arc must not be resnapshotted"
1013        );
1014        let _ = queued.chat_stream(&[], &[], None, "gold").await.unwrap();
1015        assert_eq!(a.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1016        assert!(b.requested_models.lock().unwrap().is_empty());
1017    }
1018
1019    #[tokio::test]
1020    async fn injected_fixed_queue_keeps_a_when_current_selection_becomes_b() {
1021        let a = Arc::new(RecordingProvider::default());
1022        let b = Arc::new(RecordingProvider::default());
1023        let original: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
1024        let expected_identity = Arc::as_ptr(&original) as *const () as usize;
1025        let queued = fixed_injected_provider_with_identity(a.clone(), original.clone());
1026        let current = fixed_injected_provider_with_identity(b.clone(), original.clone());
1027        assert!(!queued.queue_requires_current_route());
1028        assert!(!current.queue_requires_current_route());
1029        assert_eq!(
1030            queued.resource_allocation_identity(),
1031            Some(expected_identity)
1032        );
1033        assert_eq!(
1034            current.resource_allocation_identity(),
1035            Some(expected_identity)
1036        );
1037        let captured = queued_snapshot_for(queued.clone()).unwrap();
1038        assert!(Arc::ptr_eq(&captured, &queued));
1039        let _ = captured.chat_stream(&[], &[], None, "gold").await.unwrap();
1040        assert_eq!(
1041            *a.requested_models.lock().unwrap(),
1042            vec!["gold".to_string()]
1043        );
1044        assert!(b.requested_models.lock().unwrap().is_empty());
1045
1046        let guarded_inner: Arc<dyn LLMProvider> = Arc::new(ResnapshottingProvider {
1047            alternate: b,
1048            calls: std::sync::atomic::AtomicUsize::new(0),
1049            require_current: true,
1050        });
1051        let guarded = fixed_injected_provider_with_identity(guarded_inner, original);
1052        assert!(
1053            guarded.queue_requires_current_route(),
1054            "the selected provider's own policy remains authoritative"
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn chat_stream_with_options_delegates_to_chat_stream_with_same_model_and_tokens() {
1060        let provider = RecordingProvider::default();
1061        let options = LLMRequestOptions::default();
1062
1063        let mut stream = provider
1064            .chat_stream_with_options(&[], &[], Some(512), "gpt-test", Some(&options))
1065            .await
1066            .expect("delegation should succeed");
1067        assert!(stream.next().await.is_none());
1068
1069        assert_eq!(
1070            provider
1071                .requested_models
1072                .lock()
1073                .expect("lock poisoned")
1074                .as_slice(),
1075            ["gpt-test"]
1076        );
1077        assert_eq!(
1078            provider
1079                .requested_max_tokens
1080                .lock()
1081                .expect("lock poisoned")
1082                .as_slice(),
1083            [Some(512)]
1084        );
1085    }
1086
1087    #[tokio::test]
1088    async fn list_models_returns_empty_by_default() {
1089        let provider = RecordingProvider::default();
1090        let models = provider
1091            .list_models()
1092            .await
1093            .expect("default list_models should succeed");
1094        assert!(models.is_empty());
1095    }
1096
1097    #[test]
1098    fn request_options_default_has_no_purpose() {
1099        let opts = LLMRequestOptions::default();
1100        assert!(opts.request_purpose.is_none());
1101    }
1102
1103    #[test]
1104    fn request_options_purpose_is_set_and_readable() {
1105        let opts = LLMRequestOptions {
1106            request_purpose: Some("title_generation".to_string()),
1107            ..Default::default()
1108        };
1109        assert_eq!(opts.request_purpose.as_deref(), Some("title_generation"));
1110    }
1111}