1use 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#[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#[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#[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#[doc(hidden)]
52#[derive(Clone)]
53pub enum QueuedRouteIdentity {
54 Stable(Arc<dyn LLMProvider>),
55 Unmanaged,
56}
57
58#[derive(Error, Debug)]
60pub enum LLMError {
61 #[error("HTTP error: {0}")]
63 Http(#[from] reqwest::Error),
64
65 #[error("JSON error: {0}")]
67 Json(#[from] serde_json::Error),
68
69 #[error("Stream error: {0}")]
71 Stream(String),
72
73 #[error("API error: {0}")]
75 Api(String),
76
77 #[error("Authentication error: {0}")]
79 Auth(String),
80
81 #[error("{0}")]
84 RequestFailed(String),
85
86 #[error("Protocol conversion error: {0}")]
88 Protocol(#[from] crate::protocol::ProtocolError),
89}
90
91pub type Result<T> = std::result::Result<T, LLMError>;
93
94pub type LLMStream = Pin<Box<dyn Stream<Item = Result<LLMChunk>> + Send>>;
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum ProviderVisibleToolSegmentKind {
105 InitialFullDefinition,
107 InitialDeferredDescriptor,
110 AnthropicToolReferenceExpansion,
112 ProviderLateBound,
116}
117
118#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct ProviderVisibleToolFootprint {
152 pub segments: Vec<ProviderVisibleToolSegment>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ProviderModelInfo {
158 pub id: String,
160 pub max_context_tokens: Option<u32>,
164 pub max_output_tokens: Option<u32>,
166}
167
168impl ProviderModelInfo {
169 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#[derive(Debug, Clone, Default)]
181pub struct ResponsesRequestOptions {
182 pub instructions: Option<String>,
184 pub input_messages: Option<Vec<Message>>,
192 pub provider_transcript_groups: Vec<bamboo_domain::ProviderTranscriptGroup>,
196 pub reasoning_summary: Option<String>,
199 pub include: Option<Vec<String>>,
201 pub store: Option<bool>,
203 pub previous_response_id: Option<String>,
205 pub truncation: Option<String>,
208 pub text_verbosity: Option<String>,
211 pub prompt_cache_key: Option<String>,
215 pub prompt_cache_options: Option<serde_json::Value>,
218 pub raw_input_with_cache_breakpoints: Option<serde_json::Value>,
226 pub prefix_epoch: Option<u64>,
229 pub prefix_reset_reason: Option<ModelContextResetReason>,
231 pub retain_protocol_events: bool,
237}
238
239#[derive(Debug, Clone, Default)]
241pub struct LLMRequestOptions {
242 pub session_id: Option<String>,
244 pub reasoning_effort: Option<ReasoningEffort>,
246 pub parallel_tool_calls: Option<bool>,
251 pub required_tool: Option<String>,
255 pub responses: Option<ResponsesRequestOptions>,
257 pub request_purpose: Option<String>,
259 pub cache: Option<crate::cache::PromptCachePlan>,
265}
266
267pub(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#[async_trait]
317pub trait LLMProvider: Send + Sync {
318 #[doc(hidden)]
330 fn fixed_gold_policy_delegate(&self) -> Option<Arc<dyn LLMProvider>> {
331 None
332 }
333
334 #[doc(hidden)]
338 fn legacy_injected_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
339 Ok(None)
340 }
341
342 #[doc(hidden)]
347 fn known_pre_dispatch_error(&self) -> Option<LLMError> {
348 None
349 }
350
351 #[doc(hidden)]
355 fn local_dispatch_refused(&self) -> bool {
356 false
357 }
358
359 fn resource_allocation_identity(&self) -> Option<usize> {
362 None
363 }
364
365 #[doc(hidden)]
370 fn queue_requires_current_route(&self) -> bool {
371 false
372 }
373
374 #[doc(hidden)]
378 fn queued_route_identity(&self) -> QueuedRouteIdentity {
379 QueuedRouteIdentity::Unmanaged
380 }
381
382 #[doc(hidden)]
385 fn queued_provider_snapshot(&self) -> Result<Option<Arc<dyn LLMProvider>>> {
386 Ok(None)
387 }
388
389 async fn supports_vision(&self, model: &str) -> bool {
392 self.vision_support_override(model).await.unwrap_or(true)
393 }
394
395 async fn vision_support_override(&self, _model: &str) -> Option<bool> {
398 None
399 }
400
401 async fn capability_loading_mode(
406 &self,
407 _model: &str,
408 _required_tool: Option<&str>,
409 ) -> CapabilityLoadingMode {
410 CapabilityLoadingMode::LegacyFullCatalog
411 }
412
413 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 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 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 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 async fn list_models(&self) -> Result<Vec<String>> {
533 Ok(vec![])
535 }
536
537 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#[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#[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#[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#[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#[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 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 #[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 assert_eq!(seen.len(), 4);
811 assert!(matches!(seen[0].role, bamboo_domain::Role::System));
812
813 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}