llmprism/text/response.rs
1//! The Text generation response: [`Step`] (one round trip) and [`TextResponse`]
2//! (the final, aggregated result).
3
4use serde::{Deserialize, Serialize};
5
6use crate::value_objects::{FinishReason, Meta, ToolCall, Usage};
7
8/// The result of exactly one request/response round trip with a provider.
9///
10/// If a request never calls a tool, its [`TextResponse`] contains exactly one
11/// `Step`. If the model calls tools along the way, each round trip through
12/// [`crate::tool_loop::run_text`] produces one more `Step`, and `TextResponse`
13/// holds the full sequence of them -- handy if you want to show a user what the
14/// model did along the way, not just its final answer.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Step {
17 /// The text the model generated in this round trip, if any. Often `None`
18 /// when the model's entire reply was a tool call with no accompanying text.
19 pub text: Option<String>,
20 /// Any tools the model asked to call in this round trip.
21 #[serde(default)]
22 pub tool_calls: Vec<ToolCall>,
23 /// Why the model stopped generating on this round trip.
24 pub finish_reason: FinishReason,
25 /// Token usage for this round trip specifically (see [`TextResponse::usage`]
26 /// for the total across every round trip).
27 pub usage: Usage,
28 /// Provider-reported metadata for this round trip (response id, model name,
29 /// rate-limit info).
30 pub meta: Meta,
31}
32
33/// The final result of a Text generation call, once the tool-calling loop has
34/// finished.
35///
36/// # Example
37///
38/// ```
39/// # use llmprism::text::{Step, TextResponse};
40/// # use llmprism::value_objects::{FinishReason, Meta, Usage};
41/// # let steps = vec![Step {
42/// # text: Some("Hello!".to_string()),
43/// # tool_calls: vec![],
44/// # finish_reason: FinishReason::Stop,
45/// # usage: Usage::default(),
46/// # meta: Meta::default(),
47/// # }];
48/// let response = TextResponse::from_steps(steps);
49/// println!("{}", response.text.unwrap_or_default());
50/// ```
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TextResponse {
53 /// The model's final reply text, taken from the last [`Step`]. `None` if the
54 /// last round trip produced no text (unusual, but possible if the model
55 /// stopped for another reason).
56 pub text: Option<String>,
57 /// Why the model stopped generating on the *last* round trip -- this is the
58 /// finish reason that actually ended the conversation.
59 pub finish_reason: FinishReason,
60 /// Token usage summed across every round trip the tool-calling loop ran.
61 pub usage: Usage,
62 /// Every round trip that happened, oldest first, including the final one.
63 /// Has one entry unless tool calls extended the conversation.
64 pub steps: Vec<Step>,
65}
66
67impl TextResponse {
68 /// Builds the final response by folding a sequence of round trips together:
69 /// the reply text and finish reason come from the last step, and token usage
70 /// is summed across all of them.
71 ///
72 /// # Panics
73 ///
74 /// Panics if `steps` is empty -- the tool-calling loop always produces at
75 /// least one step before returning, so this should never happen in practice
76 /// unless you're constructing a `TextResponse` by hand.
77 pub fn from_steps(steps: Vec<Step>) -> Self {
78 let last = steps
79 .last()
80 .expect("tool loop always produces at least one step");
81 let text = last.text.clone();
82 let finish_reason = last.finish_reason;
83
84 let usage = steps.iter().fold(Usage::default(), |mut acc, step| {
85 acc.prompt_tokens += step.usage.prompt_tokens;
86 acc.completion_tokens += step.usage.completion_tokens;
87 acc
88 });
89
90 Self {
91 text,
92 finish_reason,
93 usage,
94 steps,
95 }
96 }
97}