1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::ToolCallId;
8use crate::content::{
9 MAX_TOOL_ARGUMENT_BYTES, MAX_TOOL_ARGUMENT_DEPTH, validate_provider_tool_call_id,
10 validate_tool_name,
11};
12use crate::metadata::{ProtocolMetadataError, validate_json_bounds};
13
14pub const MAX_EXTERNAL_SOURCE_URL_BYTES: usize = 8 * 1024;
16pub const MAX_EXTERNAL_SOURCE_TITLE_BYTES: usize = 1024;
18pub const MAX_EXTERNAL_SOURCE_TEXT_BYTES: usize = 64 * 1024;
20pub const MAX_HOSTED_TOOL_SOURCES: usize = 64;
22pub const MAX_PROVIDER_CONTINUATION_BYTES: usize = 4 * 1024 * 1024;
24pub const MAX_PROVIDER_CONTINUATION_DEPTH: usize = 64;
26pub const MAX_WEB_FETCH_URL_BYTES: usize = 2 * 1024;
28pub const MAX_WEB_FETCH_TITLE_BYTES: usize = 4 * 1024;
30pub const MAX_WEB_FETCH_MIME_BYTES: usize = 128;
32pub const MAX_WEB_FETCH_BODY_CHARS: usize = 100_000;
34pub const MAX_WEB_FETCH_BODY_BYTES: usize = MAX_WEB_FETCH_BODY_CHARS * 4;
36pub const MAX_WEB_FETCH_REDIRECTS: usize = 10;
38
39const MAX_PROVIDER_CONTINUATION_ID_BYTES: usize = 128;
40const MAX_HOSTED_TOOL_ERROR_CODE_BYTES: usize = 128;
41const MAX_HOSTED_TOOL_ERROR_MESSAGE_BYTES: usize = 4096;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum WebFetchTruncation {
47 CompressedBytes,
49 DecodedBytes,
51 BodyCharacters,
53 ParserComplexity,
55}
56
57#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase", try_from = "RawWebFetchRedirect")]
60pub struct WebFetchRedirect {
61 from: String,
62 to: String,
63 status: u16,
64}
65
66impl WebFetchRedirect {
67 pub fn new(
73 from: impl Into<String>,
74 to: impl Into<String>,
75 status: u16,
76 ) -> Result<Self, ExternalContentError> {
77 if !(300..=399).contains(&status) {
78 return Err(ExternalContentError::InvalidWebFetchRedirect);
79 }
80 Ok(Self {
81 from: normalize_web_fetch_url(&from.into())?,
82 to: normalize_web_fetch_url(&to.into())?,
83 status,
84 })
85 }
86
87 #[must_use]
89 pub fn from(&self) -> &str {
90 &self.from
91 }
92
93 #[must_use]
95 pub fn to(&self) -> &str {
96 &self.to
97 }
98
99 #[must_use]
101 pub const fn status(&self) -> u16 {
102 self.status
103 }
104
105 fn validate(&self) -> Result<(), ExternalContentError> {
106 if normalize_web_fetch_url(&self.from)? != self.from
107 || normalize_web_fetch_url(&self.to)? != self.to
108 || !(300..=399).contains(&self.status)
109 {
110 return Err(ExternalContentError::InvalidWebFetchRedirect);
111 }
112 Ok(())
113 }
114}
115
116impl fmt::Debug for WebFetchRedirect {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 formatter
119 .debug_struct("WebFetchRedirect")
120 .field("status", &self.status)
121 .field("from_bytes", &self.from.len())
122 .field("to_bytes", &self.to.len())
123 .finish()
124 }
125}
126
127#[derive(Deserialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129struct RawWebFetchRedirect {
130 from: String,
131 to: String,
132 status: u16,
133}
134
135impl TryFrom<RawWebFetchRedirect> for WebFetchRedirect {
136 type Error = ExternalContentError;
137
138 fn try_from(raw: RawWebFetchRedirect) -> Result<Self, Self::Error> {
139 Self::new(raw.from, raw.to, raw.status)
140 }
141}
142
143#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase", try_from = "RawWebFetchPresentation")]
149pub struct WebFetchPresentation {
150 requested_url: String,
151 final_url: String,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 title: Option<String>,
154 mime_type: String,
155 body: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 truncation: Option<WebFetchTruncation>,
158 redirects: Vec<WebFetchRedirect>,
159}
160
161impl WebFetchPresentation {
162 pub fn new(
168 requested_url: impl Into<String>,
169 final_url: impl Into<String>,
170 mime_type: impl Into<String>,
171 body: impl Into<String>,
172 ) -> Result<Self, ExternalContentError> {
173 let body = body.into();
174 validate_web_fetch_body(&body)?;
175 Ok(Self {
176 requested_url: normalize_web_fetch_url(&requested_url.into())?,
177 final_url: normalize_web_fetch_url(&final_url.into())?,
178 title: None,
179 mime_type: normalize_web_fetch_mime(&mime_type.into())?,
180 body,
181 truncation: None,
182 redirects: Vec::new(),
183 })
184 }
185
186 pub fn with_title(mut self, title: impl Into<String>) -> Result<Self, ExternalContentError> {
192 let title = title.into();
193 validate_web_fetch_title(&title)?;
194 self.title = Some(title);
195 Ok(self)
196 }
197
198 #[must_use]
200 pub const fn with_truncation(mut self, truncation: WebFetchTruncation) -> Self {
201 self.truncation = Some(truncation);
202 self
203 }
204
205 pub fn with_redirects(
211 mut self,
212 redirects: Vec<WebFetchRedirect>,
213 ) -> Result<Self, ExternalContentError> {
214 validate_web_fetch_redirects(&redirects)?;
215 self.redirects = redirects;
216 Ok(self)
217 }
218
219 #[must_use]
221 pub fn requested_url(&self) -> &str {
222 &self.requested_url
223 }
224
225 #[must_use]
227 pub fn final_url(&self) -> &str {
228 &self.final_url
229 }
230
231 #[must_use]
233 pub fn title(&self) -> Option<&str> {
234 self.title.as_deref()
235 }
236
237 #[must_use]
239 pub fn mime_type(&self) -> &str {
240 &self.mime_type
241 }
242
243 #[must_use]
245 pub fn body(&self) -> &str {
246 &self.body
247 }
248
249 #[must_use]
251 pub const fn truncation(&self) -> Option<WebFetchTruncation> {
252 self.truncation
253 }
254
255 #[must_use]
257 pub fn redirects(&self) -> &[WebFetchRedirect] {
258 &self.redirects
259 }
260}
261
262impl fmt::Debug for WebFetchPresentation {
263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter
265 .debug_struct("WebFetchPresentation")
266 .field("mime_type", &self.mime_type)
267 .field("body_chars", &self.body.chars().count())
268 .field("has_title", &self.title.is_some())
269 .field("truncation", &self.truncation)
270 .field("redirect_count", &self.redirects.len())
271 .finish_non_exhaustive()
272 }
273}
274
275#[derive(Deserialize)]
276#[serde(rename_all = "camelCase", deny_unknown_fields)]
277struct RawWebFetchPresentation {
278 requested_url: String,
279 final_url: String,
280 #[serde(default)]
281 title: Option<String>,
282 mime_type: String,
283 body: String,
284 #[serde(default)]
285 truncation: Option<WebFetchTruncation>,
286 #[serde(default)]
287 redirects: Vec<WebFetchRedirect>,
288}
289
290impl TryFrom<RawWebFetchPresentation> for WebFetchPresentation {
291 type Error = ExternalContentError;
292
293 fn try_from(raw: RawWebFetchPresentation) -> Result<Self, Self::Error> {
294 let mut presentation =
295 Self::new(raw.requested_url, raw.final_url, raw.mime_type, raw.body)?;
296 if let Some(title) = raw.title {
297 presentation = presentation.with_title(title)?;
298 }
299 if let Some(truncation) = raw.truncation {
300 presentation = presentation.with_truncation(truncation);
301 }
302 presentation.with_redirects(raw.redirects)
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase", try_from = "RawExternalSource")]
309pub struct ExternalSource {
310 url: String,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 title: Option<String>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 snippet: Option<String>,
315}
316
317impl ExternalSource {
318 pub fn new(url: impl Into<String>) -> Result<Self, ExternalContentError> {
324 let url = normalize_source_url(&url.into())?;
325 Ok(Self {
326 url,
327 title: None,
328 snippet: None,
329 })
330 }
331
332 pub fn with_title(mut self, title: impl Into<String>) -> Result<Self, ExternalContentError> {
338 let title = title.into();
339 if title.is_empty()
340 || title.len() > MAX_EXTERNAL_SOURCE_TITLE_BYTES
341 || title.chars().any(char::is_control)
342 {
343 return Err(ExternalContentError::InvalidSourceTitle);
344 }
345 self.title = Some(title);
346 Ok(self)
347 }
348
349 pub fn with_snippet(
355 mut self,
356 snippet: impl Into<String>,
357 ) -> Result<Self, ExternalContentError> {
358 let snippet = snippet.into();
359 validate_source_text(&snippet)?;
360 self.snippet = Some(snippet);
361 Ok(self)
362 }
363
364 #[must_use]
366 pub fn url(&self) -> &str {
367 &self.url
368 }
369
370 #[must_use]
372 pub fn title(&self) -> Option<&str> {
373 self.title.as_deref()
374 }
375
376 #[must_use]
378 pub fn snippet(&self) -> Option<&str> {
379 self.snippet.as_deref()
380 }
381
382 pub(crate) fn validate(&self) -> Result<(), ExternalContentError> {
383 if normalize_source_url(&self.url)? != self.url {
384 return Err(ExternalContentError::InvalidSourceUrl);
385 }
386 if let Some(title) = self.title.as_deref()
387 && (title.is_empty()
388 || title.len() > MAX_EXTERNAL_SOURCE_TITLE_BYTES
389 || title.chars().any(char::is_control))
390 {
391 return Err(ExternalContentError::InvalidSourceTitle);
392 }
393 if let Some(snippet) = self.snippet.as_deref() {
394 validate_source_text(snippet)?;
395 }
396 Ok(())
397 }
398}
399
400#[derive(Deserialize)]
401#[serde(rename_all = "camelCase")]
402struct RawExternalSource {
403 url: String,
404 #[serde(default)]
405 title: Option<String>,
406 #[serde(default)]
407 snippet: Option<String>,
408}
409
410impl TryFrom<RawExternalSource> for ExternalSource {
411 type Error = ExternalContentError;
412
413 fn try_from(raw: RawExternalSource) -> Result<Self, Self::Error> {
414 let mut source = Self::new(raw.url)?;
415 if let Some(title) = raw.title {
416 source = source.with_title(title)?;
417 }
418 if let Some(snippet) = raw.snippet {
419 source = source.with_snippet(snippet)?;
420 }
421 Ok(source)
422 }
423}
424
425#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(rename_all = "camelCase", try_from = "RawProviderContinuation")]
428pub struct ProviderContinuation {
429 provider: String,
430 format: String,
431 payload: Value,
432}
433
434impl ProviderContinuation {
435 pub fn new(
441 provider: impl Into<String>,
442 format: impl Into<String>,
443 payload: Value,
444 ) -> Result<Self, ExternalContentError> {
445 let provider = provider.into();
446 let format = format.into();
447 validate_continuation_id(&provider)?;
448 validate_continuation_id(&format)?;
449 validate_json_bounds(
450 &payload,
451 MAX_PROVIDER_CONTINUATION_BYTES,
452 MAX_PROVIDER_CONTINUATION_DEPTH,
453 )?;
454 Ok(Self {
455 provider,
456 format,
457 payload,
458 })
459 }
460
461 #[must_use]
463 pub fn provider(&self) -> &str {
464 &self.provider
465 }
466
467 #[must_use]
469 pub fn format(&self) -> &str {
470 &self.format
471 }
472
473 #[must_use]
475 pub const fn payload(&self) -> &Value {
476 &self.payload
477 }
478
479 pub(crate) fn validate(&self) -> Result<(), ExternalContentError> {
480 validate_continuation_id(&self.provider)?;
481 validate_continuation_id(&self.format)?;
482 validate_json_bounds(
483 &self.payload,
484 MAX_PROVIDER_CONTINUATION_BYTES,
485 MAX_PROVIDER_CONTINUATION_DEPTH,
486 )?;
487 Ok(())
488 }
489}
490
491impl fmt::Debug for ProviderContinuation {
492 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
493 formatter
494 .debug_struct("ProviderContinuation")
495 .field("provider", &self.provider)
496 .field("format", &self.format)
497 .field("payload", &"**REDACTED**")
498 .finish()
499 }
500}
501
502#[derive(Deserialize)]
503#[serde(rename_all = "camelCase")]
504struct RawProviderContinuation {
505 provider: String,
506 format: String,
507 payload: Value,
508}
509
510impl TryFrom<RawProviderContinuation> for ProviderContinuation {
511 type Error = ExternalContentError;
512
513 fn try_from(raw: RawProviderContinuation) -> Result<Self, Self::Error> {
514 Self::new(raw.provider, raw.format, raw.payload)
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase", try_from = "RawHostedToolError")]
521pub struct HostedToolError {
522 code: String,
523 message: String,
524}
525
526impl HostedToolError {
527 pub fn new(
533 code: impl Into<String>,
534 message: impl Into<String>,
535 ) -> Result<Self, ExternalContentError> {
536 let code = code.into();
537 let message = message.into();
538 if code.is_empty()
539 || code.len() > MAX_HOSTED_TOOL_ERROR_CODE_BYTES
540 || !code.bytes().all(|byte| {
541 byte.is_ascii_lowercase()
542 || byte.is_ascii_digit()
543 || matches!(byte, b'_' | b'-' | b'.')
544 })
545 {
546 return Err(ExternalContentError::InvalidHostedToolError);
547 }
548 if message.is_empty()
549 || message.len() > MAX_HOSTED_TOOL_ERROR_MESSAGE_BYTES
550 || message.contains('\0')
551 {
552 return Err(ExternalContentError::InvalidHostedToolError);
553 }
554 Ok(Self { code, message })
555 }
556
557 #[must_use]
559 pub fn code(&self) -> &str {
560 &self.code
561 }
562
563 #[must_use]
565 pub fn message(&self) -> &str {
566 &self.message
567 }
568}
569
570#[derive(Deserialize)]
571#[serde(rename_all = "camelCase")]
572struct RawHostedToolError {
573 code: String,
574 message: String,
575}
576
577impl TryFrom<RawHostedToolError> for HostedToolError {
578 type Error = ExternalContentError;
579
580 fn try_from(raw: RawHostedToolError) -> Result<Self, Self::Error> {
581 Self::new(raw.code, raw.message)
582 }
583}
584
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(tag = "status", content = "error", rename_all = "snake_case")]
588pub enum HostedToolOutcome {
589 Success,
591 Error(HostedToolError),
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(rename_all = "camelCase", try_from = "RawHostedToolActivity")]
598pub struct HostedToolActivity {
599 tool_call_id: ToolCallId,
600 provider_call_id: String,
601 tool_name: String,
602 arguments: Value,
603 outcome: HostedToolOutcome,
604 sources: Vec<ExternalSource>,
605 #[serde(skip_serializing_if = "Option::is_none")]
606 continuation: Option<ProviderContinuation>,
607}
608
609impl HostedToolActivity {
610 #[allow(clippy::too_many_arguments)]
616 pub fn new(
617 tool_call_id: ToolCallId,
618 provider_call_id: impl Into<String>,
619 tool_name: impl Into<String>,
620 arguments: Value,
621 outcome: HostedToolOutcome,
622 sources: Vec<ExternalSource>,
623 continuation: Option<ProviderContinuation>,
624 ) -> Result<Self, ExternalContentError> {
625 let provider_call_id = provider_call_id.into();
626 let tool_name = tool_name.into();
627 validate_provider_tool_call_id(&provider_call_id)
628 .map_err(|_| ExternalContentError::InvalidHostedToolIdentity)?;
629 validate_tool_name(&tool_name)
630 .map_err(|_| ExternalContentError::InvalidHostedToolIdentity)?;
631 if !arguments.is_object() {
632 return Err(ExternalContentError::HostedToolArgumentsMustBeObject);
633 }
634 validate_json_bounds(&arguments, MAX_TOOL_ARGUMENT_BYTES, MAX_TOOL_ARGUMENT_DEPTH)?;
635 if sources.len() > MAX_HOSTED_TOOL_SOURCES {
636 return Err(ExternalContentError::TooManyHostedToolSources);
637 }
638 for source in &sources {
639 source.validate()?;
640 }
641 if let Some(continuation) = continuation.as_ref() {
642 continuation.validate()?;
643 }
644 Ok(Self {
645 tool_call_id,
646 provider_call_id,
647 tool_name,
648 arguments,
649 outcome,
650 sources,
651 continuation,
652 })
653 }
654
655 #[must_use]
657 pub const fn tool_call_id(&self) -> ToolCallId {
658 self.tool_call_id
659 }
660
661 #[must_use]
663 pub fn provider_call_id(&self) -> &str {
664 &self.provider_call_id
665 }
666
667 #[must_use]
669 pub fn tool_name(&self) -> &str {
670 &self.tool_name
671 }
672
673 #[must_use]
675 pub const fn arguments(&self) -> &Value {
676 &self.arguments
677 }
678
679 #[must_use]
681 pub const fn outcome(&self) -> &HostedToolOutcome {
682 &self.outcome
683 }
684
685 #[must_use]
687 pub fn sources(&self) -> &[ExternalSource] {
688 &self.sources
689 }
690
691 #[must_use]
693 pub const fn continuation(&self) -> Option<&ProviderContinuation> {
694 self.continuation.as_ref()
695 }
696
697 pub(crate) fn validate(&self) -> Result<(), ExternalContentError> {
698 Self::new(
699 self.tool_call_id,
700 self.provider_call_id.clone(),
701 self.tool_name.clone(),
702 self.arguments.clone(),
703 self.outcome.clone(),
704 self.sources.clone(),
705 self.continuation.clone(),
706 )?;
707 Ok(())
708 }
709}
710
711#[derive(Deserialize)]
712#[serde(rename_all = "camelCase")]
713struct RawHostedToolActivity {
714 tool_call_id: ToolCallId,
715 provider_call_id: String,
716 tool_name: String,
717 arguments: Value,
718 outcome: HostedToolOutcome,
719 sources: Vec<ExternalSource>,
720 #[serde(default)]
721 continuation: Option<ProviderContinuation>,
722}
723
724impl TryFrom<RawHostedToolActivity> for HostedToolActivity {
725 type Error = ExternalContentError;
726
727 fn try_from(raw: RawHostedToolActivity) -> Result<Self, Self::Error> {
728 Self::new(
729 raw.tool_call_id,
730 raw.provider_call_id,
731 raw.tool_name,
732 raw.arguments,
733 raw.outcome,
734 raw.sources,
735 raw.continuation,
736 )
737 }
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(rename_all = "camelCase", try_from = "RawSourceCitation")]
743pub struct SourceCitation {
744 #[serde(skip_serializing_if = "Option::is_none")]
745 tool_call_id: Option<ToolCallId>,
746 source: ExternalSource,
747 #[serde(skip_serializing_if = "Option::is_none")]
748 start_index: Option<u32>,
749 #[serde(skip_serializing_if = "Option::is_none")]
750 end_index: Option<u32>,
751 #[serde(skip_serializing_if = "Option::is_none")]
752 cited_text: Option<String>,
753 #[serde(skip_serializing_if = "Option::is_none")]
754 continuation: Option<ProviderContinuation>,
755}
756
757impl SourceCitation {
758 #[must_use]
760 pub fn new(source: ExternalSource) -> Self {
761 Self {
762 tool_call_id: None,
763 source,
764 start_index: None,
765 end_index: None,
766 cited_text: None,
767 continuation: None,
768 }
769 }
770
771 #[must_use]
773 pub const fn with_tool_call_id(mut self, tool_call_id: ToolCallId) -> Self {
774 self.tool_call_id = Some(tool_call_id);
775 self
776 }
777
778 pub fn with_range(
784 mut self,
785 start_index: u32,
786 end_index: u32,
787 ) -> Result<Self, ExternalContentError> {
788 if start_index >= end_index {
789 return Err(ExternalContentError::InvalidCitationRange);
790 }
791 self.start_index = Some(start_index);
792 self.end_index = Some(end_index);
793 Ok(self)
794 }
795
796 pub fn with_cited_text(
802 mut self,
803 cited_text: impl Into<String>,
804 ) -> Result<Self, ExternalContentError> {
805 let cited_text = cited_text.into();
806 validate_source_text(&cited_text)?;
807 self.cited_text = Some(cited_text);
808 Ok(self)
809 }
810
811 #[must_use]
813 pub fn with_continuation(mut self, continuation: ProviderContinuation) -> Self {
814 self.continuation = Some(continuation);
815 self
816 }
817
818 #[must_use]
820 pub const fn tool_call_id(&self) -> Option<ToolCallId> {
821 self.tool_call_id
822 }
823
824 #[must_use]
826 pub const fn source(&self) -> &ExternalSource {
827 &self.source
828 }
829
830 #[must_use]
832 pub const fn range(&self) -> Option<(u32, u32)> {
833 match (self.start_index, self.end_index) {
834 (Some(start), Some(end)) => Some((start, end)),
835 _ => None,
836 }
837 }
838
839 #[must_use]
841 pub fn cited_text(&self) -> Option<&str> {
842 self.cited_text.as_deref()
843 }
844
845 #[must_use]
847 pub const fn continuation(&self) -> Option<&ProviderContinuation> {
848 self.continuation.as_ref()
849 }
850
851 pub(crate) fn validate(&self) -> Result<(), ExternalContentError> {
852 self.source.validate()?;
853 if matches!(
854 (self.start_index, self.end_index),
855 (Some(start), Some(end)) if start >= end
856 ) || self.start_index.is_some() != self.end_index.is_some()
857 {
858 return Err(ExternalContentError::InvalidCitationRange);
859 }
860 if let Some(cited_text) = self.cited_text.as_deref() {
861 validate_source_text(cited_text)?;
862 }
863 if let Some(continuation) = self.continuation.as_ref() {
864 continuation.validate()?;
865 }
866 Ok(())
867 }
868}
869
870#[derive(Deserialize)]
871#[serde(rename_all = "camelCase")]
872struct RawSourceCitation {
873 #[serde(default)]
874 tool_call_id: Option<ToolCallId>,
875 source: ExternalSource,
876 #[serde(default)]
877 start_index: Option<u32>,
878 #[serde(default)]
879 end_index: Option<u32>,
880 #[serde(default)]
881 cited_text: Option<String>,
882 #[serde(default)]
883 continuation: Option<ProviderContinuation>,
884}
885
886impl TryFrom<RawSourceCitation> for SourceCitation {
887 type Error = ExternalContentError;
888
889 fn try_from(raw: RawSourceCitation) -> Result<Self, Self::Error> {
890 let mut citation = Self::new(raw.source);
891 if let Some(tool_call_id) = raw.tool_call_id {
892 citation = citation.with_tool_call_id(tool_call_id);
893 }
894 match (raw.start_index, raw.end_index) {
895 (Some(start), Some(end)) => citation = citation.with_range(start, end)?,
896 (None, None) => {}
897 _ => return Err(ExternalContentError::InvalidCitationRange),
898 }
899 if let Some(cited_text) = raw.cited_text {
900 citation = citation.with_cited_text(cited_text)?;
901 }
902 if let Some(continuation) = raw.continuation {
903 citation = citation.with_continuation(continuation);
904 }
905 citation.validate()?;
906 Ok(citation)
907 }
908}
909
910#[derive(Debug, Error)]
912pub enum ExternalContentError {
913 #[error("external source URL is invalid")]
915 InvalidSourceUrl,
916 #[error("external source title is invalid")]
918 InvalidSourceTitle,
919 #[error("external source text is invalid")]
921 InvalidSourceText,
922 #[error("provider continuation identifier is invalid")]
924 InvalidContinuationIdentifier,
925 #[error("hosted tool identity is invalid")]
927 InvalidHostedToolIdentity,
928 #[error("hosted tool arguments must be a JSON object")]
930 HostedToolArgumentsMustBeObject,
931 #[error("hosted tool returned too many sources")]
933 TooManyHostedToolSources,
934 #[error("hosted tool error is invalid")]
936 InvalidHostedToolError,
937 #[error("source citation range is invalid")]
939 InvalidCitationRange,
940 #[error("web fetch presentation metadata is invalid")]
942 InvalidWebFetchMetadata,
943 #[error("web fetch presentation title is invalid")]
945 InvalidWebFetchTitle,
946 #[error("web fetch presentation body is invalid")]
948 InvalidWebFetchBody,
949 #[error("web fetch presentation redirect is invalid")]
951 InvalidWebFetchRedirect,
952 #[error("external content JSON exceeds protocol bounds: {0}")]
954 JsonBounds(#[from] ProtocolMetadataError),
955}
956
957fn normalize_source_url(url: &str) -> Result<String, ExternalContentError> {
958 if url.is_empty()
959 || url.len() > MAX_EXTERNAL_SOURCE_URL_BYTES
960 || url.chars().any(char::is_control)
961 || url.contains(' ')
962 {
963 return Err(ExternalContentError::InvalidSourceUrl);
964 }
965 let parsed = url::Url::parse(url).map_err(|_| ExternalContentError::InvalidSourceUrl)?;
966 if !matches!(parsed.scheme(), "http" | "https")
967 || parsed.cannot_be_a_base()
968 || parsed.host_str().is_none()
969 || !parsed.username().is_empty()
970 || parsed.password().is_some()
971 {
972 return Err(ExternalContentError::InvalidSourceUrl);
973 }
974 let normalized = parsed.to_string();
975 if normalized.len() > MAX_EXTERNAL_SOURCE_URL_BYTES {
976 return Err(ExternalContentError::InvalidSourceUrl);
977 }
978 Ok(normalized)
979}
980
981fn normalize_web_fetch_url(url: &str) -> Result<String, ExternalContentError> {
982 if url.is_empty()
983 || url.len() > MAX_WEB_FETCH_URL_BYTES
984 || url.chars().any(char::is_control)
985 || url.contains(' ')
986 {
987 return Err(ExternalContentError::InvalidWebFetchMetadata);
988 }
989 let mut parsed =
990 url::Url::parse(url).map_err(|_| ExternalContentError::InvalidWebFetchMetadata)?;
991 if !matches!(parsed.scheme(), "http" | "https")
992 || parsed.cannot_be_a_base()
993 || parsed.host_str().is_none()
994 || !parsed.username().is_empty()
995 || parsed.password().is_some()
996 {
997 return Err(ExternalContentError::InvalidWebFetchMetadata);
998 }
999 parsed.set_fragment(None);
1000 let normalized = parsed.to_string();
1001 if normalized.len() > MAX_WEB_FETCH_URL_BYTES {
1002 return Err(ExternalContentError::InvalidWebFetchMetadata);
1003 }
1004 Ok(normalized)
1005}
1006
1007fn normalize_web_fetch_mime(value: &str) -> Result<String, ExternalContentError> {
1008 let valid = !value.is_empty()
1009 && value.len() <= MAX_WEB_FETCH_MIME_BYTES
1010 && value.bytes().all(|byte| {
1011 byte.is_ascii_alphanumeric()
1012 || matches!(byte, b'/' | b'.' | b'+' | b'-' | b';' | b'=' | b' ')
1013 });
1014 valid
1015 .then(|| value.to_ascii_lowercase())
1016 .ok_or(ExternalContentError::InvalidWebFetchMetadata)
1017}
1018
1019fn validate_web_fetch_title(title: &str) -> Result<(), ExternalContentError> {
1020 if title.is_empty()
1021 || title.len() > MAX_WEB_FETCH_TITLE_BYTES
1022 || title.chars().any(char::is_control)
1023 {
1024 Err(ExternalContentError::InvalidWebFetchTitle)
1025 } else {
1026 Ok(())
1027 }
1028}
1029
1030fn validate_web_fetch_body(body: &str) -> Result<(), ExternalContentError> {
1031 if body.len() > MAX_WEB_FETCH_BODY_BYTES
1032 || body.chars().count() > MAX_WEB_FETCH_BODY_CHARS
1033 || body.contains('\0')
1034 {
1035 Err(ExternalContentError::InvalidWebFetchBody)
1036 } else {
1037 Ok(())
1038 }
1039}
1040
1041fn validate_web_fetch_redirects(
1042 redirects: &[WebFetchRedirect],
1043) -> Result<(), ExternalContentError> {
1044 if redirects.len() > MAX_WEB_FETCH_REDIRECTS {
1045 return Err(ExternalContentError::InvalidWebFetchRedirect);
1046 }
1047 for redirect in redirects {
1048 redirect.validate()?;
1049 }
1050 Ok(())
1051}
1052
1053fn validate_source_text(text: &str) -> Result<(), ExternalContentError> {
1054 if text.is_empty() || text.len() > MAX_EXTERNAL_SOURCE_TEXT_BYTES || text.contains('\0') {
1055 Err(ExternalContentError::InvalidSourceText)
1056 } else {
1057 Ok(())
1058 }
1059}
1060
1061fn validate_continuation_id(value: &str) -> Result<(), ExternalContentError> {
1062 let mut bytes = value.bytes();
1063 if value.is_empty()
1064 || value.len() > MAX_PROVIDER_CONTINUATION_ID_BYTES
1065 || !bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
1066 || !bytes.all(|byte| {
1067 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
1068 })
1069 {
1070 Err(ExternalContentError::InvalidContinuationIdentifier)
1071 } else {
1072 Ok(())
1073 }
1074}