Skip to main content

polyc_llm/
chunk.rs

1//! Streaming response types: [`Chunk`], [`Usage`], and [`StopReason`].
2//!
3//! Where [`request`](crate::request) describes what goes *into* a provider,
4//! this module describes what streams *out*. A provider yields an ordered
5//! sequence of [`Chunk`]s; the planner reassembles them into assistant turns.
6//!
7//! The shape is the richest streaming superset across the backends we target:
8//! text deltas, a tool call that announces itself and then accretes its JSON
9//! arguments incrementally ([`Chunk::ToolCallArgsDelta`] `args_json_delta`), and
10//! usage. Critically, tool-call arguments arrive as **JSON string deltas** in
11//! every backend: we do not attempt to deserialize them until the matching
12//! [`Chunk::ToolCallEnd`].
13
14use serde::{Deserialize, Serialize};
15
16// ── Chunk ──────────────────────────────────────────────────────────────────────
17
18/// A single event in a provider's streaming response.
19///
20/// A complete stream is an ordered sequence of these. Text generation surfaces
21/// as a run of [`Chunk::TextDelta`] events; a tool call surfaces as exactly one
22/// [`Chunk::ToolCallStart`], zero or more [`Chunk::ToolCallArgsDelta`] fragments
23/// (whose concatenated `args_json_delta`s form the call's JSON arguments), and
24/// exactly one [`Chunk::ToolCallEnd`]. Every tool-call event carries the call
25/// `id` so concurrently-streamed calls can be demultiplexed. [`Chunk::Usage`]
26/// reports token accounting and may appear more than once. A well-formed stream
27/// ends with a single [`Chunk::Stop`].
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum Chunk {
32    /// An incremental piece of generated text. Concatenate consecutive
33    /// `TextDelta`s to recover the full text segment.
34    TextDelta(String),
35    /// An incremental piece of model *reasoning* ("thinking") output, distinct
36    /// from the user-facing answer ([`Chunk::TextDelta`]). Concatenate
37    /// consecutive `ReasoningDelta`s to recover a reasoning segment. Providers
38    /// that don't expose reasoning never emit this (e.g. z.ai GLM streams it as
39    /// `delta.reasoning_content`). Surfaced downstream as a thought, never mixed
40    /// into the answer text.
41    ReasoningDelta(String),
42    /// The model has begun a tool call. The `id` and `name` are known up front;
43    /// arguments stream in as subsequent [`Chunk::ToolCallArgsDelta`]s bearing
44    /// the same `id`.
45    ToolCallStart {
46        /// Provider-assigned call identifier, matching a future
47        /// [`ToolCall::id`](crate::request::ToolCall::id).
48        id: String,
49        /// Name of the tool being called.
50        name: String,
51        /// Opaque provider-specific signature for this call (e.g. a thinking
52        /// model's thought signature), to be carried onto the assembled
53        /// [`ToolCall`](crate::request::ToolCall) and echoed back on the
54        /// follow-up request. `None` when the provider emits no such token.
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        signature: Option<String>,
57    },
58    /// An incremental fragment of a tool call's JSON arguments. Concatenate the
59    /// `args_json_delta`s of all fragments sharing an `id` to recover the full
60    /// `args_json`. Do not parse until the matching [`Chunk::ToolCallEnd`].
61    ToolCallArgsDelta {
62        /// Identifies which in-progress [`Chunk::ToolCallStart`] this fragment
63        /// belongs to.
64        id: String,
65        /// A partial slice of the call's JSON-encoded arguments.
66        args_json_delta: String,
67    },
68    /// The named tool call's arguments are complete and may now be parsed.
69    ToolCallEnd {
70        /// Identifies the completed [`Chunk::ToolCallStart`].
71        id: String,
72    },
73    /// A token-accounting update. May arrive more than once per stream (e.g.
74    /// input tokens early, output tokens at the end).
75    Usage(Usage),
76    /// **Evidence** that the provider's native web-search-grounding primitive
77    /// actually fired for this response — never emitted merely because
78    /// grounding was *allowed* on the request. A provider maps this from a
79    /// response-side proof-of-use signal specific to its own wire format (a
80    /// grounding-metadata field carrying non-empty search-query/source-chunk
81    /// evidence; a distinct tool-call/tool-result block, were that shape ever
82    /// wired here) — never from a request-level flag, which is identical
83    /// whether the model used the capability or ignored it. A provider that
84    /// doesn't support native grounding at all (e.g. a self-hosted
85    /// OpenAI-compatible backend) never emits this, which is the correct
86    /// answer: grounding structurally cannot have fired there.
87    Grounded,
88    /// Terminal event: generation has finished for the reason given.
89    Stop(StopReason),
90}
91
92impl Chunk {
93    /// Wraps `s` in a [`Chunk::TextDelta`].
94    #[must_use]
95    pub fn text_delta(s: impl Into<String>) -> Self {
96        Self::TextDelta(s.into())
97    }
98
99    /// Wraps `s` in a [`Chunk::ReasoningDelta`].
100    #[must_use]
101    pub fn reasoning_delta(s: impl Into<String>) -> Self {
102        Self::ReasoningDelta(s.into())
103    }
104
105    /// Constructs a [`Chunk::ToolCallStart`] event (no signature).
106    #[must_use]
107    pub fn tool_call_start(id: impl Into<String>, name: impl Into<String>) -> Self {
108        Self::ToolCallStart {
109            id: id.into(),
110            name: name.into(),
111            signature: None,
112        }
113    }
114
115    /// Constructs a [`Chunk::ToolCallStart`] event carrying an opaque
116    /// provider-specific `signature`.
117    #[must_use]
118    pub fn tool_call_start_signed(
119        id: impl Into<String>,
120        name: impl Into<String>,
121        signature: Option<String>,
122    ) -> Self {
123        Self::ToolCallStart {
124            id: id.into(),
125            name: name.into(),
126            signature,
127        }
128    }
129
130    /// Constructs a [`Chunk::ToolCallArgsDelta`] carrying a partial-args fragment.
131    #[must_use]
132    pub fn tool_call_args_delta(id: impl Into<String>, args_json_delta: impl Into<String>) -> Self {
133        Self::ToolCallArgsDelta {
134            id: id.into(),
135            args_json_delta: args_json_delta.into(),
136        }
137    }
138
139    /// Constructs a [`Chunk::ToolCallEnd`] event.
140    #[must_use]
141    pub fn tool_call_end(id: impl Into<String>) -> Self {
142        Self::ToolCallEnd { id: id.into() }
143    }
144
145    /// Constructs a [`Chunk::Grounded`] event.
146    #[must_use]
147    pub const fn grounded() -> Self {
148        Self::Grounded
149    }
150}
151
152// ── Usage ──────────────────────────────────────────────────────────────────────
153
154/// Token accounting for a request/response pair.
155///
156/// Counts are cumulative within a single stream. A turn folds these across
157/// every provider call its tool-calling loop makes; the folded total is what
158/// `polychrome_turn_prompt_tokens_total{token_kind}` (`polyc-agent`'s
159/// per-turn prompt-cache-effectiveness counter, `#1299`) surfaces — this
160/// per-call struct itself is not exported to Prometheus directly.
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
162// The `_tokens` postfix is the wire vocabulary every provider's usage payload
163// shares; renaming fields away from it would decouple them from the JSON keys
164// they deserialize.
165#[allow(clippy::struct_field_names)]
166pub struct Usage {
167    /// Tokens consumed by the prompt (system + messages + tools).
168    pub input_tokens: u64,
169    /// Tokens produced by the model.
170    pub output_tokens: u64,
171    /// Prompt tokens served from the provider's cache — the portion of
172    /// [`input_tokens`](Self::input_tokens) a cached stable prefix satisfied, so
173    /// this is a SUBSET of `input_tokens`, not an addition to it. The Phase 0
174    /// exit metric (prompt-cache hit rate) is this over `input_tokens`. Zero when
175    /// the provider reports no cache read or has no caching.
176    #[serde(default)]
177    pub cache_read_input_tokens: u64,
178    /// Prompt tokens the provider wrote INTO its cache on this call (a
179    /// cache-creation / cache-write count), when the provider distinguishes them
180    /// from a cache read. Also a subset of `input_tokens`. Zero when the provider
181    /// reports no cache write or does not surface the count.
182    #[serde(default)]
183    pub cache_creation_input_tokens: u64,
184}
185
186impl Usage {
187    /// Total tokens billed for this exchange (`input_tokens + output_tokens`).
188    ///
189    /// Saturates rather than overflowing; real responses never approach
190    /// `u64::MAX`, but the arithmetic is total so callers need no guard. The
191    /// cache counts are subsets of `input_tokens`, so they are NOT added in.
192    #[must_use]
193    pub const fn total_tokens(self) -> u64 {
194        self.input_tokens.saturating_add(self.output_tokens)
195    }
196}
197
198impl std::ops::AddAssign for Usage {
199    /// Folds `other` into `self`, field by field — every field named
200    /// explicitly, never a `..Default::default()` spread (which would
201    /// silently leave a newly-added field un-summed instead of failing to
202    /// compile when one side gains a field; see `#1241`/`#1238`). The single
203    /// canonical accumulation every caller across the workspace should use
204    /// instead of hand-rolling the same four-field fold at each call site.
205    fn add_assign(&mut self, other: Self) {
206        self.input_tokens += other.input_tokens;
207        self.output_tokens += other.output_tokens;
208        self.cache_read_input_tokens += other.cache_read_input_tokens;
209        self.cache_creation_input_tokens += other.cache_creation_input_tokens;
210    }
211}
212
213// ── StopReason ───────────────────────────────────────────────────────────────────
214
215/// Why the model stopped generating.
216///
217/// The variants are the provider-agnostic union of the common providers'
218/// terminal states.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221#[non_exhaustive]
222pub enum StopReason {
223    /// The model finished its turn naturally.
224    EndTurn,
225    /// Generation hit the request's `max_tokens` ceiling.
226    MaxTokens,
227    /// The model emitted one of the request's `stop` sequences.
228    StopSequence,
229    /// The model paused to call one or more tools; the caller is expected to
230    /// run them and continue the conversation.
231    ToolUse,
232    /// The model declined to answer, or the provider's content filter halted
233    /// generation. Mirrors the wire-side `STOP_REASON_REFUSAL`.
234    Refusal,
235}
236
237// ── Tests ─────────────────────────────────────────────────────────────────────
238
239#[cfg(test)]
240mod tests {
241    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
242
243    use serde_json::{Value, json};
244
245    use super::*;
246
247    #[test]
248    fn text_delta_constructor() {
249        assert_eq!(Chunk::text_delta("hi"), Chunk::TextDelta("hi".to_owned()));
250    }
251
252    #[test]
253    fn tool_call_start_constructor() {
254        match Chunk::tool_call_start("call-1", "search") {
255            Chunk::ToolCallStart { id, name, .. } => {
256                assert_eq!(id, "call-1");
257                assert_eq!(name, "search");
258            }
259            _ => panic!("wrong variant"),
260        }
261    }
262
263    #[test]
264    fn tool_call_args_delta_constructor() {
265        match Chunk::tool_call_args_delta("call-1", r#"{"q":"#) {
266            Chunk::ToolCallArgsDelta {
267                id,
268                args_json_delta,
269            } => {
270                assert_eq!(id, "call-1");
271                assert_eq!(args_json_delta, r#"{"q":"#);
272            }
273            _ => panic!("wrong variant"),
274        }
275    }
276
277    #[test]
278    fn tool_call_end_constructor() {
279        assert_eq!(
280            Chunk::tool_call_end("call-1"),
281            Chunk::ToolCallEnd {
282                id: "call-1".to_owned()
283            },
284        );
285    }
286
287    #[test]
288    fn text_delta_serializes_as_tagged_object() {
289        let v: Value = serde_json::to_value(Chunk::text_delta("hello")).unwrap();
290        assert_eq!(v, json!({"text_delta": "hello"}));
291    }
292
293    #[test]
294    fn tool_call_start_serializes_with_named_fields() {
295        let v: Value = serde_json::to_value(Chunk::tool_call_start("id-1", "calc")).unwrap();
296        assert_eq!(
297            v,
298            json!({"tool_call_start": {"id": "id-1", "name": "calc"}})
299        );
300    }
301
302    #[test]
303    fn tool_call_args_delta_serializes_with_named_fields() {
304        let v: Value =
305            serde_json::to_value(Chunk::tool_call_args_delta("id-1", r#"{"x":1}"#)).unwrap();
306        assert_eq!(
307            v,
308            json!({"tool_call_args_delta": {"id": "id-1", "args_json_delta": r#"{"x":1}"#}}),
309        );
310    }
311
312    #[test]
313    fn chunk_round_trips_all_variants() {
314        for chunk in [
315            Chunk::text_delta("partial"),
316            Chunk::reasoning_delta("let me think"),
317            Chunk::tool_call_start("c1", "weather"),
318            Chunk::tool_call_args_delta("c1", r#"{"city":"NYC"}"#),
319            Chunk::tool_call_end("c1"),
320            Chunk::Usage(Usage {
321                input_tokens: 10,
322                output_tokens: 20,
323                ..Default::default()
324            }),
325            Chunk::grounded(),
326            Chunk::Stop(StopReason::EndTurn),
327        ] {
328            let json = serde_json::to_string(&chunk).unwrap();
329            let back: Chunk = serde_json::from_str(&json).unwrap();
330            assert_eq!(back, chunk);
331        }
332    }
333
334    #[test]
335    fn grounded_constructor() {
336        assert_eq!(Chunk::grounded(), Chunk::Grounded);
337    }
338
339    #[test]
340    fn grounded_serializes_as_a_bare_tag() {
341        // A unit variant with no payload — the tagged-object shape every
342        // other variant here uses collapses to a bare string tag.
343        let v: Value = serde_json::to_value(Chunk::grounded()).unwrap();
344        assert_eq!(v, json!("grounded"));
345    }
346
347    #[test]
348    fn reassemble_tool_call_args_from_deltas_by_id() {
349        // Concatenating same-id ToolCallArgsDelta payloads recovers the full
350        // JSON; a foreign id must not bleed into the assembly.
351        let stream = [
352            Chunk::tool_call_start("a", "weather"),
353            Chunk::tool_call_args_delta("a", r#"{"city":"#),
354            Chunk::tool_call_args_delta("b", "IGNORED"),
355            Chunk::tool_call_args_delta("a", r#""NYC"}"#),
356            Chunk::tool_call_end("a"),
357        ];
358        let mut assembled = String::new();
359        for c in &stream {
360            if let Chunk::ToolCallArgsDelta {
361                id,
362                args_json_delta,
363            } = c
364                && id == "a"
365            {
366                assembled.push_str(args_json_delta);
367            }
368        }
369        let parsed: Value = serde_json::from_str(&assembled).unwrap();
370        assert_eq!(parsed, json!({"city": "NYC"}));
371    }
372
373    #[test]
374    fn usage_total_sums_input_and_output() {
375        let u = Usage {
376            input_tokens: 100,
377            output_tokens: 250,
378            ..Default::default()
379        };
380        assert_eq!(u.total_tokens(), 350);
381    }
382
383    #[test]
384    fn usage_total_saturates_on_overflow() {
385        let u = Usage {
386            input_tokens: u64::MAX,
387            output_tokens: 1,
388            ..Default::default()
389        };
390        assert_eq!(u.total_tokens(), u64::MAX);
391    }
392
393    #[test]
394    fn usage_default_is_all_zero() {
395        let u = Usage::default();
396        assert_eq!(u.input_tokens, 0);
397        assert_eq!(u.output_tokens, 0);
398        assert_eq!(u.cache_read_input_tokens, 0);
399        assert_eq!(u.cache_creation_input_tokens, 0);
400        assert_eq!(u.total_tokens(), 0);
401    }
402
403    #[test]
404    fn usage_add_assign_sums_every_field() {
405        let mut a = Usage {
406            input_tokens: 100,
407            output_tokens: 20,
408            cache_read_input_tokens: 30,
409            cache_creation_input_tokens: 4,
410        };
411        let b = Usage {
412            input_tokens: 5,
413            output_tokens: 6,
414            cache_read_input_tokens: 7,
415            cache_creation_input_tokens: 8,
416        };
417        a += b;
418        assert_eq!(a.input_tokens, 105);
419        assert_eq!(a.output_tokens, 26);
420        assert_eq!(a.cache_read_input_tokens, 37);
421        assert_eq!(a.cache_creation_input_tokens, 12);
422    }
423
424    #[test]
425    fn usage_add_assign_identity_is_default() {
426        let mut a = Usage {
427            input_tokens: 42,
428            output_tokens: 7,
429            cache_read_input_tokens: 3,
430            cache_creation_input_tokens: 1,
431        };
432        let original = a;
433        a += Usage::default();
434        assert_eq!(a, original);
435    }
436
437    #[test]
438    fn cache_tokens_are_a_subset_of_input_not_added_to_the_total() {
439        // Cache-read / cache-creation counts are a portion of `input_tokens`, so
440        // the billed total stays input + output — they are not double-counted.
441        let u = Usage {
442            input_tokens: 100,
443            output_tokens: 40,
444            cache_read_input_tokens: 90,
445            cache_creation_input_tokens: 10,
446        };
447        assert_eq!(u.total_tokens(), 140);
448    }
449
450    #[test]
451    fn usage_cache_tokens_default_to_zero_on_deserialize() {
452        // A payload from a provider that omits the cache counts still decodes,
453        // with the counts defaulting to zero.
454        let u: Usage =
455            serde_json::from_value(json!({"input_tokens": 12, "output_tokens": 3})).unwrap();
456        assert_eq!(u.cache_read_input_tokens, 0);
457        assert_eq!(u.cache_creation_input_tokens, 0);
458    }
459
460    #[test]
461    fn stop_reason_serializes_to_snake_case() {
462        assert_eq!(
463            serde_json::to_string(&StopReason::EndTurn).unwrap(),
464            r#""end_turn""#
465        );
466        assert_eq!(
467            serde_json::to_string(&StopReason::MaxTokens).unwrap(),
468            r#""max_tokens""#
469        );
470        assert_eq!(
471            serde_json::to_string(&StopReason::StopSequence).unwrap(),
472            r#""stop_sequence""#,
473        );
474        assert_eq!(
475            serde_json::to_string(&StopReason::ToolUse).unwrap(),
476            r#""tool_use""#
477        );
478        assert_eq!(
479            serde_json::to_string(&StopReason::Refusal).unwrap(),
480            r#""refusal""#,
481        );
482    }
483
484    #[test]
485    fn stop_reason_round_trips() {
486        for reason in [
487            StopReason::EndTurn,
488            StopReason::MaxTokens,
489            StopReason::StopSequence,
490            StopReason::ToolUse,
491            StopReason::Refusal,
492        ] {
493            let json = serde_json::to_string(&reason).unwrap();
494            let back: StopReason = serde_json::from_str(&json).unwrap();
495            assert_eq!(back, reason);
496        }
497    }
498}