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, UsageCarry};
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            // The abandoned (pre-refresh) stream may have reported usage before
188            // its 401 — providers now emit usage before a terminal error — and
189            // [`StreamAccumulator`] keeps only the last usage delta. Carry the
190            // abandoned stream's usage forward and rewrite the retried stream's
191            // usage to the running total so those tokens are not un-billed.
192            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    /// Delegate live model discovery to the current inner snapshot so wrapping
277    /// in a refreshing provider never silently loses `list_models`.
278    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    // Delegate capability shaping to the wrapped provider so that wrapping
299    // never silently changes request shaping (e.g. losing Vertex's max-token
300    // clamp or a provider's adaptive-thinking validation). These methods are
301    // synchronous and credential-independent, so they go through the captured
302    // `template` rather than locking the async-refreshable `inner`.
303
304    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        // Sentinel capability overrides used to prove the wrapper delegates
440        // rather than falling back to trait defaults.
441        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 0: capability delegation (finding #12)
520    #[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        // Without delegation these would return trait defaults (4096 / Native
527        // is coincidental / Ok), masking per-provider clamps and validation.
528        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        // Without delegation this would return the trait-default "unsupported"
548        // error, silently losing discovery through the wrapper.
549        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 1
556    #[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    // Test 2
573    #[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    // Test 3
589    #[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    // Test 4
606    #[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    // Test 5
638    #[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    // Test 6
670    #[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        // Consumer sees only the post-refresh stream.
692        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        // Composition: FallbackProvider[ RefreshingProvider(mock_a), mock_b ].
709        // Each wrapper's `UsageCarry` sums only across ITS OWN abandoned
710        // boundary (refresh vs failover), which are disjoint, so no token is
711        // counted twice.
712        //
713        // mock_a stream 1: Usage(100/50) then a 401 -> Refreshing refreshes.
714        // mock_a stream 2 (retried): Usage(10/5) then a recoverable rate limit.
715        //   Refreshing carries 100/50, rewrites stream-2 usage to 110/55, and
716        //   forwards the (non-401) rate-limit error unchanged.
717        // Fallback sees Usage(110/55) + a recoverable error -> fails over.
718        // mock_b: Usage(20/10) + Done -> Fallback rewrites to 130/65.
719        // Real tokens billed: 100/50 + 10/5 + 20/10 = 130/65. No double-add.
720        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        // The pre-refresh stream reports usage (billed) and then a 401. After
778        // the refresh, the retried stream's usage must not erase the abandoned
779        // attempt's — the accumulator keeps only the last usage delta, so the
780        // wrapper rewrites the retried usage to the running total.
781        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    // Test 7
828    #[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    // Test 8
895    #[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    // Custom deterministic mock for the concurrent scenario: the first
922    // two chat() calls both wait on a barrier so both concurrent tasks
923    // observe a 401 on their initial call, then all subsequent calls
924    // return Success. This avoids flakiness from scheduling order in a
925    // shared FIFO queue.
926    #[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    // Test 9
965    #[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}