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, UsageCarry};
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 let mut usage_carry = UsageCarry::new();
193 'attempts: loop {
194 let provider = this.snapshot().await;
195 let mut stream = provider.chat_stream(request.clone());
196 let mut saw_output = false;
197
198 while let Some(item) = stream.next().await {
199 match item {
200 Ok(StreamDelta::Error { message, kind })
201 if !saw_output
202 && !refreshed
203 && is_unauthorized_error(&message) =>
204 {
205 match this.run_refresh().await {
206 Ok(()) => {
207 refreshed = true;
208 usage_carry.abandon();
209 continue 'attempts;
210 }
211 Err(error) => {
212 log::warn!(
213 "RefreshingProvider refresh after streaming 401 failed: {error:#}"
214 );
215 yield Ok(StreamDelta::Error { message, kind });
216 return;
217 }
218 }
219 }
220 Ok(delta) => {
221 if matches!(
222 delta,
223 StreamDelta::TextDelta { .. }
224 | StreamDelta::ThinkingDelta { .. }
225 | StreamDelta::ToolUseStart { .. }
226 | StreamDelta::ToolInputDelta { .. }
227 | StreamDelta::SignatureDelta { .. }
228 | StreamDelta::RedactedThinking { .. }
229 | StreamDelta::OpaqueReasoning { .. }
230 ) {
231 saw_output = true;
232 }
233 let done = matches!(delta, StreamDelta::Done { .. });
234 let delta = match delta {
235 StreamDelta::Usage(usage) => {
236 StreamDelta::Usage(usage_carry.running_total(usage))
237 }
238 other => other,
239 };
240 yield Ok(delta);
241 if done {
242 return;
243 }
244 }
245 Err(error)
246 if !saw_output
247 && !refreshed
248 && is_unauthorized_error(&error.to_string()) =>
249 {
250 match this.run_refresh().await {
251 Ok(()) => {
252 refreshed = true;
253 usage_carry.abandon();
254 continue 'attempts;
255 }
256 Err(refresh_error) => {
257 log::warn!(
258 "RefreshingProvider refresh after stream failure failed: {refresh_error:#}"
259 );
260 yield Err(error);
261 return;
262 }
263 }
264 }
265 Err(error) => {
266 yield Err(error);
267 return;
268 }
269 }
270 }
271 return;
272 }
273 })
274 }
275
276 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
279 self.snapshot().await.list_models().await
280 }
281
282 async fn probe_connectivity(&self) -> bool {
283 self.snapshot().await.probe_connectivity().await
284 }
285
286 fn model(&self) -> &str {
287 &self.model
288 }
289
290 fn provider(&self) -> &'static str {
291 self.provider
292 }
293
294 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
295 self.thinking.as_ref()
296 }
297
298 fn capabilities(&self) -> Option<&'static ModelCapabilities> {
305 self.template.capabilities()
306 }
307
308 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
309 self.template.validate_thinking_config(thinking)
310 }
311
312 fn default_max_tokens(&self) -> u32 {
313 self.template.default_max_tokens()
314 }
315
316 fn structured_output_support(&self) -> StructuredOutputSupport {
317 self.template.structured_output_support()
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 use std::collections::VecDeque;
326 use std::sync::Mutex as StdMutex;
327 use std::sync::atomic::{AtomicUsize, Ordering};
328
329 use agent_sdk_foundation::llm::{ChatResponse, ContentBlock, StopReason, Usage};
330 use anyhow::Context;
331
332 use crate::streaming::StreamErrorKind;
333
334 #[derive(Clone)]
335 enum MockStreamItem {
336 Ok(StreamDelta),
337 Err(String),
338 }
339
340 #[derive(Clone)]
341 struct MockProvider {
342 model: String,
343 provider_name: &'static str,
344 outcomes: Arc<StdMutex<VecDeque<ChatOutcome>>>,
345 stream_batches: Arc<StdMutex<VecDeque<Vec<MockStreamItem>>>>,
346 chat_calls: Arc<AtomicUsize>,
347 stream_calls: Arc<AtomicUsize>,
348 }
349
350 impl MockProvider {
351 fn new() -> Self {
352 Self {
353 model: "mock-model".to_string(),
354 provider_name: "mock",
355 outcomes: Arc::new(StdMutex::new(VecDeque::new())),
356 stream_batches: Arc::new(StdMutex::new(VecDeque::new())),
357 chat_calls: Arc::new(AtomicUsize::new(0)),
358 stream_calls: Arc::new(AtomicUsize::new(0)),
359 }
360 }
361
362 fn queue_chat(&self, outcome: ChatOutcome) -> Result<()> {
363 self.outcomes
364 .lock()
365 .ok()
366 .context("outcomes lock poisoned")?
367 .push_back(outcome);
368 Ok(())
369 }
370
371 fn queue_stream(&self, batch: Vec<MockStreamItem>) -> Result<()> {
372 self.stream_batches
373 .lock()
374 .ok()
375 .context("stream_batches lock poisoned")?
376 .push_back(batch);
377 Ok(())
378 }
379
380 fn chat_call_count(&self) -> usize {
381 self.chat_calls.load(Ordering::SeqCst)
382 }
383
384 fn stream_call_count(&self) -> usize {
385 self.stream_calls.load(Ordering::SeqCst)
386 }
387 }
388
389 #[async_trait]
390 impl LlmProvider for MockProvider {
391 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
392 self.chat_calls.fetch_add(1, Ordering::SeqCst);
393 let mut queue = self
394 .outcomes
395 .lock()
396 .ok()
397 .context("outcomes lock poisoned")?;
398 queue.pop_front().context("MockProvider: no queued outcome")
399 }
400
401 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
402 Ok(vec![crate::provider::ModelInfo {
403 id: "mock-discovered-model".to_owned(),
404 display_name: None,
405 context_window: None,
406 max_output_tokens: None,
407 }])
408 }
409
410 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
411 self.stream_calls.fetch_add(1, Ordering::SeqCst);
412 let batch: Vec<MockStreamItem> = self
413 .stream_batches
414 .lock()
415 .ok()
416 .and_then(|mut q| q.pop_front())
417 .unwrap_or_else(|| vec![MockStreamItem::Err("no queued stream batch".into())]);
418 Box::pin(async_stream::stream! {
419 for item in batch {
420 match item {
421 MockStreamItem::Ok(delta) => yield Ok(delta),
422 MockStreamItem::Err(msg) => {
423 yield Err(anyhow::anyhow!(msg));
424 return;
425 }
426 }
427 }
428 })
429 }
430
431 fn model(&self) -> &str {
432 &self.model
433 }
434
435 fn provider(&self) -> &'static str {
436 self.provider_name
437 }
438
439 fn default_max_tokens(&self) -> u32 {
442 32_000
443 }
444
445 fn structured_output_support(&self) -> StructuredOutputSupport {
446 StructuredOutputSupport::Native
447 }
448
449 fn validate_thinking_config(&self, thinking: Option<&ThinkingConfig>) -> Result<()> {
450 if thinking.is_some() {
451 Err(anyhow::anyhow!("mock rejects thinking"))
452 } else {
453 Ok(())
454 }
455 }
456 }
457
458 fn success_response() -> ChatResponse {
459 ChatResponse {
460 id: "msg_test".to_string(),
461 content: vec![ContentBlock::Text {
462 text: "ok".to_string(),
463 }],
464 model: "mock-model".to_string(),
465 stop_reason: Some(StopReason::EndTurn),
466 usage: Usage {
467 input_tokens: 1,
468 output_tokens: 1,
469 cached_input_tokens: 0,
470 cache_creation_input_tokens: 0,
471 },
472 }
473 }
474
475 fn empty_request() -> ChatRequest {
476 ChatRequest {
477 system: String::new(),
478 messages: Vec::new(),
479 tools: None,
480 max_tokens: 100,
481 max_tokens_explicit: false,
482 session_id: None,
483 cached_content: None,
484 thinking: None,
485 tool_choice: None,
486 response_format: None,
487 cache: None,
488 }
489 }
490
491 type BoxedFut = std::pin::Pin<Box<dyn Future<Output = Result<MockProvider>> + Send>>;
492 type RefreshFn = Box<dyn Fn() -> BoxedFut + Send + Sync + 'static>;
493 type Wrapped = RefreshingProvider<MockProvider, RefreshFn>;
494
495 fn wrap_success(mock: &MockProvider, counter: &Arc<AtomicUsize>) -> Wrapped {
496 let counter = Arc::clone(counter);
497 let template = mock.clone();
498 let cb: RefreshFn = Box::new(move || {
499 counter.fetch_add(1, Ordering::SeqCst);
500 let provider = template.clone();
501 Box::pin(async move { Ok(provider) })
502 });
503 RefreshingProvider::new(mock.clone(), cb)
504 }
505
506 fn wrap_failure(
507 mock: &MockProvider,
508 counter: &Arc<AtomicUsize>,
509 error: &'static str,
510 ) -> Wrapped {
511 let counter = Arc::clone(counter);
512 let cb: RefreshFn = Box::new(move || {
513 counter.fetch_add(1, Ordering::SeqCst);
514 Box::pin(async move { Err(anyhow::anyhow!(error)) })
515 });
516 RefreshingProvider::new(mock.clone(), cb)
517 }
518
519 #[test]
521 fn wrapper_delegates_capability_overrides_to_inner() {
522 let mock = MockProvider::new();
523 let refresh_count = Arc::new(AtomicUsize::new(0));
524 let wrapped = wrap_success(&mock, &refresh_count);
525
526 assert_eq!(wrapped.default_max_tokens(), 32_000);
529 assert_eq!(
530 wrapped.structured_output_support(),
531 StructuredOutputSupport::Native
532 );
533 assert!(
534 wrapped
535 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
536 .is_err()
537 );
538 assert!(wrapped.validate_thinking_config(None).is_ok());
539 }
540
541 #[tokio::test]
542 async fn wrapper_delegates_list_models_to_inner_snapshot() -> Result<()> {
543 let mock = MockProvider::new();
544 let refresh_count = Arc::new(AtomicUsize::new(0));
545 let wrapped = wrap_success(&mock, &refresh_count);
546
547 let models = wrapped.list_models().await?;
550 assert_eq!(models.len(), 1);
551 assert_eq!(models[0].id, "mock-discovered-model");
552 Ok(())
553 }
554
555 #[test]
557 fn is_unauthorized_error_matches_expected_strings() {
558 assert!(is_unauthorized_error("HTTP 401"));
559 assert!(is_unauthorized_error("status=401 Unauthorized"));
560 assert!(is_unauthorized_error("Invalid API key"));
561 assert!(is_unauthorized_error("invalid_api_key"));
562 assert!(is_unauthorized_error("token_expired"));
563 assert!(is_unauthorized_error("Authentication failed"));
564 assert!(is_unauthorized_error("UNAUTHORIZED"));
565
566 assert!(!is_unauthorized_error("rate limited"));
567 assert!(!is_unauthorized_error("network error"));
568 assert!(!is_unauthorized_error(""));
569 assert!(!is_unauthorized_error("internal server error"));
570 }
571
572 #[tokio::test]
574 async fn chat_successful_pass_through_does_not_refresh() -> Result<()> {
575 let mock = MockProvider::new();
576 mock.queue_chat(ChatOutcome::Success(success_response()))?;
577
578 let refresh_count = Arc::new(AtomicUsize::new(0));
579 let wrapped = wrap_success(&mock, &refresh_count);
580
581 let outcome = wrapped.chat(empty_request()).await?;
582 assert!(matches!(outcome, ChatOutcome::Success(_)));
583 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
584 assert_eq!(mock.chat_call_count(), 1);
585 Ok(())
586 }
587
588 #[tokio::test]
590 async fn chat_401_triggers_refresh_and_retries() -> Result<()> {
591 let mock = MockProvider::new();
592 mock.queue_chat(ChatOutcome::InvalidRequest("401 Unauthorized".into()))?;
593 mock.queue_chat(ChatOutcome::Success(success_response()))?;
594
595 let refresh_count = Arc::new(AtomicUsize::new(0));
596 let wrapped = wrap_success(&mock, &refresh_count);
597
598 let outcome = wrapped.chat(empty_request()).await?;
599 assert!(matches!(outcome, ChatOutcome::Success(_)));
600 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
601 assert_eq!(mock.chat_call_count(), 2);
602 Ok(())
603 }
604
605 #[tokio::test]
607 async fn chat_surfaces_original_401_when_refresh_fails() -> Result<()> {
608 let mock = MockProvider::new();
609 mock.queue_chat(ChatOutcome::InvalidRequest(
610 "status=401 Unauthorized".into(),
611 ))?;
612
613 let refresh_count = Arc::new(AtomicUsize::new(0));
614 let wrapped = wrap_failure(&mock, &refresh_count, "refresh callback failed");
615
616 let outcome = wrapped.chat(empty_request()).await?;
617 match outcome {
618 ChatOutcome::InvalidRequest(msg) => assert!(
619 msg.contains("401"),
620 "expected original 401 message, got {msg}"
621 ),
622 other => panic!("expected InvalidRequest, got {other:?}"),
623 }
624 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
625 assert_eq!(mock.chat_call_count(), 1);
626 Ok(())
627 }
628
629 async fn drain(mut stream: StreamBox<'_>) -> Vec<Result<StreamDelta>> {
630 let mut out = Vec::new();
631 while let Some(item) = stream.next().await {
632 out.push(item);
633 }
634 out
635 }
636
637 #[tokio::test]
639 async fn chat_stream_successful_pass_through() -> Result<()> {
640 let mock = MockProvider::new();
641 mock.queue_stream(vec![
642 MockStreamItem::Ok(StreamDelta::TextDelta {
643 delta: "hi".into(),
644 block_index: 0,
645 }),
646 MockStreamItem::Ok(StreamDelta::Done {
647 stop_reason: Some(StopReason::EndTurn),
648 }),
649 ])?;
650
651 let refresh_count = Arc::new(AtomicUsize::new(0));
652 let wrapped = wrap_success(&mock, &refresh_count);
653
654 let deltas = drain(wrapped.chat_stream(empty_request())).await;
655 assert_eq!(deltas.len(), 2);
656 assert!(matches!(
657 deltas[0].as_ref().ok(),
658 Some(StreamDelta::TextDelta { delta, .. }) if delta == "hi"
659 ));
660 assert!(matches!(
661 deltas[1].as_ref().ok(),
662 Some(StreamDelta::Done { .. })
663 ));
664 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
665 assert_eq!(mock.stream_call_count(), 1);
666 Ok(())
667 }
668
669 #[tokio::test]
671 async fn chat_stream_401_before_output_retries() -> Result<()> {
672 let mock = MockProvider::new();
673 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
674 message: "status=401 Unauthorized".into(),
675 kind: StreamErrorKind::InvalidRequest,
676 })])?;
677 mock.queue_stream(vec![
678 MockStreamItem::Ok(StreamDelta::TextDelta {
679 delta: "retried".into(),
680 block_index: 0,
681 }),
682 MockStreamItem::Ok(StreamDelta::Done {
683 stop_reason: Some(StopReason::EndTurn),
684 }),
685 ])?;
686
687 let refresh_count = Arc::new(AtomicUsize::new(0));
688 let wrapped = wrap_success(&mock, &refresh_count);
689
690 let deltas = drain(wrapped.chat_stream(empty_request())).await;
691 assert_eq!(deltas.len(), 2);
693 assert!(matches!(
694 deltas[0].as_ref().ok(),
695 Some(StreamDelta::TextDelta { delta, .. }) if delta == "retried"
696 ));
697 assert!(matches!(
698 deltas[1].as_ref().ok(),
699 Some(StreamDelta::Done { .. })
700 ));
701 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
702 assert_eq!(mock.stream_call_count(), 2);
703 Ok(())
704 }
705
706 #[tokio::test]
707 async fn stacked_refresh_inside_fallback_does_not_double_add_usage() -> Result<()> {
708 let mock_a = MockProvider::new();
721 let usage = |i, o| {
722 MockStreamItem::Ok(StreamDelta::Usage(Usage {
723 input_tokens: i,
724 output_tokens: o,
725 cached_input_tokens: 0,
726 cache_creation_input_tokens: 0,
727 }))
728 };
729 mock_a.queue_stream(vec![
730 usage(100, 50),
731 MockStreamItem::Ok(StreamDelta::Error {
732 message: "status=401 Unauthorized".into(),
733 kind: StreamErrorKind::InvalidRequest,
734 }),
735 ])?;
736 mock_a.queue_stream(vec![
737 usage(10, 5),
738 MockStreamItem::Ok(StreamDelta::Error {
739 message: "rate limited".into(),
740 kind: StreamErrorKind::RateLimited(None),
741 }),
742 ])?;
743
744 let mock_b = MockProvider::new();
745 mock_b.queue_stream(vec![
746 MockStreamItem::Ok(StreamDelta::TextDelta {
747 delta: "from secondary".into(),
748 block_index: 0,
749 }),
750 usage(20, 10),
751 MockStreamItem::Ok(StreamDelta::Done {
752 stop_reason: Some(StopReason::EndTurn),
753 }),
754 ])?;
755
756 let refresh_count = Arc::new(AtomicUsize::new(0));
757 let refreshing = wrap_success(&mock_a, &refresh_count);
758 let fallback = crate::fallback::FallbackProvider::new(Arc::new(refreshing))
759 .with_fallback(Arc::new(mock_b));
760
761 let deltas = drain(fallback.chat_stream(empty_request())).await;
762 let mut accumulator = crate::streaming::StreamAccumulator::new();
763 for delta in deltas.iter().flatten() {
764 accumulator.apply(delta);
765 }
766 let total = accumulator
767 .usage()
768 .context("the stacked stream must report usage")?;
769 assert_eq!(total.input_tokens, 130, "100 + 10 + 20, each counted once");
770 assert_eq!(total.output_tokens, 65, "50 + 5 + 10, each counted once");
771 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
772 Ok(())
773 }
774
775 #[tokio::test]
776 async fn chat_stream_carries_failed_attempt_usage_across_refresh() -> Result<()> {
777 let mock = MockProvider::new();
782 mock.queue_stream(vec![
783 MockStreamItem::Ok(StreamDelta::Usage(Usage {
784 input_tokens: 100,
785 output_tokens: 50,
786 cached_input_tokens: 0,
787 cache_creation_input_tokens: 0,
788 })),
789 MockStreamItem::Ok(StreamDelta::Error {
790 message: "status=401 Unauthorized".into(),
791 kind: StreamErrorKind::InvalidRequest,
792 }),
793 ])?;
794 mock.queue_stream(vec![
795 MockStreamItem::Ok(StreamDelta::TextDelta {
796 delta: "retried".into(),
797 block_index: 0,
798 }),
799 MockStreamItem::Ok(StreamDelta::Usage(Usage {
800 input_tokens: 10,
801 output_tokens: 5,
802 cached_input_tokens: 0,
803 cache_creation_input_tokens: 0,
804 })),
805 MockStreamItem::Ok(StreamDelta::Done {
806 stop_reason: Some(StopReason::EndTurn),
807 }),
808 ])?;
809
810 let refresh_count = Arc::new(AtomicUsize::new(0));
811 let wrapped = wrap_success(&mock, &refresh_count);
812
813 let deltas = drain(wrapped.chat_stream(empty_request())).await;
814 let mut accumulator = crate::streaming::StreamAccumulator::new();
815 for delta in deltas.iter().flatten() {
816 accumulator.apply(delta);
817 }
818 let usage = accumulator
819 .usage()
820 .context("the refreshed stream must still report usage")?;
821 assert_eq!(usage.input_tokens, 110, "100 abandoned + 10 retried");
822 assert_eq!(usage.output_tokens, 55, "50 abandoned + 5 retried");
823 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
824 Ok(())
825 }
826
827 #[tokio::test]
829 async fn chat_stream_401_after_output_does_not_retry() -> Result<()> {
830 let mock = MockProvider::new();
831 mock.queue_stream(vec![
832 MockStreamItem::Ok(StreamDelta::TextDelta {
833 delta: "partial".into(),
834 block_index: 0,
835 }),
836 MockStreamItem::Ok(StreamDelta::Error {
837 message: "401 Unauthorized".into(),
838 kind: StreamErrorKind::InvalidRequest,
839 }),
840 ])?;
841
842 let refresh_count = Arc::new(AtomicUsize::new(0));
843 let wrapped = wrap_success(&mock, &refresh_count);
844
845 let deltas = drain(wrapped.chat_stream(empty_request())).await;
846 assert_eq!(deltas.len(), 2);
847 assert!(matches!(
848 deltas[0].as_ref().ok(),
849 Some(StreamDelta::TextDelta { delta, .. }) if delta == "partial"
850 ));
851 assert!(matches!(
852 deltas[1].as_ref().ok(),
853 Some(StreamDelta::Error { message, .. }) if message.contains("401")
854 ));
855 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
856 assert_eq!(mock.stream_call_count(), 1);
857 Ok(())
858 }
859
860 #[tokio::test]
861 async fn chat_stream_401_after_opaque_reasoning_does_not_retry() -> Result<()> {
862 let mock = MockProvider::new();
863 mock.queue_stream(vec![
864 MockStreamItem::Ok(StreamDelta::OpaqueReasoning {
865 provider: "test-provider".to_owned(),
866 data: serde_json::json!({"encrypted_content": "ciphertext"}),
867 block_index: 0,
868 }),
869 MockStreamItem::Ok(StreamDelta::Error {
870 message: "401 Unauthorized".into(),
871 kind: StreamErrorKind::InvalidRequest,
872 }),
873 ])?;
874
875 let refresh_count = Arc::new(AtomicUsize::new(0));
876 let wrapped = wrap_success(&mock, &refresh_count);
877 let deltas = drain(wrapped.chat_stream(empty_request())).await;
878
879 assert_eq!(deltas.len(), 2);
880 assert!(matches!(
881 deltas[0].as_ref().ok(),
882 Some(StreamDelta::OpaqueReasoning { provider, .. })
883 if provider == "test-provider"
884 ));
885 assert!(matches!(
886 deltas[1].as_ref().ok(),
887 Some(StreamDelta::Error { message, .. }) if message.contains("401")
888 ));
889 assert_eq!(refresh_count.load(Ordering::SeqCst), 0);
890 assert_eq!(mock.stream_call_count(), 1);
891 Ok(())
892 }
893
894 #[tokio::test]
896 async fn chat_stream_only_one_retry_per_call() -> Result<()> {
897 let mock = MockProvider::new();
898 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
899 message: "status=401 Unauthorized".into(),
900 kind: StreamErrorKind::InvalidRequest,
901 })])?;
902 mock.queue_stream(vec![MockStreamItem::Ok(StreamDelta::Error {
903 message: "still 401 Unauthorized".into(),
904 kind: StreamErrorKind::InvalidRequest,
905 })])?;
906
907 let refresh_count = Arc::new(AtomicUsize::new(0));
908 let wrapped = wrap_success(&mock, &refresh_count);
909
910 let deltas = drain(wrapped.chat_stream(empty_request())).await;
911 assert_eq!(deltas.len(), 1);
912 assert!(matches!(
913 deltas[0].as_ref().ok(),
914 Some(StreamDelta::Error { message, .. }) if message == "still 401 Unauthorized"
915 ));
916 assert_eq!(refresh_count.load(Ordering::SeqCst), 1);
917 assert_eq!(mock.stream_call_count(), 2);
918 Ok(())
919 }
920
921 #[derive(Clone)]
927 struct ConcurrentMock {
928 model: String,
929 provider_name: &'static str,
930 total_calls: Arc<AtomicUsize>,
931 initial_barrier: Arc<tokio::sync::Barrier>,
932 }
933
934 type CMFut = std::pin::Pin<Box<dyn Future<Output = Result<ConcurrentMock>> + Send>>;
935 type CMRefresh = Box<dyn Fn() -> CMFut + Send + Sync + 'static>;
936
937 #[async_trait]
938 impl LlmProvider for ConcurrentMock {
939 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
940 let call_index = self.total_calls.fetch_add(1, Ordering::SeqCst);
941 if call_index < 2 {
942 self.initial_barrier.wait().await;
943 Ok(ChatOutcome::InvalidRequest("401 Unauthorized".into()))
944 } else {
945 Ok(ChatOutcome::Success(success_response()))
946 }
947 }
948
949 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
950 Box::pin(async_stream::stream! {
951 yield Err(anyhow::anyhow!("chat_stream not used in this test"));
952 })
953 }
954
955 fn model(&self) -> &str {
956 &self.model
957 }
958
959 fn provider(&self) -> &'static str {
960 self.provider_name
961 }
962 }
963
964 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
966 async fn chat_concurrent_callers_share_refresh() -> Result<()> {
967 let mock = ConcurrentMock {
968 model: "mock-model".to_string(),
969 provider_name: "mock",
970 total_calls: Arc::new(AtomicUsize::new(0)),
971 initial_barrier: Arc::new(tokio::sync::Barrier::new(2)),
972 };
973 let call_count = Arc::clone(&mock.total_calls);
974 let refresh_count = Arc::new(AtomicUsize::new(0));
975 let refresh_counter = Arc::clone(&refresh_count);
976 let template = mock.clone();
977
978 let cb: CMRefresh = Box::new(move || {
979 refresh_counter.fetch_add(1, Ordering::SeqCst);
980 let provider = template.clone();
981 Box::pin(async move { Ok(provider) })
982 });
983 let wrapped = RefreshingProvider::new(mock, cb);
984
985 let a = wrapped.clone();
986 let b = wrapped.clone();
987 let task_a = tokio::spawn(async move { a.chat(empty_request()).await });
988 let task_b = tokio::spawn(async move { b.chat(empty_request()).await });
989
990 let outcome_a = task_a.await.context("task_a join")??;
991 let outcome_b = task_b.await.context("task_b join")??;
992
993 assert!(matches!(outcome_a, ChatOutcome::Success(_)));
994 assert!(matches!(outcome_b, ChatOutcome::Success(_)));
995 assert_eq!(call_count.load(Ordering::SeqCst), 4);
996 let refreshes = refresh_count.load(Ordering::SeqCst);
997 assert!(
998 refreshes <= 2,
999 "expected at most 2 refresh calls (one per caller), got {refreshes}"
1000 );
1001 Ok(())
1002 }
1003}