adk_model/
part_conversion.rs1use adk_core::{AdkError, ErrorCategory, ErrorComponent};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum PartDisposition {
32 Converted,
34 Downgraded {
37 to: &'static str,
39 },
40 Omitted,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PartOutcome {
47 pub kind: &'static str,
49 pub mime_type: Option<String>,
51 pub disposition: PartDisposition,
53 pub detail: String,
55}
56
57#[derive(Debug, Clone)]
59pub struct ConversionReport {
60 provider: &'static str,
61 outcomes: Vec<PartOutcome>,
62}
63
64impl ConversionReport {
65 pub fn new(provider: &'static str) -> Self {
67 Self { provider, outcomes: Vec::new() }
68 }
69
70 pub fn provider(&self) -> &'static str {
72 self.provider
73 }
74
75 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 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 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 pub fn outcomes(&self) -> &[PartOutcome] {
135 &self.outcomes
136 }
137
138 pub fn omitted_parts(&self) -> impl Iterator<Item = &PartOutcome> {
140 self.outcomes.iter().filter(|outcome| outcome.disposition == PartDisposition::Omitted)
141 }
142
143 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 pub fn has_omissions(&self) -> bool {
152 self.omitted_parts().next().is_some()
153 }
154
155 pub fn has_losses(&self) -> bool {
157 self.has_omissions() || self.downgraded_parts().next().is_some()
158 }
159
160 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}