1use std::collections::BTreeSet;
2
3use crate::ModelRequestError;
4
5pub const MAX_WEB_SEARCH_DOMAINS: usize = 100;
7pub const MAX_WEB_SEARCH_DOMAIN_BYTES: usize = 253;
9pub const MAX_WEB_SEARCH_LOCATION_FIELD_BYTES: usize = 128;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum HostedToolKind {
15 WebSearch,
17}
18
19impl HostedToolKind {
20 #[must_use]
22 pub const fn name(self) -> &'static str {
23 match self {
24 Self::WebSearch => "web_search",
25 }
26 }
27}
28
29#[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 #[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 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 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 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 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 #[must_use]
105 pub fn country(&self) -> Option<&str> {
106 self.country.as_deref()
107 }
108
109 #[must_use]
111 pub fn city(&self) -> Option<&str> {
112 self.city.as_deref()
113 }
114
115 #[must_use]
117 pub fn region(&self) -> Option<&str> {
118 self.region.as_deref()
119 }
120
121 #[must_use]
123 pub fn timezone(&self) -> Option<&str> {
124 self.timezone.as_deref()
125 }
126
127 #[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#[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 #[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 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 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 #[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 #[must_use]
199 pub fn allowed_domains(&self) -> &[String] {
200 &self.allowed_domains
201 }
202
203 #[must_use]
205 pub fn blocked_domains(&self) -> &[String] {
206 &self.blocked_domains
207 }
208
209 #[must_use]
211 pub const fn location(&self) -> Option<&WebSearchLocation> {
212 self.location.as_ref()
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum HostedToolOptions {
219 WebSearch(WebSearchOptions),
221}
222
223impl HostedToolOptions {
224 #[must_use]
226 pub const fn kind(&self) -> HostedToolKind {
227 match self {
228 Self::WebSearch(_) => HostedToolKind::WebSearch,
229 }
230 }
231
232 #[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}