use std::{future::Future, pin::Pin, sync::Arc, time::Duration};
use async_trait::async_trait;
use futures::Stream;
use crate::{
LLMProvider,
chat::{
ChatMessage, ChatProvider, ChatResponse, StreamChunk, StreamResponse,
StructuredOutputFormat, Tool,
},
completion::{CompletionProvider, CompletionRequest, CompletionResponse},
embedding::EmbeddingProvider,
error::LLMError,
models::{ModelListRequest, ModelListResponse, ModelsProvider},
pipeline::LLMLayer,
};
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub jitter: bool,
pub retryable: fn(&LLMError) -> bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(30),
jitter: true,
retryable: default_is_retryable,
}
}
}
pub fn default_is_retryable(err: &LLMError) -> bool {
crate::error::is_retryable(err)
}
pub struct RetryLayer {
config: RetryConfig,
}
impl RetryLayer {
pub fn new(config: RetryConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self::new(RetryConfig::default())
}
}
impl LLMLayer for RetryLayer {
fn build(self: Box<Self>, next: Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> {
Arc::new(RetryProvider {
inner: next,
config: self.config,
})
}
}
struct RetryProvider {
inner: Arc<dyn LLMProvider>,
config: RetryConfig,
}
thread_local! {
static JITTER_RNG: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn jitter_seed() -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::default();
std::thread::current().id().hash(&mut hasher);
std::ptr::from_ref(&JITTER_RNG).hash(&mut hasher);
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
duration.as_nanos().hash(&mut hasher);
}
hasher.finish().max(1)
}
fn next_jitter_random() -> u64 {
JITTER_RNG.with(|rng| {
let mut state = rng.get();
if state == 0 {
state = jitter_seed();
}
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
rng.set(state);
state
})
}
#[inline]
fn jitter_duration(ceiling: Duration) -> Duration {
let nanos = ceiling.as_nanos();
if nanos == 0 {
return Duration::ZERO;
}
Duration::from_nanos(((next_jitter_random() as u128) % nanos) as u64)
}
#[inline]
fn compute_backoff(config: &RetryConfig, attempt: u32) -> Duration {
let initial_ns = config.initial_backoff.as_nanos().min(u64::MAX as u128) as u64;
let multiplier = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
let max_ns = config.max_backoff.as_nanos().min(u64::MAX as u128) as u64;
let ceiling = Duration::from_nanos(initial_ns.saturating_mul(multiplier).min(max_ns));
if config.jitter {
jitter_duration(ceiling)
} else {
ceiling
}
}
fn resolve_retry_sleep(err: &LLMError, config: &RetryConfig, attempt: u32) -> Duration {
let backoff = compute_backoff(config, attempt);
let retry_after = match err {
LLMError::RateLimitError { retry_after, .. }
| LLMError::HttpStatusError { retry_after, .. } => *retry_after,
_ => None,
};
let sleep_for = match retry_after {
Some(retry_after) => backoff.max(retry_after),
None => backoff,
};
sleep_for.min(config.max_backoff)
}
async fn retry_call<F, Fut, T>(config: &RetryConfig, mut f: F) -> Result<T, LLMError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, LLMError>>,
{
let max = config.max_attempts.max(1);
let mut attempt = 0u32;
loop {
match f().await {
Ok(v) => return Ok(v),
Err(e) if attempt + 1 < max && (config.retryable)(&e) => {
let sleep_for = resolve_retry_sleep(&e, config, attempt);
log::warn!(
"LLM call failed (attempt {}/{}): {e}. Retrying in {sleep_for:?}.",
attempt + 1,
max,
);
tokio::time::sleep(sleep_for).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
#[async_trait]
impl ChatProvider for RetryProvider {
async fn chat(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
retry_call(&self.config, || {
self.inner.chat(messages, json_schema.clone())
})
.await
}
async fn chat_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
retry_call(&self.config, || {
self.inner
.chat_with_tools(messages, tools, json_schema.clone())
})
.await
}
async fn chat_with_web_search(&self, input: String) -> Result<Box<dyn ChatResponse>, LLMError> {
retry_call(&self.config, || {
self.inner.chat_with_web_search(input.clone())
})
.await
}
async fn chat_stream(
&self,
messages: &[ChatMessage],
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, LLMError>> + Send>>, LLMError> {
retry_call(&self.config, || {
self.inner.chat_stream(messages, json_schema.clone())
})
.await
}
async fn chat_stream_struct(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamResponse, LLMError>> + Send>>, LLMError>
{
retry_call(&self.config, || {
self.inner
.chat_stream_struct(messages, tools, json_schema.clone())
})
.await
}
async fn chat_stream_with_tools(
&self,
messages: &[ChatMessage],
tools: Option<&[Tool]>,
json_schema: Option<StructuredOutputFormat>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>, LLMError> {
retry_call(&self.config, || {
self.inner
.chat_stream_with_tools(messages, tools, json_schema.clone())
})
.await
}
fn model(&self) -> &str {
self.inner.model()
}
}
#[async_trait]
impl CompletionProvider for RetryProvider {
async fn complete(
&self,
req: &CompletionRequest,
json_schema: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
retry_call(&self.config, || {
self.inner.complete(req, json_schema.clone())
})
.await
}
}
#[async_trait]
impl EmbeddingProvider for RetryProvider {
async fn embed(&self, input: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
retry_call(&self.config, || self.inner.embed(input.clone())).await
}
}
#[async_trait]
impl ModelsProvider for RetryProvider {
async fn list_models(
&self,
request: Option<&ModelListRequest>,
) -> Result<Box<dyn ModelListResponse>, LLMError> {
self.inner.list_models(request).await
}
}
impl LLMProvider for RetryProvider {}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
FunctionCall, ToolCall,
chat::{ChatResponse, StructuredOutputFormat, Tool},
completion::CompletionRequest,
error::LLMError,
};
use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
struct MockResponse(String);
impl ChatResponse for MockResponse {
fn text(&self) -> Option<String> {
Some(self.0.clone())
}
fn tool_calls(&self) -> Option<Vec<ToolCall>> {
None
}
}
impl std::fmt::Debug for MockResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MockResponse({})", self.0)
}
}
impl std::fmt::Display for MockResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
struct CountingMock {
calls: AtomicU32,
chat_calls: AtomicU32,
chat_with_tools_calls: AtomicU32,
success_after: u32,
err: LLMError,
}
impl CountingMock {
fn new(success_after: u32, err: LLMError) -> Arc<Self> {
Arc::new(Self {
calls: AtomicU32::new(0),
chat_calls: AtomicU32::new(0),
chat_with_tools_calls: AtomicU32::new(0),
success_after,
err,
})
}
fn call_count(&self) -> u32 {
self.calls.load(Ordering::Relaxed)
}
fn next_result(&self) -> Result<Box<dyn ChatResponse>, LLMError> {
let n = self.calls.fetch_add(1, Ordering::Relaxed) + 1;
if n >= self.success_after {
Ok(Box::new(MockResponse("ok".into())))
} else {
Err(match &self.err {
LLMError::HttpError(m) => LLMError::HttpError(m.clone()),
LLMError::ProviderError(m) => LLMError::ProviderError(m.clone()),
LLMError::Generic(m) => LLMError::Generic(m.clone()),
LLMError::AuthError {
message,
status_code,
response_body,
} => LLMError::AuthError {
message: message.clone(),
status_code: *status_code,
response_body: response_body.clone(),
},
LLMError::RateLimitError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => LLMError::RateLimitError {
status_code: *status_code,
message: message.clone(),
response_body: response_body.clone(),
retry_after: *retry_after,
provider_code: provider_code.clone(),
},
LLMError::HttpStatusError {
status_code,
message,
response_body,
retry_after,
provider_code,
} => LLMError::HttpStatusError {
status_code: *status_code,
message: message.clone(),
response_body: response_body.clone(),
retry_after: *retry_after,
provider_code: provider_code.clone(),
},
LLMError::InvalidRequest {
message,
status_code,
response_body,
} => LLMError::InvalidRequest {
message: message.clone(),
status_code: *status_code,
response_body: response_body.clone(),
},
other => LLMError::Generic(other.to_string()),
})
}
}
}
#[async_trait]
impl ChatProvider for CountingMock {
async fn chat(
&self,
_messages: &[ChatMessage],
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_calls.fetch_add(1, Ordering::Relaxed);
self.next_result()
}
async fn chat_with_tools(
&self,
_messages: &[ChatMessage],
_tools: Option<&[Tool]>,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<Box<dyn ChatResponse>, LLMError> {
self.chat_with_tools_calls.fetch_add(1, Ordering::Relaxed);
self.next_result()
}
}
#[async_trait]
impl CompletionProvider for CountingMock {
async fn complete(
&self,
_req: &CompletionRequest,
_json_schema: Option<StructuredOutputFormat>,
) -> Result<CompletionResponse, LLMError> {
let n = self.calls.fetch_add(1, Ordering::Relaxed) + 1;
if n >= self.success_after {
Ok(CompletionResponse {
text: "done".into(),
})
} else {
Err(sample_http_status_error(503))
}
}
}
#[async_trait]
impl EmbeddingProvider for CountingMock {
async fn embed(&self, _input: Vec<String>) -> Result<Vec<Vec<f32>>, LLMError> {
let n = self.calls.fetch_add(1, Ordering::Relaxed) + 1;
if n >= self.success_after {
Ok(vec![vec![1.0, 2.0]])
} else {
Err(sample_rate_limit_error())
}
}
}
#[async_trait]
impl ModelsProvider for CountingMock {}
impl LLMProvider for CountingMock {}
impl crate::HasConfig for CountingMock {
type Config = crate::NoConfig;
}
fn sample_rate_limit_error() -> LLMError {
LLMError::RateLimitError {
status_code: 429,
message: "rate limited".into(),
response_body: "limit".into(),
retry_after: None,
provider_code: None,
}
}
fn sample_http_status_error(status_code: u16) -> LLMError {
LLMError::HttpStatusError {
status_code,
message: format!("status {status_code}"),
response_body: "down".into(),
retry_after: None,
provider_code: None,
}
}
#[test]
fn resolve_retry_sleep_honors_retry_after_on_http_status_error() {
let config = RetryConfig {
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(60),
jitter: false,
..RetryConfig::default()
};
let err = LLMError::HttpStatusError {
status_code: 503,
message: "maintenance".into(),
response_body: "body".into(),
retry_after: Some(Duration::from_secs(45)),
provider_code: None,
};
assert_eq!(
resolve_retry_sleep(&err, &config, 0),
Duration::from_secs(45)
);
}
#[test]
fn resolve_retry_sleep_honors_retry_after_header() {
let config = RetryConfig {
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(60),
jitter: false,
..RetryConfig::default()
};
let err = LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: Some(Duration::from_secs(45)),
provider_code: None,
};
assert_eq!(
resolve_retry_sleep(&err, &config, 0),
Duration::from_secs(45)
);
}
#[test]
fn resolve_retry_sleep_caps_at_max_backoff() {
let config = RetryConfig {
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(30),
jitter: false,
..RetryConfig::default()
};
let err = LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: Some(Duration::from_secs(86_400)),
provider_code: None,
};
assert_eq!(
resolve_retry_sleep(&err, &config, 0),
Duration::from_secs(30)
);
}
#[test]
fn resolve_retry_sleep_uses_backoff_when_retry_after_is_shorter() {
let config = RetryConfig {
initial_backoff: Duration::from_secs(5),
max_backoff: Duration::from_secs(30),
jitter: false,
..RetryConfig::default()
};
let err = LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: Some(Duration::from_secs(1)),
provider_code: None,
};
assert_eq!(
resolve_retry_sleep(&err, &config, 0),
Duration::from_secs(5)
);
}
#[test]
fn jitter_duration_produces_varied_samples() {
let ceiling = Duration::from_secs(30);
let first = jitter_duration(ceiling);
let varied = (1..64).any(|_| jitter_duration(ceiling) != first);
assert!(varied, "jitter should produce multiple distinct durations");
}
#[test]
fn backoff_grows_exponentially() {
let cfg = RetryConfig {
initial_backoff: Duration::from_millis(100),
max_backoff: Duration::from_secs(60),
jitter: false,
..RetryConfig::default()
};
assert_eq!(compute_backoff(&cfg, 0), Duration::from_millis(100));
assert_eq!(compute_backoff(&cfg, 1), Duration::from_millis(200));
assert_eq!(compute_backoff(&cfg, 2), Duration::from_millis(400));
assert_eq!(compute_backoff(&cfg, 3), Duration::from_millis(800));
}
#[test]
fn backoff_capped_at_max() {
let cfg = RetryConfig {
initial_backoff: Duration::from_millis(500),
max_backoff: Duration::from_millis(1000),
jitter: false,
..RetryConfig::default()
};
assert_eq!(compute_backoff(&cfg, 2), Duration::from_millis(1000));
}
#[test]
fn backoff_with_jitter_within_bounds() {
let cfg = RetryConfig {
initial_backoff: Duration::from_millis(200),
max_backoff: Duration::from_secs(30),
jitter: true,
..RetryConfig::default()
};
let ceiling = Duration::from_millis(200);
for _ in 0..20 {
let b = compute_backoff(&cfg, 0);
assert!(b <= ceiling, "jitter exceeded ceiling: {b:?}");
}
}
#[test]
fn large_attempt_does_not_overflow() {
let cfg = RetryConfig {
jitter: false,
..RetryConfig::default()
};
let b = compute_backoff(&cfg, 200);
assert_eq!(b, cfg.max_backoff);
}
#[test]
fn retryable_errors() {
assert!(!default_is_retryable(&LLMError::Generic(
"connection reset".into()
)));
assert!(default_is_retryable(&LLMError::RateLimitError {
status_code: 429,
message: "limit".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}));
assert!(default_is_retryable(&LLMError::HttpStatusError {
status_code: 503,
message: "down".into(),
response_body: "body".into(),
retry_after: None,
provider_code: None,
}));
assert!(default_is_retryable(&LLMError::HttpError(
"request timed out: operation timed out".into()
)));
assert!(!default_is_retryable(&LLMError::ProviderError(
"overloaded".into()
)));
}
#[test]
fn non_retryable_errors() {
assert!(!default_is_retryable(&LLMError::missing_api_key(
"invalid key"
)));
assert!(!default_is_retryable(&LLMError::invalid_request(
"bad param"
)));
assert!(!default_is_retryable(&LLMError::GuardrailBlocked {
phase: crate::error::GuardrailPhase::Input,
guard: "prompt-injection".into(),
rule_id: "prompt_injection_detected".into(),
category: "prompt_injection".into(),
severity: "high".into(),
message: "detected suspicious instruction pattern".into(),
}));
assert!(!default_is_retryable(&LLMError::GuardrailExecutionFailed {
guard: "prompt-injection".into(),
message: "guard runtime error".into(),
}));
assert!(!default_is_retryable(&LLMError::JsonError(
"parse error".into()
)));
assert!(!default_is_retryable(&LLMError::ToolConfigError(
"bad tool".into()
)));
assert!(!default_is_retryable(&LLMError::NoToolSupport(
"unsupported".into()
)));
}
fn build_retry(mock: Arc<CountingMock>, cfg: RetryConfig) -> Arc<dyn LLMProvider> {
RetryLayer::new(cfg).build_arc(mock as Arc<dyn LLMProvider>)
}
impl RetryLayer {
fn build_arc(self, next: Arc<dyn LLMProvider>) -> Arc<dyn LLMProvider> {
Box::new(self).build(next)
}
}
#[tokio::test]
async fn success_on_first_attempt_makes_one_call() {
let mock = CountingMock::new(1, LLMError::Generic("never".into()));
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 3,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
provider.chat(&[msg], None).await.unwrap();
assert_eq!(mock.call_count(), 1, "should call inner exactly once");
}
#[tokio::test]
async fn retries_on_retryable_error_and_succeeds() {
let mock = CountingMock::new(3, sample_rate_limit_error());
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 5,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
let resp = provider.chat(&[msg], None).await.unwrap();
assert_eq!(resp.text().unwrap(), "ok");
assert_eq!(mock.call_count(), 3);
}
#[tokio::test]
async fn exhausts_attempts_and_returns_last_error() {
let mock = CountingMock::new(99, sample_http_status_error(503));
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 3,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
let err = provider.chat(&[msg], None).await.unwrap_err();
assert!(err.to_string().contains("503"));
assert_eq!(mock.call_count(), 3);
}
#[tokio::test]
async fn non_retryable_error_is_not_retried() {
let mock = CountingMock::new(99, LLMError::missing_api_key("invalid key".to_string()));
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 5,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
provider.chat(&[msg], None).await.unwrap_err();
assert_eq!(mock.call_count(), 1, "auth error must not be retried");
}
#[tokio::test]
async fn max_attempts_one_means_no_retry() {
let mock = CountingMock::new(99, sample_rate_limit_error());
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 1,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
provider.chat(&[msg], None).await.unwrap_err();
assert_eq!(mock.call_count(), 1);
}
#[tokio::test]
async fn chat_preserves_chat_method_shape() {
let mock = CountingMock::new(1, LLMError::Generic("never".into()));
let provider = build_retry(mock.clone(), RetryConfig::default());
let msg = ChatMessage::user().content("Hello").build();
let resp = provider.chat(&[msg], None).await.unwrap();
assert_eq!(resp.text().as_deref(), Some("ok"));
assert_eq!(mock.chat_calls.load(Ordering::Relaxed), 1);
assert_eq!(mock.chat_with_tools_calls.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn completion_is_retried() {
let mock = CountingMock::new(2, sample_http_status_error(503));
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 3,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let req = CompletionRequest::new("test");
let resp = provider.complete(&req, None).await.unwrap();
assert_eq!(resp.text, "done");
assert_eq!(mock.call_count(), 2);
}
#[tokio::test]
async fn embedding_is_retried() {
let mock = CountingMock::new(2, sample_rate_limit_error());
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 3,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
},
);
let result = provider.embed(vec!["hello".into()]).await.unwrap();
assert_eq!(result, vec![vec![1.0, 2.0]]);
assert_eq!(mock.call_count(), 2);
}
#[tokio::test]
async fn custom_retryable_predicate() {
let mock = CountingMock::new(3, LLMError::missing_api_key("retry me".to_string()));
let provider = build_retry(
mock.clone(),
RetryConfig {
max_attempts: 5,
jitter: false,
initial_backoff: Duration::from_millis(1),
retryable: |err| matches!(err, LLMError::AuthError { .. }),
..RetryConfig::default()
},
);
let msg = ChatMessage::user().content("hi").build();
let resp = provider.chat(&[msg], None).await.unwrap();
assert_eq!(resp.text().unwrap(), "ok");
assert_eq!(mock.call_count(), 3);
}
#[tokio::test]
async fn retries_on_http_429_from_provider() {
use crate::backends::groq::Groq;
use httpmock::{Method::POST, MockServer};
static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(POST).path("/openai/v1/chat/completions");
then.respond_with(move |_req| {
let attempt = ATTEMPTS.fetch_add(1, Ordering::Relaxed) + 1;
if attempt == 1 {
httpmock::HttpMockResponse::builder()
.status(429)
.header("Retry-After", "0")
.body(r#"{"error":{"message":"rate limited"}}"#)
.build()
} else {
httpmock::HttpMockResponse::builder()
.status(200)
.body(
r#"{"choices":[{"message":{"role":"assistant","content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#,
)
.build()
}
});
});
let inner = Groq::with_config(
"key",
Some(format!("{}/openai/v1", server.base_url())),
Some("llama3-8b-8192".to_string()),
None,
None,
Some(5),
None,
None,
None,
None,
None,
None,
None,
None,
None,
);
let provider = RetryLayer::new(RetryConfig {
max_attempts: 3,
jitter: false,
initial_backoff: Duration::from_millis(1),
..RetryConfig::default()
})
.build_arc(Arc::new(inner) as Arc<dyn LLMProvider>);
let msg = ChatMessage::user().content("hi").build();
let resp = provider.chat(&[msg], None).await.unwrap();
assert_eq!(resp.text().as_deref(), Some("ok"));
assert_eq!(ATTEMPTS.load(Ordering::Relaxed), 2);
}
#[test]
fn function_call_construction() {
let _ = FunctionCall {
name: "f".into(),
arguments: "{}".into(),
};
}
}