rig_core/streaming/identity.rs
1//! Stream-part identity: an opaque accumulation key, and the separate
2//! durable provider handle.
3//!
4//! One streamed part has two distinct identity concerns, carried as two
5//! values (never fused — review 84a43e9e root cause B):
6//!
7//! - [`StreamPartId`] — the **accumulation key**. Opaque: `Eq + Hash` and
8//! nothing else. It has no rendering, no serialization, and no path into a
9//! request or a public stream item; it exists to key the accumulator's
10//! maps for the life of one stream and then dies. Because nothing can
11//! observe it, an adapter may freely mint it ([`SyntheticIds`]) without
12//! any global-uniqueness obligation.
13//! - [`WireId`] — the **durable provider handle**, present only when the
14//! provider actually issued one. It is the only value that may populate
15//! the replayable message types ([`crate::message::Reasoning::id`],
16//! [`crate::message::ToolCall::id`]) and travel upstream. Its only
17//! constructor rejects the empty string, so "absent" is `Option::None` —
18//! never a fabricated `""` a serializer must remember to filter.
19//!
20//! Consumer-facing correlation uses neither: public stream items carry
21//! rig-generated correlators (`internal_call_id` for tool calls, the
22//! part-scoped correlators the stream mints for reasoning), unique per run
23//! by construction — pydantic-ai's part-index shape.
24//!
25//! Reference designs: vercel-ai-sdk carries the per-part stream key on the
26//! event and the durable handle in `providerMetadata.openai.itemId`;
27//! pydantic-ai's `VendorId = Hashable` is an arbitrary private key with
28//! durable ids as separate part fields.
29
30/// What kind of part a minted identity was fabricated for.
31///
32/// The kind partitions minted keys per subsystem so independent minters
33/// need no coordination. This is bookkeeping, not a public contract: the
34/// key is opaque, so overlapping kinds could at most confuse a debugger,
35/// never a consumer or a serializer.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum MintKind {
38 /// Reasoning blocks on constant-id wires (gemini REST, ollama,
39 /// chat-compat `reasoning_content`, candle).
40 Reasoning,
41 /// Encrypted/opaque reasoning payloads on id-less wires (openrouter's
42 /// `reasoning.encrypted` detail). A distinct kind from [`MintKind::Reasoning`] so
43 /// a whole encrypted block can never restate — and replace — the text
44 /// block accumulating under the wire's constant reasoning key.
45 EncryptedReasoning,
46 /// Content blocks on index-as-id wires (anthropic, bedrock).
47 Block,
48 /// OpenAI Responses `output_index` fallback for delta events lacking
49 /// `item_id`.
50 Output,
51 /// Tool-call fragments whose wire omits the tool-call id.
52 Tool,
53 /// Text blocks opened by a bare `Message` on wires that never announce
54 /// text-block boundaries.
55 Text,
56}
57
58impl MintKind {
59 /// The minted key for a wire-supplied index (anthropic's content-block
60 /// index pattern). Unsigned by contract: signed wire index types must be
61 /// converted at the adapter boundary, so a negative index is a decode
62 /// error there rather than a divergent identity here.
63 pub fn for_wire_index(self, index: u64) -> StreamPartId {
64 StreamPartId::minted(self, index)
65 }
66}
67
68/// Opaque accumulation key of one streamed part.
69///
70/// `Eq + Hash + Clone + Debug` and nothing else — deliberately no
71/// `Serialize`/`Deserialize`, no rendering, and no *public* accessor into
72/// the durable id space (crate-internal legacy-fallback sites read
73/// `StreamPartId::wire_str`; the `identity_leak` compile-fail suite pins
74/// the public boundary). Keys derived from wire ids (`StreamPartId::Wire`)
75/// stay distinguishable from minted ones because the accumulator's
76/// interleaving-boundary lifecycle still asks
77/// [`StreamPartId::is_minted`]; that discriminant is stream-internal
78/// bookkeeping, not provenance a serializer may consult.
79/// The representation is **private**: outside the crate, pattern-matching
80/// cannot extract the wire string (or any other payload), so the public
81/// surface's opacity is a property of the type, not a convention.
82/// Construction goes through [`StreamPartId::wire`] and
83/// [`StreamPartId::minted`] / [`MintKind::for_wire_index`].
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
85pub struct StreamPartId(Repr);
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88enum Repr {
89 /// A key derived from an identifier the provider put on the wire.
90 ///
91 /// This is a *key*, not the durable handle: the handle travels
92 /// separately as [`WireId`] on the events that have one.
93 Wire(String),
94 /// A key rig minted at a stream boundary because the wire supplied none.
95 Minted {
96 /// The subsystem that minted this key.
97 kind: MintKind,
98 /// Position within the mint's own sequence (a counter or the wire's
99 /// unsigned index).
100 index: u64,
101 },
102}
103
104/// A bare string is by definition a wire-derived key — fabricating a
105/// `StreamPartId::Minted` requires naming a [`MintKind`] explicitly
106/// (normally via [`SyntheticIds`]), so no conversion can launder a
107/// fabricated key into the wire-derived space. The empty string converts
108/// too — as a *key* that is harmless (it can collide only with itself
109/// within one stream) — but it carries no durable handle: [`WireId`]
110/// construction is separate and rejects emptiness.
111impl From<String> for StreamPartId {
112 fn from(id: String) -> Self {
113 Self(Repr::Wire(id))
114 }
115}
116
117impl From<&str> for StreamPartId {
118 fn from(id: &str) -> Self {
119 Self(Repr::Wire(id.to_owned()))
120 }
121}
122
123impl StreamPartId {
124 /// A key derived from a wire-supplied identifier.
125 pub fn wire(id: impl Into<String>) -> Self {
126 Self(Repr::Wire(id.into()))
127 }
128
129 /// A key minted at a stream boundary because the wire supplied none.
130 /// `const` so per-stream constant keys can live in `const` items.
131 pub const fn minted(kind: MintKind, index: u64) -> Self {
132 Self(Repr::Minted { kind, index })
133 }
134
135 /// Whether this key was minted at a stream boundary (stream-internal
136 /// lifecycle bookkeeping: minted-key reasoning items close on
137 /// interleaving output).
138 pub fn is_minted(&self) -> bool {
139 match &self.0 {
140 Repr::Wire(_) => false,
141 Repr::Minted { .. } => true,
142 }
143 }
144
145 /// The wire-supplied identifier this key was derived from, when it was
146 /// — crate-internal: the legacy durable-fallback sites read it, the
147 /// public surface never does (the `identity_leak` suite pins that).
148 pub(crate) fn wire_str(&self) -> Option<&str> {
149 match &self.0 {
150 Repr::Wire(wire) => Some(wire),
151 _ => None,
152 }
153 }
154}
155
156/// The durable provider handle: an identifier the provider actually issued,
157/// ready for the replayable message types and request payloads.
158///
159/// The only constructor rejects the empty string, so an absent handle is
160/// `Option::None` by construction — no serializer ever needs an
161/// empty-string filter.
162#[derive(Debug, Clone, PartialEq, Eq, Hash)]
163pub struct WireId(String);
164
165impl WireId {
166 /// A provider-issued identifier. `None` for the empty string: absence
167 /// is not an id.
168 pub fn new(id: impl Into<String>) -> Option<Self> {
169 let id = id.into();
170 if id.is_empty() { None } else { Some(Self(id)) }
171 }
172
173 /// The identifier, ready for a request payload.
174 pub fn into_string(self) -> String {
175 self.0
176 }
177
178 /// Borrow the identifier.
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182}
183
184/// Fabricated per-stream keys for wires that carry none.
185///
186/// Every id-less wire mints keys the same way — a [`MintKind`] plus a
187/// counter or the wire's own unsigned index — and the result is a
188/// `StreamPartId::Minted` that, like every stream key, structurally
189/// cannot reach a request or a public stream item.
190#[derive(Debug)]
191pub struct SyntheticIds {
192 kind: MintKind,
193 next: u64,
194}
195
196impl SyntheticIds {
197 /// A minter for `kind`.
198 pub fn new(kind: MintKind) -> Self {
199 Self { kind, next: 0 }
200 }
201
202 /// Keys for the Responses `output_index` fallback.
203 pub fn output() -> Self {
204 Self::new(MintKind::Output)
205 }
206
207 /// Keys for tool-call fragments whose wire supplies no tool-call id.
208 pub fn tool() -> Self {
209 Self::new(MintKind::Tool)
210 }
211
212 /// Keys for text blocks opened by a bare `Message`.
213 pub fn text() -> Self {
214 Self::new(MintKind::Text)
215 }
216
217 /// Mint the next counter-based key (vercel's `blockCounter++` pattern).
218 pub fn mint(&mut self) -> StreamPartId {
219 let id = self.for_index(self.next);
220 self.next = self.next.saturating_add(1);
221 id
222 }
223
224 /// The key for a stable wire-supplied index; see
225 /// [`MintKind::for_wire_index`] (the public spelling).
226 fn for_index(&self, index: u64) -> StreamPartId {
227 self.kind.for_wire_index(index)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 /// The opaque-key contract, at the API-shape level (the stronger
236 /// property — no `Serialize`, no rendering, `WireId` rejecting
237 /// emptiness at its only constructor — is enforced by the
238 /// `identity_leak` compile-fail tests).
239 #[test]
240 fn an_absent_provider_handle_is_none_not_empty() {
241 assert!(WireId::new("").is_none());
242 assert_eq!(
243 WireId::new("rs_123").expect("non-empty").into_string(),
244 "rs_123"
245 );
246 }
247
248 #[test]
249 fn mint_counts_up_per_stream() {
250 let mut ids = SyntheticIds::new(MintKind::Reasoning);
251 assert_eq!(ids.mint(), StreamPartId::minted(MintKind::Reasoning, 0));
252 assert_eq!(ids.mint(), StreamPartId::minted(MintKind::Reasoning, 1));
253 }
254
255 /// Keys minted by different subsystems stay distinct even at equal
256 /// indices — bookkeeping hygiene (nothing can observe a collision, but
257 /// the accumulator's maps deserve distinct keys anyway).
258 #[test]
259 fn minted_keys_are_distinct_across_kinds_and_indices() {
260 let kinds = [
261 MintKind::Reasoning,
262 MintKind::Block,
263 MintKind::Output,
264 MintKind::Tool,
265 MintKind::Text,
266 ];
267 let mut seen = std::collections::HashSet::new();
268 for kind in kinds {
269 for index in [0u64, 1, 7, u64::MAX] {
270 assert!(
271 seen.insert(StreamPartId::minted(kind, index)),
272 "collision at {kind:?}:{index}"
273 );
274 }
275 }
276 }
277}