From 7c7cecb5abe15777998caf244ab1ee9ac5d44da7 Mon Sep 17 00:00:00 2001 From: karl Date: Tue, 13 Oct 2026 13:59:22 +0200 Subject: [PATCH] Reject incomplete AI completions before showing generated answers --- crates/searchi-ai/src/lib.rs | 12 ++++++++++++ crates/searchi-ai/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ docs/AI-ANSWERS.md | 8 ++++++++ 3 files changed, 53 insertions(+) diff --git a/crates/searchi-ai/src/lib.rs b/crates/searchi-ai/src/lib.rs index 4c29e5a..f2ba516 100644 --- a/crates/searchi-ai/src/lib.rs +++ b/crates/searchi-ai/src/lib.rs @@ -165,6 +165,15 @@ impl AiClient { .into_iter() .next() .ok_or(AiError::InvalidResponse("AI endpoint returned no choices"))?; + if choice + .finish_reason + .as_deref() + .is_some_and(|reason| reason != "stop") + { + return Err(AiError::InvalidResponse( + "AI endpoint did not finish a complete answer", + )); + } let content = choice.message.content.ok_or(AiError::InvalidResponse( "AI endpoint returned no textual content", ))?; @@ -351,6 +360,9 @@ struct Completion { #[derive(Deserialize)] struct Choice { message: Message, + // Some compatible local servers omit this. When present, incomplete, + // filtered or tool-directed completions must not appear as finished drafts. + finish_reason: Option, } #[derive(Deserialize)] struct Message { diff --git a/crates/searchi-ai/src/tests.rs b/crates/searchi-ai/src/tests.rs index 38629cb..e051a23 100644 --- a/crates/searchi-ai/src/tests.rs +++ b/crates/searchi-ai/src/tests.rs @@ -20,6 +20,39 @@ fn completion(answer: &str, citations: Vec<&str>) -> Vec { serde_json::to_vec(&serde_json::json!({"choices":[{"message":{"content":serde_json::json!({"answer":answer,"citations":citations}).to_string()}}]})).unwrap() } type Wire = Arc)>>>; + +#[test] +fn incomplete_or_filtered_completions_are_not_accepted_as_finished_answers() { + for reason in ["length", "content_filter", "tool_calls", "unexpected"] { + let mut body: serde_json::Value = serde_json::from_slice(&completion( + "Sixty days written notice. [selected]", + vec!["selected"], + )) + .unwrap(); + body["choices"][0]["finish_reason"] = serde_json::json!(reason); + let (base, _, worker) = server(200, serde_json::to_vec(&body).unwrap(), Duration::ZERO); + let result = AiClient::new(config(base)).unwrap().generate(&request()); + worker.join().unwrap(); + assert_eq!( + result.unwrap_err(), + AiError::InvalidResponse("AI endpoint did not finish a complete answer") + ); + } +} + +#[test] +fn explicit_stop_completion_is_accepted() { + let mut body: serde_json::Value = serde_json::from_slice(&completion( + "Sixty days written notice. [selected]", + vec!["selected"], + )) + .unwrap(); + body["choices"][0]["finish_reason"] = serde_json::json!("stop"); + let (base, _, worker) = server(200, serde_json::to_vec(&body).unwrap(), Duration::ZERO); + let result = AiClient::new(config(base)).unwrap().generate(&request()); + worker.join().unwrap(); + assert!(result.is_ok()); +} fn server(status: u16, body: Vec, delay: Duration) -> (String, Wire, thread::JoinHandle<()>) { server_with_length(status, body, delay, true) } diff --git a/docs/AI-ANSWERS.md b/docs/AI-ANSWERS.md index a31882c..0ce3fc4 100644 --- a/docs/AI-ANSWERS.md +++ b/docs/AI-ANSWERS.md @@ -49,6 +49,14 @@ model is instructed to return JSON containing `answer` and `citations` inside chosen model must follow that instruction. Unknown source citations and invalid responses produce an error; search remains available. +When an endpoint provides `choices[0].finish_reason`, it must be `stop`. +Truncated (`length`), filtered, tool-directed and unknown completion reasons +are rejected even if the returned text happens to parse as valid cited JSON. +Compatible local servers may omit this field. Nine local HTTP adapter tests +passed on 2026-10-13, including explicit completion reasons, citation validation, +credential handling, response limits and timeouts; strict Clippy passed. +This does not validate any hosted provider or generated answer accuracy. + Requests use timeouts and size limits. Redirects and ambient proxies are disabled to keep the configured destination explicit. Endpoint URLs must use HTTP or HTTPS and must not contain embedded credentials, query strings or