fix(TASK-044): streaming response.incomplete parity — keep partial output, record usage

Review findings: the streaming arm now mirrors the non-streaming twin
(openai_extract_responses): partial text/tool calls are returned rather
than discarded by a hard error; only an empty truncated response bails.
Billed usage on truncated turns is recorded before either outcome.
Also: CHANGELOG notes the streaming-incomplete surfacing and corrects
the include/strict phrasing (sent on every responses-wire body).
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:31:52 -06:00
1 parent 9e7c957a3d
commit ce20927713
3 files changed
+69 -1

No files matched your search

+2 -1
View File
@@ -17,7 +17,8 @@ reasoning items from the Responses API); older coyote binaries cannot load those
- support `wire_api: responses | chat` on the openai and openai-compatible clients, with wire-aware request patching on both patch layers
- report token usage (including cached input tokens) on the openai responses path
- opting a third-party endpoint into `wire_api: responses` sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions — strict `/v1/responses` servers may reject these as unknown params
- streaming responses that end with `response.incomplete` now surface the truncation reason (e.g. `max_output_tokens`) when no output was delivered — matching the non-streaming behavior — and record token usage for truncated turns instead of ending silently
- the responses wire always sends `include: ["reasoning.encrypted_content"]` and `"strict": false` tool definitions; strict third-party `/v1/responses` servers may reject these as unknown params when opted in via `wire_api: responses` (workaround: a `responses`-keyed client patch, e.g. `include: null` for Groq)
## v0.10.3 (2026-09-15)
+59
View File
@@ -944,6 +944,18 @@ fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Resu
None => bail!("Response failed: {data}"),
},
Some("response.incomplete") => {
// Truncated turns are the most expensive ones; record their
// billed usage before deciding how to surface the truncation.
if let Some(usage) = openai_parse_responses_usage(&data["response"]["usage"]) {
debug!("token-usage: {usage:?}");
handler.usage(usage);
}
// Parity with the non-streaming path (`openai_extract_responses`):
// partial text or tool calls are returned rather than discarded
// by a hard error; only an empty truncated response bails.
if handler.has_received_visible_output() {
return Ok(true);
}
match data["response"]["incomplete_details"]["reason"].as_str() {
Some(reason) => bail!("The response was cut off: {reason}"),
None => bail!("The response was cut off: {data}"),
@@ -1406,6 +1418,53 @@ mod tests {
);
}
#[test]
fn responses_stream_incomplete_without_reason_still_bails() {
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
let abort_signal = crate::utils::create_abort_signal();
let mut handler = SseHandler::new(sender, abort_signal);
let event = json!({
"type": "response.incomplete",
"response": {}
});
let err = openai_responses_handle_event(&event, &mut handler)
.unwrap_err()
.to_string();
assert!(err.contains("The response was cut off"), "unexpected error: {err}");
}
#[test]
fn responses_stream_incomplete_with_partial_output_ends_stream_and_records_usage() {
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
let abort_signal = crate::utils::create_abort_signal();
let mut handler = SseHandler::new(sender, abort_signal);
handler.set_silent(true);
handler.text("partial answer").unwrap();
let event = json!({
"type": "response.incomplete",
"response": {
"incomplete_details": { "reason": "max_output_tokens" },
"usage": {
"input_tokens": 50,
"output_tokens": 10,
"input_tokens_details": { "cached_tokens": 25 }
}
}
});
let done = openai_responses_handle_event(&event, &mut handler).unwrap();
assert!(done, "partial output should end the stream, not bail");
let (text, _, _, usage) = handler.take();
assert_eq!(text, "partial answer");
let usage = usage.expect("usage should be recorded for truncated turns");
assert_eq!(usage.input_tokens, Some(50));
assert_eq!(usage.output_tokens, Some(10));
assert_eq!(usage.cache_read_input_tokens, Some(25));
}
fn openai_config(name: &str, auth: Option<&str>, oauth: Option<OAuthConfig>) -> OpenAIConfig {
OpenAIConfig {
name: Some(name.into()),
+8
View File
@@ -215,6 +215,14 @@ impl SseHandler {
!self.buffer.is_empty() || !self.tool_calls.is_empty() || !self.thinking.is_empty()
}
/// Whether visible output (text or tool calls) has been accumulated.
/// Unlike [`Self::has_received_content`], thinking blocks do not count:
/// a stream truncated before any visible output is still an empty
/// response to the caller.
pub fn has_received_visible_output(&self) -> bool {
!self.buffer.is_empty() || !self.tool_calls.is_empty()
}
pub fn abort(&self) -> AbortSignal {
self.abort_signal.clone()
}