Skip to main content

agent_sdk_providers/
refresh.rs

1//! Provider wrapper that refreshes credentials on 401 and retries once.
2//!
3//! [`RefreshingProvider`] wraps any [`LlmProvider`] and adds a host-driven
4//! credential refresh step: when the inner provider reports an unauthorized
5//! error (HTTP 401, expired OAuth token, invalid API key, etc.), the wrapper
6//! calls a host-supplied async callback to rebuild the inner provider with
7//! fresh credentials and retries the original request once.
8//!
9//! This is the generic form of the per-provider refresh wrappers that
10//! OAuth-backed hosts would otherwise copy across every provider they use.
11//!
12//! # Example
13//!
14//! ```no_run
15//! # use std::sync::Arc;
16//! # use anyhow::Result;
17//! # use agent_sdk_providers::{LlmProvider, RefreshingProvider};
18//! # async fn demo<P: LlmProvider + Clone + 'static>(initial: P) -> Result<()> {
19//! let refreshed_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20//! let counter = Arc::clone(&refreshed_count);
21//! let wrapped = RefreshingProvider::new(initial.clone(), move || {
22//!     let counter = Arc::clone(&counter);
23//!     let provider = initial.clone();
24//!     async move {
25//!         counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
26//!         Ok(provider)
27//!     }
28//! });
29//! # let _ = wrapped;
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Streaming semantics
35//!
36//! For non-streaming [`chat`](LlmProvider::chat), the retry happens when the
37//! first call resolves to [`ChatOutcome::InvalidRequest`] with a 401-looking
38//! message. The second call replaces the first — callers only see the final
39//! outcome.
40//!
41//! For [`chat_stream`](LlmProvider::chat_stream), retry happens only when the
42//! 401 arrives before any content delta is forwarded. If a
43//! [`StreamDelta::TextDelta`], [`StreamDelta::ThinkingDelta`], tool-call
44//! delta, or thinking signature has already been yielded to the consumer,
45//! the error is forwarded as-is — retrying would duplicate partial output.
46//!
47//! At most one retry happens per call, whether streaming or not. If the
48//! retried call fails in the same way, the wrapper surfaces the second
49//! error unchanged.
50
51use 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
64/// Wraps a provider with host-driven credential refresh on 401.
65///
66/// The inner provider is stored behind `Arc<Mutex<P>>` so it can be swapped
67/// atomically when the refresh callback produces a new provider. Cloning a
68/// wrapper is cheap — clones share the same inner state.
69///
70/// Metadata (`model`, `provider`, `configured_thinking`) and capability shaping
71/// (`capabilities`, `default_max_tokens`, `validate_thinking_config`,
72/// `structured_output_support`) are captured from the initial provider at
73/// construction time and assumed constant across refreshes (the refresh
74/// callback rebuilds the same provider shape with a fresh token, not a
75/// different model).
76pub struct RefreshingProvider<P, F> {
77    inner: Arc<Mutex<P>>,
78    refresh: Arc<F>,
79    /// A clone of the initial provider kept solely for delegating the
80    /// **synchronous** capability methods (which never touch credentials), so
81    /// wrapping a provider never changes how requests are shaped. The async
82    /// `chat`/`chat_stream` paths still go through the refreshable `inner`.
83    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    /// Build a wrapper from an initial provider and a refresh callback.
109    ///
110    /// The refresh callback is invoked each time the inner provider emits a
111    /// 401 response. It must be idempotent and safe to call concurrently.
112    /// The callback should return a fully-built provider ready to use;
113    /// typically it reads fresh credentials from its auth store and calls
114    /// the inner provider's constructor.
115    #[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/// Classify a provider error message as a 401 / unauthorized condition.
143///
144/// Returns `true` when the message looks like the provider rejected the
145/// request because of missing, invalid, or expired credentials. Detection is
146/// case-insensitive and matches the error-body shapes emitted by the
147/// first-party providers in this crate. Hosts that implement their own retry
148/// logic on top of [`LlmProvider`] can gate on the same helper.
149#[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    /// Delegate live model discovery to the current inner snapshot so wrapping
263    /// in a refreshing provider never silently loses `list_models`.
264    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    // Delegate capability shaping to the wrapped provider so that wrapping
281    // never silently changes request shaping (e.g. losing Vertex's max-token
282    // clamp or a provider's adaptive-thinking validation). These methods are
283    // synchronous and credential-independent, so they go through the captured
284    // `template` rather than locking the async-refreshable `inner`.
285
286    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        // Sentinel capability overrides used to prove the wrapper delegates
422        // rather than falling back to trait defaults.
423        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 0: capability delegation (finding #12)
502    #[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        // Without delegation these would return trait defaults (4096 / Native
509        // is coincidental / Ok), masking per-provider clamps and validation.
510        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        // Without delegation this would return the trait-default "unsupported"
530        // error, silently losing discovery through the wrapper.
531        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 1
538    #[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    // Test 2
555    #[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    // Test 3
571    #[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    // Test 4
588    #[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    // Test 5
620    #[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    // Test 6
652    #[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        // Consumer sees only the post-refresh stream.
674        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    // Test 7
689    #[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    // Test 8
756    #[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    // Custom deterministic mock for the concurrent scenario: the first
783    // two chat() calls both wait on a barrier so both concurrent tasks
784    // observe a 401 on their initial call, then all subsequent calls
785    // return Success. This avoids flakiness from scheduling order in a
786    // shared FIFO queue.
787    #[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    // Test 9
826    #[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}