Skip to main content

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}