Skip to main content

adk_model/
part_conversion.rs

1//! What happened to each content part on the way to a provider.
2//!
3//! `Content` can express more than any single provider transport accepts, so every adapter
4//! has to decide what to do with the remainder. The decisions themselves are legitimate;
5//! making them invisible is not. Adapters used unrelated fallback policies — drop,
6//! textualize, or encode — with no record, so a request could reach a provider without
7//! material the caller supplied and the model could answer as though it had seen a document
8//! it never received.
9//!
10//! Recording an outcome here emits a `tracing` event at the same moment, so an omission or
11//! downgrade is always observable. [`ConversionReport::into_error`](crate::part_conversion::ConversionReport::into_error) lets a caller that
12//! cannot tolerate loss turn omissions into a failure before dispatch.
13//!
14//! # Example
15//!
16//! ```rust
17//! use adk_model::part_conversion::ConversionReport;
18//!
19//! let mut report = ConversionReport::new("bedrock");
20//! report.converted("Text");
21//! report.omitted("InlineData", Some("audio/wav"), "no Bedrock Converse block accepts audio");
22//!
23//! assert!(report.has_omissions());
24//! assert_eq!(report.omitted_parts().count(), 1);
25//! ```
26
27use adk_core::{AdkError, ErrorCategory, ErrorComponent};
28
29/// What an adapter did with one content part.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PartDisposition {
32    /// Carried to the provider in an equivalent native form.
33    Converted,
34    /// Carried, but in a lossier form than the caller supplied — a file reference rendered
35    /// as descriptive text, for instance, which the model reads but cannot open.
36    Downgraded {
37        /// The form actually sent.
38        to: &'static str,
39    },
40    /// Not carried at all.
41    Omitted,
42}
43
44/// One part's fate, with enough detail to act on.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PartOutcome {
47    /// The `Part` variant name, such as `"InlineData"`.
48    pub kind: &'static str,
49    /// The part's MIME type where it has one.
50    pub mime_type: Option<String>,
51    /// What the adapter did.
52    pub disposition: PartDisposition,
53    /// Why, in terms a caller can act on.
54    pub detail: String,
55}
56
57/// Every part outcome for one request conversion.
58#[derive(Debug, Clone)]
59pub struct ConversionReport {
60    provider: &'static str,
61    outcomes: Vec<PartOutcome>,
62}
63
64impl ConversionReport {
65    /// Starts a report for `provider`.
66    pub fn new(provider: &'static str) -> Self {
67        Self { provider, outcomes: Vec::new() }
68    }
69
70    /// The provider this report describes.
71    pub fn provider(&self) -> &'static str {
72        self.provider
73    }
74
75    /// Records a part carried natively.
76    pub fn converted(&mut self, kind: &'static str) {
77        self.outcomes.push(PartOutcome {
78            kind,
79            mime_type: None,
80            disposition: PartDisposition::Converted,
81            detail: String::new(),
82        });
83    }
84
85    /// Records a part carried in a lossier form, and warns.
86    pub fn downgraded(
87        &mut self,
88        kind: &'static str,
89        mime_type: Option<&str>,
90        to: &'static str,
91        detail: impl Into<String>,
92    ) {
93        let detail = detail.into();
94        tracing::warn!(
95            provider = self.provider,
96            part.kind = kind,
97            part.mime_type = mime_type.unwrap_or("none"),
98            part.sent_as = to,
99            reason = %detail,
100            "content part downgraded for provider"
101        );
102        self.outcomes.push(PartOutcome {
103            kind,
104            mime_type: mime_type.map(str::to_string),
105            disposition: PartDisposition::Downgraded { to },
106            detail,
107        });
108    }
109
110    /// Records a part left out entirely, and warns.
111    pub fn omitted(
112        &mut self,
113        kind: &'static str,
114        mime_type: Option<&str>,
115        detail: impl Into<String>,
116    ) {
117        let detail = detail.into();
118        tracing::warn!(
119            provider = self.provider,
120            part.kind = kind,
121            part.mime_type = mime_type.unwrap_or("none"),
122            reason = %detail,
123            "content part omitted for provider"
124        );
125        self.outcomes.push(PartOutcome {
126            kind,
127            mime_type: mime_type.map(str::to_string),
128            disposition: PartDisposition::Omitted,
129            detail,
130        });
131    }
132
133    /// Every recorded outcome, in the order the parts appeared.
134    pub fn outcomes(&self) -> &[PartOutcome] {
135        &self.outcomes
136    }
137
138    /// The parts that did not reach the provider.
139    pub fn omitted_parts(&self) -> impl Iterator<Item = &PartOutcome> {
140        self.outcomes.iter().filter(|outcome| outcome.disposition == PartDisposition::Omitted)
141    }
142
143    /// The parts that reached the provider in a lossier form.
144    pub fn downgraded_parts(&self) -> impl Iterator<Item = &PartOutcome> {
145        self.outcomes
146            .iter()
147            .filter(|outcome| matches!(outcome.disposition, PartDisposition::Downgraded { .. }))
148    }
149
150    /// Whether any part was left out.
151    pub fn has_omissions(&self) -> bool {
152        self.omitted_parts().next().is_some()
153    }
154
155    /// Whether any part was omitted or downgraded.
156    pub fn has_losses(&self) -> bool {
157        self.has_omissions() || self.downgraded_parts().next().is_some()
158    }
159
160    /// An error naming every omitted part, for callers that must not send a partial request.
161    ///
162    /// Returns `None` when nothing was omitted. Downgrades are excluded: the material still
163    /// reaches the model, and refusing them would reject the documented textual fallback.
164    ///
165    /// # Example
166    ///
167    /// ```rust
168    /// use adk_model::part_conversion::ConversionReport;
169    ///
170    /// let mut report = ConversionReport::new("bedrock");
171    /// report.omitted("InlineData", Some("audio/wav"), "unsupported media type");
172    ///
173    /// let error = report.into_error().expect("an omission must produce an error");
174    /// assert!(error.to_string().contains("audio/wav"));
175    /// ```
176    pub fn into_error(self) -> Option<AdkError> {
177        if !self.has_omissions() {
178            return None;
179        }
180
181        let omitted = self
182            .omitted_parts()
183            .map(|outcome| {
184                let mime = outcome.mime_type.as_deref().unwrap_or("no mime type");
185                format!("{} ({}): {}", outcome.kind, mime, outcome.detail)
186            })
187            .collect::<Vec<_>>()
188            .join("; ");
189
190        Some(AdkError::new(
191            ErrorComponent::Model,
192            ErrorCategory::Unsupported,
193            "model.content.parts_omitted",
194            format!(
195                "{} cannot carry every supplied content part, so the request would reach the \
196                 model incomplete: {omitted}. Remove the part, convert it to a supported type, \
197                 or choose a provider that accepts it.",
198                self.provider
199            ),
200        ))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn a_report_separates_omissions_from_downgrades() {
210        let mut report = ConversionReport::new("bedrock");
211        report.converted("Text");
212        report.downgraded("FileData", Some("image/png"), "text", "only S3 URIs are native");
213        report.omitted("InlineData", Some("audio/wav"), "no block accepts audio");
214
215        assert_eq!(report.outcomes().len(), 3);
216        assert_eq!(report.omitted_parts().count(), 1);
217        assert_eq!(report.downgraded_parts().count(), 1);
218        assert!(report.has_omissions());
219        assert!(report.has_losses());
220    }
221
222    #[test]
223    fn downgrades_alone_are_not_an_error() {
224        let mut report = ConversionReport::new("gemini");
225        report.downgraded("FileData", Some("application/pdf"), "text", "rendered as a reference");
226
227        assert!(!report.has_omissions());
228        assert!(report.has_losses());
229        assert!(report.into_error().is_none(), "a downgrade still reaches the model");
230    }
231
232    #[test]
233    fn an_omission_names_the_part_in_the_error() {
234        let mut report = ConversionReport::new("bedrock");
235        report.omitted("ServerToolCall", None, "Gemini-specific part");
236
237        let error = report.into_error().expect("omission must error");
238        let message = error.to_string();
239        assert!(message.contains("ServerToolCall"), "{message}");
240        assert!(message.contains("bedrock"), "{message}");
241    }
242}