1use std::future::Future;
52use std::sync::Arc;
53
54use agent_sdk_foundation::llm::{ChatOutcome, ChatRequest, ThinkingConfig};
55use anyhow::Result;
56use async_trait::async_trait;
57use futures::StreamExt;
58use tokio::sync::Mutex;
59
60use crate::model_capabilities::ModelCapabilities;
61use crate::provider::{LlmProvider, StructuredOutputSupport};
62use crate::streaming::{StreamBox, StreamDelta};
63
64pub struct RefreshingProvider<P, F> {
77 inner: Arc<Mutex<P>>,
78 refresh: Arc<F>,
79 template: P,
84 model: String,
85 provider: &'static str,
86 thinking: Option<ThinkingConfig>,
87}
88
89impl<P: Clone, F> Clone for RefreshingProvider<P, F> {
90 fn clone(&self) -> Self {
91 Self {
92 inner: Arc::clone(&self.inner),
93 refresh: Arc::clone(&self.refresh),
94 template: self.template.clone(),
95 model: self.model.clone(),
96 provider: self.provider,
97 thinking: self.thinking.clone(),
98 }
99 }
100}
101
102impl<P, F, Fut> RefreshingProvider<P, F>
103where
104 P: LlmProvider + Clone + 'static,
105 F: Fn() -> Fut + Send + Sync + 'static,
106 Fut: Future<Output = Result<P>> + Send + 'static,
107{
108 #[must_use]
116 pub fn new(inner: P, refresh: F) -> Self {
117 let model = inner.model().to_string();
118 let provider = inner.provider();
119 let thinking = inner.configured_thinking().cloned();
120 let template = inner.clone();
121 Self {
122 inner: Arc::new(Mutex::new(inner)),
123 refresh: Arc::new(refresh),
124 template,
125 model,
126 provider,
127 thinking,
128 }
129 }
130
131 async fn snapshot(&self) -> P {
132 self.inner.lock().await.clone()
133 }
134
135 async fn run_refresh(&self) -> Result<()> {
136 let fresh = (self.refresh)().await?;
137 *self.inner.lock().await = fresh;
138 Ok(())
139 }
140}
141
142#[must_use]
150pub fn is_unauthorized_error(message: &str) -> bool {
151 let lower = message.to_ascii_lowercase();
152 lower.contains(" 401")
153 || lower.contains("status=401")
154 || lower.contains("unauthorized")
155 || lower.contains("authentication")
156 || lower.contains("token_expired")
157 || lower.contains("invalid api key")
158 || lower.contains("invalid_api_key")
159}
160
161#[async_trait]
162impl<P, F, Fut> LlmProvider for RefreshingProvider<P, F>
163where
164 P: LlmProvider + Clone + 'static,
165 F: Fn() -> Fut + Send + Sync + 'static,
166 Fut: Future<Output = Result<P>> + Send + 'static,
167{
168 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
169 let outcome = self.snapshot().await.chat(request.clone()).await?;
170 if let ChatOutcome::InvalidRequest(message) = &outcome
171 && is_unauthorized_error(message)
172 {
173 match self.run_refresh().await {
174 Ok(()) => return self.snapshot().await.chat(request).await,
175 Err(error) => {
176 log::warn!("RefreshingProvider refresh after 401 failed: {error:#}");
177 }
178 }
179 }
180 Ok(outcome)
181 }
182
183 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
184 let this = self.clone();
185 Box::pin(async_stream::stream! {
186 let mut refreshed = false;
187 'attempts: loop {
188 let provider = this.snapshot().await;
189 let mut stream = provider.chat_stream(request.clone());
190 let mut saw_output = false;
191
192 while let Some(item) = stream.next().await {
193 match item {
194 Ok(StreamDelta::Error { message, kind })
195 if !saw_output
196 && !refreshed
197 && is_unauthorized_error(&message) =>
198 {
199 match this.run_refresh().await {
200 Ok(()) => {
201 refreshed = true;
202 continue 'attempts;
203 }
204 Err(error) => {
205 log::warn!(
206 "RefreshingProvider refresh after streaming 401 failed: {error:#}"
207 );
208 yield Ok(StreamDelta::Error { message, kind });
209 return;
210 }
211 }
212 }
213 Ok(delta) => {
214 if matches!(
215 delta,
216 StreamDelta::TextDelta { .. }
217 | StreamDelta::ThinkingDelta { .. }
218 | StreamDelta::ToolUseStart { .. }
219 | StreamDelta::ToolInputDelta { .. }
220 | StreamDelta::SignatureDelta { .. }
221 | StreamDelta::RedactedThinking { .. }
222 | StreamDelta::OpaqueReasoning { .. }
223 ) {
224 saw_output = true;
225 }
226 let done = matches!(delta, StreamDelta::Done { .. });
227 yield Ok(delta);
228 if done {
229 return;
230 }
231 }
232 Err(error)
233 if !saw_output
234 && !refreshed
235 && is_unauthorized_error(&error.to_string()) =>
236 {
237 match this.run_refresh().await {
238 Ok(()) => {
239 refreshed = true;
240 continue 'attempts;
241 }
242 Err(refresh_error) => {
243 log::warn!(
244 "RefreshingProvider refresh after stream failure failed: {refresh_error:#}"
245 );
246 yield Err(error);
247 return;
248 }
249 }
250 }
251 Err(error) => {
252 yield Err(error);
253 return;
254 }
255 }
256 }
257 return;
258 }
259 })
260 }
261
262 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
265 self.snapshot().await.list_models().await
266 }
267
268 fn model(&self) -> &str {
269 &self.model
270 }
271
272 fn provider(&self) -> &'static str {
273 self.provider
274 }
275
276 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
277 self.thinking.as_ref()
278 }
279
280 fn capabilities(&self) -> Option<&'static ModelCapabilities> {
287 self.template.capabilities()
288 }
289
290 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
291 self.template.validate_thinking_config(thinking)
292 }
293
294 fn default_max_tokens(&self) -> u32 {
295 self.template.default_max_tokens()
296 }
297
298 fn structured_output_support(&self) -> StructuredOutputSupport {
299 self.template.structured_output_support()
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 use std::collections::VecDeque;
308 use std::sync::Mutex as StdMutex;
309 use std::sync::atomic::{AtomicUsize, Ordering};
310
311 use agent_sdk_foundation::llm::{ChatResponse, ContentBlock, StopReason, Usage};
312 use anyhow::Context;
313
314 use crate::streaming::StreamErrorKind;
315
316 #[derive(Clone)]
317 enum MockStreamItem {
318 Ok(StreamDelta),
319 Err(String),
320 }
321
322 #[derive(Clone)]
323 struct MockProvider {
324 model: String,
325 provider_name: &'static str,
326 outcomes: Arc<StdMutex<VecDeque<ChatOutcome>>>,
327 stream_batches: Arc<StdMutex<VecDeque<Vec<MockStreamItem>>>>,
328 chat_calls: Arc<AtomicUsize>,
329 stream_calls: Arc<AtomicUsize>,
330 }
331
332 impl MockProvider {
333 fn new() -> Self {
334 Self {
335 model: "mock-model".to_string(),
336 provider_name: "mock",
337 outcomes: Arc::new(StdMutex::new(VecDeque::new())),
338 stream_batches: Arc::new(StdMutex::new(VecDeque::new())),
339 chat_calls: Arc::new(AtomicUsize::new(0)),
340 stream_calls: Arc::new(AtomicUsize::new(0)),
341 }
342 }
343
344 fn queue_chat(&self, outcome: ChatOutcome) -> Result<()> {
345 self.outcomes
346 .lock()
347 .ok()
348 .context("outcomes lock poisoned")?
349 .push_back(outcome);
350 Ok(())
351 }
352
353 fn queue_stream(&self, batch: Vec<MockStreamItem>) -> Result<()> {
354 self.stream_batches
355 .lock()
356 .ok()
357 .context("stream_batches lock poisoned")?
358 .push_back(batch);
359 Ok(())
360 }
361
362 fn chat_call_count(&self) -> usize {
363 self.chat_calls.load(Ordering::SeqCst)
364 }
365
366 fn stream_call_count(&self) -> usize {
367 self.stream_calls.load(Ordering::SeqCst)
368 }
369 }
370
371 #[async_trait]
372 impl LlmProvider for MockProvider {
373 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
374 self.chat_calls.fetch_add(1, Ordering::SeqCst);
375 let mut queue = self
376 .outcomes
377 .lock()
378 .ok()
379 .context("outcomes lock poisoned")?;
380 queue.pop_front().context("MockProvider: no queued outcome")
381 }
382
383 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
384 Ok(vec![crate::provider::ModelInfo {
385 id: "mock-discovered-model".to_owned(),
386 display_name: None,
387 context_window: None,
388 max_output_tokens: None,
389 }])
390 }
391
392 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
393 self.stream_calls.fetch_add(1, Ordering::SeqCst);
394 let batch: Vec<MockStreamItem> = self
395 .stream_batches
396 .lock()
397 .ok()
398 .and_then(|mut q| q.pop_front())
399 .unwrap_or_else(|| vec![MockStreamItem::Err("no queued stream batch".into())]);
400 Box::pin(async_stream::stream! {
401 for item in batch {
402 match item {
403 MockStreamItem::Ok(delta) => yield Ok(delta),
404 MockStreamItem::Err(msg) => {
405 yield Err(anyhow::anyhow!(msg));
406 return;
407 }
408 }
409 }
410 })
411 }
412
413 fn model(&self) -> &str {
414 &self.model
415 }
416
417 fn provider(&self) -> &'static str {
418 self.provider_name
419 }
420
421 fn default_max_tokens(&self) -> u32 {
424 32_000
425 }
426
427 fn structured_output_support(&self) -> StructuredOutputSupport {
428 StructuredOutputSupport::Native
429 }
430
431 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
432 if thinking.is_some() {
433 Err(anyhow::anyhow!("mock rejects thinking"))
434 } else {
435 Ok(())
436 }
437 }
438 }
439
440 fn success_response() -> ChatResponse {
441 ChatResponse {
442 id: "msg_test".to_string(),
443 content: vec![ContentBlock::Text {
444 text: "ok".to_string(),
445 }],
446 model: "mock-model".to_string(),
447 stop_reason: Some(StopReason::EndTurn),
448 usage: Usage {
449 input_tokens: 1,
450 output_tokens: 1,
451 cached_input_tokens: 0,
452 cache_creation_input_tokens: 0,
453 },
454 }
455 }
456
457 fn empty_request() -> ChatRequest {
458 ChatRequest {
459 system: String::new(),
460 messages: Vec::new(),
461 tools: None,
462 max_tokens: 100,
463 max_tokens_explicit: false,
464 session_id: None,
465 cached_content: None,
466 thinking: None,
467 tool_choice: None,
468 response_format: None,
469 cache: None,
470 }
471 }
472
473 type BoxedFut = std::pin::Pin<Box<dyn Future<Output = Result<MockProvider>> + Send>>;
474 type RefreshFn = Box<dyn Fn() -> BoxedFut + Send + Sync + 'static>;
475 type Wrapped = RefreshingProvider<MockProvider, RefreshFn>;
476
477 fn wrap_success(mock: &MockProvider, counter: &Arc<AtomicUsize>) -> Wrapped {
478 let counter = Arc::clone(counter);
479 let template = mock.clone();
480 let cb: RefreshFn = Box::new(move || {
481 counter.fetch_add(1, Ordering::SeqCst);
482 let provider = template.clone();
483 Box::pin(async move { Ok(provider) })
484 });
485 RefreshingProvider::new(mock.clone(), cb)
486 }
487
488 fn wrap_failure(
489 mock: &MockProvider,
490 counter: &Arc<AtomicUsize>,
491 error: &'static str,
492 ) -> Wrapped {
493 let counter = Arc::clone(counter);
494 let cb: RefreshFn = Box::new(move || {
495 counter.fetch_add(1, Ordering::SeqCst);
496 Box::pin(async move { Err(anyhow::anyhow!(error)) })
497 });
498 RefreshingProvider::new(mock.clone(), cb)
499 }
500
501 #[test]
503 fn wrapper_delegates_capability_overrides_to_inner() {
504 let mock = MockProvider::new();
505 let refresh_count = Arc::new(AtomicUsize::new(0));
506 let wrapped = wrap_success(&mock, &refresh_count);
507
508 assert_eq!(wrapped.default_max_tokens(), 32_000);
511 assert_eq!(
512 wrapped.structured_output_support(),
513 StructuredOutputSupport::Native
514 );
515 assert!(
516 wrapped
517 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
518 .is_err()
519 );
520 assert!(wrapped.validate_thinking_config(None).is_ok());
521 }
522
523 #[tokio::test]
524 async fn wrapper_delegates_list_models_to_inner_snapshot() -> Result<()> {
525 let mock = MockProvider::new();
526 let refresh_count = Arc::new(AtomicUsize::new(0));
527 let wrapped = wrap_success(&mock, &refresh_count);
528
529 let models = wrapped.list_models().await?;
532 assert_eq!(models.len(), 1);
533 assert_eq!(models[0].id, "mock-discovered-model");
534 Ok(())
535 }
536
537 #[test]
539 fn is_unauthorized_error_matches_expected_strings() {
540 assert!(is_unauthorized_error("HTTP 401"));
541 assert!(is_unauthorized_error("status=401 Unauthorized"));
542 assert!(is_unauthorized_error("Invalid API key"));
543 assert!(is_unauthorized_error("invalid_api_key"));
544 assert!(is_unauthorized_error("token_expired"));
545 assert!(is_unauthorized_error("Authentication failed"));
546 assert!(is_unauthorized_error("UNAUTHORIZED"));
547
548 assert!(!is_unauthorized_error("rate limited"));
549 assert!(!is_unauthorized_error("network error"));
550 assert!(!is_unauthorized_error(""));
551 assert!(!is_unauthorized_error("internal server error"));
552 }
553
554 #[tokio::test]
556 async fn chat_successful_pass_through_does_not_refresh() -> Result<()> {
557 let mock = MockProvider::new();
558 mock.queue_chat(ChatOutcome::Success(success_response()))?;
559
560 let refresh_count = Arc::new(AtomicUsize::new(0));
561 let wrapped = wrap_success(&mock, &refresh_count);
562
563 let outcome = wrapped.chat(empty_request()).await?;
564 assert!(matches!(outcome, ChatOutcome::Success(_)));
565 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
566 assert_eq!(mock.chat_call_count(), 1);
567 Ok(())
568 }
569
570 #[tokio::test]
572 async fn chat_401_triggers_refresh_and_retries() -> Result<()> {
573 let mock = MockProvider::new();
574 mock.queue_chat(ChatOutcome::InvalidRequest("401 Unauthorized".into()))?;
575 mock.queue_chat(ChatOutcome::Success(success_response()))?;
576
577 let refresh_count = Arc::new(AtomicUsize::new(0));
578 let wrapped = wrap_success(&mock, &refresh_count);
579
580 let outcome = wrapped.chat(empty_request()).await?;
581 assert!(matches!(outcome, ChatOutcome::Success(_)));
582 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
583 assert_eq!(mock.chat_call_count(), 2);
584 Ok(())
585 }
586
587 #[tokio::test]
589 async fn chat_surfaces_original_401_when_refresh_fails() -> Result<()> {
590 let mock = MockProvider::new();
591 mock.queue_chat(ChatOutcome::InvalidRequest(
592 "status=401 Unauthorized".into(),
593 ))?;
594
595 let refresh_count = Arc::new(AtomicUsize::new(0));
596 let wrapped = wrap_failure(&mock, &refresh_count, "refresh callback failed");
597
598 let outcome = wrapped.chat(empty_request()).await?;
599 match outcome {
600 ChatOutcome::InvalidRequest(msg) => assert!(
601 msg.contains("401"),
602 "expected original 401 message, got {msg}"
603 ),
604 other => panic!("expected InvalidRequest, got {other:?}"),
605 }
606 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
607 assert_eq!(mock.chat_call_count(), 1);
608 Ok(())
609 }
610
611 async fn drain(mut stream: StreamBox<'_>) -> Vec<Result<StreamDelta>> {
612 let mut out = Vec::new();
613 while let Some(item) = stream.next().await {
614 out.push(item);
615 }
616 out
617 }
618
619 #[tokio::test]
621 async fn chat_stream_successful_pass_through() -> Result<()> {
622 let mock = MockProvider::new();
623 mock.queue_stream(vec![
624 MockStreamItem::Ok(StreamDelta::TextDelta {
625 delta: "hi".into(),
626 block_index: 0,
627 }),
628 MockStreamItem::Ok(StreamDelta::Done {
629 stop_reason: Some(StopReason::EndTurn),
630 }),
631 ])?;
632
633 let refresh_count = Arc::new(AtomicUsize::new(0));
634 let wrapped = wrap_success(&mock, &refresh_count);
635
636 let deltas = drain(wrapped.chat_stream(empty_request())).await;
637 assert_eq!(deltas.len(), 2);
638 assert!(matches!(
639 deltas[0].as_ref().ok(),
640 Some(StreamDelta::TextDelta { delta, .. }) if delta == "hi"
641 ));
642 assert!(matches!(
643 deltas[1].as_ref().ok(),
644 Some(StreamDelta::Done { .. })
645 ));
646 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
647 assert_eq!(mock.stream_call_count(), 1);
648 Ok(())
649 }
650
651 #[tokio::test]
653 async fn chat_stream_401_before_output_retries() -> Result<()> {
654 let mock = MockProvider::new();
655 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
656 message: "status=401 Unauthorized".into(),
657 kind: StreamErrorKind::InvalidRequest,
658 })])?;
659 mock.queue_stream(vec![
660 MockStreamItem::Ok(StreamDelta::TextDelta {
661 delta: "retried".into(),
662 block_index: 0,
663 }),
664 MockStreamItem::Ok(StreamDelta::Done {
665 stop_reason: Some(StopReason::EndTurn),
666 }),
667 ])?;
668
669 let refresh_count = Arc::new(AtomicUsize::new(0));
670 let wrapped = wrap_success(&mock, &refresh_count);
671
672 let deltas = drain(wrapped.chat_stream(empty_request())).await;
673 assert_eq!(deltas.len(), 2);
675 assert!(matches!(
676 deltas[0].as_ref().ok(),
677 Some(StreamDelta::TextDelta { delta, .. }) if delta == "retried"
678 ));
679 assert!(matches!(
680 deltas[1].as_ref().ok(),
681 Some(StreamDelta::Done { .. })
682 ));
683 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
684 assert_eq!(mock.stream_call_count(), 2);
685 Ok(())
686 }
687
688 #[tokio::test]
690 async fn chat_stream_401_after_output_does_not_retry() -> Result<()> {
691 let mock = MockProvider::new();
692 mock.queue_stream(vec![
693 MockStreamItem::Ok(StreamDelta::TextDelta {
694 delta: "partial".into(),
695 block_index: 0,
696 }),
697 MockStreamItem::Ok(StreamDelta::Error {
698 message: "401 Unauthorized".into(),
699 kind: StreamErrorKind::InvalidRequest,
700 }),
701 ])?;
702
703 let refresh_count = Arc::new(AtomicUsize::new(0));
704 let wrapped = wrap_success(&mock, &refresh_count);
705
706 let deltas = drain(wrapped.chat_stream(empty_request())).await;
707 assert_eq!(deltas.len(), 2);
708 assert!(matches!(
709 deltas[0].as_ref().ok(),
710 Some(StreamDelta::TextDelta { delta, .. }) if delta == "partial"
711 ));
712 assert!(matches!(
713 deltas[1].as_ref().ok(),
714 Some(StreamDelta::Error { message, .. }) if message.contains("401")
715 ));
716 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
717 assert_eq!(mock.stream_call_count(), 1);
718 Ok(())
719 }
720
721 #[tokio::test]
722 async fn chat_stream_401_after_opaque_reasoning_does_not_retry() -> Result<()> {
723 let mock = MockProvider::new();
724 mock.queue_stream(vec![
725 MockStreamItem::Ok(StreamDelta::OpaqueReasoning {
726 provider: "test-provider".to_owned(),
727 data: serde_json::json!({"encrypted_content": "ciphertext"}),
728 block_index: 0,
729 }),
730 MockStreamItem::Ok(StreamDelta::Error {
731 message: "401 Unauthorized".into(),
732 kind: StreamErrorKind::InvalidRequest,
733 }),
734 ])?;
735
736 let refresh_count = Arc::new(AtomicUsize::new(0));
737 let wrapped = wrap_success(&mock, &refresh_count);
738 let deltas = drain(wrapped.chat_stream(empty_request())).await;
739
740 assert_eq!(deltas.len(), 2);
741 assert!(matches!(
742 deltas[0].as_ref().ok(),
743 Some(StreamDelta::OpaqueReasoning { provider, .. })
744 if provider == "test-provider"
745 ));
746 assert!(matches!(
747 deltas[1].as_ref().ok(),
748 Some(StreamDelta::Error { message, .. }) if message.contains("401")
749 ));
750 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
751 assert_eq!(mock.stream_call_count(), 1);
752 Ok(())
753 }
754
755 #[tokio::test]
757 async fn chat_stream_only_one_retry_per_call() -> Result<()> {
758 let mock = MockProvider::new();
759 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
760 message: "status=401 Unauthorized".into(),
761 kind: StreamErrorKind::InvalidRequest,
762 })])?;
763 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
764 message: "still 401 Unauthorized".into(),
765 kind: StreamErrorKind::InvalidRequest,
766 })])?;
767
768 let refresh_count = Arc::new(AtomicUsize::new(0));
769 let wrapped = wrap_success(&mock, &refresh_count);
770
771 let deltas = drain(wrapped.chat_stream(empty_request())).await;
772 assert_eq!(deltas.len(), 1);
773 assert!(matches!(
774 deltas[0].as_ref().ok(),
775 Some(StreamDelta::Error { message, .. }) if message == "still 401 Unauthorized"
776 ));
777 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
778 assert_eq!(mock.stream_call_count(), 2);
779 Ok(())
780 }
781
782 #[derive(Clone)]
788 struct ConcurrentMock {
789 model: String,
790 provider_name: &'static str,
791 total_calls: Arc<AtomicUsize>,
792 initial_barrier: Arc<tokio::sync::Barrier>,
793 }
794
795 type CMFut = std::pin::Pin<Box<dyn Future<Output = Result<ConcurrentMock>> + Send>>;
796 type CMRefresh = Box<dyn Fn() -> CMFut + Send + Sync + 'static>;
797
798 #[async_trait]
799 impl LlmProvider for ConcurrentMock {
800 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
801 let call_index = self.total_calls.fetch_add(1, Ordering::SeqCst);
802 if call_index < 2 {
803 self.initial_barrier.wait().await;
804 Ok(ChatOutcome::InvalidRequest("401 Unauthorized".into()))
805 } else {
806 Ok(ChatOutcome::Success(success_response()))
807 }
808 }
809
810 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
811 Box::pin(async_stream::stream! {
812 yield Err(anyhow::anyhow!("chat_stream not used in this test"));
813 })
814 }
815
816 fn model(&self) -> &str {
817 &self.model
818 }
819
820 fn provider(&self) -> &'static str {
821 self.provider_name
822 }
823 }
824
825 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
827 async fn chat_concurrent_callers_share_refresh() -> Result<()> {
828 let mock = ConcurrentMock {
829 model: "mock-model".to_string(),
830 provider_name: "mock",
831 total_calls: Arc::new(AtomicUsize::new(0)),
832 initial_barrier: Arc::new(tokio::sync::Barrier::new(2)),
833 };
834 let call_count = Arc::clone(&mock.total_calls);
835 let refresh_count = Arc::new(AtomicUsize::new(0));
836 let refresh_counter = Arc::clone(&refresh_count);
837 let template = mock.clone();
838
839 let cb: CMRefresh = Box::new(move || {
840 refresh_counter.fetch_add(1, Ordering::SeqCst);
841 let provider = template.clone();
842 Box::pin(async move { Ok(provider) })
843 });
844 let wrapped = RefreshingProvider::new(mock, cb);
845
846 let a = wrapped.clone();
847 let b = wrapped.clone();
848 let task_a = tokio::spawn(async move { a.chat(empty_request()).await });
849 let task_b = tokio::spawn(async move { b.chat(empty_request()).await });
850
851 let outcome_a = task_a.await.context("task_a join")??;
852 let outcome_b = task_b.await.context("task_b join")??;
853
854 assert!(matches!(outcome_a, ChatOutcome::Success(_)));
855 assert!(matches!(outcome_b, ChatOutcome::Success(_)));
856 assert_eq!(call_count.load(Ordering::SeqCst), 4);
857 let refreshes = refresh_count.load(Ordering::SeqCst);
858 assert!(
859 refreshes <= 2,
860 "expected at most 2 refresh calls (one per caller), got {refreshes}"
861 );
862 Ok(())
863 }
864}