Skip to main content

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}