Reject incomplete AI completions before showing generated answers

This commit is contained in:
karl committed 2026-10-13 13:59:22 +02:00
1 parent 8c005f5f57
commit 7c7cecb5ab
3 files changed
+53

No files matched your search

+12
View File
@@ -165,6 +165,15 @@ impl AiClient {
.into_iter() .into_iter()
.next() .next()
.ok_or(AiError::InvalidResponse("AI endpoint returned no choices"))?; .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( let content = choice.message.content.ok_or(AiError::InvalidResponse(
"AI endpoint returned no textual content", "AI endpoint returned no textual content",
))?; ))?;
@@ -351,6 +360,9 @@ struct Completion {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Choice { struct Choice {
message: Message, 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<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct Message { struct Message {
+33
View File
@@ -20,6 +20,39 @@ fn completion(answer: &str, citations: Vec<&str>) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({"choices":[{"message":{"content":serde_json::json!({"answer":answer,"citations":citations}).to_string()}}]})).unwrap() serde_json::to_vec(&serde_json::json!({"choices":[{"message":{"content":serde_json::json!({"answer":answer,"citations":citations}).to_string()}}]})).unwrap()
} }
type Wire = Arc<Mutex<Option<(String, Vec<u8>)>>>; type Wire = Arc<Mutex<Option<(String, Vec<u8>)>>>;
#[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<u8>, delay: Duration) -> (String, Wire, thread::JoinHandle<()>) { fn server(status: u16, body: Vec<u8>, delay: Duration) -> (String, Wire, thread::JoinHandle<()>) {
server_with_length(status, body, delay, true) server_with_length(status, body, delay, true)
} }
+8
View File
@@ -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 chosen model must follow that instruction. Unknown source citations and invalid
responses produce an error; search remains available. 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 Requests use timeouts and size limits. Redirects and ambient proxies are
disabled to keep the configured destination explicit. Endpoint URLs must use disabled to keep the configured destination explicit. Endpoint URLs must use
HTTP or HTTPS and must not contain embedded credentials, query strings or HTTP or HTTPS and must not contain embedded credentials, query strings or