Skip to main content

a2a_llm/
lib.rs

1//! Provider-neutral vocabulary for chat completions, plus the providers that
2//! speak it.
3//!
4//! [`LlmProvider`] is the port: [`chat_completion`](LlmProvider::chat_completion)
5//! and [`chat_completion_stream`](LlmProvider::chat_completion_stream) over
6//! [`LlmRequest`] / [`LlmResponse`]. [`openai`] covers OpenAI and every
7//! OpenAI-compatible endpoint (OpenRouter, vLLM, llama.cpp); [`gemini`] covers
8//! Google's API. [`provider_from_env`] picks one from the environment.
9//!
10//! The types are deliberately not tied to A2A. [`ToolCall`] and
11//! [`ToolDefinition`] are the tool-calling vocabulary shared with the MCP
12//! bridge, which is why they live in their own crate rather than inside an
13//! agent framework.
14
15use async_trait::async_trait;
16use futures::stream::BoxStream;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20pub mod gemini;
21pub mod openai;
22pub mod provider;
23pub mod tool_call;
24
25pub use provider::{
26    LlmConfigError, LlmSettings, PROVIDER_ENV_VARS, ReasoningPlan, SUPPORTED_PROVIDERS,
27    SelectedLlm, provider_from_env, provider_from_settings,
28};
29pub use tool_call::{PartialToolCall, ToolCallAccumulator};
30
31/// The environment, as this crate reads it when building a provider.
32///
33/// Passed in rather than read directly so the selection rules can be tested
34/// without mutating the process environment, which would race other tests.
35#[derive(Clone, Copy)]
36pub(crate) struct Env<'a>(&'a dyn Fn(&str) -> Option<String>);
37
38impl<'a> Env<'a> {
39    /// A stand-in environment. Only tests need one; production reads
40    /// [`Env::os`].
41    #[cfg(test)]
42    pub(crate) fn new(lookup: &'a dyn Fn(&str) -> Option<String>) -> Self {
43        Self(lookup)
44    }
45
46    /// The process environment.
47    pub(crate) fn os() -> Env<'static> {
48        const LOOKUP: &dyn Fn(&str) -> Option<String> = &os_lookup;
49        Env(LOOKUP)
50    }
51
52    /// A variable set to whitespace reads as unset. `.env` files leave those
53    /// behind, and an empty `OPENROUTER_API_KEY` would otherwise select a
54    /// provider that cannot authenticate.
55    pub(crate) fn get(&self, key: &str) -> Option<String> {
56        (self.0)(key)
57            .map(|value| value.trim().to_string())
58            .filter(|value| !value.is_empty())
59    }
60}
61
62fn os_lookup(key: &str) -> Option<String> {
63    std::env::var(key).ok()
64}
65
66/// Represents an error returned by an LLM provider.
67#[derive(Debug, thiserror::Error)]
68pub enum LlmError {
69    #[error("API error: {0}")]
70    ApiError(String),
71    /// The request was larger than the model's context window.
72    ///
73    /// Separate from [`LlmError::ApiError`] because it is the one API failure a
74    /// caller can act on: drop history and try again. Folded into `ApiError` it
75    /// reached the handler as `A2AError::Internal("LLM error: API error (400)
76    /// …")` and simply failed the task.
77    #[error("context length exceeded: {0}")]
78    ContextLengthExceeded(String),
79    #[error("Network error: {0}")]
80    NetworkError(String),
81    #[error("Serialization error: {0}")]
82    SerializationError(String),
83    #[error("Provider error: {0}")]
84    ProviderError(String),
85}
86
87/// An error and everything under it, as one line.
88///
89/// `reqwest::Error`'s `Display` omits its source chain, so a DNS failure, a
90/// refused connection and an untrusted certificate all read as `error sending
91/// request for url (…)` — which is what made a TLS-intercepting proxy
92/// indistinguishable from the network being down, and cost a full investigation
93/// (see `NOTES.md`). The certificate error was one `source()` away the whole
94/// time. Takes `dyn Error` so the SSE stream's wrapper is covered by the same
95/// rule.
96pub(crate) fn describe_transport_error(error: &dyn std::error::Error) -> String {
97    let mut message = error.to_string();
98    let mut source = error.source();
99    while let Some(cause) = source {
100        message.push_str(": ");
101        message.push_str(&cause.to_string());
102        source = cause.source();
103    }
104    message
105}
106
107/// Substrings that identify an over-long request in a provider's error body.
108///
109/// Providers disagree on both the status code and the shape, and several return
110/// a plain 400 with prose, so matching on text is the only thing that works
111/// across all of them. Checked lowercase.
112const CONTEXT_LENGTH_MARKERS: [&str; 8] = [
113    // OpenAI (`"code": "context_length_exceeded"`), and OpenRouter passes it through.
114    "context_length_exceeded",
115    // OpenAI / OpenRouter prose, and most OpenAI-compatible servers.
116    "maximum context length",
117    "context length",
118    // The same concept spelled "size", which is llama.cpp's word for it and
119    // matched none of the above. Kept as a bare marker, symmetric with
120    // "context length", so a build whose error `type` differs from the one
121    // below is still recognized by its prose.
122    "context size",
123    // llama.cpp, vLLM.
124    "too many tokens",
125    "exceeds the maximum",
126    // llama.cpp b10524's `type`, for the same overflow:
127    // `{"type":"exceed_context_size_error","message":"request (40089 tokens)
128    // exceeds the available context size (32768 tokens)"}`. Matched on the
129    // `type` as well as the prose above, that being the half of the body least
130    // likely to be reworded.
131    "exceed_context_size_error",
132    // Gemini: INVALID_ARGUMENT naming the input token count.
133    "input token count",
134];
135
136/// Classify a provider's failure body, so an over-long request becomes
137/// [`LlmError::ContextLengthExceeded`] rather than an opaque API error.
138///
139/// Takes the already-formatted message so both providers and both code paths
140/// (streaming and not) classify identically.
141pub(crate) fn classify_api_error(message: String) -> LlmError {
142    let haystack = message.to_lowercase();
143    if CONTEXT_LENGTH_MARKERS
144        .iter()
145        .any(|marker| haystack.contains(marker))
146    {
147        return LlmError::ContextLengthExceeded(message);
148    }
149    LlmError::ApiError(message)
150}
151
152/// Whether the model behind an endpoint accepts the reasoning parameter its
153/// adapter sends.
154///
155/// Support turns on the *model*, not the provider: `reasoning_effort` is a 400
156/// on `gpt-4o-mini` and mandatory on `gpt-5-pro`, and Gemini's `thinkingLevel`
157/// is accepted by some 2.5-generation models and refused by others. A table of
158/// model names answers that for the models it lists and goes stale with every
159/// release, so the parameter is sent instead, the refusal is read back off the
160/// 400, and the request is retried once without it.
161///
162/// The answer is remembered here, so only the first refused call of a process
163/// pays the extra round trip — and only once the retry has confirmed it, since a
164/// 400 that names the field can still be about something else. Shared across
165/// clones of a provider, since a provider is cloned per handler and they all
166/// call the same model.
167#[derive(Clone, Default)]
168pub(crate) struct ReasoningSupport(std::sync::Arc<std::sync::atomic::AtomicBool>);
169
170impl ReasoningSupport {
171    /// Whether a refusal has already been seen. Nothing is sent after one.
172    pub(crate) fn refused(&self) -> bool {
173        self.0.load(std::sync::atomic::Ordering::Relaxed)
174    }
175
176    /// Remember that this endpoint refused the parameter. `Relaxed` because a
177    /// racing caller re-sending once is the cost of being wrong.
178    pub(crate) fn record_refusal(&self) {
179        self.0.store(true, std::sync::atomic::Ordering::Relaxed);
180    }
181}
182
183/// Whether a failed request is the endpoint refusing the reasoning parameter,
184/// rather than the request failing on its own merits.
185///
186/// Both providers answer 400 and name the field: OpenAI with
187/// `Unsupported parameter: 'reasoning_effort' is not supported with this model`,
188/// Gemini with `Unknown name "thinkingLevel": Cannot find field`. A refused
189/// *value* reads the same way (`Invalid value: 'none'. Supported values are …`)
190/// and wants the same recovery, so the test is the field name rather than the
191/// prose. `field` is the one word every such message contains — `reasoning`,
192/// `thinking` — which is specific enough because this is only asked when that
193/// field was actually sent.
194pub(crate) fn refuses_reasoning(status: u16, body: &str, field: &str) -> bool {
195    status == 400 && body.to_lowercase().contains(field)
196}
197
198/// Tokens a provider reported for one request.
199///
200/// Reported rather than estimated: a caller's own token estimate decides what to
201/// send, and this says what it actually cost. Every field is optional because
202/// providers disagree on which they return, and a missing count must not read as
203/// zero.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub struct TokenUsage {
206    /// Tokens in the request, including the system prompt and tool definitions.
207    pub prompt_tokens: Option<u32>,
208    /// Tokens the model generated, excluding reasoning where a provider splits
209    /// them out.
210    pub completion_tokens: Option<u32>,
211    /// Reasoning tokens, where the provider reports them separately. Billed, and
212    /// invisible in `completion_tokens` on most providers.
213    pub reasoning_tokens: Option<u32>,
214    /// The provider's own total. Not derived from the fields above — a provider
215    /// that reports only this one is common, and a total that disagrees with the
216    /// parts is the provider's answer, not ours to correct.
217    pub total_tokens: Option<u32>,
218}
219
220impl TokenUsage {
221    /// Whether the provider reported anything at all. A response carrying no
222    /// counts is `Some(TokenUsage::default())` nowhere — it is `None`.
223    pub fn is_empty(&self) -> bool {
224        *self == Self::default()
225    }
226}
227
228impl std::fmt::Display for TokenUsage {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        let field = |value: Option<u32>| match value {
231            Some(count) => count.to_string(),
232            None => "?".to_string(),
233        };
234        write!(
235            f,
236            "prompt={} completion={} reasoning={} total={}",
237            field(self.prompt_tokens),
238            field(self.completion_tokens),
239            field(self.reasoning_tokens),
240            field(self.total_tokens)
241        )
242    }
243}
244
245/// The role of the message sender.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "lowercase")]
248pub enum MessageRole {
249    System,
250    User,
251    Assistant,
252    Tool,
253}
254
255/// Defines a tool (function) available for the LLM to call.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ToolDefinition {
258    pub name: String,
259    pub description: String,
260    pub parameters: Value, // JSON Schema representation of arguments
261}
262
263/// Represents a specific tool invocation requested by the LLM.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ToolCall {
266    pub id: String, // ID of the tool call
267    pub name: String,
268    pub arguments: String, // Stringified JSON arguments
269}
270
271/// A single message in a chat conversation.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ChatMessage {
274    pub role: MessageRole,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub content: Option<String>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub tool_calls: Option<Vec<ToolCall>>,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub tool_call_id: Option<String>,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub name: Option<String>,
283}
284
285impl ChatMessage {
286    pub fn system(content: impl Into<String>) -> Self {
287        Self {
288            role: MessageRole::System,
289            content: Some(content.into()),
290            tool_calls: None,
291            tool_call_id: None,
292            name: None,
293        }
294    }
295
296    pub fn user(content: impl Into<String>) -> Self {
297        Self {
298            role: MessageRole::User,
299            content: Some(content.into()),
300            tool_calls: None,
301            tool_call_id: None,
302            name: None,
303        }
304    }
305
306    pub fn assistant(content: impl Into<String>) -> Self {
307        Self {
308            role: MessageRole::Assistant,
309            content: Some(content.into()),
310            tool_calls: None,
311            tool_call_id: None,
312            name: None,
313        }
314    }
315
316    pub fn tool_result(
317        tool_call_id: impl Into<String>,
318        name: impl Into<String>,
319        content: impl Into<String>,
320    ) -> Self {
321        Self {
322            role: MessageRole::Tool,
323            content: Some(content.into()),
324            tool_calls: None,
325            tool_call_id: Some(tool_call_id.into()),
326            name: Some(name.into()),
327        }
328    }
329}
330
331/// How hard a reasoning model should think, when reasoning is requested.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum ReasoningEffort {
334    Low,
335    Medium,
336    High,
337}
338
339impl ReasoningEffort {
340    /// The wire token used by OpenRouter's `reasoning.effort`.
341    pub fn as_str(self) -> &'static str {
342        match self {
343            ReasoningEffort::Low => "low",
344            ReasoningEffort::Medium => "medium",
345            ReasoningEffort::High => "high",
346        }
347    }
348}
349
350/// What to ask a reasoning-capable model to do with its thinking.
351///
352/// This is a *request*, not a capability: `Some(_)` says what the caller wants
353/// and `None` says nothing at all, leaving the model's own default alone.
354/// Whether the endpoint can carry it is the provider's business — a provider
355/// that cannot say this on the wire drops it rather than making every caller
356/// ask first.
357///
358/// Where the model does honour it, its thinking comes back on a separate channel
359/// ([`LlmResponse::reasoning`] / [`LlmStreamEvent::Reasoning`]).
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum Reasoning {
362    /// Don't think — for models that let reasoning be turned off. The one
363    /// setting a small, fast model usually wants, and the one an effort-only
364    /// knob cannot express.
365    Off,
366    /// Think at one of the provider's named effort levels.
367    Effort(ReasoningEffort),
368    /// Think within a hard budget of reasoning tokens.
369    Budget(u32),
370}
371
372/// What a host's config or environment may spell, listed once so the parser,
373/// the error message, and the docs cannot drift apart.
374const REASONING_EXPECTED: &str =
375    r#""off", "low", "medium", "high", or a number of reasoning tokens"#;
376
377impl std::str::FromStr for Reasoning {
378    type Err = String;
379
380    /// Parses the tokens a host config or `OPENROUTER_REASONING` accepts:
381    /// `off`, `low`, `medium`, `high`, or a plain token budget (`2000`).
382    fn from_str(s: &str) -> Result<Self, Self::Err> {
383        match s.trim() {
384            "off" => Ok(Reasoning::Off),
385            "low" => Ok(Reasoning::Effort(ReasoningEffort::Low)),
386            "medium" => Ok(Reasoning::Effort(ReasoningEffort::Medium)),
387            "high" => Ok(Reasoning::Effort(ReasoningEffort::High)),
388            budget => budget
389                .parse()
390                .map(Reasoning::Budget)
391                .map_err(|_| format!("expected {REASONING_EXPECTED}; got {s:?}")),
392        }
393    }
394}
395
396impl std::fmt::Display for Reasoning {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        match self {
399            Reasoning::Off => f.write_str("off"),
400            Reasoning::Effort(effort) => f.write_str(effort.as_str()),
401            Reasoning::Budget(tokens) => write!(f, "{tokens}"),
402        }
403    }
404}
405
406impl Serialize for Reasoning {
407    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
408        match self {
409            // A budget round-trips as the number it was written as; the levels
410            // as their token. Both are what a host config spells.
411            Reasoning::Budget(tokens) => serializer.serialize_u32(*tokens),
412            level => serializer.serialize_str(&level.to_string()),
413        }
414    }
415}
416
417impl<'de> Deserialize<'de> for Reasoning {
418    /// Accepts a level (`"high"`) or a token budget (`2000`) — one parser for
419    /// every host, so a bad value is refused the same way with the same message
420    /// wherever it was written.
421    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
422        use serde::de::{Error, Unexpected, Visitor};
423
424        struct ReasoningVisitor;
425
426        impl Visitor<'_> for ReasoningVisitor {
427            type Value = Reasoning;
428
429            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430                f.write_str(REASONING_EXPECTED)
431            }
432
433            fn visit_str<E: Error>(self, value: &str) -> Result<Reasoning, E> {
434                value
435                    .parse()
436                    .map_err(|_| E::invalid_value(Unexpected::Str(value), &self))
437            }
438
439            fn visit_u64<E: Error>(self, value: u64) -> Result<Reasoning, E> {
440                u32::try_from(value)
441                    .map(Reasoning::Budget)
442                    .map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self))
443            }
444
445            fn visit_i64<E: Error>(self, value: i64) -> Result<Reasoning, E> {
446                u32::try_from(value)
447                    .map(Reasoning::Budget)
448                    .map_err(|_| E::invalid_value(Unexpected::Signed(value), &self))
449            }
450        }
451
452        deserializer.deserialize_any(ReasoningVisitor)
453    }
454}
455
456/// A request to an LLM provider for chat completion.
457#[derive(Debug, Clone)]
458pub struct LlmRequest {
459    pub messages: Vec<ChatMessage>,
460    pub tools: Option<Vec<ToolDefinition>>,
461    pub temperature: Option<f32>,
462    pub max_tokens: Option<u32>,
463    pub force_json: bool,
464    /// What this request asks of a reasoning model; `None` defers to whatever
465    /// default the provider was configured with, and then to the model's own.
466    pub reasoning: Option<Reasoning>,
467}
468
469impl LlmRequest {
470    pub fn new(messages: Vec<ChatMessage>) -> Self {
471        Self {
472            messages,
473            tools: None,
474            temperature: None,
475            max_tokens: None,
476            force_json: false,
477            reasoning: None,
478        }
479    }
480
481    pub fn reasoning(mut self, reasoning: Reasoning) -> Self {
482        self.reasoning = Some(reasoning);
483        self
484    }
485
486    pub fn temperature(mut self, temp: f32) -> Self {
487        self.temperature = Some(temp);
488        self
489    }
490
491    pub fn max_tokens(mut self, tokens: u32) -> Self {
492        self.max_tokens = Some(tokens);
493        self
494    }
495
496    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
497        self.tools = Some(tools);
498        self
499    }
500
501    pub fn force_json(mut self, force: bool) -> Self {
502        self.force_json = force;
503        self
504    }
505}
506
507/// A response from an LLM provider.
508#[derive(Debug, Clone)]
509pub struct LlmResponse {
510    pub content: Option<String>,
511    pub tool_calls: Option<Vec<ToolCall>>,
512    /// Reasoning-model "thinking" text, when the provider exposes it separately
513    /// from the answer (e.g. OpenRouter's `reasoning`, Zhipu/GLM's
514    /// `reasoning_content`). `None` for providers that don't surface it.
515    pub reasoning: Option<String>,
516    /// What the provider says the request cost. `None` when it reported nothing.
517    pub usage: Option<TokenUsage>,
518}
519
520/// An event emitted during a streaming LLM response.
521#[derive(Debug, Clone)]
522pub enum LlmStreamEvent {
523    ContentChunk(String),
524    /// A chunk of reasoning-model "thinking" text, distinct from the answer
525    /// content (e.g. OpenRouter's `reasoning` / Zhipu's `reasoning_content`).
526    Reasoning(String),
527    ToolCallChunk {
528        id: String,
529        name: Option<String>,
530        arguments: String,
531    },
532    ToolCall(ToolCall),
533    /// What the request cost, as reported by the provider. Terminal: it arrives
534    /// in the final chunk, after the content. Absent on endpoints that do not
535    /// report usage while streaming — see `OpenAiConfig::stream_usage`.
536    Usage(TokenUsage),
537}
538
539/// Trait defining a generic LLM provider for standardizing AI integration across agents.
540#[async_trait]
541pub trait LlmProvider: Send + Sync {
542    /// Generates a chat completion based on the provided request.
543    async fn chat_completion(&self, request: LlmRequest) -> Result<LlmResponse, LlmError>;
544
545    /// Generates a streaming chat completion.
546    async fn chat_completion_stream(
547        &self,
548        request: LlmRequest,
549    ) -> Result<BoxStream<'static, Result<LlmStreamEvent, LlmError>>, LlmError>;
550}
551
552#[cfg(test)]
553mod error_tests {
554    use super::*;
555
556    /// The one API failure a caller can act on has to be tellable from the rest,
557    /// across the shapes the providers actually return.
558    #[test]
559    fn an_over_long_request_is_classified_as_a_context_length_failure() {
560        let bodies = [
561            r#"OpenAI API error (400): {"error":{"message":"This model's maximum context length is 128000 tokens","code":"context_length_exceeded"}}"#,
562            "OpenAI stream error (400): Requested 200000 tokens, exceeds the maximum for this model",
563            r#"Gemini API error (400): {"error":{"status":"INVALID_ARGUMENT","message":"The input token count (1200000) exceeds the maximum"}}"#,
564            "OpenAI API error (400): too many tokens in prompt",
565            // llama.cpp b10524, verbatim. None of the markers above match it:
566            // it says "context size", not "context length", and "exceeds the
567            // available", not "exceeds the maximum".
568            r#"OpenAI stream error (400 Bad Request): {"error":{"code":400,"message":"request (40089 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":40089,"n_ctx":32768}}"#,
569        ];
570        for body in bodies {
571            assert!(
572                matches!(
573                    classify_api_error(body.to_string()),
574                    LlmError::ContextLengthExceeded(_)
575                ),
576                "should classify as context length: {body}"
577            );
578        }
579    }
580
581    /// Everything else stays an ordinary API error. Classifying a bad key as
582    /// "too long" would send the handler into a compaction loop it can never win.
583    #[test]
584    fn other_failures_stay_api_errors() {
585        let bodies = [
586            r#"OpenAI API error (401): {"error":{"message":"Incorrect API key provided"}}"#,
587            "OpenAI API error (429): Rate limit reached for requests",
588            "Gemini API error (503): The model is overloaded",
589        ];
590        for body in bodies {
591            assert!(
592                matches!(classify_api_error(body.to_string()), LlmError::ApiError(_)),
593                "should stay an API error: {body}"
594            );
595        }
596    }
597
598    /// The recovery turns on recognizing this one failure, so it is checked
599    /// against the bodies the two APIs actually answer with — an unsupported
600    /// parameter, an unknown field, and a value the model does not offer, which
601    /// wants the same retry as the other two.
602    #[test]
603    fn a_refused_reasoning_parameter_is_recognized_from_the_body() {
604        let openai = [
605            r#"{"error":{"message":"Unsupported parameter: 'reasoning_effort' is not supported with this model.","type":"invalid_request_error","param":"reasoning_effort","code":"unsupported_parameter"}}"#,
606            r#"{"error":{"message":"Invalid value: 'none'. Supported values are: 'low', 'medium' and 'high'.","type":"invalid_request_error","param":"reasoning_effort","code":"invalid_value"}}"#,
607            r#"{"error":{"message":"Unrecognized request argument supplied: reasoning_effort"}}"#,
608        ];
609        for body in openai {
610            assert!(refuses_reasoning(400, body, "reasoning"), "{body}");
611        }
612
613        let gemini = [
614            r#"{"error":{"code":400,"message":"Invalid JSON payload received. Unknown name \"thinkingLevel\" at 'generation_config': Cannot find field.","status":"INVALID_ARGUMENT"}}"#,
615            r#"{"error":{"code":400,"message":"Budget 128 is invalid. thinkingBudget must be 0 or in the range [128, 32768]","status":"INVALID_ARGUMENT"}}"#,
616        ];
617        for body in gemini {
618            assert!(refuses_reasoning(400, body, "thinking"), "{body}");
619        }
620    }
621
622    /// Everything else is the request failing on its own merits, and retrying it
623    /// without reasoning would waste a round trip and hide the real cause. A 5xx
624    /// that happens to name the field is the same: dropping the setting on an
625    /// outage would leave the model thinking at its default long after.
626    #[test]
627    fn other_failures_are_not_read_as_a_refusal() {
628        assert!(!refuses_reasoning(
629            400,
630            r#"{"error":{"message":"Incorrect API key provided"}}"#,
631            "reasoning"
632        ));
633        assert!(!refuses_reasoning(
634            429,
635            r#"{"error":{"message":"Rate limit reached"}}"#,
636            "reasoning"
637        ));
638        assert!(!refuses_reasoning(
639            503,
640            r#"{"error":{"message":"reasoning_effort backend unavailable"}}"#,
641            "reasoning"
642        ));
643    }
644
645    /// A refusal is remembered once, and every clone of the provider sees it:
646    /// they all call the same model.
647    #[test]
648    fn a_recorded_refusal_is_shared() {
649        let support = ReasoningSupport::default();
650        let clone = support.clone();
651        assert!(!support.refused());
652        clone.record_refusal();
653        assert!(support.refused());
654    }
655
656    #[test]
657    fn usage_with_nothing_reported_reads_as_empty() {
658        assert!(TokenUsage::default().is_empty());
659        assert!(
660            !TokenUsage {
661                prompt_tokens: Some(0),
662                ..Default::default()
663            }
664            .is_empty(),
665            "a reported zero is a report, not an absence"
666        );
667    }
668}