diff --git a/CHANGELOG.md b/CHANGELOG.md index a24f88d..1665b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/client/openai.rs b/src/client/openai.rs index 73a84fb..c3853b2 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -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) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()), diff --git a/src/client/stream.rs b/src/client/stream.rs index 2d9d3ab..960eedf 100644 --- a/src/client/stream.rs +++ b/src/client/stream.rs @@ -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() }