Skip to main content

tea_model/
hosted.rs

1use std::collections::BTreeSet;
2
3use crate::ModelRequestError;
4
5/// Maximum number of domains in one portable web-search policy.
6pub const MAX_WEB_SEARCH_DOMAINS: usize = 100;
7/// Maximum bytes in one canonical web-search domain.
8pub const MAX_WEB_SEARCH_DOMAIN_BYTES: usize = 253;
9/// Maximum bytes in one approximate location field.
10pub const MAX_WEB_SEARCH_LOCATION_FIELD_BYTES: usize = 128;
11
12/// Provider-neutral kind of tool executed by the model provider.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum HostedToolKind {
15    /// Searches the public web inside the provider response lifecycle.
16    WebSearch,
17}
18
19impl HostedToolKind {
20    /// Returns the stable model-visible tool name.
21    #[must_use]
22    pub const fn name(self) -> &'static str {
23        match self {
24            Self::WebSearch => "web_search",
25        }
26    }
27}
28
29/// Bounded approximate user location for hosted web search.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct WebSearchLocation {
32    country: Option<String>,
33    city: Option<String>,
34    region: Option<String>,
35    timezone: Option<String>,
36}
37
38impl WebSearchLocation {
39    /// Creates an empty location that can be populated through validated builders.
40    #[must_use]
41    pub const fn new() -> Self {
42        Self {
43            country: None,
44            city: None,
45            region: None,
46            timezone: None,
47        }
48    }
49
50    /// Sets an ISO 3166-1 alpha-2 uppercase country code.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error when the value is not exactly two uppercase ASCII letters.
55    pub fn with_country(mut self, country: impl Into<String>) -> Result<Self, ModelRequestError> {
56        let country = country.into();
57        if country.len() != 2 || !country.bytes().all(|byte| byte.is_ascii_uppercase()) {
58            return Err(ModelRequestError::InvalidWebSearchLocation);
59        }
60        self.country = Some(country);
61        Ok(self)
62    }
63
64    /// Sets a bounded city name.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error for empty, oversized, or control-containing text.
69    pub fn with_city(mut self, city: impl Into<String>) -> Result<Self, ModelRequestError> {
70        self.city = Some(validate_location_text(city.into())?);
71        Ok(self)
72    }
73
74    /// Sets a bounded region name.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error for empty, oversized, or control-containing text.
79    pub fn with_region(mut self, region: impl Into<String>) -> Result<Self, ModelRequestError> {
80        self.region = Some(validate_location_text(region.into())?);
81        Ok(self)
82    }
83
84    /// Sets a canonical IANA-style timezone name.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error for empty, oversized, or non-canonical text.
89    pub fn with_timezone(mut self, timezone: impl Into<String>) -> Result<Self, ModelRequestError> {
90        let timezone = timezone.into();
91        if timezone.is_empty()
92            || timezone.len() > MAX_WEB_SEARCH_LOCATION_FIELD_BYTES
93            || !timezone.bytes().all(|byte| {
94                byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'+')
95            })
96        {
97            return Err(ModelRequestError::InvalidWebSearchLocation);
98        }
99        self.timezone = Some(timezone);
100        Ok(self)
101    }
102
103    /// Returns the optional country code.
104    #[must_use]
105    pub fn country(&self) -> Option<&str> {
106        self.country.as_deref()
107    }
108
109    /// Returns the optional city.
110    #[must_use]
111    pub fn city(&self) -> Option<&str> {
112        self.city.as_deref()
113    }
114
115    /// Returns the optional region.
116    #[must_use]
117    pub fn region(&self) -> Option<&str> {
118        self.region.as_deref()
119    }
120
121    /// Returns the optional timezone.
122    #[must_use]
123    pub fn timezone(&self) -> Option<&str> {
124        self.timezone.as_deref()
125    }
126
127    /// Returns whether no location field is configured.
128    #[must_use]
129    pub const fn is_empty(&self) -> bool {
130        self.country.is_none()
131            && self.city.is_none()
132            && self.region.is_none()
133            && self.timezone.is_none()
134    }
135}
136
137/// Portable policy shared by hosted web-search adapters.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct WebSearchOptions {
140    allowed_domains: Vec<String>,
141    blocked_domains: Vec<String>,
142    location: Option<WebSearchLocation>,
143}
144
145impl WebSearchOptions {
146    /// Creates unrestricted web-search options with no location disclosure.
147    #[must_use]
148    pub const fn new() -> Self {
149        Self {
150            allowed_domains: Vec::new(),
151            blocked_domains: Vec::new(),
152            location: None,
153        }
154    }
155
156    /// Sets a canonical allowlist, replacing any previous allowlist.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error for invalid domains, too many domains, or a configured blocklist.
161    pub fn with_allowed_domains<I, S>(mut self, domains: I) -> Result<Self, ModelRequestError>
162    where
163        I: IntoIterator<Item = S>,
164        S: Into<String>,
165    {
166        if !self.blocked_domains.is_empty() {
167            return Err(ModelRequestError::ConflictingWebSearchDomainFilters);
168        }
169        self.allowed_domains = collect_domains(domains)?;
170        Ok(self)
171    }
172
173    /// Sets a canonical blocklist, replacing any previous blocklist.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error for invalid domains, too many domains, or a configured allowlist.
178    pub fn with_blocked_domains<I, S>(mut self, domains: I) -> Result<Self, ModelRequestError>
179    where
180        I: IntoIterator<Item = S>,
181        S: Into<String>,
182    {
183        if !self.allowed_domains.is_empty() {
184            return Err(ModelRequestError::ConflictingWebSearchDomainFilters);
185        }
186        self.blocked_domains = collect_domains(domains)?;
187        Ok(self)
188    }
189
190    /// Adds an approximate location disclosed only after the tool is active.
191    #[must_use]
192    pub fn with_location(mut self, location: WebSearchLocation) -> Self {
193        self.location = (!location.is_empty()).then_some(location);
194        self
195    }
196
197    /// Returns allowed domains in deterministic canonical order.
198    #[must_use]
199    pub fn allowed_domains(&self) -> &[String] {
200        &self.allowed_domains
201    }
202
203    /// Returns blocked domains in deterministic canonical order.
204    #[must_use]
205    pub fn blocked_domains(&self) -> &[String] {
206        &self.blocked_domains
207    }
208
209    /// Returns the optional approximate location.
210    #[must_use]
211    pub const fn location(&self) -> Option<&WebSearchLocation> {
212        self.location.as_ref()
213    }
214}
215
216/// Provider-neutral options for one hosted tool definition.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum HostedToolOptions {
219    /// Hosted web-search policy.
220    WebSearch(WebSearchOptions),
221}
222
223impl HostedToolOptions {
224    /// Returns the hosted capability kind required by these options.
225    #[must_use]
226    pub const fn kind(&self) -> HostedToolKind {
227        match self {
228            Self::WebSearch(_) => HostedToolKind::WebSearch,
229        }
230    }
231
232    /// Returns web-search options.
233    #[must_use]
234    pub const fn web_search(&self) -> &WebSearchOptions {
235        match self {
236            Self::WebSearch(options) => options,
237        }
238    }
239}
240
241fn collect_domains<I, S>(domains: I) -> Result<Vec<String>, ModelRequestError>
242where
243    I: IntoIterator<Item = S>,
244    S: Into<String>,
245{
246    let mut values = BTreeSet::new();
247    for domain in domains {
248        if values.len() == MAX_WEB_SEARCH_DOMAINS {
249            return Err(ModelRequestError::TooManyWebSearchDomains);
250        }
251        let domain = domain.into();
252        validate_domain(&domain)?;
253        values.insert(domain);
254    }
255    Ok(values.into_iter().collect())
256}
257
258fn validate_domain(domain: &str) -> Result<(), ModelRequestError> {
259    if domain.is_empty()
260        || domain.len() > MAX_WEB_SEARCH_DOMAIN_BYTES
261        || domain.starts_with('.')
262        || domain.ends_with('.')
263        || !domain.bytes().all(|byte| {
264            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-')
265        })
266        || domain.split('.').any(|label| {
267            label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-')
268        })
269    {
270        Err(ModelRequestError::InvalidWebSearchDomain)
271    } else {
272        Ok(())
273    }
274}
275
276fn validate_location_text(value: String) -> Result<String, ModelRequestError> {
277    if value.is_empty()
278        || value.len() > MAX_WEB_SEARCH_LOCATION_FIELD_BYTES
279        || value.chars().any(char::is_control)
280    {
281        Err(ModelRequestError::InvalidWebSearchLocation)
282    } else {
283        Ok(value)
284    }
285}