Skip to main content

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}