aither_core/llm/reasoning.rs
1//! Provider-opaque reasoning state.
2
3use alloc::string::String;
4
5/// Reasoning state that a provider requires to be handed back unmodified.
6///
7/// Current models do not treat their own reasoning as throwaway. Anthropic
8/// requires thinking blocks to be replayed "complete and unmodified" across a
9/// tool-use turn, `OpenAI`'s stateless flow needs the encrypted reasoning item
10/// echoed back into the next request, and Gemini attaches a thought signature
11/// to each function call. Dropping any of them degrades multi-turn tool use
12/// quietly: the model loses the reasoning that produced the very call it is
13/// being handed a result for.
14///
15/// The payload is provider-encoded and deliberately opaque. Core never parses
16/// it, and neither should anything else — the encoding belongs to the provider,
17/// the meaning belongs to the provider, and the only correct operation is to
18/// give it back. Encoding the whole block rather than its parts is what lets
19/// one type carry Anthropic's `thinking` *and* `redacted_thinking` blocks,
20/// whose shapes differ, without core knowing either.
21///
22/// # Provider tagging
23///
24/// State is tied to the model that produced it. Anthropic documents that
25/// thinking blocks must be stripped when switching models, and cross-provider
26/// replay is at best ignored and at worst rejected. Every value therefore
27/// records its origin, and providers read it back with
28/// [`payload_for`](Self::payload_for) rather than trusting whatever the
29/// conversation happens to carry.
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct ReasoningState {
33 provider: String,
34 payload: String,
35}
36
37impl ReasoningState {
38 /// Creates reasoning state tagged with the provider that produced it.
39 ///
40 /// `provider` should be the stable short name the provider crate uses for
41 /// itself (`"claude"`, `"openai"`, `"gemini"`), and `payload` its own
42 /// encoding of the block, which it alone will decode.
43 #[must_use]
44 pub fn new(provider: impl Into<String>, payload: impl Into<String>) -> Self {
45 Self {
46 provider: provider.into(),
47 payload: payload.into(),
48 }
49 }
50
51 /// The provider that produced this state.
52 #[must_use]
53 pub fn provider(&self) -> &str {
54 &self.provider
55 }
56
57 /// The opaque payload, without checking its origin.
58 ///
59 /// Prefer [`payload_for`](Self::payload_for) when about to put the value on
60 /// the wire; this accessor is for logging and inspection.
61 #[must_use]
62 pub fn payload(&self) -> &str {
63 &self.payload
64 }
65
66 /// The payload, but only if this state came from `provider`.
67 ///
68 /// Returns `None` for foreign state so callers drop it instead of replaying
69 /// it into an API that cannot verify it.
70 #[must_use]
71 pub fn payload_for(&self, provider: &str) -> Option<&str> {
72 (self.provider == provider).then_some(self.payload.as_str())
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use alloc::vec::Vec;
80
81 #[test]
82 fn payload_is_returned_only_to_its_own_provider() {
83 let state = ReasoningState::new("claude", "opaque-signature");
84 assert_eq!(state.payload_for("claude"), Some("opaque-signature"));
85 assert_eq!(state.payload_for("openai"), None);
86 assert_eq!(state.payload_for("gemini"), None);
87 }
88
89 /// The documented behaviour when switching models mid-conversation is to
90 /// strip foreign reasoning, so filtering must leave a usable remainder
91 /// rather than failing the whole turn.
92 #[test]
93 fn filtering_a_mixed_conversation_keeps_only_native_state() {
94 let states = [
95 ReasoningState::new("claude", "a"),
96 ReasoningState::new("openai", "b"),
97 ReasoningState::new("claude", "c"),
98 ];
99 let kept: Vec<&str> = states
100 .iter()
101 .filter_map(|state| state.payload_for("claude"))
102 .collect();
103 assert_eq!(kept, ["a", "c"]);
104 }
105}