Skip to main content

ferrin_spec/language_model/
content.rs

1//! Content produced by a language model call.
2
3use serde::Deserialize;
4use serde::Serialize;
5
6use crate::json::JsonValue;
7use crate::shared::ApprovalId;
8use crate::shared::FileData;
9use crate::shared::MediaType;
10use crate::shared::ProviderMetadata;
11use crate::shared::ToolCallId;
12use crate::shared::ToolName;
13
14/// A content part of a generation result, tagged by `type`.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "kebab-case")]
17#[non_exhaustive]
18pub enum Content {
19    /// Generated text.
20    Text {
21        /// The text.
22        text: String,
23        /// Provider-specific metadata.
24        #[serde(default, skip_serializing_if = "Option::is_none")]
25        provider_metadata: Option<ProviderMetadata>,
26    },
27    /// Reasoning text.
28    Reasoning {
29        /// The reasoning text.
30        text: String,
31        /// Provider-specific metadata (signatures, encrypted state, ...).
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        provider_metadata: Option<ProviderMetadata>,
34    },
35    /// A reasoning artifact stored as a file.
36    ReasoningFile {
37        /// File payload (inline bytes or URL).
38        data: FileData,
39        /// Media type of the payload.
40        media_type: MediaType,
41        /// Provider-specific metadata.
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        provider_metadata: Option<ProviderMetadata>,
44    },
45    /// A generated file (image, audio, ...).
46    File {
47        /// File payload (inline bytes or URL).
48        data: FileData,
49        /// Media type of the payload.
50        media_type: MediaType,
51        /// Optional file name.
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        filename: Option<String>,
54        /// Provider-specific metadata.
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        provider_metadata: Option<ProviderMetadata>,
57    },
58    /// Provider-specific content identified by a `provider.type` kind.
59    Custom {
60        /// Kind of the content.
61        kind: CustomKind,
62        /// Provider-specific metadata carrying the payload.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        provider_metadata: Option<ProviderMetadata>,
65    },
66    /// A citation source.
67    Source(Source),
68    /// A tool call requested by the model.
69    ToolCall(ToolCall),
70    /// The result of a provider-executed tool.
71    ToolResult(ProviderToolResult),
72    /// The provider asks for approval before executing a tool call.
73    ToolApprovalRequest {
74        /// Identifier of the approval request.
75        approval_id: ApprovalId,
76        /// Identifier of the tool call awaiting approval.
77        tool_call_id: ToolCallId,
78        /// Provider-specific metadata.
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        provider_metadata: Option<ProviderMetadata>,
81    },
82}
83
84impl Content {
85    /// Creates a text part without metadata.
86    #[must_use]
87    pub fn text(text: impl Into<String>) -> Self {
88        Self::Text {
89            text: text.into(),
90            provider_metadata: None,
91        }
92    }
93
94    /// Creates a reasoning part without metadata.
95    #[must_use]
96    pub fn reasoning(text: impl Into<String>) -> Self {
97        Self::Reasoning {
98            text: text.into(),
99            provider_metadata: None,
100        }
101    }
102
103    /// Returns the text of a [`Content::Text`] part.
104    #[must_use]
105    pub fn as_text(&self) -> Option<&str> {
106        match self {
107            Self::Text { text, .. } => Some(text),
108            _ => None,
109        }
110    }
111
112    /// Returns the tool call of a [`Content::ToolCall`] part.
113    #[must_use]
114    pub fn as_tool_call(&self) -> Option<&ToolCall> {
115        match self {
116            Self::ToolCall(call) => Some(call),
117            _ => None,
118        }
119    }
120
121    /// Returns the wire name of the variant (`text`, `tool-call`, ...).
122    #[must_use]
123    pub fn kind_name(&self) -> &'static str {
124        match self {
125            Self::Text { .. } => "text",
126            Self::Reasoning { .. } => "reasoning",
127            Self::ReasoningFile { .. } => "reasoning-file",
128            Self::File { .. } => "file",
129            Self::Custom { .. } => "custom",
130            Self::Source(_) => "source",
131            Self::ToolCall(_) => "tool-call",
132            Self::ToolResult(_) => "tool-result",
133            Self::ToolApprovalRequest { .. } => "tool-approval-request",
134        }
135    }
136}
137
138/// A tool call requested by the model.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ToolCall {
141    /// Identifier of the tool call.
142    pub tool_call_id: ToolCallId,
143    /// Name of the tool.
144    pub tool_name: ToolName,
145    /// Raw JSON text as emitted by the provider; parsed and validated by the core.
146    pub input: String,
147    /// Whether the provider executes the tool itself.
148    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
149    pub provider_executed: bool,
150    /// Whether the tool was not part of the static tool set (dynamic tool).
151    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
152    pub dynamic: bool,
153    /// Provider-specific metadata.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub provider_metadata: Option<ProviderMetadata>,
156}
157
158impl ToolCall {
159    /// Creates a client-executed, static tool call.
160    #[must_use]
161    pub fn new(
162        tool_call_id: impl Into<ToolCallId>,
163        tool_name: impl Into<ToolName>,
164        input: impl Into<String>,
165    ) -> Self {
166        Self {
167            tool_call_id: tool_call_id.into(),
168            tool_name: tool_name.into(),
169            input: input.into(),
170            provider_executed: false,
171            dynamic: false,
172            provider_metadata: None,
173        }
174    }
175}
176
177/// The result of a tool executed by the provider.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct ProviderToolResult {
180    /// Identifier of the tool call.
181    pub tool_call_id: ToolCallId,
182    /// Name of the tool.
183    pub tool_name: ToolName,
184    /// The result value.
185    pub result: JsonValue,
186    /// Whether the result is an error.
187    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188    pub is_error: bool,
189    /// Whether this is a preliminary result that will be superseded.
190    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
191    pub preliminary: bool,
192    /// Whether the tool is dynamic.
193    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
194    pub dynamic: bool,
195    /// Provider-specific metadata.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub provider_metadata: Option<ProviderMetadata>,
198}
199
200/// A citation source, tagged by `source_type`.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "source_type", rename_all = "kebab-case")]
203#[non_exhaustive]
204pub enum Source {
205    /// A web URL.
206    Url {
207        /// Provider-assigned source id.
208        id: String,
209        /// The URL as reported by the provider.
210        url: String,
211        /// Optional title.
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        title: Option<String>,
214        /// Provider-specific metadata.
215        #[serde(default, skip_serializing_if = "Option::is_none")]
216        provider_metadata: Option<ProviderMetadata>,
217    },
218    /// A document.
219    Document {
220        /// Provider-assigned source id.
221        id: String,
222        /// Media type of the document.
223        media_type: MediaType,
224        /// Title of the document.
225        title: String,
226        /// Optional file name.
227        #[serde(default, skip_serializing_if = "Option::is_none")]
228        filename: Option<String>,
229        /// Provider-specific metadata.
230        #[serde(default, skip_serializing_if = "Option::is_none")]
231        provider_metadata: Option<ProviderMetadata>,
232    },
233}
234
235/// Kind of a custom content part, in `provider.type` form.
236#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
237#[serde(try_from = "String", into = "String")]
238pub struct CustomKind(String);
239
240impl CustomKind {
241    /// Parses a kind; it must be `<provider>.<type>` with non-empty halves.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`InvalidCustomKind`] when the text does not contain exactly
246    /// one separating dot with non-empty text on both sides.
247    pub fn parse(text: impl Into<String>) -> Result<Self, InvalidCustomKind> {
248        let text = text.into();
249        match text.split_once('.') {
250            Some((provider, kind))
251                if !provider.is_empty()
252                    && !kind.is_empty()
253                    && !provider.contains(char::is_whitespace)
254                    && !kind.contains(char::is_whitespace) =>
255            {
256                Ok(Self(text))
257            }
258            _ => Err(InvalidCustomKind { text }),
259        }
260    }
261
262    /// Builds a kind from its two halves.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`InvalidCustomKind`] when either half is empty or contains
267    /// whitespace or a dot.
268    pub fn new(provider: &str, kind: &str) -> Result<Self, InvalidCustomKind> {
269        if provider.contains('.') {
270            return Err(InvalidCustomKind {
271                text: format!("{provider}.{kind}"),
272            });
273        }
274        Self::parse(format!("{provider}.{kind}"))
275    }
276
277    /// Returns the provider half (`openai` in `openai.web_search`).
278    #[must_use]
279    pub fn provider(&self) -> &str {
280        self.0.split_once('.').map_or("", |(provider, _)| provider)
281    }
282
283    /// Returns the type half (`web_search` in `openai.web_search`).
284    #[must_use]
285    pub fn kind(&self) -> &str {
286        self.0.split_once('.').map_or("", |(_, kind)| kind)
287    }
288
289    /// Returns the full `provider.type` string.
290    #[must_use]
291    pub fn as_str(&self) -> &str {
292        &self.0
293    }
294}
295
296impl std::fmt::Display for CustomKind {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        f.write_str(&self.0)
299    }
300}
301
302impl TryFrom<String> for CustomKind {
303    type Error = InvalidCustomKind;
304
305    fn try_from(value: String) -> Result<Self, Self::Error> {
306        Self::parse(value)
307    }
308}
309
310impl From<CustomKind> for String {
311    fn from(kind: CustomKind) -> Self {
312        kind.0
313    }
314}
315
316impl AsRef<str> for CustomKind {
317    fn as_ref(&self) -> &str {
318        &self.0
319    }
320}
321
322/// Error returned when a custom kind is not of the form `provider.type`.
323#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
324#[error("invalid custom kind `{text}`: expected `<provider>.<type>`")]
325pub struct InvalidCustomKind {
326    /// The rejected text.
327    pub text: String,
328}