Skip to main content

kcode_k1_codex_web_search_values/
lib.rs

1//! Concrete values exchanged by the K1 Codex Web Search consumer.
2
3use std::fmt;
4use std::str::FromStr;
5use std::time::Duration;
6
7/// A rejected request argument or context-size spelling.
8#[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/// The provider-supported search-context size.
31#[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/// Validated inputs for one web-search request.
62#[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/// A source observed while an interim message is produced.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct WebSearchSourceCandidate {
119    pub title: String,
120    pub url: String,
121    pub provenance: String,
122}
123
124/// A source retained with the final answer.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct WebSearchSource {
127    pub title: String,
128    pub url: String,
129    pub provenance: String,
130}
131
132/// Nonterminal output from an in-progress search.
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct WebSearchMessage {
135    pub text: String,
136    pub source_candidates: Vec<WebSearchSourceCandidate>,
137}
138
139/// Provider token accounting. `None` means that the count is unknown.
140#[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/// Cost evidence for a completed search.
148#[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/// Elapsed time for one named phase.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct WebSearchPhaseTiming {
161    pub phase: String,
162    pub duration: Duration,
163}
164
165/// Phase observations and aggregate elapsed time.
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct WebSearchTiming {
168    pub phases: Vec<WebSearchPhaseTiming>,
169    pub aggregate: Duration,
170}
171
172/// Stable terminal result recorded in provenance.
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum WebSearchOutcome {
175    Success,
176    Failure,
177}
178
179/// Stable, safe failure classification.
180#[derive(Clone, Copy, Debug, Eq, PartialEq)]
181pub enum WebSearchFailureCategory {
182    InvalidRequest,
183    Authentication,
184    Provider,
185    Timeout,
186    Unavailable,
187    Internal,
188}
189
190/// Whether a failed operation could have affected the provider.
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum WebSearchProviderEffect {
193    None,
194    Possible,
195}
196
197/// Evidence tying terminal values to the request and backend execution.
198#[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/// A completed search that produced a final answer.
211#[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/// A completed search that did not produce a final answer.
222#[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/// Terminal search result.
235#[derive(Clone, Debug, Eq, PartialEq)]
236pub enum WebSearchFinished {
237    Success(WebSearchSuccess),
238    Failure(WebSearchFailure),
239}
240
241/// Stream value emitted by one search.
242#[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}