Skip to main content

entelix_core/ir/
response.rs

1//! `ModelResponse` — the provider-neutral reply shape.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ir::content::ContentPart;
6use crate::ir::provider_echo::ProviderEchoSnapshot;
7use crate::ir::usage::Usage;
8use crate::ir::warning::ModelWarning;
9use crate::rate_limit::RateLimitSnapshot;
10
11impl ModelResponse {
12    /// Borrow the first text block, if any. Convenient when the
13    /// model is expected to reply with a single text answer (the
14    /// 5-line-agent path) — saves the manual `match
15    /// &response.content[0] { ContentPart::Text { text, .. } => …,
16    /// _ => panic!() }` dance at every call site.
17    ///
18    /// Returns `None` when the response has no text block (e.g. the
19    /// reply is purely a `ToolUse` block).
20    #[must_use]
21    pub fn first_text(&self) -> Option<&str> {
22        self.content.iter().find_map(|part| match part {
23            ContentPart::Text { text, .. } => Some(text.as_str()),
24            _ => None,
25        })
26    }
27
28    /// Concatenate every text block in order. Useful when the model
29    /// emits multiple `Text` blocks interleaved with `Thinking` or
30    /// `ToolUse` blocks and the caller only wants the user-visible
31    /// answer string.
32    #[must_use]
33    pub fn full_text(&self) -> String {
34        let mut out = String::new();
35        for part in &self.content {
36            if let ContentPart::Text { text, .. } = part {
37                if !out.is_empty() {
38                    out.push('\n');
39                }
40                out.push_str(text);
41            }
42        }
43        out
44    }
45
46    /// Borrow every `ToolUse` block in declaration order. Empty
47    /// when the response carried no tool calls. Used by ReAct-style
48    /// agents to drive the next dispatch round.
49    #[must_use]
50    pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
51        self.content
52            .iter()
53            .filter_map(|part| match part {
54                ContentPart::ToolUse {
55                    id, name, input, ..
56                } => Some(ToolUseRef { id, name, input }),
57                _ => None,
58            })
59            .collect()
60    }
61
62    /// True iff the response has at least one `ToolUse` block —
63    /// hot path for agent loops that branch on "did the model ask
64    /// to call a tool?".
65    #[must_use]
66    pub fn has_tool_uses(&self) -> bool {
67        self.content
68            .iter()
69            .any(|part| matches!(part, ContentPart::ToolUse { .. }))
70    }
71}
72
73/// Borrowed view of a [`ContentPart::ToolUse`] block — returned by
74/// [`ModelResponse::tool_uses`] so callers can iterate without
75/// pattern-matching every entry. Owned data still lives on the
76/// `ModelResponse`; this is a zero-copy projection.
77#[derive(Clone, Copy, Debug)]
78pub struct ToolUseRef<'a> {
79    /// Stable id matched by the corresponding `ToolResult` reply.
80    pub id: &'a str,
81    /// Tool name the model invoked.
82    pub name: &'a str,
83    /// JSON arguments the model produced for the tool.
84    pub input: &'a serde_json::Value,
85}
86
87/// One reply from a model invocation, after decoding.
88#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
89pub struct ModelResponse {
90    /// Vendor-assigned response ID (used for tracing and replay).
91    pub id: String,
92    /// Echo of the model that produced this response — useful when the codec
93    /// resolved an alias (e.g. `claude-opus` → `claude-opus-4-7-20260415`).
94    pub model: String,
95    /// Why the model stopped producing tokens.
96    pub stop_reason: StopReason,
97    /// Returned content blocks (text, tool calls, etc.).
98    pub content: Vec<ContentPart>,
99    /// Token / cache accounting from the vendor.
100    pub usage: Usage,
101    /// Provider rate-limit state at response time, when the codec could
102    /// extract it from response headers (`Codec::extract_rate_limit`).
103    #[serde(default)]
104    pub rate_limit: Option<RateLimitSnapshot>,
105    /// Codec-emitted warnings (lossy encoding, unknown stop reasons, etc.).
106    /// Always non-fatal; consumers may surface them in observability.
107    #[serde(default)]
108    pub warnings: Vec<ModelWarning>,
109    /// Vendor-keyed opaque round-trip tokens that ride at the
110    /// response root (rather than on a single content part) —
111    /// OpenAI Responses `Response.id` is the canonical example,
112    /// captured here so the *next* `ModelRequest::continued_from`
113    /// can chain via `previous_response_id`. Codecs only populate
114    /// entries matching their own `Codec::name`.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub provider_echoes: Vec<ProviderEchoSnapshot>,
117}
118
119/// Why a refusal happened, when the model halts on a non-success
120/// signal that codecs map to [`StopReason::Refusal`]. Vendors expose
121/// distinct flavours — safety filters, copyright/recitation guards,
122/// guardrail interventions, vendor-side failures — and the IR keeps
123/// them separate so observability can report the right thing instead
124/// of collapsing every refusal-shaped stop into a single bucket.
125#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127#[non_exhaustive]
128pub enum RefusalReason {
129    /// Vendor safety / content filter blocked the response
130    /// (Anthropic `refusal`, OpenAI `content_filter`, Gemini `SAFETY`).
131    Safety,
132    /// Vendor recitation / copyright guard blocked the response
133    /// (Gemini `RECITATION`).
134    Recitation,
135    /// Vendor guardrail intervened (Bedrock `guardrail_intervened` /
136    /// `content_filtered`).
137    Guardrail,
138    /// Vendor declared the request failed for a non-safety reason
139    /// (OpenAI Responses `status: "failed"`). Distinct from
140    /// `Safety` because the cause is server-side rather than a
141    /// content-policy decision.
142    ProviderFailure,
143    /// Vendor signalled a refusal but did not classify it. The raw
144    /// vendor token is preserved so dashboards can group by it.
145    Other {
146        /// Raw vendor refusal string.
147        raw: String,
148    },
149}
150
151/// Reason the model halted generation.
152#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "kind", rename_all = "snake_case")]
154#[non_exhaustive]
155pub enum StopReason {
156    /// Natural end of turn.
157    EndTurn,
158    /// Hit `max_tokens` cap.
159    MaxTokens,
160    /// Matched one of `stop_sequences`.
161    StopSequence {
162        /// The matched stop string.
163        sequence: String,
164    },
165    /// Model emitted a tool call and is waiting for the result.
166    ToolUse,
167    /// Model refused (safety / recitation / guardrail / provider
168    /// failure). The codec classifies the flavour into
169    /// [`RefusalReason`] so observability can split by cause.
170    Refusal {
171        /// Why the refusal happened.
172        reason: RefusalReason,
173    },
174    /// Vendor returned a stop reason we don't model yet. Codec emits a
175    /// `ModelWarning::UnknownStopReason` alongside.
176    Other {
177        /// Raw vendor reason string.
178        raw: String,
179    },
180}