Reject incomplete AI completions before showing generated answers
This commit is contained in:
1 parent
8c005f5f57
commit
7c7cecb5ab
3 files changed
+53
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user