brazen/canonical/event.rs
1//! The canonical streaming event taxonomy (§3.2): the one vocabulary every
2//! provider response folds into. No IO; the serde reprs are byte-identical to
3//! the §5.2 NDJSON wire sample (`Event` keeps `"type"` internal tagging;
4//! `ContentKind`/`Delta` are externally tagged per CR-4 — their hand-rolled
5//! impls live in the sibling `event_serde`, mirroring request.rs/request_de.rs).
6//!
7//! **The `v=1` forward-compat contract (§3.2).** Within a fixed
8//! `EVENT_SCHEMA_VERSION` the vocabulary only GROWS: a consumer MUST tolerate an
9//! unknown event `type`, content `kind`, or `delta` variant — and unknown object
10//! fields — by ignoring it, so a new additive kind/event never breaks a pinned
11//! consumer. Every open enum here carries an `Other` catch-all (the general
12//! path; `FinishReason::Other` is the same rule, not a special case) and is
13//! `#[non_exhaustive]` (a new Rust variant is non-breaking too). `v` bumps ONLY
14//! for a removal, rename, or semantic change — never for an addition.
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::canonical::error::CanonicalError;
20use crate::canonical::request::Role;
21
22/// Event-schema version stamped into the first `MessageStart` (§3.2). The one
23/// handshake a harness pins to; a backward-incompatible change to the `Event`
24/// vocabulary bumps it (an additive kind/event does NOT — see the module doc).
25pub const EVENT_SCHEMA_VERSION: u8 = 1;
26
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum Event {
31 MessageStart {
32 v: u8,
33 #[serde(default)]
34 id: Option<String>,
35 #[serde(default)]
36 model: Option<String>,
37 role: Role,
38 },
39 ContentStart {
40 index: u32,
41 kind: ContentKind,
42 },
43 ContentDelta {
44 index: u32,
45 delta: Delta,
46 },
47 ContentStop {
48 index: u32,
49 },
50 Usage(Usage),
51 Finish {
52 #[serde(flatten)]
53 reason: FinishReason,
54 },
55 Error(CanonicalError),
56 /// Only under `--raw`; written verbatim by the raw sink, never serialized.
57 #[serde(skip)]
58 Raw(Vec<u8>),
59 /// THE provider-agnostic terminator.
60 End,
61 /// Forward-compat (§3.2 `v=1` contract): an event `type` this build does not
62 /// model decodes here instead of erroring. `#[serde(other)]` is internal
63 /// tagging's skip path — the payload drops, a pinned consumer ignores it.
64 #[serde(other)]
65 Other,
66}
67
68impl Event {
69 /// Build the opening event, stamping the schema version from the single
70 /// `EVENT_SCHEMA_VERSION` const so adapters never retype the number (§3.2).
71 pub fn message_start(id: Option<String>, model: Option<String>, role: Role) -> Event {
72 Event::MessageStart {
73 v: EVENT_SCHEMA_VERSION,
74 id,
75 model,
76 role,
77 }
78 }
79}
80
81/// What kind of content block is opening (§3.2). Externally tagged so it
82/// renders `{"text":{}}` / `{"tool_use":{…}}` exactly as the §5.2 sample shows.
83#[derive(Clone, Debug, PartialEq)]
84#[non_exhaustive]
85pub enum ContentKind {
86 Text {},
87 ToolUse {
88 id: String,
89 name: String,
90 },
91 /// `id` is the OpenAI Responses reasoning-item id (`rs_…`), surfaced at block
92 /// open so a `--json` harness can rebuild the item for replay; `None` for
93 /// Anthropic/Google (no reasoning-item id). Serializes `{"thinking":{}}` when
94 /// `None` — byte-identical to the pre-reasoning-round-trip shape (bl-61a9).
95 Thinking {
96 id: Option<String>,
97 },
98 /// The Anthropic opaque blob, present AT block open (the wire delivers it on
99 /// the block start, mirroring `ServerToolResult`'s inline content — no delta
100 /// follows), so it round-trips through the decoded stream (bl-61a9).
101 RedactedThinking {
102 data: String,
103 },
104 /// Opaque server-tool invocation (CR-4). Streams start+json_delta+stop like ToolUse.
105 ServerToolUse {
106 id: String,
107 name: String,
108 },
109 /// Opaque server-tool RESULT. `kind` is the verbatim wire tag (open set); the full
110 /// `content` arrives INLINE at content_block_start (no deltas).
111 ServerToolResult {
112 kind: String,
113 tool_use_id: String,
114 content: Value,
115 },
116 /// Forward-compat: an unknown externally-tagged `kind` rides here verbatim
117 /// (the whole `{tag: body}` object) so a pinned consumer passes it through.
118 Other(Value),
119}
120
121/// A streamed content fragment (§3.2). Externally tagged so a newtype variant
122/// renders `{"text_delta":"Hel"}`. Tool arguments ride `JsonDelta` as text
123/// fragments, never a parsed `Value`.
124// The `*Delta` variant names mirror the wire tags the manual `Serialize`/`Deserialize`
125// below emit (`text_delta`/`json_delta`/`thinking_delta`), so the `Delta` suffix is
126// intentional, not a naming slip. `enum_variant_names` only began firing once `Delta`
127// left the public surface (arch §9.8) — clippy exempts exported API from it — so the
128// allow records the deliberate, wire-tied names.
129#[derive(Clone, Debug, PartialEq)]
130#[non_exhaustive]
131#[allow(clippy::enum_variant_names)]
132pub enum Delta {
133 TextDelta(String),
134 JsonDelta(String),
135 ThinkingDelta(String),
136 /// The opaque signature for the block at this index (bl-61a9): the Anthropic
137 /// thinking `signature_delta` (folds to `Content::Thinking.signature`) AND the
138 /// Google `thoughtSignature` on a `functionCall` part (folds to
139 /// `Content::ToolUse.signature`) — ONE grain, "the signature for block N".
140 /// Arrives in wire order, just before the block's stop.
141 SignatureDelta(String),
142 /// The OpenAI Responses reasoning `encrypted_content` (bl-61a9): a close-
143 /// adjacent opaque blob folding to `Content::Thinking.encrypted_content`,
144 /// emitted just before the reasoning block's stop (the wire reveals it on the
145 /// `output_item.done`). A Delta, not a `ContentStop` field — the terminator
146 /// stays a pure, uniform `{index}` for every block kind.
147 EncryptedReasoningDelta(String),
148 /// Forward-compat: an unknown `delta` rides here verbatim (the whole
149 /// `{tag: body}` object) so a pinned consumer passes it through.
150 Other(Value),
151}
152
153/// Token accounting (§3.2). Every field is `Option`: a provider that never
154/// reports a counter leaves it `None` (`0` would be a lie), never fabricated.
155/// Token-explicit names — these count tokens (Anthropic `input_tokens`/…,
156/// OpenAI `prompt_tokens`/…) — frozen with the rest of the `v=1` vocabulary.
157///
158/// `#[non_exhaustive]`: a future counter (e.g. `reasoning_tokens`, deferred
159/// server-tool counts — §3.2) is an additive `v=1` change, never breaking a
160/// downstream reader. Out-of-crate construction is `Usage::default()` then field
161/// assignment (the fields stay `pub`); the struct literal is in-crate-only.
162#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
163#[non_exhaustive]
164pub struct Usage {
165 pub input_tokens: Option<u32>,
166 pub output_tokens: Option<u32>,
167 pub cache_read_tokens: Option<u32>,
168 pub cache_write_tokens: Option<u32>,
169 /// The call's WHOLE prompt in tokens, cached slices included — the one counter
170 /// whose meaning does not depend on which provider answered (§3.2). The four
171 /// counters above are each provider's own number, and the providers disagree
172 /// about whether the cached slice sits INSIDE the prompt counter (OpenAI chat,
173 /// OpenAI Responses, Google: documented as contained) or BESIDE it (Anthropic:
174 /// documented as "tokens which were not read from or used to create a cache").
175 /// So `input + output + cache_read + cache_write` is right on one dialect and
176 /// double-bills the cached slice on the others, growing with the hit rate — worst
177 /// exactly where a long conversation is cheapest (bl-d192). The decoder knows the
178 /// shape, so it answers here once rather than leaving every consumer to learn the
179 /// protocol brazen exists to hide: this call consumed `input_total_tokens +
180 /// output_tokens`, everywhere.
181 ///
182 /// Equal to `input_tokens` wherever the provider's prompt counter is already the
183 /// total; that coincidence is those providers' accounting, not this field's
184 /// definition. `None` exactly when `input_tokens` is — absent stays absent, never
185 /// a fabricated `0` (§3.2), and a partial event that reports only `output_tokens`
186 /// (Anthropic's `message_delta`) leaves it `None` rather than claiming a prompt of
187 /// zero. Merge a stream's usage events per FIELD, last-wins, then add.
188 pub input_total_tokens: Option<u32>,
189 /// The resolved model's context window (input token limit) — the DENOMINATOR
190 /// for the counters above, carried in-band so a harness that makes no
191 /// `--list-models` call still learns it (model-discovery §3, §5.5). NOT a
192 /// counter and never wire-served: no provider reports it on a generation
193 /// response, so every decoder leaves it `None` and the ONE stamp site
194 /// (`run::drive::canonical_events`) carries it off the resolved model row —
195 /// the same carry-the-fact rule as the 404 hint and `Retry-After`.
196 /// `None` when the row does not state one — absent stays absent, never a
197 /// fabricated number (the Usage zero-vs-unknown principle applied to a
198 /// capability fact). Unlike the four counters (whose `null` says "this
199 /// provider did not report it for THIS call"), it is `serde(default)` +
200 /// `skip_serializing_if`, the grows-only shape `Model`'s metadata trio
201 /// already uses: a window-less stream serializes byte-identically to the
202 /// pre-window event.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub context_window: Option<u32>,
205}
206
207impl Usage {
208 /// Seal [`Usage::input_total_tokens`] — the ONE home of the containment rule
209 /// (§3.2), called by every decoder as it builds the event. `cache_outside_input`
210 /// is the dialect's documented accounting: `true` where the cached/written slices
211 /// sit BESIDE the prompt counter and must be added back (Anthropic), `false` where
212 /// the prompt counter already contains them (OpenAI chat, OpenAI Responses,
213 /// Google) or where no cache counter exists at all (Ollama — the two formulas
214 /// coincide on the empty case, so it is the general path, not a third rule).
215 pub(crate) fn with_input_total(mut self, cache_outside_input: bool) -> Self {
216 self.input_total_tokens = match (self.input_tokens, cache_outside_input) {
217 (Some(n), true) => Some(
218 n.saturating_add(self.cache_read_tokens.unwrap_or(0))
219 .saturating_add(self.cache_write_tokens.unwrap_or(0)),
220 ),
221 (n, _) => n,
222 };
223 self
224 }
225}
226
227/// Why generation stopped (§3.2). Carried flattened into `Event::Finish`, keyed
228/// on `reason`. Refusal is a `Finish`, never an `Error`. `Other` preserves any
229/// unknown reason string so decode never panics on a new value.
230#[derive(Clone, Debug, PartialEq)]
231#[non_exhaustive]
232pub enum FinishReason {
233 Stop,
234 Length,
235 ToolUse,
236 StopSequence,
237 Refusal {
238 category: String,
239 explanation: Option<String>,
240 },
241 Pause,
242 Other(String),
243}