use crate::prompt_ir::PromptIR;
use crate::types::LLMChunk;
use async_trait::async_trait;
use bamboo_domain::ToolSchema;
use bamboo_domain::{CapabilityLoadingMode, ReasoningEffort};
use bamboo_domain::{Message, ModelContextResetReason};
use futures::Stream;
use serde::Serialize;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use thiserror::Error;
#[doc(hidden)]
pub type MainDispatchObserver = Arc<dyn Fn(Arc<dyn LLMProvider>) + Send + Sync>;
tokio::task_local! {
static MAIN_DISPATCH_OBSERVER: MainDispatchObserver;
}
#[doc(hidden)]
pub async fn scope_main_dispatch<F: Future>(
observer: MainDispatchObserver,
future: F,
) -> F::Output {
MAIN_DISPATCH_OBSERVER.scope(observer, future).await
}
#[doc(hidden)]
pub fn observe_main_dispatch(selected: Arc<dyn LLMProvider>) -> bool {
MAIN_DISPATCH_OBSERVER
.try_with(|observer| observer(selected))
.is_ok()
}
#[doc(hidden)]
#[derive(Clone)]
pub enum QueuedRouteIdentity {
Stable(Arc<dyn LLMProvider>),
Unmanaged,
}
#[derive(Error, Debug)]
pub enum LLMError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Stream error: {0}")]
Stream(String),
#[error("API error: {0}")]
Api(String),
#[error("Authentication error: {0}")]
Auth(String),
#[error("{0}")]
RequestFailed(String),
#[error("Protocol conversion error: {0}")]
Protocol(#[from] crate::protocol::ProtocolError),
}
pub type Result<T> = std::result::Result<T, LLMError>;
pub type LLMStream = Pin<Box<dyn Stream<Item = Result<LLMChunk>> + Send>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderVisibleToolSegmentKind {
InitialFullDefinition,
InitialDeferredDescriptor,
AnthropicToolReferenceExpansion,
ProviderLateBound,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderVisibleToolSegment {
pub kind: ProviderVisibleToolSegmentKind,
pub serialized: String,
}
impl ProviderVisibleToolSegment {
pub(crate) fn from_serializable<T: Serialize + ?Sized>(
kind: ProviderVisibleToolSegmentKind,
value: &T,
) -> Result<Self> {
Ok(Self {
kind,
serialized: serde_json::to_string(value)?,
})
}
pub(crate) fn empty_marker(kind: ProviderVisibleToolSegmentKind) -> Self {
Self {
kind,
serialized: String::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderVisibleToolFootprint {
pub segments: Vec<ProviderVisibleToolSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelInfo {
pub id: String,
pub max_context_tokens: Option<u32>,
pub max_output_tokens: Option<u32>,
}
impl ProviderModelInfo {
pub fn from_id(id: impl Into<String>) -> Self {
Self {
id: id.into(),
max_context_tokens: None,
max_output_tokens: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ResponsesRequestOptions {
pub instructions: Option<String>,
pub input_messages: Option<Vec<Message>>,
pub provider_transcript_groups: Vec<bamboo_domain::ProviderTranscriptGroup>,
pub reasoning_summary: Option<String>,
pub include: Option<Vec<String>>,
pub store: Option<bool>,
pub previous_response_id: Option<String>,
pub truncation: Option<String>,
pub text_verbosity: Option<String>,
pub prompt_cache_key: Option<String>,
pub prompt_cache_options: Option<serde_json::Value>,
pub raw_input_with_cache_breakpoints: Option<serde_json::Value>,
pub prefix_epoch: Option<u64>,
pub prefix_reset_reason: Option<ModelContextResetReason>,
pub retain_protocol_events: bool,
}
#[derive(Debug, Clone, Default)]
pub struct LLMRequestOptions {
pub session_id: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
pub parallel_tool_calls: Option<bool>,
pub required_tool: Option<String>,
pub responses: Option<ResponsesRequestOptions>,
pub request_purpose: Option<String>,
pub cache: Option<crate::cache::PromptCachePlan>,
}
pub(crate) fn required_tool_from_options<'a>(
options: Option<&'a LLMRequestOptions>,
tools: &[ToolSchema],
) -> Result<Option<&'a str>> {
let Some(name) = options
.and_then(|options| options.required_tool.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
else {
return Ok(None);
};
if tools.iter().any(|tool| tool.function.name == name) {
Ok(Some(name))
} else {
Err(LLMError::Api(format!(
"required tool schema '{name}' was not offered"
)))
}
}
#[async_trait]
pub trait LLMProvider: Send + Sync {
#[doc(hidden)]
fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn LLMProvider>> {
None
}
#[doc(hidden)]
fn legacy_injected_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
Ok(None)
}
#[doc(hidden)]
fn known_pre_dispatch_error(&self) -> Option<LLMError> {
None
}
#[doc(hidden)]
fn local_dispatch_refused(&self) -> bool {
false
}
fn resource_allocation_identity(&self) -> Option<usize> {
None
}
#[doc(hidden)]
fn queue_requires_current_route(&self) -> bool {
false
}
#[doc(hidden)]
fn queued_route_identity(&self) -> QueuedRouteIdentity {
QueuedRouteIdentity::Unmanaged
}
#[doc(hidden)]
fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
Ok(None)
}
async fn supports_vision(&self, model: &str) -> bool {
self.vision_support_override(model).await.unwrap_or(true)
}
async fn vision_support_override(&self, _model: &str) -> Option<bool> {
None
}
async fn capability_loading_mode(
&self,
_model: &str,
_required_tool: Option<&str>,
) -> CapabilityLoadingMode {
CapabilityLoadingMode::LegacyFullCatalog
}
async fn provider_visible_tool_footprint(
&self,
_ir: &PromptIR,
tools: &[ToolSchema],
_model: &str,
_required_tool: Option<&str>,
) -> Result<ProviderVisibleToolFootprint> {
if tools.is_empty() {
return Ok(ProviderVisibleToolFootprint::default());
}
let projected = crate::providers::common::openai_compat::tools_to_openai_compat_json(tools);
Ok(ProviderVisibleToolFootprint {
segments: vec![ProviderVisibleToolSegment::from_serializable(
ProviderVisibleToolSegmentKind::InitialFullDefinition,
&projected,
)?],
})
}
async fn chat_stream(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
) -> Result<LLMStream>;
async fn chat_stream_with_options(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
_options: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
self.chat_stream(messages, tools, max_output_tokens, model)
.await
}
async fn chat_stream_ir(
&self,
ir: &PromptIR,
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
options: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
crate::image_budget::validate_tool_image_budget(ir.body_chat_iter())
.map_err(LLMError::Api)?;
let messages = if ir.continuation.is_some() {
ir.continuation_delta()
} else {
ir.flatten()
};
let mut effective_options = options.cloned().unwrap_or_default();
effective_options.responses =
Some(ir.responses_request_options(effective_options.responses.as_ref()));
self.chat_stream_with_options(
&messages,
tools,
max_output_tokens,
model,
Some(&effective_options),
)
.await
}
async fn list_models(&self) -> Result<Vec<String>> {
Ok(vec![])
}
async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
Ok(self
.list_models()
.await?
.into_iter()
.map(ProviderModelInfo::from_id)
.collect())
}
}
#[doc(hidden)]
pub fn queued_identity_for(provider: &Arc<dyn LLMProvider>) -> QueuedRouteIdentity {
match provider.queued_route_identity() {
QueuedRouteIdentity::Unmanaged => QueuedRouteIdentity::Stable(provider.clone()),
identity => identity,
}
}
#[doc(hidden)]
pub fn queued_snapshot_for(provider: Arc<dyn LLMProvider>) -> Result<Arc<dyn LLMProvider>> {
match provider.queued_provider_snapshot()? {
Some(inner) => Ok(Arc::new(QueuedBudgetProvider {
inner,
require_current: provider.queue_requires_current_route(),
original: provider,
fixed_inner: false,
})),
None => Ok(provider),
}
}
#[doc(hidden)]
pub fn mark_host_queued_route(provider: Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> {
Arc::new(QueuedBudgetProvider {
inner: provider.clone(),
original: provider,
require_current: true,
fixed_inner: false,
})
}
#[doc(hidden)]
pub fn fixed_provider_with_identity(
selected: Arc<dyn LLMProvider>,
original: Arc<dyn LLMProvider>,
) -> Arc<dyn LLMProvider> {
Arc::new(QueuedBudgetProvider {
inner: selected,
original,
require_current: true,
fixed_inner: true,
})
}
#[doc(hidden)]
pub fn fixed_injected_provider_with_identity(
selected: Arc<dyn LLMProvider>,
original: Arc<dyn LLMProvider>,
) -> Arc<dyn LLMProvider> {
Arc::new(QueuedBudgetProvider {
inner: selected,
original,
require_current: false,
fixed_inner: true,
})
}
struct QueuedBudgetProvider {
inner: Arc<dyn LLMProvider>,
original: Arc<dyn LLMProvider>,
require_current: bool,
fixed_inner: bool,
}
#[async_trait]
impl LLMProvider for QueuedBudgetProvider {
fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn LLMProvider>> {
Some(self.inner.clone())
}
fn local_dispatch_refused(&self) -> bool {
self.inner.local_dispatch_refused()
}
fn queue_requires_current_route(&self) -> bool {
self.require_current || self.inner.queue_requires_current_route()
}
fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
if self.fixed_inner {
return Ok(None);
}
let Some(inner) = self.inner.queued_provider_snapshot()? else {
return Ok(None);
};
Ok(Some(Arc::new(Self {
inner,
original: self.original.clone(),
require_current: self.require_current,
fixed_inner: false,
})))
}
fn queued_route_identity(&self) -> QueuedRouteIdentity {
queued_identity_for(&self.inner)
}
fn known_pre_dispatch_error(&self) -> Option<LLMError> {
self.inner.known_pre_dispatch_error()
}
fn resource_allocation_identity(&self) -> Option<usize> {
Some(
self.original
.resource_allocation_identity()
.unwrap_or(Arc::as_ptr(&self.original) as *const () as usize),
)
}
async fn supports_vision(&self, model: &str) -> bool {
self.inner.supports_vision(model).await
}
async fn vision_support_override(&self, model: &str) -> Option<bool> {
self.inner.vision_support_override(model).await
}
async fn capability_loading_mode(
&self,
model: &str,
required_tool: Option<&str>,
) -> CapabilityLoadingMode {
self.inner
.capability_loading_mode(model, required_tool)
.await
}
async fn provider_visible_tool_footprint(
&self,
ir: &PromptIR,
tools: &[ToolSchema],
model: &str,
required_tool: Option<&str>,
) -> Result<ProviderVisibleToolFootprint> {
self.inner
.provider_visible_tool_footprint(ir, tools, model, required_tool)
.await
}
async fn chat_stream(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
) -> Result<LLMStream> {
self.inner
.chat_stream(messages, tools, max_output_tokens, model)
.await
}
async fn chat_stream_with_options(
&self,
messages: &[Message],
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
options: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
self.inner
.chat_stream_with_options(messages, tools, max_output_tokens, model, options)
.await
}
async fn chat_stream_ir(
&self,
ir: &PromptIR,
tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
options: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
self.inner
.chat_stream_ir(ir, tools, max_output_tokens, model, options)
.await
}
async fn list_models(&self) -> Result<Vec<String>> {
self.inner.list_models().await
}
async fn list_model_info(&self) -> Result<Vec<ProviderModelInfo>> {
self.inner.list_model_info().await
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures::{stream, StreamExt};
use super::*;
#[tokio::test]
async fn chat_stream_ir_default_flattens_and_delegates() {
use crate::prompt_ir::{PromptIR, Segment, SegmentRole};
#[derive(Default)]
struct Capture {
seen: Arc<Mutex<Vec<Message>>>,
seen_responses: Arc<Mutex<Option<crate::provider::ResponsesRequestOptions>>>,
}
#[async_trait]
impl LLMProvider for Capture {
async fn chat_stream(
&self,
_m: &[Message],
_t: &[ToolSchema],
_mt: Option<u32>,
_model: &str,
) -> Result<LLMStream> {
unreachable!("default chat_stream_ir must route via chat_stream_with_options")
}
async fn chat_stream_with_options(
&self,
messages: &[Message],
_t: &[ToolSchema],
_mt: Option<u32>,
_model: &str,
o: Option<&LLMRequestOptions>,
) -> Result<LLMStream> {
*self.seen.lock().expect("seen lock") = messages.to_vec();
*self.seen_responses.lock().expect("resp lock") =
o.and_then(|value| value.responses.clone());
Ok(Box::pin(stream::iter(Vec::<Result<LLMChunk>>::new())))
}
}
let cap = Capture::default();
assert_eq!(
cap.capability_loading_mode("any-model", None).await,
CapabilityLoadingMode::LegacyFullCatalog
);
let ir = PromptIR {
system_text: "sys".into(),
segments: vec![
Segment::new(SegmentRole::StablePrefix, vec![Message::user("guide")]),
Segment::new(SegmentRole::DynamicContext, vec![Message::user("dyn")]),
Segment::new(SegmentRole::Conversation, vec![Message::user("ask")]),
],
..PromptIR::default()
};
let _ = cap
.chat_stream_ir(&ir, &[], None, "m", None)
.await
.expect("ir stream");
let seen = cap.seen.lock().expect("seen lock").clone();
let expected = ir.flatten();
assert_eq!(seen.len(), expected.len(), "delegates the flattened IR");
for (got, want) in seen.iter().zip(expected.iter()) {
assert_eq!(got.role, want.role);
assert_eq!(got.content, want.content);
}
assert_eq!(seen.len(), 4);
assert!(matches!(seen[0].role, bamboo_domain::Role::System));
let responses = cap
.seen_responses
.lock()
.expect("resp lock")
.clone()
.expect("default derives Responses options from the IR");
assert_eq!(responses.instructions.as_deref(), Some("sys"));
let input = responses.input_messages.expect("input_messages derived");
assert_eq!(
input.iter().map(|m| m.content.clone()).collect::<Vec<_>>(),
vec!["guide".to_string(), "dyn".to_string(), "ask".to_string()],
"input_messages is the responses_input view: NO leading system message"
);
}
#[derive(Clone, Default)]
struct RecordingProvider {
requested_models: Arc<Mutex<Vec<String>>>,
requested_max_tokens: Arc<Mutex<Vec<Option<u32>>>>,
}
#[tokio::test]
async fn default_tool_footprint_is_one_compact_openai_compat_position() {
let provider = RecordingProvider::default();
let tools = vec![ToolSchema {
schema_type: "function".to_string(),
function: bamboo_domain::FunctionSchema {
name: "lookup".to_string(),
description: "Look up a value".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {"key": {"type": "string"}},
"oneOf": [{"required": ["key"]}]
}),
},
}];
let footprint = provider
.provider_visible_tool_footprint(&PromptIR::default(), &tools, "model", None)
.await
.expect("footprint");
assert_eq!(footprint.segments.len(), 1);
assert_eq!(
footprint.segments[0].kind,
ProviderVisibleToolSegmentKind::InitialFullDefinition
);
let rendered: serde_json::Value =
serde_json::from_str(&footprint.segments[0].serialized).unwrap();
assert_eq!(
footprint.segments[0].serialized,
serde_json::to_string(&rendered).unwrap()
);
assert_eq!(rendered[0]["function"]["name"], "lookup");
assert!(rendered[0]["function"]["parameters"].get("oneOf").is_none());
assert_eq!(
provider
.provider_visible_tool_footprint(&PromptIR::default(), &[], "model", None)
.await
.unwrap(),
ProviderVisibleToolFootprint::default()
);
}
#[async_trait]
impl LLMProvider for RecordingProvider {
async fn chat_stream(
&self,
_messages: &[Message],
_tools: &[ToolSchema],
max_output_tokens: Option<u32>,
model: &str,
) -> Result<LLMStream> {
if let Ok(mut models) = self.requested_models.lock() {
models.push(model.to_string());
}
if let Ok(mut max_tokens) = self.requested_max_tokens.lock() {
max_tokens.push(max_output_tokens);
}
Ok(Box::pin(stream::empty()))
}
}
#[tokio::test]
async fn main_dispatch_scope_is_attempt_local_and_not_inherited_by_spawned_tasks() {
let a: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
let b: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
let outer_seen = Arc::new(Mutex::new(Vec::<Arc<dyn LLMProvider>>::new()));
let inner_seen = Arc::new(Mutex::new(Vec::<Arc<dyn LLMProvider>>::new()));
let outer_copy = outer_seen.clone();
let inner_copy = inner_seen.clone();
let outer: MainDispatchObserver =
Arc::new(move |provider| outer_copy.lock().unwrap().push(provider));
let inner: MainDispatchObserver =
Arc::new(move |provider| inner_copy.lock().unwrap().push(provider));
assert!(!observe_main_dispatch(a.clone()));
scope_main_dispatch(outer, async {
assert!(observe_main_dispatch(a.clone()));
let spawned_b = b.clone();
assert!(
!tokio::spawn(async move { observe_main_dispatch(spawned_b) })
.await
.unwrap(),
"a new task must not silently inherit an attempt receipt"
);
scope_main_dispatch(inner, async {
assert!(observe_main_dispatch(b.clone()));
})
.await;
assert!(observe_main_dispatch(a.clone()), "outer scope is restored");
})
.await;
assert!(
!observe_main_dispatch(b.clone()),
"scope must not leak after completion"
);
let outer = outer_seen.lock().unwrap();
assert_eq!(outer.len(), 2);
assert!(outer.iter().all(|provider| Arc::ptr_eq(provider, &a)));
let inner = inner_seen.lock().unwrap();
assert_eq!(inner.len(), 1);
assert!(Arc::ptr_eq(&inner[0], &b));
}
#[tokio::test]
async fn cancelling_scoped_main_attempt_does_not_install_a_later_observer() {
let selected: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
let observed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let observed_copy = observed.clone();
let observer: MainDispatchObserver = Arc::new(move |_| {
observed_copy.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
});
let (started, ready) = tokio::sync::oneshot::channel();
let run = tokio::spawn(scope_main_dispatch(observer, async move {
started.send(()).unwrap();
std::future::pending::<()>().await;
}));
ready.await.unwrap();
run.abort();
assert!(run.await.unwrap_err().is_cancelled());
assert!(!observe_main_dispatch(selected));
assert_eq!(observed.load(std::sync::atomic::Ordering::SeqCst), 0);
}
struct ResnapshottingProvider {
alternate: Arc<dyn LLMProvider>,
calls: std::sync::atomic::AtomicUsize,
require_current: bool,
}
#[async_trait]
impl LLMProvider for ResnapshottingProvider {
fn queue_requires_current_route(&self) -> bool {
self.require_current
}
fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
Ok(Some(self.alternate.clone()))
}
async fn chat_stream(
&self,
_: &[Message],
_: &[ToolSchema],
_: Option<u32>,
_: &str,
) -> Result<LLMStream> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(Box::pin(stream::empty()))
}
}
#[tokio::test]
async fn fixed_dispatch_delegate_keeps_selected_arc_and_outer_budget_identity() {
let b = Arc::new(RecordingProvider::default());
let a = Arc::new(ResnapshottingProvider {
alternate: b.clone(),
calls: std::sync::atomic::AtomicUsize::new(0),
require_current: false,
});
let selected: Arc<dyn LLMProvider> = a.clone();
let original: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
let expected_identity = Arc::as_ptr(&original) as *const () as usize;
let fixed = fixed_provider_with_identity(selected, original);
assert_eq!(
fixed.resource_allocation_identity(),
Some(expected_identity)
);
assert!(fixed.queue_requires_current_route());
let queued = queued_snapshot_for(fixed.clone()).unwrap();
assert!(
Arc::ptr_eq(&queued, &fixed),
"receipt-bound Arc must not be resnapshotted"
);
let _ = queued.chat_stream(&[], &[], None, "gold").await.unwrap();
assert_eq!(a.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(b.requested_models.lock().unwrap().is_empty());
}
#[tokio::test]
async fn injected_fixed_queue_keeps_a_when_current_selection_becomes_b() {
let a = Arc::new(RecordingProvider::default());
let b = Arc::new(RecordingProvider::default());
let original: Arc<dyn LLMProvider> = Arc::new(RecordingProvider::default());
let expected_identity = Arc::as_ptr(&original) as *const () as usize;
let queued = fixed_injected_provider_with_identity(a.clone(), original.clone());
let current = fixed_injected_provider_with_identity(b.clone(), original.clone());
assert!(!queued.queue_requires_current_route());
assert!(!current.queue_requires_current_route());
assert_eq!(
queued.resource_allocation_identity(),
Some(expected_identity)
);
assert_eq!(
current.resource_allocation_identity(),
Some(expected_identity)
);
let captured = queued_snapshot_for(queued.clone()).unwrap();
assert!(Arc::ptr_eq(&captured, &queued));
let _ = captured.chat_stream(&[], &[], None, "gold").await.unwrap();
assert_eq!(
*a.requested_models.lock().unwrap(),
vec!["gold".to_string()]
);
assert!(b.requested_models.lock().unwrap().is_empty());
let guarded_inner: Arc<dyn LLMProvider> = Arc::new(ResnapshottingProvider {
alternate: b,
calls: std::sync::atomic::AtomicUsize::new(0),
require_current: true,
});
let guarded = fixed_injected_provider_with_identity(guarded_inner, original);
assert!(
guarded.queue_requires_current_route(),
"the selected provider's own policy remains authoritative"
);
}
#[tokio::test]
async fn chat_stream_with_options_delegates_to_chat_stream_with_same_model_and_tokens() {
let provider = RecordingProvider::default();
let options = LLMRequestOptions::default();
let mut stream = provider
.chat_stream_with_options(&[], &[], Some(512), "gpt-test", Some(&options))
.await
.expect("delegation should succeed");
assert!(stream.next().await.is_none());
assert_eq!(
provider
.requested_models
.lock()
.expect("lock poisoned")
.as_slice(),
["gpt-test"]
);
assert_eq!(
provider
.requested_max_tokens
.lock()
.expect("lock poisoned")
.as_slice(),
[Some(512)]
);
}
#[tokio::test]
async fn list_models_returns_empty_by_default() {
let provider = RecordingProvider::default();
let models = provider
.list_models()
.await
.expect("default list_models should succeed");
assert!(models.is_empty());
}
#[test]
fn request_options_default_has_no_purpose() {
let opts = LLMRequestOptions::default();
assert!(opts.request_purpose.is_none());
}
#[test]
fn request_options_purpose_is_set_and_readable() {
let opts = LLMRequestOptions {
request_purpose: Some("title_generation".to_string()),
..Default::default()
};
assert_eq!(opts.request_purpose.as_deref(), Some("title_generation"));
}
}