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}