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}