Skip to main content

everruns_provider/
reasoning.rs

1//! Provider reasoning artifacts.
2//!
3//! Reasoning is a provider-wire concept, like [`crate::execution_phase`]: the
4//! driver types and core's `Message` both need it, so it lives in the provider
5//! abstraction rather than being redefined on either side.
6
7use serde::{Deserialize, Serialize};
8
9#[cfg(feature = "openapi")]
10use utoipa::ToSchema;
11
12/// Readable reasoning text, in the form the provider actually exposes.
13///
14/// Providers differ in *what* they are willing to show, and collapsing that
15/// difference loses the one thing a consumer needs to know: whether it is
16/// looking at the model's own words or a curated gloss of them.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[cfg_attr(feature = "openapi", derive(ToSchema))]
19#[serde(tag = "kind", rename_all = "snake_case")]
20pub enum ReasoningText {
21    /// Verbatim chain-of-thought exposed by the provider (Anthropic extended
22    /// thinking, Gemini thought parts, Chat Completions `reasoning_content`).
23    Plain { text: String },
24    /// Provider-curated summary segments, not raw chain-of-thought (OpenAI
25    /// Responses `summary_text`). Safe to display; never the model's own words.
26    Summary { parts: Vec<String> },
27    /// The provider withheld the content (Anthropic `redacted_thinking`). The
28    /// artifact must still be replayed verbatim, so the part keeps its
29    /// signature/encrypted payload while carrying no readable text.
30    Redacted,
31}
32
33impl ReasoningText {
34    /// Text safe to render on a reasoning channel, if any.
35    pub fn display_text(&self) -> Option<String> {
36        match self {
37            Self::Plain { text } if !text.is_empty() => Some(text.clone()),
38            Self::Summary { parts } if !parts.is_empty() => Some(parts.join("\n\n")),
39            _ => None,
40        }
41    }
42
43    /// Whether this is raw chain-of-thought rather than a curated summary.
44    pub fn is_raw_chain_of_thought(&self) -> bool {
45        matches!(self, Self::Plain { .. })
46    }
47}
48
49/// One provider-issued reasoning artifact, ordered in `Message.content`
50/// alongside text and tool calls.
51///
52/// Ordering is the point. Providers interleave reasoning with text and tool
53/// calls, and every current provider requires its artifacts replayed in the
54/// position it issued them: Anthropic verifies each thinking block against its
55/// own `signature`, OpenAI keys reasoning items by the `item_id` it issued and
56/// expects them adjacent to the item they precede, and Gemini binds a
57/// `thoughtSignature` to a specific function call. A flattened per-message
58/// field cannot express any of that, so this is a content part.
59///
60/// `signature` and `encrypted` are opaque provider artifacts. They are carried
61/// verbatim and never interpreted, never rendered, and never published on an
62/// API surface — see [`ReasoningContentPart::to_public`].
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64#[cfg_attr(feature = "openapi", derive(ToSchema))]
65pub struct ReasoningContentPart {
66    /// Provider that produced this artifact (e.g. `anthropic`, `openai`).
67    /// Replay is only valid against the provider that issued it.
68    pub provider: String,
69
70    /// Provider-assigned identifier, carried verbatim (e.g. OpenAI `rs_…`).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub item_id: Option<String>,
73
74    /// Provider signature over this specific block (Anthropic thinking
75    /// signature, Gemini `thoughtSignature`). Opaque.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub signature: Option<String>,
78
79    /// Provider-encrypted reasoning context (OpenAI `encrypted_content`).
80    /// Opaque.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub encrypted: Option<String>,
83
84    /// Readable reasoning, when the provider exposes any.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub text: Option<ReasoningText>,
87
88    /// Reasoning tokens attributed to this artifact, when reported.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub tokens: Option<u32>,
91
92    /// Id of the tool call this artifact is bound to, when the provider scopes
93    /// it that way (Gemini attaches a thought signature to one function call).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub bound_tool_call_id: Option<String>,
96}
97
98impl ReasoningContentPart {
99    /// A reasoning part carrying only opaque replay state.
100    pub fn opaque(provider: impl Into<String>) -> Self {
101        Self {
102            provider: provider.into(),
103            item_id: None,
104            signature: None,
105            encrypted: None,
106            text: None,
107            tokens: None,
108            bound_tool_call_id: None,
109        }
110    }
111
112    pub fn with_item_id(mut self, item_id: impl Into<String>) -> Self {
113        self.item_id = Some(item_id.into());
114        self
115    }
116
117    pub fn with_signature(mut self, signature: impl Into<String>) -> Self {
118        self.signature = Some(signature.into());
119        self
120    }
121
122    pub fn with_encrypted(mut self, encrypted: impl Into<String>) -> Self {
123        self.encrypted = Some(encrypted.into());
124        self
125    }
126
127    pub fn with_text(mut self, text: ReasoningText) -> Self {
128        self.text = Some(text);
129        self
130    }
131
132    pub fn with_tokens(mut self, tokens: u32) -> Self {
133        self.tokens = Some(tokens);
134        self
135    }
136
137    pub fn with_bound_tool_call_id(mut self, tool_call_id: impl Into<String>) -> Self {
138        self.bound_tool_call_id = Some(tool_call_id.into());
139        self
140    }
141
142    /// Text safe to render on a reasoning channel, if any.
143    pub fn display_text(&self) -> Option<String> {
144        self.text.as_ref().and_then(ReasoningText::display_text)
145    }
146
147    /// Whether this part carries provider state that must be replayed.
148    pub fn has_replay_state(&self) -> bool {
149        self.signature.is_some() || self.encrypted.is_some() || self.item_id.is_some()
150    }
151
152    /// Projection safe to publish on an API surface: opaque provider artifacts
153    /// removed, readable reasoning kept.
154    ///
155    /// `signature` and `encrypted` are replay state, not content. Publishing
156    /// them leaks provider-internal material through an API and invites clients
157    /// to round-trip values they cannot validate.
158    pub fn to_public(&self) -> Self {
159        Self {
160            provider: self.provider.clone(),
161            item_id: self.item_id.clone(),
162            signature: None,
163            encrypted: None,
164            text: self.text.clone(),
165            tokens: self.tokens,
166            bound_tool_call_id: self.bound_tool_call_id.clone(),
167        }
168    }
169}