Skip to main content

tea_protocol/
external.rs

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
14/// Maximum UTF-8 bytes in one external source URL.
15pub const MAX_EXTERNAL_SOURCE_URL_BYTES: usize = 8 * 1024;
16/// Maximum UTF-8 bytes in one external source title.
17pub const MAX_EXTERNAL_SOURCE_TITLE_BYTES: usize = 1024;
18/// Maximum UTF-8 bytes in a source snippet or cited text.
19pub const MAX_EXTERNAL_SOURCE_TEXT_BYTES: usize = 64 * 1024;
20/// Maximum normalized sources retained for one hosted tool activity.
21pub const MAX_HOSTED_TOOL_SOURCES: usize = 64;
22/// Maximum encoded JSON bytes in one opaque provider continuation envelope.
23pub const MAX_PROVIDER_CONTINUATION_BYTES: usize = 4 * 1024 * 1024;
24/// Maximum nesting depth in provider continuation JSON.
25pub const MAX_PROVIDER_CONTINUATION_DEPTH: usize = 64;
26/// Maximum UTF-8 bytes in a normalized client web-fetch URL.
27pub const MAX_WEB_FETCH_URL_BYTES: usize = 2 * 1024;
28/// Maximum UTF-8 bytes in a normalized client web-fetch title.
29pub const MAX_WEB_FETCH_TITLE_BYTES: usize = 4 * 1024;
30/// Maximum UTF-8 bytes in a normalized client web-fetch MIME type.
31pub const MAX_WEB_FETCH_MIME_BYTES: usize = 128;
32/// Maximum Unicode scalar values in an extracted client web-fetch body.
33pub const MAX_WEB_FETCH_BODY_CHARS: usize = 100_000;
34/// Maximum UTF-8 bytes in an extracted client web-fetch body.
35pub const MAX_WEB_FETCH_BODY_BYTES: usize = MAX_WEB_FETCH_BODY_CHARS * 4;
36/// Maximum redirect records retained by a normalized client web-fetch result.
37pub 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/// Why a normalized client web-fetch body was truncated.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum WebFetchTruncation {
47    /// Compressed bytes reached the transport bound.
48    CompressedBytes,
49    /// Decoded bytes reached the decoding bound.
50    DecodedBytes,
51    /// Extracted body characters reached the request bound.
52    BodyCharacters,
53    /// Content extraction reached its parser-complexity bound.
54    ParserComplexity,
55}
56
57/// One bounded redirect in a normalized client web-fetch result.
58#[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    /// Creates a normalized redirect record.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error for invalid URLs or a non-redirect status code.
72    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    /// Returns the normalized source URL.
88    #[must_use]
89    pub fn from(&self) -> &str {
90        &self.from
91    }
92
93    /// Returns the normalized destination URL.
94    #[must_use]
95    pub fn to(&self) -> &str {
96        &self.to
97    }
98
99    /// Returns the HTTP redirect status.
100    #[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/// Bounded provider-neutral presentation of one client web-fetch result.
144///
145/// This contains only normalized URLs, extracted text, and explicit metadata.
146/// It deliberately has no provider-owned continuation or raw-response field.
147#[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    /// Creates a normalized presentation with bounded metadata and extracted body.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error when a URL, MIME type, or body violates durable bounds.
167    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    /// Adds an optional bounded document title.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error for an empty, oversized, or control-containing title.
191    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    /// Records why the extracted body was truncated.
199    #[must_use]
200    pub const fn with_truncation(mut self, truncation: WebFetchTruncation) -> Self {
201        self.truncation = Some(truncation);
202        self
203    }
204
205    /// Adds bounded normalized redirect metadata.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error when more than ten redirects are supplied or one is invalid.
210    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    /// Returns the normalized requested URL.
220    #[must_use]
221    pub fn requested_url(&self) -> &str {
222        &self.requested_url
223    }
224
225    /// Returns the normalized final URL after redirects.
226    #[must_use]
227    pub fn final_url(&self) -> &str {
228        &self.final_url
229    }
230
231    /// Returns the extracted document title when present.
232    #[must_use]
233    pub fn title(&self) -> Option<&str> {
234        self.title.as_deref()
235    }
236
237    /// Returns the normalized MIME type.
238    #[must_use]
239    pub fn mime_type(&self) -> &str {
240        &self.mime_type
241    }
242
243    /// Returns the bounded extracted body.
244    #[must_use]
245    pub fn body(&self) -> &str {
246        &self.body
247    }
248
249    /// Returns the explicit truncation reason when present.
250    #[must_use]
251    pub const fn truncation(&self) -> Option<WebFetchTruncation> {
252        self.truncation
253    }
254
255    /// Returns normalized redirects in request order.
256    #[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/// A normalized external source returned by search or another hosted tool.
307#[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    /// Creates a source with a bounded HTTP(S) URL.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error for non-HTTP(S), empty, oversized, or control-containing URLs.
323    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    /// Adds a bounded, control-free source title.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error when the title is empty, oversized, or contains controls.
337    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    /// Adds a bounded source snippet.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error when the snippet is empty, oversized, or contains a null character.
354    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    /// Returns the source URL.
365    #[must_use]
366    pub fn url(&self) -> &str {
367        &self.url
368    }
369
370    /// Returns the optional source title.
371    #[must_use]
372    pub fn title(&self) -> Option<&str> {
373        self.title.as_deref()
374    }
375
376    /// Returns the optional source snippet.
377    #[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/// Bounded provider-owned data needed to reconstruct a later request.
426#[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    /// Creates a bounded opaque continuation envelope.
436    ///
437    /// # Errors
438    ///
439    /// Returns an error for non-canonical identifiers or JSON outside protocol bounds.
440    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    /// Returns the adapter provider identifier.
462    #[must_use]
463    pub fn provider(&self) -> &str {
464        &self.provider
465    }
466
467    /// Returns the adapter-owned payload format identifier.
468    #[must_use]
469    pub fn format(&self) -> &str {
470        &self.format
471    }
472
473    /// Returns the opaque payload for a matching provider adapter.
474    #[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/// Provider-reported hosted tool failure.
519#[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    /// Creates a bounded machine-readable hosted tool error.
528    ///
529    /// # Errors
530    ///
531    /// Returns an error for a non-canonical code or invalid message.
532    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    /// Returns the stable provider-neutral error code.
558    #[must_use]
559    pub fn code(&self) -> &str {
560        &self.code
561    }
562
563    /// Returns the bounded diagnostic message.
564    #[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/// Terminal provider-hosted tool outcome.
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(tag = "status", content = "error", rename_all = "snake_case")]
588pub enum HostedToolOutcome {
589    /// The provider completed the activity.
590    Success,
591    /// The provider reported a tool-level error, possibly inside HTTP 200.
592    Error(HostedToolError),
593}
594
595/// One complete provider-hosted tool activity retained in assistant content.
596#[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    /// Creates a validated complete hosted tool activity.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error for invalid identity, arguments, source count, or continuation data.
615    #[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    /// Returns the canonical activity identifier.
656    #[must_use]
657    pub const fn tool_call_id(&self) -> ToolCallId {
658        self.tool_call_id
659    }
660
661    /// Returns the provider-owned activity identifier.
662    #[must_use]
663    pub fn provider_call_id(&self) -> &str {
664        &self.provider_call_id
665    }
666
667    /// Returns the stable registered tool name.
668    #[must_use]
669    pub fn tool_name(&self) -> &str {
670        &self.tool_name
671    }
672
673    /// Returns normalized provider-neutral arguments.
674    #[must_use]
675    pub const fn arguments(&self) -> &Value {
676        &self.arguments
677    }
678
679    /// Returns the terminal hosted outcome.
680    #[must_use]
681    pub const fn outcome(&self) -> &HostedToolOutcome {
682        &self.outcome
683    }
684
685    /// Returns normalized sources in provider order.
686    #[must_use]
687    pub fn sources(&self) -> &[ExternalSource] {
688        &self.sources
689    }
690
691    /// Returns provider-owned continuation data for matching adapters.
692    #[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/// A normalized citation associated with assistant text and an external source.
741#[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    /// Creates a citation with no text range or provider continuation.
759    #[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    /// Links the citation to one hosted tool activity.
772    #[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    /// Adds a half-open UTF-8 byte range in the associated assistant text.
779    ///
780    /// # Errors
781    ///
782    /// Returns an error when the range is empty or reversed.
783    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    /// Adds bounded provider-supplied cited text.
797    ///
798    /// # Errors
799    ///
800    /// Returns an error for empty, oversized, or null-containing text.
801    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    /// Adds opaque provider citation state for same-provider continuation.
812    #[must_use]
813    pub fn with_continuation(mut self, continuation: ProviderContinuation) -> Self {
814        self.continuation = Some(continuation);
815        self
816    }
817
818    /// Returns the linked hosted tool activity, when known.
819    #[must_use]
820    pub const fn tool_call_id(&self) -> Option<ToolCallId> {
821        self.tool_call_id
822    }
823
824    /// Returns the normalized cited source.
825    #[must_use]
826    pub const fn source(&self) -> &ExternalSource {
827        &self.source
828    }
829
830    /// Returns the optional half-open text range.
831    #[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    /// Returns optional cited text supplied by the provider.
840    #[must_use]
841    pub fn cited_text(&self) -> Option<&str> {
842        self.cited_text.as_deref()
843    }
844
845    /// Returns opaque provider citation state.
846    #[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/// Validation failure for hosted tool, source, citation, or continuation content.
911#[derive(Debug, Error)]
912pub enum ExternalContentError {
913    /// Source URL is not a bounded HTTP(S) URL.
914    #[error("external source URL is invalid")]
915    InvalidSourceUrl,
916    /// Source title is empty, oversized, or contains controls.
917    #[error("external source title is invalid")]
918    InvalidSourceTitle,
919    /// Source snippet or cited text is empty, oversized, or contains a null character.
920    #[error("external source text is invalid")]
921    InvalidSourceText,
922    /// Provider or continuation format identifier is not canonical.
923    #[error("provider continuation identifier is invalid")]
924    InvalidContinuationIdentifier,
925    /// Hosted tool provider id or tool name is invalid.
926    #[error("hosted tool identity is invalid")]
927    InvalidHostedToolIdentity,
928    /// Hosted tool arguments must be a JSON object.
929    #[error("hosted tool arguments must be a JSON object")]
930    HostedToolArgumentsMustBeObject,
931    /// Hosted tool source collection exceeds protocol bounds.
932    #[error("hosted tool returned too many sources")]
933    TooManyHostedToolSources,
934    /// Hosted tool error code or message is invalid.
935    #[error("hosted tool error is invalid")]
936    InvalidHostedToolError,
937    /// Citation range is missing one endpoint, empty, or reversed.
938    #[error("source citation range is invalid")]
939    InvalidCitationRange,
940    /// Client web-fetch metadata, MIME type, or URL is invalid.
941    #[error("web fetch presentation metadata is invalid")]
942    InvalidWebFetchMetadata,
943    /// Client web-fetch title is empty, oversized, or contains controls.
944    #[error("web fetch presentation title is invalid")]
945    InvalidWebFetchTitle,
946    /// Client web-fetch extracted body exceeds durable bounds or contains a null.
947    #[error("web fetch presentation body is invalid")]
948    InvalidWebFetchBody,
949    /// Client web-fetch redirect metadata is invalid or exceeds its count bound.
950    #[error("web fetch presentation redirect is invalid")]
951    InvalidWebFetchRedirect,
952    /// JSON payload exceeds encoded size or nesting limits.
953    #[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}