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}