wip(0.10.2): issue-6889-item-3

This commit is contained in:
CodeWhale Bot committed 2026-10-07 16:53:21 -07:00
1 parent 7f28f4201e
commit 3ae9013dd4
5 files changed
+227 -5

No files matched your search

+71
View File
@@ -108,6 +108,34 @@ pub fn is_output_limit_stop_reason(reason: Option<&str>) -> bool {
})
}
/// Stop reason recorded for a response that reported `stop` while its own usage
/// shows it ran to the requested output ceiling (#6889). The Chat Completions
/// spelling, so every existing output-limit path treats it as a `length` stop.
pub const OUTPUT_CEILING_STOP_REASON: &str = "length";
/// True when a Chat Completions `stop` is contradicted by the provider's own
/// usage: the completion tokens it reported equal the output ceiling the
/// request asked for (#6889). A finished answer leaves the allowance unfilled,
/// so this response was cut at the ceiling, typically by reasoning that used
/// the allowance before or during the answer.
///
/// `reported_output_tokens` is `None` when the provider reported no usage; no
/// inference is made then. The match is exact: a provider that does not count
/// reasoning against the cap can report more than the ceiling on a finished
/// answer. Only `stop` is second-guessed. The Anthropic and Responses wires
/// report a limit stop through typed fields of their own, and one Responses
/// route sends no ceiling at all.
#[must_use]
pub fn stop_contradicts_output_ceiling(
reason: Option<&str>,
reported_output_tokens: Option<u32>,
requested_max_tokens: u32,
) -> bool {
requested_max_tokens > 0
&& reported_output_tokens == Some(requested_max_tokens)
&& reason.is_some_and(|reason| reason.trim().eq_ignore_ascii_case("stop"))
}
/// True when the provider explicitly reported that it did not complete the
/// response. Responses API reasons carry an `incomplete:` prefix so unknown
/// future reasons cannot accidentally be accepted as a finished answer.
@@ -692,6 +720,49 @@ mod tests {
}
}
#[test]
fn stop_at_the_requested_ceiling_is_an_output_limit_stop() {
// #6889: 16000 requested, 16000 reported, `finish_reason: stop`.
assert!(stop_contradicts_output_ceiling(
Some("stop"),
Some(16_000),
16_000
));
assert!(stop_contradicts_output_ceiling(
Some(" STOP "),
Some(50),
50
));
assert!(is_output_limit_stop_reason(Some(
OUTPUT_CEILING_STOP_REASON
)));
assert!(is_incomplete_stop_reason(Some(OUTPUT_CEILING_STOP_REASON)));
// A finished answer leaves the allowance unfilled.
assert!(!stop_contradicts_output_ceiling(
Some("stop"),
Some(15_999),
16_000
));
// Reasoning the provider does not count against the cap.
assert!(!stop_contradicts_output_ceiling(
Some("stop"),
Some(20_000),
16_000
));
// No usage reported: nothing to compare.
assert!(!stop_contradicts_output_ceiling(Some("stop"), None, 16_000));
// No ceiling requested.
assert!(!stop_contradicts_output_ceiling(Some("stop"), Some(0), 0));
// Other wires and other stops keep their own reporting.
for reason in [None, Some("end_turn"), Some("tool_calls"), Some("length")] {
assert!(
!stop_contradicts_output_ceiling(reason, Some(16_000), 16_000),
"{reason:?}"
);
}
}
#[test]
fn incomplete_stop_reason_never_accepts_unknown_responses_failures() {
assert!(is_incomplete_stop_reason(Some("incomplete:content_filter")));
+3 -2
View File
@@ -68,8 +68,9 @@ use codewhale_execpolicy::ApprovalMode;
#[cfg(test)]
use codewhale_models::ToolCaller;
use codewhale_models::{
ContentBlock, ContentBlockStart, Delta, Message, StreamEvent, SystemPrompt, Tool, Usage,
is_incomplete_stop_reason, is_output_limit_stop_reason, stop_reason_detail,
ContentBlock, ContentBlockStart, Delta, Message, OUTPUT_CEILING_STOP_REASON, StreamEvent,
SystemPrompt, Tool, Usage, is_incomplete_stop_reason, is_output_limit_stop_reason,
stop_contradicts_output_ceiling, stop_reason_detail,
};
#[cfg(test)]
@@ -357,9 +357,10 @@ impl Engine {
let message = if zero_tool_text_call {
"Model answered only with a tool call, and this turn offers no tools."
.to_string()
} else if has_provider_reasoning
&& stop_reason_is_output_limit(stop_reason.as_deref())
{
} else if stop_reason_is_output_limit(stop_reason.as_deref()) {
// With or without streamed reasoning: a provider that
// keeps its reasoning to itself still spends the
// allowance on it (#6889).
format!(
"Model reached the response output limit with no answer or tool call (requested allowance: {} tokens, including reasoning).",
prepared_output_tokens
@@ -387,6 +387,26 @@ impl Engine {
visible_text_chars = current_text_visible.chars().count(),
"parent model response settled"
);
// #6889: a Chat Completions `stop` whose own usage equals the output
// ceiling this request asked for was cut at that ceiling. From here
// on it is the `length` stop it should have been, so it takes the
// one output-limit path below (continue from the partial answer, or
// fail by name when there is none) instead of passing for a finished
// answer or being re-requested unchanged. The diagnostics above keep
// the reason the provider sent.
let stop_reason = if stop_contradicts_output_ceiling(
stop_reason.as_deref(),
usage_reported.then_some(usage.output_tokens),
stream_request.max_tokens,
) {
crate::logging::warn(format!(
"Provider reported stop reason `stop`, but its usage shows the whole requested output allowance was used ({} tokens, reasoning tokens: {:?}); treating the response as cut at the output limit.",
usage.output_tokens, usage.reasoning_tokens
));
Some(OUTPUT_CEILING_STOP_REASON.to_string())
} else {
stop_reason
};
// These belong to post-stream response assembly, not stream
// consumption: blocks are built from the completed stream state,
// and truncation is derived from its terminal stop reason below.
@@ -610,6 +610,135 @@ async fn plain_exec_bounds_output_limit_continuations() {
}
}
/// SSE for a response that reports `finish_reason: stop` while its usage says
/// the whole requested output allowance was used (#6889). `completion_tokens`
/// echoes the ceiling the request carried, whichever key it used.
fn stop_at_ceiling_response(content: Option<&'static str>) -> impl wiremock::Respond {
move |request: &wiremock::Request| {
let body: Value = serde_json::from_slice(&request.body).expect("request body JSON");
let ceiling = body
.get("max_tokens")
.or_else(|| body.get("max_completion_tokens"))
.and_then(Value::as_u64)
.expect("the request names an output ceiling");
let delta = content.map_or_else(|| json!({}), |text| json!({ "content": text }));
sse_response(
[
sse_chunk(json!({
"id": "chatcmpl-ceiling",
"object": "chat.completion.chunk",
"model": TEST_MODEL,
"choices": [{"index": 0, "delta": delta, "finish_reason": null}]
})),
sse_chunk(json!({
"id": "chatcmpl-ceiling",
"object": "chat.completion.chunk",
"model": TEST_MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": 20,
"completion_tokens": ceiling,
"total_tokens": ceiling + 20
}
})),
"data: [DONE]\n\n".to_string(),
]
.join(""),
)
}
}
/// #6889: a provider can cut a response at the requested ceiling and still
/// report `finish_reason: stop`. Its own usage gives it away: the completion
/// tokens equal the ceiling. That response follows the same policy as a
/// `length` stop, so the partial answer is kept and the model is asked to
/// continue instead of the cut-off answer passing for a finished one.
#[tokio::test(flavor = "multi_thread")]
async fn plain_exec_continues_past_a_stop_that_used_the_whole_output_allowance() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(json_response(json!({
"object": "list",
"data": [{ "id": TEST_MODEL, "object": "model" }]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(stop_at_ceiling_response(Some("first half")))
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(sse_response(answer_sse_without_usage(" second half")))
.with_priority(2)
.mount(&server)
.await;
let json_stdout = run_exec(
&server,
&["--json", "--model", TEST_MODEL, "answer briefly"],
);
let receipt: Value = serde_json::from_str(&json_stdout)
.unwrap_or_else(|err| panic!("--json receipt should parse: {err}\n{json_stdout}"));
assert_eq!(receipt["success"], true, "{receipt}");
let output = receipt["output"].as_str().unwrap_or_default();
assert!(
output.contains("first half") && output.contains("second half"),
"{receipt}"
);
let bodies = chat_bodies(&server).await;
assert_eq!(bodies.len(), 2, "one continuation request: {bodies:#?}");
assert!(
bodies[1]["messages"]
.to_string()
.contains("stopped generation at its output limit"),
"the continuation names the truncation: {}",
bodies[1]["messages"]
);
}
/// #6889: when the allowance was spent before any answer was written, the
/// response is empty and still says `stop`. Asking again reproduces it, so
/// the run fails after that one request instead of re-requesting it.
#[tokio::test(flavor = "multi_thread")]
async fn plain_exec_does_not_re_request_an_empty_stop_at_the_output_ceiling() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(json_response(json!({
"object": "list",
"data": [{ "id": TEST_MODEL, "object": "model" }]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(stop_at_ceiling_response(None))
.mount(&server)
.await;
let (success, stdout, stderr) = run_exec_unchecked(
&server,
&["--json", "--model", TEST_MODEL, "answer briefly"],
);
assert!(
!success,
"an answerless response must not exit 0\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let bodies = chat_bodies(&server).await;
assert_eq!(
bodies.len(),
1,
"a stop at the ceiling is not re-requested: {bodies:#?}"
);
}
/// A stdio MCP server that reads `initialize`, closes its stdin, answers, and
/// stays alive: the client's next write (`notifications/initialized`) always
/// hits a pipe with no reader. That is the shape of the real failure, where a