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()
.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<String>,
}
#[derive(Deserialize)]
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()
}
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<()>) {
server_with_length(status, body, delay, true)
}