1use std::fmt;
4use std::str::FromStr;
5use std::time::Duration;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ArgumentValidationError {
10 BlankQuestion,
11 BlankModel,
12 BlankReasoningEffort,
13 InvalidSearchContextSize,
14}
15
16impl fmt::Display for ArgumentValidationError {
17 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18 let message = match self {
19 Self::BlankQuestion => "question must not be blank",
20 Self::BlankModel => "model must not be blank",
21 Self::BlankReasoningEffort => "reasoning effort must not be blank",
22 Self::InvalidSearchContextSize => "search context size must be low, medium, or high",
23 };
24 formatter.write_str(message)
25 }
26}
27
28impl std::error::Error for ArgumentValidationError {}
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum SearchContextSize {
33 Low,
34 Medium,
35 High,
36}
37
38impl fmt::Display for SearchContextSize {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str(match self {
41 Self::Low => "low",
42 Self::Medium => "medium",
43 Self::High => "high",
44 })
45 }
46}
47
48impl FromStr for SearchContextSize {
49 type Err = ArgumentValidationError;
50
51 fn from_str(value: &str) -> Result<Self, Self::Err> {
52 match value {
53 "low" => Ok(Self::Low),
54 "medium" => Ok(Self::Medium),
55 "high" => Ok(Self::High),
56 _ => Err(ArgumentValidationError::InvalidSearchContextSize),
57 }
58 }
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct WebSearchRequest {
64 question: String,
65 model: String,
66 reasoning_effort: String,
67 search_context_size: SearchContextSize,
68}
69
70impl WebSearchRequest {
71 pub fn new(
72 question: impl Into<String>,
73 model: impl Into<String>,
74 reasoning_effort: impl Into<String>,
75 search_context_size: SearchContextSize,
76 ) -> Result<Self, ArgumentValidationError> {
77 let question = question.into();
78 let model = model.into();
79 let reasoning_effort = reasoning_effort.into();
80
81 if question.trim().is_empty() {
82 return Err(ArgumentValidationError::BlankQuestion);
83 }
84 if model.trim().is_empty() {
85 return Err(ArgumentValidationError::BlankModel);
86 }
87 if reasoning_effort.trim().is_empty() {
88 return Err(ArgumentValidationError::BlankReasoningEffort);
89 }
90
91 Ok(Self {
92 question,
93 model,
94 reasoning_effort,
95 search_context_size,
96 })
97 }
98
99 pub fn question(&self) -> &str {
100 &self.question
101 }
102
103 pub fn model(&self) -> &str {
104 &self.model
105 }
106
107 pub fn reasoning_effort(&self) -> &str {
108 &self.reasoning_effort
109 }
110
111 pub fn search_context_size(&self) -> SearchContextSize {
112 self.search_context_size
113 }
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct WebSearchSourceCandidate {
119 pub title: String,
120 pub url: String,
121 pub provenance: String,
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct WebSearchSource {
127 pub title: String,
128 pub url: String,
129 pub provenance: String,
130}
131
132#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct WebSearchMessage {
135 pub text: String,
136 pub source_candidates: Vec<WebSearchSourceCandidate>,
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub struct WebSearchUsage {
142 pub input_tokens: Option<u64>,
143 pub output_tokens: Option<u64>,
144 pub total_tokens: Option<u64>,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
149pub enum WebSearchCost {
150 Unknown,
151 Estimate {
152 decimal_amount: String,
153 currency: String,
154 pricing_revision: String,
155 },
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct WebSearchPhaseTiming {
161 pub phase: String,
162 pub duration: Duration,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct WebSearchTiming {
168 pub phases: Vec<WebSearchPhaseTiming>,
169 pub aggregate: Duration,
170}
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum WebSearchOutcome {
175 Success,
176 Failure,
177}
178
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
181pub enum WebSearchFailureCategory {
182 InvalidRequest,
183 Authentication,
184 Provider,
185 Timeout,
186 Unavailable,
187 Internal,
188}
189
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum WebSearchProviderEffect {
193 None,
194 Possible,
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct WebSearchProvenance {
200 pub backend: String,
201 pub auth: String,
202 pub requested_model: String,
203 pub requested_reasoning_effort: String,
204 pub requested_search_context_size: SearchContextSize,
205 pub ephemeral: bool,
206 pub resumable: bool,
207 pub outcome: WebSearchOutcome,
208}
209
210#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct WebSearchSuccess {
213 pub answer: String,
214 pub sources: Vec<WebSearchSource>,
215 pub usage: WebSearchUsage,
216 pub cost: WebSearchCost,
217 pub timing: WebSearchTiming,
218 pub provenance: WebSearchProvenance,
219}
220
221#[derive(Clone, Debug, Eq, PartialEq)]
223pub struct WebSearchFailure {
224 pub safe_detail: String,
225 pub category: WebSearchFailureCategory,
226 pub provider_effect: WebSearchProviderEffect,
227 pub sources: Vec<WebSearchSource>,
228 pub usage: WebSearchUsage,
229 pub cost: WebSearchCost,
230 pub timing: WebSearchTiming,
231 pub provenance: WebSearchProvenance,
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub enum WebSearchFinished {
237 Success(WebSearchSuccess),
238 Failure(WebSearchFailure),
239}
240
241#[derive(Clone, Debug, Eq, PartialEq)]
243pub enum WebSearchEvent {
244 Message(WebSearchMessage),
245 Finished(Box<WebSearchFinished>),
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn request_rejects_blank_fields_and_preserves_accepted_strings() {
254 assert_eq!(
255 WebSearchRequest::new(" \t", "model", "effort", SearchContextSize::Low),
256 Err(ArgumentValidationError::BlankQuestion)
257 );
258 assert_eq!(
259 WebSearchRequest::new("question", "\n", "effort", SearchContextSize::Low),
260 Err(ArgumentValidationError::BlankModel)
261 );
262 assert_eq!(
263 WebSearchRequest::new("question", "model", " ", SearchContextSize::Low),
264 Err(ArgumentValidationError::BlankReasoningEffort)
265 );
266
267 let request = WebSearchRequest::new(
268 " keep this question ",
269 " model-name ",
270 " effort-name ",
271 SearchContextSize::High,
272 )
273 .expect("nonblank fields are valid");
274 assert_eq!(request.question(), " keep this question ");
275 assert_eq!(request.model(), " model-name ");
276 assert_eq!(request.reasoning_effort(), " effort-name ");
277 assert_eq!(request.search_context_size(), SearchContextSize::High);
278 }
279
280 #[test]
281 fn context_size_has_exact_spellings() {
282 for (text, value) in [
283 ("low", SearchContextSize::Low),
284 ("medium", SearchContextSize::Medium),
285 ("high", SearchContextSize::High),
286 ] {
287 assert_eq!(text.parse::<SearchContextSize>(), Ok(value));
288 assert_eq!(value.to_string(), text);
289 }
290 for invalid in ["Low", "HIGH", " high", "high ", ""] {
291 assert_eq!(
292 invalid.parse::<SearchContextSize>(),
293 Err(ArgumentValidationError::InvalidSearchContextSize)
294 );
295 }
296 }
297
298 #[test]
299 fn unknown_accounting_is_explicit() {
300 let usage = WebSearchUsage {
301 input_tokens: None,
302 output_tokens: None,
303 total_tokens: None,
304 };
305 assert_eq!(usage.input_tokens, None);
306 assert_eq!(usage.output_tokens, None);
307 assert_eq!(usage.total_tokens, None);
308 assert_eq!(WebSearchCost::Unknown, WebSearchCost::Unknown);
309 }
310
311 #[test]
312 fn message_is_interim_and_boxed_success_carries_final_answer() {
313 let message = WebSearchEvent::Message(WebSearchMessage {
314 text: "searching".to_owned(),
315 source_candidates: vec![WebSearchSourceCandidate {
316 title: "candidate".to_owned(),
317 url: "https://example.test/candidate".to_owned(),
318 provenance: "backend-result".to_owned(),
319 }],
320 });
321 assert!(matches!(message, WebSearchEvent::Message(_)));
322
323 let success = WebSearchSuccess {
324 answer: "final answer".to_owned(),
325 sources: vec![WebSearchSource {
326 title: "source".to_owned(),
327 url: "https://example.test/source".to_owned(),
328 provenance: "final-selection".to_owned(),
329 }],
330 usage: WebSearchUsage {
331 input_tokens: Some(7),
332 output_tokens: Some(3),
333 total_tokens: Some(10),
334 },
335 cost: WebSearchCost::Unknown,
336 timing: WebSearchTiming {
337 phases: vec![WebSearchPhaseTiming {
338 phase: "search".to_owned(),
339 duration: Duration::from_millis(20),
340 }],
341 aggregate: Duration::from_millis(25),
342 },
343 provenance: WebSearchProvenance {
344 backend: "codex".to_owned(),
345 auth: "configured".to_owned(),
346 requested_model: "model-name".to_owned(),
347 requested_reasoning_effort: "effort-name".to_owned(),
348 requested_search_context_size: SearchContextSize::Medium,
349 ephemeral: true,
350 resumable: false,
351 outcome: WebSearchOutcome::Success,
352 },
353 };
354 let finished = WebSearchEvent::Finished(Box::new(WebSearchFinished::Success(success)));
355
356 match finished {
357 WebSearchEvent::Finished(result) => match *result {
358 WebSearchFinished::Success(value) => {
359 assert_eq!(value.answer, "final answer");
360 assert_eq!(value.sources.len(), 1);
361 }
362 WebSearchFinished::Failure(_) => panic!("expected success"),
363 },
364 WebSearchEvent::Message(_) => panic!("expected terminal event"),
365 }
366 }
367}