Skip to main content

agent_sdk_foundation/
llm.rs

1//! LLM message and chat data types.
2//!
3//! These are the wire-format types shared between the runtime, providers,
4//! and the server.  The module intentionally contains **no** async traits
5//! or runtime-specific logic so it can be depended on from thin crates.
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11// ── Thinking ──────────────────────────────────────────────────────────
12
13/// The mode of extended thinking.
14#[derive(Debug, Clone)]
15pub enum ThinkingMode {
16    /// Explicitly enabled with a token budget.
17    Enabled { budget_tokens: u32 },
18    /// Adaptive thinking — the model decides how much to think.
19    Adaptive,
20    /// Provider-default thinking: no explicit budget, not adaptive. An
21    /// effort level can still be sent alongside it.
22    Default,
23}
24
25/// How thinking content is returned in responses.
26///
27/// The Anthropic API accepts exactly these two values; the per-model
28/// default differs (`Omitted` on Fable 5 / Sonnet 5 / Opus 4.7+,
29/// `Summarized` on the 4.6 generation), so the SDK always sends one.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum ThinkingDisplay {
33    /// Thinking blocks carry a readable summary of the reasoning.
34    Summarized,
35    /// Thinking blocks arrive with an empty `thinking` field; the
36    /// encrypted `signature` still carries multi-turn continuity.
37    Omitted,
38}
39
40/// Effort level for adaptive thinking via `output_config`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Effort {
44    Low,
45    Medium,
46    High,
47    XHigh,
48    Max,
49}
50
51/// Configuration for extended thinking.
52///
53/// When enabled, the model will show its reasoning process before
54/// generating the final response.
55#[derive(Debug, Clone)]
56pub struct ThinkingConfig {
57    /// Which thinking mode to use.
58    pub mode: ThinkingMode,
59    /// Optional effort level (sent via `output_config`).
60    pub effort: Option<Effort>,
61    /// How thinking content is returned.
62    pub display: ThinkingDisplay,
63}
64
65impl ThinkingConfig {
66    /// Default budget: 10,000 tokens.
67    ///
68    /// This provides enough capacity for meaningful reasoning on most tasks
69    /// while keeping costs reasonable. Increase for complex multi-step problems.
70    pub const DEFAULT_BUDGET_TOKENS: u32 = 10_000;
71
72    /// Minimum budget required by the Anthropic API.
73    pub const MIN_BUDGET_TOKENS: u32 = 1_024;
74
75    /// Create a config with an explicit token budget (Enabled mode).
76    #[must_use]
77    pub const fn new(budget_tokens: u32) -> Self {
78        Self {
79            mode: ThinkingMode::Enabled { budget_tokens },
80            effort: None,
81            display: ThinkingDisplay::Omitted,
82        }
83    }
84
85    /// Create an adaptive thinking config.
86    #[must_use]
87    pub const fn adaptive() -> Self {
88        Self {
89            mode: ThinkingMode::Adaptive,
90            effort: None,
91            display: ThinkingDisplay::Omitted,
92        }
93    }
94
95    /// Create an adaptive thinking config with an effort level.
96    #[must_use]
97    pub const fn adaptive_with_effort(effort: Effort) -> Self {
98        Self {
99            mode: ThinkingMode::Adaptive,
100            effort: Some(effort),
101            display: ThinkingDisplay::Omitted,
102        }
103    }
104
105    /// Create a provider-default-mode config with an effort level.
106    #[must_use]
107    pub const fn default_with_effort(effort: Effort) -> Self {
108        Self {
109            mode: ThinkingMode::Default,
110            effort: Some(effort),
111            display: ThinkingDisplay::Omitted,
112        }
113    }
114
115    /// Set how thinking content is returned.
116    #[must_use]
117    pub const fn with_display(mut self, display: ThinkingDisplay) -> Self {
118        self.display = display;
119        self
120    }
121
122    /// Set the effort level on an existing config.
123    #[must_use]
124    pub const fn with_effort(mut self, effort: Effort) -> Self {
125        self.effort = Some(effort);
126        self
127    }
128}
129
130impl Default for ThinkingConfig {
131    fn default() -> Self {
132        Self::new(Self::DEFAULT_BUDGET_TOKENS)
133    }
134}
135
136// ── Request / Response ────────────────────────────────────────────────
137
138/// Controls whether the model must use a tool.
139#[derive(Debug, Clone)]
140pub enum ToolChoice {
141    /// Let the model decide whether to use tools (default when `None`).
142    Auto,
143    /// Force the model to call a specific tool by name.
144    Tool(String),
145}
146
147/// Requests that the model constrain its final answer to a JSON Schema.
148///
149/// This is the wire-level description of a structured-output request. The
150/// runtime maps it to each provider's native capability:
151///
152/// - **`OpenAI` / Gemini**: native JSON-mode / structured-outputs
153///   (`response_format` / `responseSchema`).
154/// - **Anthropic**: tool-forcing fallback — the runtime injects a single
155///   "respond" tool whose `input_schema` is [`schema`](Self::schema) and
156///   forces the model to call it.
157///
158/// The runtime validates the model's final output against [`schema`](Self::schema)
159/// and, on mismatch, bounded-re-prompts before failing with a typed error.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ResponseFormat {
162    /// Stable identifier for the schema. Surfaced to providers that require a
163    /// name (`OpenAI` `json_schema.name`, the Anthropic fallback tool name).
164    pub name: String,
165    /// The JSON Schema the final assistant output must satisfy.
166    ///
167    /// This is a raw JSON Schema document (an object), not a Rust type. Callers
168    /// that derive schemas from Rust types can plug in `schemars` upstream and
169    /// pass the resulting document here.
170    pub schema: serde_json::Value,
171    /// Whether the provider should enforce strict schema adherence when it
172    /// supports a strict mode (`OpenAI` `strict: true`). Has no effect on
173    /// providers without a strict mode.
174    pub strict: bool,
175}
176
177impl ResponseFormat {
178    /// Create a response format from a schema name and a JSON Schema document.
179    ///
180    /// Defaults to `strict = true` so providers with a strict mode enforce the
181    /// schema rather than treating it as a hint.
182    #[must_use]
183    pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
184        Self {
185            name: name.into(),
186            schema,
187            strict: true,
188        }
189    }
190
191    /// Set whether strict schema adherence is requested.
192    #[must_use]
193    pub const fn with_strict(mut self, strict: bool) -> Self {
194        self.strict = strict;
195        self
196    }
197}
198
199/// Time-to-live for a provider-side prompt-cache breakpoint.
200///
201/// Only the values the Anthropic Messages API accepts are modelled, so the
202/// enum maps losslessly onto the wire `ttl` string. Providers without an
203/// equivalent control ignore it.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum CacheTtl {
206    /// Five-minute ephemeral cache (the provider default).
207    FiveMinutes,
208    /// One-hour ephemeral cache (extended retention).
209    OneHour,
210}
211
212impl CacheTtl {
213    /// The wire string a provider sends for this TTL (`"5m"` / `"1h"`).
214    #[must_use]
215    pub const fn as_wire_str(self) -> &'static str {
216        match self {
217            Self::FiveMinutes => "5m",
218            Self::OneHour => "1h",
219        }
220    }
221}
222
223/// Caller-facing control over provider-side prompt caching.
224///
225/// This is additive: a [`ChatRequest`] with `cache = None` preserves each
226/// provider's default caching behaviour. Set it to shape (or disable) caching:
227///
228/// - `enabled = false` opts the request out of caching entirely — providers
229///   send no `cache_control` breakpoints.
230/// - `ttl` selects the cache retention window (Anthropic ephemeral TTL).
231/// - `max_breakpoints` caps how many cache breakpoints the provider may emit,
232///   in decreasing order of prefix stability (tools, then system, then the
233///   conversation tail). `None` leaves the provider's default count.
234///
235/// Providers without a prompt-cache control ignore every field gracefully.
236#[derive(Debug, Clone)]
237pub struct CacheConfig {
238    /// Whether prompt caching is enabled for this request.
239    pub enabled: bool,
240    /// Optional cache retention window. `None` uses the provider default.
241    pub ttl: Option<CacheTtl>,
242    /// Optional cap on the number of cache breakpoints the provider emits.
243    pub max_breakpoints: Option<u8>,
244}
245
246impl Default for CacheConfig {
247    fn default() -> Self {
248        Self::enabled()
249    }
250}
251
252impl CacheConfig {
253    /// An enabled cache config with provider defaults (no TTL override, all
254    /// breakpoints).
255    #[must_use]
256    pub const fn enabled() -> Self {
257        Self {
258            enabled: true,
259            ttl: None,
260            max_breakpoints: None,
261        }
262    }
263
264    /// A config that opts the request out of provider-side caching.
265    #[must_use]
266    pub const fn disabled() -> Self {
267        Self {
268            enabled: false,
269            ttl: None,
270            max_breakpoints: None,
271        }
272    }
273
274    /// Set the cache retention window.
275    #[must_use]
276    pub const fn with_ttl(mut self, ttl: CacheTtl) -> Self {
277        self.ttl = Some(ttl);
278        self
279    }
280
281    /// Cap the number of cache breakpoints the provider may emit.
282    #[must_use]
283    pub const fn with_max_breakpoints(mut self, max_breakpoints: u8) -> Self {
284        self.max_breakpoints = Some(max_breakpoints);
285        self
286    }
287}
288
289/// Inference speed tier — the "pay a premium for lower latency" knob.
290///
291/// Providers expose this under different names for different mechanisms, so
292/// only the shared economics are modelled here: [`Self::Fast`] costs more per
293/// token and is expected to return sooner.
294///
295/// - Anthropic calls it *fast mode* (`speed: "fast"`): the same model weights
296///   on a faster inference configuration, up to 2.5x the output tokens per
297///   second. Supported only on Opus 5 and Opus 4.8.
298/// - `OpenAI` calls it *priority processing* (`service_tier: "priority"`):
299///   queue priority for lower, more consistent latency.
300///
301/// Neither mechanism changes model behaviour or capabilities. Because both
302/// providers can serve a premium request at standard speed — and bill it at
303/// standard rates — a requested tier is not a guarantee; see
304/// [`LlmProvider::validate_speed_tier`](https://docs.rs/agent-sdk-providers)
305/// for how unsupported combinations are rejected up front.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308#[non_exhaustive]
309pub enum SpeedTier {
310    /// The provider's normal inference path at standard pricing.
311    #[default]
312    Standard,
313    /// The provider's premium low-latency path at premium pricing.
314    Fast,
315}
316
317impl SpeedTier {
318    /// Whether this tier asks for the premium low-latency path.
319    ///
320    /// Prefer this over matching on the variant so that a future intermediate
321    /// tier does not silently read as standard at every call site.
322    #[must_use]
323    pub const fn is_premium(self) -> bool {
324        matches!(self, Self::Fast)
325    }
326
327    /// `const`-callable equality, since `PartialEq::eq` is not `const`.
328    #[must_use]
329    pub const fn same(self, other: Self) -> bool {
330        matches!(
331            (self, other),
332            (Self::Standard, Self::Standard) | (Self::Fast, Self::Fast)
333        )
334    }
335}
336
337#[derive(Debug, Clone)]
338pub struct ChatRequest {
339    pub system: String,
340    pub messages: Vec<Message>,
341    pub tools: Option<Vec<Tool>>,
342    pub max_tokens: u32,
343    /// Whether `max_tokens` was explicitly configured by the caller.
344    pub max_tokens_explicit: bool,
345    /// Optional session identifier for provider-side prompt caching or routing.
346    pub session_id: Option<String>,
347    /// Optional provider-managed cached content reference.
348    ///
349    /// This currently maps to Gemini / Vertex AI `cachedContent` handles.
350    pub cached_content: Option<String>,
351    /// Optional extended thinking configuration.
352    pub thinking: Option<ThinkingConfig>,
353    /// Optional constraint on tool usage.
354    ///
355    /// When `None` the provider's default behaviour applies (typically `auto`).
356    pub tool_choice: Option<ToolChoice>,
357    /// Optional request for the final answer to be constrained to a JSON
358    /// Schema.
359    ///
360    /// When `Some`, the provider maps this to its native JSON-mode /
361    /// structured-output capability (or a tool-forcing fallback) and the
362    /// runtime validates the final output against the schema. When `None`
363    /// (default) the model responds freely.
364    pub response_format: Option<ResponseFormat>,
365    /// Optional control over provider-side prompt caching.
366    ///
367    /// When `None` (default) each provider keeps its built-in caching
368    /// behaviour. When `Some`, providers that support prompt caching honour
369    /// the [`CacheConfig`] (TTL, opt-out, breakpoint cap); others ignore it.
370    pub cache: Option<CacheConfig>,
371}
372
373impl ChatRequest {
374    /// Default token budget used by [`ChatRequest::new`] when the caller does
375    /// not set one explicitly. Providers clamp this to their own ceiling.
376    pub const DEFAULT_MAX_TOKENS: u32 = 4096;
377
378    /// Build a request from a system prompt and a message list, leaving every
379    /// optional knob at its default.
380    ///
381    /// This is the ergonomic counterpart to the (still-public) struct literal:
382    /// the common case only needs `system` + `messages`, so callers no longer
383    /// have to spell out the eight `None`/default fields. Layer optional
384    /// settings on with the chainable `with_*` setters:
385    ///
386    /// ```
387    /// use agent_sdk_foundation::llm::{ChatRequest, Message, ToolChoice};
388    ///
389    /// let req = ChatRequest::new("You are helpful.", vec![Message::user("Hi")])
390    ///     .with_max_tokens(1024)
391    ///     .with_tool_choice(ToolChoice::Auto);
392    /// ```
393    #[must_use]
394    pub fn new(system: impl Into<String>, messages: Vec<Message>) -> Self {
395        Self {
396            system: system.into(),
397            messages,
398            tools: None,
399            max_tokens: Self::DEFAULT_MAX_TOKENS,
400            max_tokens_explicit: false,
401            session_id: None,
402            cached_content: None,
403            thinking: None,
404            tool_choice: None,
405            response_format: None,
406            cache: None,
407        }
408    }
409
410    /// Set the tool list the model may call.
411    #[must_use]
412    pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
413        self.tools = Some(tools);
414        self
415    }
416
417    /// Set the maximum output-token budget (marks it as explicitly configured).
418    #[must_use]
419    pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
420        self.max_tokens = max_tokens;
421        self.max_tokens_explicit = true;
422        self
423    }
424
425    /// Set the session identifier (provider-side prompt caching / routing).
426    #[must_use]
427    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
428        self.session_id = Some(session_id.into());
429        self
430    }
431
432    /// Set the extended-thinking configuration.
433    #[must_use]
434    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
435        self.thinking = Some(thinking);
436        self
437    }
438
439    /// Constrain tool usage (defaults to the provider's `auto` when unset).
440    #[must_use]
441    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
442        self.tool_choice = Some(tool_choice);
443        self
444    }
445
446    /// Request the final answer be constrained to the given JSON-Schema
447    /// [`ResponseFormat`] (structured output).
448    #[must_use]
449    pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
450        self.response_format = Some(response_format);
451        self
452    }
453
454    /// Set the provider-side prompt-cache control ([`CacheConfig`]).
455    #[must_use]
456    pub const fn with_cache(mut self, cache: CacheConfig) -> Self {
457        self.cache = Some(cache);
458        self
459    }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct Message {
464    pub role: Role,
465    pub content: Content,
466}
467
468impl Message {
469    #[must_use]
470    pub fn user(text: impl Into<String>) -> Self {
471        Self {
472            role: Role::User,
473            content: Content::Text(text.into()),
474        }
475    }
476
477    #[must_use]
478    pub const fn user_with_content(blocks: Vec<ContentBlock>) -> Self {
479        Self {
480            role: Role::User,
481            content: Content::Blocks(blocks),
482        }
483    }
484
485    #[must_use]
486    pub fn assistant(text: impl Into<String>) -> Self {
487        Self {
488            role: Role::Assistant,
489            content: Content::Text(text.into()),
490        }
491    }
492
493    #[must_use]
494    pub const fn assistant_with_content(blocks: Vec<ContentBlock>) -> Self {
495        Self {
496            role: Role::Assistant,
497            content: Content::Blocks(blocks),
498        }
499    }
500
501    #[must_use]
502    pub fn assistant_with_tool_use(
503        text: Option<String>,
504        id: impl Into<String>,
505        name: impl Into<String>,
506        input: serde_json::Value,
507    ) -> Self {
508        let mut blocks = Vec::new();
509        if let Some(t) = text {
510            blocks.push(ContentBlock::Text { text: t });
511        }
512        blocks.push(ContentBlock::ToolUse {
513            id: id.into(),
514            name: name.into(),
515            input,
516            thought_signature: None,
517        });
518        Self {
519            role: Role::Assistant,
520            content: Content::Blocks(blocks),
521        }
522    }
523
524    #[must_use]
525    pub fn tool_result(
526        tool_use_id: impl Into<String>,
527        content: impl Into<String>,
528        is_error: bool,
529    ) -> Self {
530        Self {
531            role: Role::User,
532            content: Content::Blocks(vec![ContentBlock::ToolResult {
533                tool_use_id: tool_use_id.into(),
534                content: content.into(),
535                is_error: if is_error { Some(true) } else { None },
536            }]),
537        }
538    }
539}
540
541#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
542#[serde(rename_all = "lowercase")]
543pub enum Role {
544    User,
545    Assistant,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549#[serde(untagged)]
550pub enum Content {
551    Text(String),
552    Blocks(Vec<ContentBlock>),
553}
554
555impl Content {
556    #[must_use]
557    pub fn first_text(&self) -> Option<&str> {
558        match self {
559            Self::Text(s) => Some(s),
560            Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
561                ContentBlock::Text { text } => Some(text.as_str()),
562                _ => None,
563            }),
564        }
565    }
566}
567
568/// Source data for image and document content blocks.
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct ContentSource {
571    pub media_type: String,
572    pub data: String,
573}
574
575impl ContentSource {
576    #[must_use]
577    pub fn new(media_type: impl Into<String>, data: impl Into<String>) -> Self {
578        Self {
579            media_type: media_type.into(),
580            data: data.into(),
581        }
582    }
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(tag = "type")]
587#[non_exhaustive]
588pub enum ContentBlock {
589    #[serde(rename = "text")]
590    Text { text: String },
591
592    #[serde(rename = "thinking")]
593    Thinking {
594        thinking: String,
595        /// Opaque signature for round-tripping thinking blocks back to the API.
596        #[serde(skip_serializing_if = "Option::is_none")]
597        signature: Option<String>,
598    },
599
600    #[serde(rename = "redacted_thinking")]
601    RedactedThinking { data: String },
602
603    /// Provider-owned reasoning state that must be replayed exactly on a
604    /// later request, but must never be interpreted or surfaced by the SDK.
605    ///
606    /// `provider` names the wire protocol that owns `data`; providers must
607    /// ignore blocks owned by a different protocol. The JSON payload is kept
608    /// opaque so a provider can evolve its state-item shape without requiring
609    /// another SDK wire-format change.
610    #[serde(rename = "opaque_reasoning")]
611    OpaqueReasoning {
612        provider: String,
613        data: serde_json::Value,
614    },
615
616    #[serde(rename = "tool_use")]
617    ToolUse {
618        id: String,
619        name: String,
620        input: serde_json::Value,
621        /// Gemini thought signature for preserving reasoning context.
622        /// Required for Gemini 3 models when sending function calls back.
623        #[serde(skip_serializing_if = "Option::is_none")]
624        thought_signature: Option<String>,
625    },
626
627    #[serde(rename = "tool_result")]
628    ToolResult {
629        tool_use_id: String,
630        content: String,
631        #[serde(skip_serializing_if = "Option::is_none")]
632        is_error: Option<bool>,
633    },
634
635    #[serde(rename = "image")]
636    Image { source: ContentSource },
637
638    #[serde(rename = "document")]
639    Document { source: ContentSource },
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643pub struct Tool {
644    pub name: String,
645    pub description: String,
646    pub input_schema: serde_json::Value,
647    /// Human-readable display name shown in UI and audit records.
648    pub display_name: String,
649    /// Permission tier for this tool.
650    pub tier: super::types::ToolTier,
651}
652
653#[derive(Debug, Clone)]
654pub struct ChatResponse {
655    pub id: String,
656    pub content: Vec<ContentBlock>,
657    pub model: String,
658    pub stop_reason: Option<StopReason>,
659    pub usage: Usage,
660}
661
662impl ChatResponse {
663    #[must_use]
664    pub fn first_text(&self) -> Option<&str> {
665        self.content.iter().find_map(|b| match b {
666            ContentBlock::Text { text } => Some(text.as_str()),
667            _ => None,
668        })
669    }
670
671    #[must_use]
672    pub fn first_thinking(&self) -> Option<&str> {
673        self.content.iter().find_map(|b| match b {
674            ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
675            _ => None,
676        })
677    }
678
679    pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
680        self.content.iter().filter_map(|b| match b {
681            ContentBlock::ToolUse {
682                id, name, input, ..
683            } => Some((id.as_str(), name.as_str(), input)),
684            _ => None,
685        })
686    }
687
688    #[must_use]
689    pub fn has_tool_use(&self) -> bool {
690        self.content
691            .iter()
692            .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
693    }
694}
695
696#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
697#[serde(rename_all = "snake_case")]
698#[non_exhaustive]
699pub enum StopReason {
700    EndTurn,
701    ToolUse,
702    MaxTokens,
703    StopSequence,
704    Refusal,
705    ModelContextWindowExceeded,
706    /// A stop reason this version of the SDK does not recognize.
707    ///
708    /// Providers may introduce new stop reasons at any time. Rather than
709    /// failing deserialization of an otherwise-valid response (or a
710    /// persisted/replayed audit row), unknown values map here via
711    /// `#[serde(other)]`. Consumers should treat it like
712    /// [`StopReason::EndTurn`] (turn finished, nothing actionable) unless
713    /// they have a more specific fallback.
714    #[serde(other)]
715    Unknown,
716}
717
718impl StopReason {
719    /// Stable discriminant string used for durable rows, metrics, and
720    /// dashboards.  Matches the serde representation.
721    #[must_use]
722    pub const fn as_str(&self) -> &'static str {
723        match self {
724            Self::EndTurn => "end_turn",
725            Self::ToolUse => "tool_use",
726            Self::MaxTokens => "max_tokens",
727            Self::StopSequence => "stop_sequence",
728            Self::Refusal => "refusal",
729            Self::ModelContextWindowExceeded => "model_context_window_exceeded",
730            Self::Unknown => "unknown",
731        }
732    }
733}
734
735/// Which speed tier a provider actually used, as reported back on the response.
736///
737/// This is the observed counterpart to the requested [`SpeedTier`], and it is a
738/// distinct type because a [`Usage`] is not always one response: the agent loop
739/// folds per-call readings into a running total, and a total that mixes an
740/// expedited call with a downgraded one has no single tier. Collapsing that case
741/// to "unknown" would make a real downgrade indistinguishable from a provider
742/// that never reported a tier at all, so it gets its own variant.
743#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
744#[serde(rename_all = "snake_case")]
745#[non_exhaustive]
746pub enum ServedSpeed {
747    /// Every folded reading reported this same tier.
748    Uniform(SpeedTier),
749    /// Folded readings disagreed — at least one call ran on a different tier
750    /// than another. Worth investigating: asking for a premium tier and getting
751    /// this back means something was downgraded.
752    Mixed,
753}
754
755impl ServedSpeed {
756    /// Fold another reading in, tracking disagreement rather than hiding it.
757    ///
758    /// `None` means "no tier reported", which is not itself a disagreement —
759    /// folding it in leaves the known side untouched. Kept `const` so the
760    /// usage accumulators it is called from stay `const` too.
761    #[must_use]
762    pub const fn merge(left: Option<Self>, right: Option<Self>) -> Option<Self> {
763        match (left, right) {
764            (None, other) | (other, None) => other,
765            (Some(Self::Uniform(left)), Some(Self::Uniform(right))) => {
766                if left.same(right) {
767                    Some(Self::Uniform(left))
768                } else {
769                    Some(Self::Mixed)
770                }
771            }
772            // Any pairing that involves an already-Mixed side stays Mixed.
773            (Some(_), Some(_)) => Some(Self::Mixed),
774        }
775    }
776
777    /// Whether any folded reading ran on a premium tier.
778    #[must_use]
779    pub const fn used_premium(self) -> bool {
780        match self {
781            Self::Uniform(tier) => tier.is_premium(),
782            // Mixed only arises from disagreeing readings, and Standard is the
783            // only non-premium tier, so at least one side was premium.
784            Self::Mixed => true,
785        }
786    }
787}
788
789#[derive(Debug, Clone, Default, Serialize, Deserialize)]
790pub struct Usage {
791    /// Total input tokens reported by the provider.
792    pub input_tokens: u32,
793    pub output_tokens: u32,
794    /// Portion of `input_tokens` billed at a cached-input rate, when reported.
795    #[serde(default)]
796    pub cached_input_tokens: u32,
797    /// Portion of `input_tokens` spent creating provider-side prompt cache entries.
798    #[serde(default)]
799    pub cache_creation_input_tokens: u32,
800    /// Which speed tier actually served the request, when the provider says.
801    ///
802    /// Requesting a premium tier does not guarantee getting one: Anthropic
803    /// serves `claude-opus-4-6` at standard speed without erroring, and
804    /// `OpenAI` downgrades priority requests under a sharp traffic ramp. Both
805    /// bill the tier they actually ran, so this is the field that says whether
806    /// the premium request was honoured.
807    ///
808    /// `None` when the provider reported no tier — which is the normal case for
809    /// every provider and model that has no premium tier to begin with.
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub served_speed: Option<ServedSpeed>,
812}
813
814#[derive(Debug, Clone)]
815#[non_exhaustive]
816pub enum ChatOutcome {
817    Success(ChatResponse),
818    /// The provider rate-limited the request (HTTP 429).
819    ///
820    /// Carries the retry delay parsed from the response's `Retry-After`
821    /// header when the provider supplied one (see [`parse_retry_after`]), so
822    /// the caller can honour the server's hint instead of guessing a backoff.
823    /// `None` when no usable `Retry-After` was present.
824    RateLimited(Option<Duration>),
825    InvalidRequest(String),
826    ServerError(String),
827}
828
829/// Parse the value of an HTTP `Retry-After` header into a [`Duration`].
830///
831/// Per [RFC 9110 §10.2.3], `Retry-After` is either a non-negative number of
832/// seconds (delta-seconds) or an IMF-fixdate HTTP timestamp
833/// (`Sun, 06 Nov 1994 08:49:37 GMT`). For the date form the delay is the
834/// difference between that instant and now; a timestamp at or before now (or
835/// any value that cannot be parsed) yields `None`.
836///
837/// [RFC 9110 §10.2.3]: https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3
838#[must_use]
839pub fn parse_retry_after(value: &str) -> Option<Duration> {
840    let trimmed = value.trim();
841    if trimmed.is_empty() {
842        return None;
843    }
844
845    // delta-seconds: a bare non-negative integer number of seconds.
846    if let Ok(seconds) = trimmed.parse::<u64>() {
847        return Some(Duration::from_secs(seconds));
848    }
849
850    // IMF-fixdate: compute the remaining delay from now, dropping past dates.
851    let target = parse_imf_fixdate(trimmed)?;
852    let now = time::OffsetDateTime::now_utc();
853    if target <= now {
854        return None;
855    }
856    (target - now).try_into().ok()
857}
858
859/// Parse an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) as a UTC instant.
860fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
861    // IMF-fixdate is always UTC ("GMT"); parse the civil datetime and assume
862    // UTC. A custom description avoids depending on the `macros` feature.
863    let format = time::format_description::parse_borrowed::<1>(
864        "[weekday repr:short], [day] [month repr:short] [year] \
865         [hour]:[minute]:[second] GMT",
866    )
867    .ok()?;
868    time::PrimitiveDateTime::parse(value, &format)
869        .ok()
870        .map(time::PrimitiveDateTime::assume_utc)
871}
872
873// ─────────────────────────────────────────────────────────────────────
874// Tool-use / tool-result balancing
875// ─────────────────────────────────────────────────────────────────────
876
877/// Default `tool_result` text used to close a `tool_use` block the user
878/// cancelled (or otherwise abandoned) before it produced a real result.
879///
880/// Surfaced to the model so it understands the call did not run, rather
881/// than silently dropping the loop. Used by [`balance_tool_results`].
882pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
883
884/// Collect the `tool_use` block ids carried by a single message, in the
885/// order they appear. Empty for any message that carries no `tool_use`
886/// blocks (the common case for user messages and text-only assistant
887/// turns).
888fn message_tool_use_ids(message: &Message) -> Vec<&str> {
889    match &message.content {
890        Content::Text(_) => Vec::new(),
891        Content::Blocks(blocks) => blocks
892            .iter()
893            .filter_map(|block| match block {
894                ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
895                _ => None,
896            })
897            .collect(),
898    }
899}
900
901/// Collect the set of `tool_use_id`s answered by `tool_result` blocks in a
902/// single message. Empty unless the message actually carries
903/// `tool_result` blocks.
904fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
905    match &message.content {
906        Content::Text(_) => std::collections::HashSet::new(),
907        Content::Blocks(blocks) => blocks
908            .iter()
909            .filter_map(|block| match block {
910                ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
911                _ => None,
912            })
913            .collect(),
914    }
915}
916
917/// Collect every `tool_use_id` answered by a `tool_result` block *anywhere*
918/// in `messages`.
919///
920/// Answeredness is judged across the whole conversation, not just the
921/// message immediately after a `tool_use`: an id that already has a real
922/// `tool_result` somewhere must never be synthesized again, or balancing
923/// would emit a duplicate `tool_result` for the same id (itself an API
924/// rejection) and mislabel a successful call as cancelled.
925fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
926    messages.iter().flat_map(message_tool_result_ids).collect()
927}
928
929/// True when `messages` contains a `tool_use` block whose id is not
930/// answered by any `tool_result` block anywhere in the conversation.
931///
932/// This is exactly the condition the Anthropic Messages API rejects with
933/// *"`tool_use` ids were found without `tool_result` blocks immediately
934/// after"*. It arises whenever a turn is interrupted after the assistant
935/// `tool_use` was persisted but before every result was recorded — most
936/// commonly when the user answers one of several questions and cancels
937/// the rest, or cancels a tool mid-flight.
938#[must_use]
939pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
940    let answered = all_answered_tool_use_ids(messages);
941    messages
942        .iter()
943        .flat_map(message_tool_use_ids)
944        .any(|id| !answered.contains(id))
945}
946
947/// Close every unanswered `tool_use` loop in `messages`.
948///
949/// Re-balances the conversation so each `tool_use` block is answered by a
950/// `tool_result` block in the immediately following message, synthesizing
951/// an error `tool_result` carrying `cancel_text` for every id left
952/// unanswered.
953///
954/// The Anthropic Messages API requires that an assistant message's
955/// `tool_use` ids each have a matching `tool_result` in the *next*
956/// message. A turn that is cancelled or abandoned after the assistant
957/// `tool_use` was persisted — but before all tool results landed — leaves
958/// the conversation unbalanced, and the next request 400s. This pass
959/// closes those loops so the conversation can continue.
960///
961/// Behaviour per assistant `tool_use` message:
962/// - An id that already has a real `tool_result` anywhere in the
963///   conversation is left alone (never duplicated or relabelled cancelled).
964/// - If the following message already answers some ids (the partial case:
965///   the user answered one question and cancelled the others), the missing
966///   results are appended to that existing message.
967/// - Otherwise a fresh user message carrying the synthetic results is
968///   inserted directly after the assistant message.
969///
970/// Idempotent and order-preserving: a no-op clone when history is already
971/// balanced (see [`has_unbalanced_tool_use`]).
972#[must_use]
973pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
974    // Judge answeredness across the whole conversation so a real result
975    // that is not at idx+1 still suppresses synthesis (no duplicate id).
976    let answered = all_answered_tool_use_ids(messages);
977    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
978    let mut idx = 0;
979    while idx < messages.len() {
980        let message = &messages[idx];
981        let tool_use_ids = message_tool_use_ids(message);
982        if tool_use_ids.is_empty() {
983            out.push(message.clone());
984            idx += 1;
985            continue;
986        }
987
988        let synthetic: Vec<ContentBlock> = tool_use_ids
989            .iter()
990            .filter(|id| !answered.contains(*id))
991            .map(|id| ContentBlock::ToolResult {
992                tool_use_id: (*id).to_owned(),
993                content: cancel_text.to_owned(),
994                is_error: Some(true),
995            })
996            .collect();
997
998        out.push(message.clone());
999
1000        let next = messages.get(idx + 1);
1001
1002        if synthetic.is_empty() {
1003            // Already balanced — leave the following message for the next
1004            // loop iteration to handle normally.
1005            idx += 1;
1006            continue;
1007        }
1008
1009        // A following message that already carries tool_result blocks is
1010        // *the* results message for this turn (the partial-answer case):
1011        // merge the synthetic results into it. Anything else (a fresh user
1012        // prompt, another assistant turn, or end-of-history) gets a brand
1013        // new results message inserted right after the assistant turn.
1014        match next {
1015            Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
1016                let mut merged = next_message.clone();
1017                if let Content::Blocks(blocks) = &mut merged.content {
1018                    blocks.extend(synthetic);
1019                } else {
1020                    // A text-only message can't carry tool_result blocks, so
1021                    // this arm is unreachable given the guard above, but stay
1022                    // defensive rather than silently dropping the results.
1023                    merged.content = Content::Blocks(synthetic);
1024                }
1025                out.push(merged);
1026                idx += 2;
1027            }
1028            _ => {
1029                out.push(Message::user_with_content(synthetic));
1030                idx += 1;
1031            }
1032        }
1033    }
1034    out
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn served_speed_merge_reports_disagreement_instead_of_hiding_it() {
1043        let fast = Some(ServedSpeed::Uniform(SpeedTier::Fast));
1044        let standard = Some(ServedSpeed::Uniform(SpeedTier::Standard));
1045
1046        // Nothing reported stays nothing reported.
1047        assert_eq!(ServedSpeed::merge(None, None), None);
1048
1049        // An unreported reading is not a disagreement — it must not erase a
1050        // known tier, or a single quiet call would hide a whole turn's tier.
1051        assert_eq!(ServedSpeed::merge(None, fast), fast);
1052        assert_eq!(ServedSpeed::merge(fast, None), fast);
1053
1054        // Agreement folds to itself.
1055        assert_eq!(ServedSpeed::merge(fast, fast), fast);
1056        assert_eq!(ServedSpeed::merge(standard, standard), standard);
1057
1058        // The case this type exists for: a downgraded call folded in with an
1059        // expedited one must not read as either.
1060        assert_eq!(ServedSpeed::merge(fast, standard), Some(ServedSpeed::Mixed));
1061        assert_eq!(ServedSpeed::merge(standard, fast), Some(ServedSpeed::Mixed));
1062
1063        // Mixed is absorbing.
1064        let mixed = Some(ServedSpeed::Mixed);
1065        assert_eq!(ServedSpeed::merge(mixed, fast), mixed);
1066        assert_eq!(ServedSpeed::merge(mixed, mixed), mixed);
1067        assert_eq!(ServedSpeed::merge(None, mixed), mixed);
1068    }
1069
1070    #[test]
1071    fn served_speed_used_premium_flags_any_premium_call() {
1072        assert!(!ServedSpeed::Uniform(SpeedTier::Standard).used_premium());
1073        assert!(ServedSpeed::Uniform(SpeedTier::Fast).used_premium());
1074        // Mixed can only arise from disagreeing readings, and Standard is the
1075        // only non-premium tier, so a premium call is implied.
1076        assert!(ServedSpeed::Mixed.used_premium());
1077    }
1078
1079    #[test]
1080    fn usage_defaults_report_no_served_tier() {
1081        let usage = Usage::default();
1082        assert_eq!(usage.input_tokens, 0);
1083        assert_eq!(usage.served_speed, None);
1084    }
1085
1086    #[test]
1087    fn speed_tier_defaults_to_standard_and_only_fast_is_premium() {
1088        assert_eq!(SpeedTier::default(), SpeedTier::Standard);
1089        assert!(!SpeedTier::Standard.is_premium());
1090        assert!(SpeedTier::Fast.is_premium());
1091    }
1092
1093    #[test]
1094    fn chat_request_new_defaults_then_setters() {
1095        let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1096        assert_eq!(req.system, "sys");
1097        assert_eq!(req.messages.len(), 1);
1098        assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
1099        assert!(!req.max_tokens_explicit);
1100        assert!(req.tools.is_none());
1101        assert!(req.tool_choice.is_none());
1102        assert!(req.response_format.is_none());
1103
1104        let req = req
1105            .with_max_tokens(1234)
1106            .with_tool_choice(ToolChoice::Auto)
1107            .with_response_format(ResponseFormat::new(
1108                "r",
1109                serde_json::json!({"type": "object"}),
1110            ))
1111            .with_session_id("s-1");
1112        assert_eq!(req.max_tokens, 1234);
1113        assert!(req.max_tokens_explicit);
1114        assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
1115        assert!(req.response_format.is_some());
1116        assert_eq!(req.session_id.as_deref(), Some("s-1"));
1117    }
1118
1119    #[test]
1120    fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
1121        for (json, expected) in [
1122            ("\"end_turn\"", StopReason::EndTurn),
1123            ("\"tool_use\"", StopReason::ToolUse),
1124            ("\"max_tokens\"", StopReason::MaxTokens),
1125            ("\"stop_sequence\"", StopReason::StopSequence),
1126            ("\"refusal\"", StopReason::Refusal),
1127            (
1128                "\"model_context_window_exceeded\"",
1129                StopReason::ModelContextWindowExceeded,
1130            ),
1131        ] {
1132            let parsed: StopReason = serde_json::from_str(json)?;
1133            assert_eq!(parsed, expected);
1134            assert_eq!(serde_json::to_string(&parsed)?, json);
1135        }
1136        Ok(())
1137    }
1138
1139    #[test]
1140    fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
1141        // An unrecognized provider stop reason must not fail deserialization;
1142        // `#[serde(other)]` routes it to `StopReason::Unknown`.
1143        let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
1144        assert_eq!(parsed, StopReason::Unknown);
1145        assert_eq!(parsed.as_str(), "unknown");
1146        Ok(())
1147    }
1148
1149    #[test]
1150    fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
1151        assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
1152        Ok(())
1153    }
1154
1155    // ── ContentBlock wire format ────────────────────────────────
1156    //
1157    // `ContentBlock` is persisted durably (AgentContinuation.response_content,
1158    // AgentEvent::UserInput), so its tag strings and optional-field omission
1159    // are part of the wire contract. A tag rename or variant reorder must fail
1160    // a test here, not silently corrupt persisted threads.
1161
1162    #[test]
1163    fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
1164        let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
1165        assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
1166        Ok(())
1167    }
1168
1169    #[test]
1170    fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
1171        let none = serde_json::to_value(ContentBlock::Thinking {
1172            thinking: "t".into(),
1173            signature: None,
1174        })?;
1175        assert_eq!(
1176            none,
1177            serde_json::json!({"type": "thinking", "thinking": "t"})
1178        );
1179
1180        let some = serde_json::to_value(ContentBlock::Thinking {
1181            thinking: "t".into(),
1182            signature: Some("sig".into()),
1183        })?;
1184        assert_eq!(
1185            some,
1186            serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
1187        );
1188        Ok(())
1189    }
1190
1191    #[test]
1192    fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
1193        let none = serde_json::to_value(ContentBlock::ToolUse {
1194            id: "i".into(),
1195            name: "n".into(),
1196            input: serde_json::json!({"a": 1}),
1197            thought_signature: None,
1198        })?;
1199        assert_eq!(
1200            none,
1201            serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
1202        );
1203
1204        let some = serde_json::to_value(ContentBlock::ToolUse {
1205            id: "i".into(),
1206            name: "n".into(),
1207            input: serde_json::json!({}),
1208            thought_signature: Some("ts".into()),
1209        })?;
1210        assert_eq!(
1211            some.get("thought_signature").and_then(|v| v.as_str()),
1212            Some("ts")
1213        );
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
1219        let none = serde_json::to_value(ContentBlock::ToolResult {
1220            tool_use_id: "t".into(),
1221            content: "out".into(),
1222            is_error: None,
1223        })?;
1224        assert_eq!(
1225            none,
1226            serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1227        );
1228
1229        let some = serde_json::to_value(ContentBlock::ToolResult {
1230            tool_use_id: "t".into(),
1231            content: "out".into(),
1232            is_error: Some(true),
1233        })?;
1234        assert_eq!(
1235            some.get("is_error").and_then(serde_json::Value::as_bool),
1236            Some(true)
1237        );
1238        Ok(())
1239    }
1240
1241    #[test]
1242    fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1243        assert_eq!(
1244            serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1245            serde_json::json!({"type": "redacted_thinking", "data": "d"})
1246        );
1247        assert_eq!(
1248            serde_json::to_value(ContentBlock::Image {
1249                source: ContentSource::new("image/png", "b64"),
1250            })?,
1251            serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1252        );
1253        assert_eq!(
1254            serde_json::to_value(ContentBlock::Document {
1255                source: ContentSource::new("application/pdf", "b64"),
1256            })?,
1257            serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1258        );
1259        assert_eq!(
1260            serde_json::to_value(ContentBlock::OpaqueReasoning {
1261                provider: "test-provider".into(),
1262                data: serde_json::json!({"id": "reasoning_1", "encrypted": "ciphertext"}),
1263            })?,
1264            serde_json::json!({
1265                "type": "opaque_reasoning",
1266                "provider": "test-provider",
1267                "data": {"id": "reasoning_1", "encrypted": "ciphertext"}
1268            })
1269        );
1270        Ok(())
1271    }
1272
1273    #[test]
1274    fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1275        let blocks = vec![
1276            ContentBlock::Text { text: "t".into() },
1277            ContentBlock::Thinking {
1278                thinking: "th".into(),
1279                signature: Some("s".into()),
1280            },
1281            ContentBlock::RedactedThinking { data: "d".into() },
1282            ContentBlock::OpaqueReasoning {
1283                provider: "test-provider".into(),
1284                data: serde_json::json!({"id": "reasoning_1", "state": [1, 2, 3]}),
1285            },
1286            ContentBlock::ToolUse {
1287                id: "i".into(),
1288                name: "n".into(),
1289                input: serde_json::json!({"x": 1}),
1290                thought_signature: None,
1291            },
1292            ContentBlock::ToolResult {
1293                tool_use_id: "t".into(),
1294                content: "c".into(),
1295                is_error: Some(true),
1296            },
1297            ContentBlock::Image {
1298                source: ContentSource::new("image/png", "b"),
1299            },
1300            ContentBlock::Document {
1301                source: ContentSource::new("application/pdf", "b"),
1302            },
1303        ];
1304        for block in blocks {
1305            let json = serde_json::to_value(&block)?;
1306            let back: ContentBlock = serde_json::from_value(json.clone())?;
1307            assert_eq!(serde_json::to_value(&back)?, json);
1308        }
1309        Ok(())
1310    }
1311
1312    // ── Content (untagged) wire format ──────────────────────────
1313
1314    #[test]
1315    fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1316        let json = serde_json::to_value(Content::Text("hello".into()))?;
1317        assert_eq!(json, serde_json::json!("hello"));
1318        let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1319        assert!(matches!(back, Content::Text(s) if s == "hello"));
1320        Ok(())
1321    }
1322
1323    #[test]
1324    fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1325        let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1326            text: "x".into(),
1327        }]))?;
1328        assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1329
1330        // Empty blocks → `[]` and must round-trip back to `Blocks`, not `Text`,
1331        // even though `Text` is the first untagged variant.
1332        let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1333        assert_eq!(empty, serde_json::json!([]));
1334        let back: Content = serde_json::from_value(empty)?;
1335        assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1336        Ok(())
1337    }
1338
1339    // ── Message wire format ─────────────────────────────────────
1340
1341    #[test]
1342    fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1343        let user = serde_json::to_value(Message::user("hi"))?;
1344        assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1345
1346        let assistant =
1347            serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1348                text: "yo".into(),
1349            }]))?;
1350        assert_eq!(
1351            assistant,
1352            serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1353        );
1354
1355        let back: Message =
1356            serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1357        assert_eq!(back.role, Role::User);
1358        assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1359        Ok(())
1360    }
1361
1362    // ── Retry-After parsing ─────────────────────────────────────
1363
1364    #[test]
1365    fn parse_retry_after_delta_seconds() {
1366        assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1367        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1368        // Surrounding whitespace is tolerated.
1369        assert_eq!(parse_retry_after("  30 "), Some(Duration::from_secs(30)));
1370    }
1371
1372    #[test]
1373    fn parse_retry_after_rejects_garbage_and_empty() {
1374        assert_eq!(parse_retry_after(""), None);
1375        assert_eq!(parse_retry_after("   "), None);
1376        assert_eq!(parse_retry_after("soon"), None);
1377        // Negative deltas are not valid delta-seconds.
1378        assert_eq!(parse_retry_after("-5"), None);
1379    }
1380
1381    #[test]
1382    fn parse_retry_after_past_imf_date_is_none() {
1383        // A date well in the past must not produce a (would-be negative) delay.
1384        assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1385    }
1386
1387    #[test]
1388    fn parse_retry_after_future_imf_date_is_some() {
1389        // Far-future date: must parse and yield a positive, large delay (the
1390        // 1_000_000s ≈ 11.6-day lower bound is trivially exceeded by a year-9999
1391        // target and avoids a round-unit literal).
1392        let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1393        assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1394    }
1395
1396    // ── CacheConfig ─────────────────────────────────────────────
1397
1398    #[test]
1399    fn cache_ttl_wire_strings() {
1400        assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1401        assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1402    }
1403
1404    #[test]
1405    fn cache_config_builders_and_default_request_cache_is_none() {
1406        let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1407        assert!(
1408            req.cache.is_none(),
1409            "default request must not set a cache config"
1410        );
1411
1412        let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1413        assert!(enabled.enabled);
1414        assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1415        assert_eq!(enabled.max_breakpoints, None);
1416
1417        let disabled = CacheConfig::disabled();
1418        assert!(!disabled.enabled);
1419
1420        let capped = CacheConfig::enabled().with_max_breakpoints(2);
1421        assert_eq!(capped.max_breakpoints, Some(2));
1422
1423        let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1424        assert!(req.cache.is_some_and(|c| !c.enabled));
1425    }
1426
1427    fn assistant_tool_uses(ids: &[&str]) -> Message {
1428        let blocks = ids
1429            .iter()
1430            .map(|id| ContentBlock::ToolUse {
1431                id: (*id).to_string(),
1432                name: "ask_user".to_string(),
1433                input: serde_json::json!({}),
1434                thought_signature: None,
1435            })
1436            .collect();
1437        Message::assistant_with_content(blocks)
1438    }
1439
1440    fn tool_results(ids: &[&str]) -> Message {
1441        let blocks = ids
1442            .iter()
1443            .map(|id| ContentBlock::ToolResult {
1444                tool_use_id: (*id).to_string(),
1445                content: "answered".to_string(),
1446                is_error: None,
1447            })
1448            .collect();
1449        Message::user_with_content(blocks)
1450    }
1451
1452    fn assert_balanced(messages: &[Message]) {
1453        assert!(
1454            !has_unbalanced_tool_use(messages),
1455            "expected balanced history, found an orphaned tool_use",
1456        );
1457    }
1458
1459    #[test]
1460    fn balanced_history_is_left_untouched() {
1461        let messages = vec![
1462            Message::user("hi"),
1463            assistant_tool_uses(&["a"]),
1464            tool_results(&["a"]),
1465        ];
1466        assert!(!has_unbalanced_tool_use(&messages));
1467        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1468        assert_eq!(out.len(), 3);
1469        assert_balanced(&out);
1470    }
1471
1472    #[test]
1473    fn partial_cancellation_merges_into_existing_results_message() {
1474        // Four questions, one answered, three cancelled.
1475        let messages = vec![
1476            assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1477            tool_results(&["q1"]),
1478        ];
1479        assert!(has_unbalanced_tool_use(&messages));
1480
1481        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1482        assert_eq!(
1483            out.len(),
1484            2,
1485            "synthetic results merge into the existing message"
1486        );
1487        assert_balanced(&out);
1488
1489        let Content::Blocks(blocks) = &out[1].content else {
1490            panic!("results message must carry blocks");
1491        };
1492        let cancelled: Vec<&str> = blocks
1493            .iter()
1494            .filter_map(|b| match b {
1495                ContentBlock::ToolResult {
1496                    tool_use_id,
1497                    content,
1498                    is_error: Some(true),
1499                } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1500                _ => None,
1501            })
1502            .collect();
1503        assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1504    }
1505
1506    #[test]
1507    fn all_cancelled_with_no_following_message_appends_results() {
1508        // Cancel-all: the assistant turn is the last message, no results at all.
1509        let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1510        assert!(has_unbalanced_tool_use(&messages));
1511
1512        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1513        assert_eq!(out.len(), 2, "a fresh results message is inserted");
1514        assert_eq!(out[1].role, Role::User);
1515        assert_balanced(&out);
1516    }
1517
1518    #[test]
1519    fn orphan_followed_by_user_prompt_inserts_results_between() {
1520        // A fresh user turn arrived after an abandoned tool_use turn: the
1521        // results must be inserted *between* them, not after the prompt.
1522        let messages = vec![
1523            assistant_tool_uses(&["q1"]),
1524            Message::user("a brand new question from the user"),
1525        ];
1526        assert!(has_unbalanced_tool_use(&messages));
1527
1528        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1529        assert_eq!(out.len(), 3);
1530        assert_balanced(&out);
1531        // Order: assistant tool_use, synthetic results, then the user prompt.
1532        assert!(!message_tool_use_ids(&out[0]).is_empty());
1533        assert!(!message_tool_result_ids(&out[1]).is_empty());
1534        assert_eq!(
1535            out[2].content.first_text(),
1536            Some("a brand new question from the user")
1537        );
1538    }
1539
1540    #[test]
1541    fn balancing_is_idempotent() {
1542        let messages = vec![
1543            assistant_tool_uses(&["q1", "q2", "q3"]),
1544            tool_results(&["q2"]),
1545        ];
1546        let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1547        let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1548        assert_eq!(once.len(), twice.len());
1549        assert_balanced(&twice);
1550    }
1551
1552    #[test]
1553    fn no_tool_use_history_is_a_noop() {
1554        let messages = vec![Message::user("hi"), Message::assistant("hello")];
1555        assert!(!has_unbalanced_tool_use(&messages));
1556        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1557        assert_eq!(out.len(), 2);
1558    }
1559
1560    #[test]
1561    fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1562        // A `tool_use` whose genuine result is separated from it by another
1563        // message must NOT get a synthetic "User cancelled" result — that
1564        // would emit two tool_result blocks for the same id (a 400) and lie
1565        // that a successful call was cancelled. Answeredness is judged over
1566        // the whole conversation, so the real result suppresses synthesis.
1567        let messages = vec![
1568            assistant_tool_uses(&["a"]),
1569            Message::user("an interjection between the call and its result"),
1570            tool_results(&["a"]),
1571        ];
1572        // No id is genuinely unanswered, so there is nothing to balance.
1573        assert!(!has_unbalanced_tool_use(&messages));
1574
1575        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1576        // Exactly one tool_result for "a", and none of them is a synthetic
1577        // cancellation.
1578        let a_results: Vec<&ContentBlock> = out
1579            .iter()
1580            .flat_map(|m| match &m.content {
1581                Content::Blocks(b) => b.as_slice(),
1582                Content::Text(_) => &[][..],
1583            })
1584            .filter(
1585                |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1586            )
1587            .collect();
1588        assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1589        assert!(
1590            !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1591            "the real successful result must not be relabelled cancelled",
1592        );
1593    }
1594}