mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
wip(0.10.2): issue-6889-item-3
This commit is contained in:
1 parent
7f28f4201e
commit
3ae9013dd4
5 files changed
+227
-5
No files matched your search
@@ -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")));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user