use std::fmt;
use std::str::FromStr;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ArgumentValidationError {
BlankQuestion,
BlankModel,
BlankReasoningEffort,
InvalidSearchContextSize,
}
impl fmt::Display for ArgumentValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::BlankQuestion => "question must not be blank",
Self::BlankModel => "model must not be blank",
Self::BlankReasoningEffort => "reasoning effort must not be blank",
Self::InvalidSearchContextSize => "search context size must be low, medium, or high",
};
formatter.write_str(message)
}
}
impl std::error::Error for ArgumentValidationError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SearchContextSize {
Low,
Medium,
High,
}
impl fmt::Display for SearchContextSize {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
})
}
}
impl FromStr for SearchContextSize {
type Err = ArgumentValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
_ => Err(ArgumentValidationError::InvalidSearchContextSize),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchRequest {
question: String,
model: String,
reasoning_effort: String,
search_context_size: SearchContextSize,
}
impl WebSearchRequest {
pub fn new(
question: impl Into<String>,
model: impl Into<String>,
reasoning_effort: impl Into<String>,
search_context_size: SearchContextSize,
) -> Result<Self, ArgumentValidationError> {
let question = question.into();
let model = model.into();
let reasoning_effort = reasoning_effort.into();
if question.trim().is_empty() {
return Err(ArgumentValidationError::BlankQuestion);
}
if model.trim().is_empty() {
return Err(ArgumentValidationError::BlankModel);
}
if reasoning_effort.trim().is_empty() {
return Err(ArgumentValidationError::BlankReasoningEffort);
}
Ok(Self {
question,
model,
reasoning_effort,
search_context_size,
})
}
pub fn question(&self) -> &str {
&self.question
}
pub fn model(&self) -> &str {
&self.model
}
pub fn reasoning_effort(&self) -> &str {
&self.reasoning_effort
}
pub fn search_context_size(&self) -> SearchContextSize {
self.search_context_size
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchSourceCandidate {
pub title: String,
pub url: String,
pub provenance: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchSource {
pub title: String,
pub url: String,
pub provenance: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchMessage {
pub text: String,
pub source_candidates: Vec<WebSearchSourceCandidate>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WebSearchUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WebSearchCost {
Unknown,
Estimate {
decimal_amount: String,
currency: String,
pricing_revision: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchPhaseTiming {
pub phase: String,
pub duration: Duration,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchTiming {
pub phases: Vec<WebSearchPhaseTiming>,
pub aggregate: Duration,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebSearchOutcome {
Success,
Failure,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebSearchFailureCategory {
InvalidRequest,
Authentication,
Provider,
Timeout,
Unavailable,
Internal,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebSearchProviderEffect {
None,
Possible,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchProvenance {
pub backend: String,
pub auth: String,
pub requested_model: String,
pub requested_reasoning_effort: String,
pub requested_search_context_size: SearchContextSize,
pub ephemeral: bool,
pub resumable: bool,
pub outcome: WebSearchOutcome,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchSuccess {
pub answer: String,
pub sources: Vec<WebSearchSource>,
pub usage: WebSearchUsage,
pub cost: WebSearchCost,
pub timing: WebSearchTiming,
pub provenance: WebSearchProvenance,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSearchFailure {
pub safe_detail: String,
pub category: WebSearchFailureCategory,
pub provider_effect: WebSearchProviderEffect,
pub sources: Vec<WebSearchSource>,
pub usage: WebSearchUsage,
pub cost: WebSearchCost,
pub timing: WebSearchTiming,
pub provenance: WebSearchProvenance,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WebSearchFinished {
Success(WebSearchSuccess),
Failure(WebSearchFailure),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum WebSearchEvent {
Message(WebSearchMessage),
Finished(Box<WebSearchFinished>),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_rejects_blank_fields_and_preserves_accepted_strings() {
assert_eq!(
WebSearchRequest::new(" \t", "model", "effort", SearchContextSize::Low),
Err(ArgumentValidationError::BlankQuestion)
);
assert_eq!(
WebSearchRequest::new("question", "\n", "effort", SearchContextSize::Low),
Err(ArgumentValidationError::BlankModel)
);
assert_eq!(
WebSearchRequest::new("question", "model", " ", SearchContextSize::Low),
Err(ArgumentValidationError::BlankReasoningEffort)
);
let request = WebSearchRequest::new(
" keep this question ",
" model-name ",
" effort-name ",
SearchContextSize::High,
)
.expect("nonblank fields are valid");
assert_eq!(request.question(), " keep this question ");
assert_eq!(request.model(), " model-name ");
assert_eq!(request.reasoning_effort(), " effort-name ");
assert_eq!(request.search_context_size(), SearchContextSize::High);
}
#[test]
fn context_size_has_exact_spellings() {
for (text, value) in [
("low", SearchContextSize::Low),
("medium", SearchContextSize::Medium),
("high", SearchContextSize::High),
] {
assert_eq!(text.parse::<SearchContextSize>(), Ok(value));
assert_eq!(value.to_string(), text);
}
for invalid in ["Low", "HIGH", " high", "high ", ""] {
assert_eq!(
invalid.parse::<SearchContextSize>(),
Err(ArgumentValidationError::InvalidSearchContextSize)
);
}
}
#[test]
fn unknown_accounting_is_explicit() {
let usage = WebSearchUsage {
input_tokens: None,
output_tokens: None,
total_tokens: None,
};
assert_eq!(usage.input_tokens, None);
assert_eq!(usage.output_tokens, None);
assert_eq!(usage.total_tokens, None);
assert_eq!(WebSearchCost::Unknown, WebSearchCost::Unknown);
}
#[test]
fn message_is_interim_and_boxed_success_carries_final_answer() {
let message = WebSearchEvent::Message(WebSearchMessage {
text: "searching".to_owned(),
source_candidates: vec![WebSearchSourceCandidate {
title: "candidate".to_owned(),
url: "https://example.test/candidate".to_owned(),
provenance: "backend-result".to_owned(),
}],
});
assert!(matches!(message, WebSearchEvent::Message(_)));
let success = WebSearchSuccess {
answer: "final answer".to_owned(),
sources: vec![WebSearchSource {
title: "source".to_owned(),
url: "https://example.test/source".to_owned(),
provenance: "final-selection".to_owned(),
}],
usage: WebSearchUsage {
input_tokens: Some(7),
output_tokens: Some(3),
total_tokens: Some(10),
},
cost: WebSearchCost::Unknown,
timing: WebSearchTiming {
phases: vec![WebSearchPhaseTiming {
phase: "search".to_owned(),
duration: Duration::from_millis(20),
}],
aggregate: Duration::from_millis(25),
},
provenance: WebSearchProvenance {
backend: "codex".to_owned(),
auth: "configured".to_owned(),
requested_model: "model-name".to_owned(),
requested_reasoning_effort: "effort-name".to_owned(),
requested_search_context_size: SearchContextSize::Medium,
ephemeral: true,
resumable: false,
outcome: WebSearchOutcome::Success,
},
};
let finished = WebSearchEvent::Finished(Box::new(WebSearchFinished::Success(success)));
match finished {
WebSearchEvent::Finished(result) => match *result {
WebSearchFinished::Success(value) => {
assert_eq!(value.answer, "final answer");
assert_eq!(value.sources.len(), 1);
}
WebSearchFinished::Failure(_) => panic!("expected success"),
},
WebSearchEvent::Message(_) => panic!("expected terminal event"),
}
}
}