Compare commits

..
1 Commits
Author SHA1 Message Date
infosave2007 0b071279ac fix(stream): no choice-less chunks unless the client asks for usage
CI / lint (push) Canceled after 0s
CI / test (macos-14) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Docker / publish (push) Canceled after 0s
Every stream used to carry two chunks with an empty choices array: usage,
which the upstream emits BEFORE the stop reason, and a cortiq_cost_usd chunk
the gateway added itself. OpenAI ships the usage chunk only when the request
sets stream_options: {"include_usage": true}, and chat UIs do not set it -
which is why the same tool, in the same Open WebUI, sees neither chunk from
Groq and two from us.

Honour the flag. With no stream_options a stream is now exactly
role -> content... -> tool_calls -> finish_reason -> [DONE], with no
choice-less chunks at all. When usage is requested it ships once, after the
stop reason, with cortiq_cost_usd folded into the same chunk rather than
trailing behind it.

Token accounting, cost and the X-Cortiq-* headers are untouched: those numbers
were never read back off the wire.

Verified: the default stream matches the reference shape byte for byte, the
opt-in stream matches OpenAI's, the official OpenAI SDK reassembles the
streamed tool call, and the full Open WebUI 0.11.0 tool loop still runs the
tool and answers from its result.
2026-08-21 21:24:11 +03:00
6 changed files with 123 additions and 15 deletions
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.2.49] - 2026-08-21
### Fixed
- **The SSE stream now has the same shape every hosted provider sends.** Two choice-less
chunks used to ride along on every stream: `usage` (emitted by the upstream *before* the
stop reason) and a `cortiq_cost_usd` chunk of the gateway's own. OpenAI ships the usage
chunk only when the client sets `stream_options: {"include_usage": true}` — and chat UIs
do not set it, which is why a Groq stream carries neither. The gateway now honours that
flag: with no `stream_options`, a stream is exactly
`role → content… → tool_calls → finish_reason → [DONE]` with no choice-less chunks at
all. When usage *is* requested it ships once, **after** the stop reason, with
`cortiq_cost_usd` folded into the same chunk instead of trailing behind it.
Token accounting, cost and the `X-Cortiq-*` headers are unchanged — the numbers were
never taken from those chunks.
## [0.2.48] - 2026-08-21
Verified end to end: Open WebUI 0.11.0 → gateway → managed CMF/Qwen, native
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.48"
version = "0.2.49"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.48"
version = "0.2.49"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+6
View File
@@ -121,6 +121,12 @@ opens with a `role` delta, carries exactly one terminal `finish_reason`, and end
failure arrives as an SSE `error` event. A client can therefore never be left waiting on a
half-formed stream.
Streaming carries no choice-less chunks unless asked: `usage` is emitted only when the
request sets `stream_options: {"include_usage": true}`, and then exactly once, after the
`finish_reason` chunk, with `cortiq_cost_usd` folded in. Without it a stream is
`role → content… → tool_calls → finish_reason → [DONE]` and nothing else — the shape the
hosted providers send.
### Tool calls
`tools` and `tool_calls` are proxied through, with the wire kept clean:
+6
View File
@@ -119,6 +119,12 @@ HTTP 400 — но не «голый» 422, который OpenAI-клиенты
`data: [DONE]` — даже если upstream оборвался или упал на середине (тогда ошибка приходит
SSE-событием `error`). Клиент никогда не остаётся ждать недосказанный поток.
В стриме нет чанков без `choices`, если их не просили: `usage` отдаётся, только когда
запрос содержит `stream_options: {"include_usage": true}`, и тогда ровно один раз — после
чанка с `finish_reason`, с вложенным `cortiq_cost_usd`. Без него поток выглядит как
`role → content… → tool_calls → finish_reason → [DONE]` и ничего больше — ровно так же,
как у хостовых провайдеров.
### Tool calls
`tools` и `tool_calls` проксируются, но провод держится чистым:
+93 -13
View File
@@ -233,6 +233,16 @@ async fn handler(
.unwrap_or("cortiq-auto")
.to_string();
let stream = req.stream.unwrap_or(false);
// OpenAI only emits the trailing usage chunk when the client asks for it,
// and chat UIs do not ask. Mirroring that keeps our stream byte-shaped like
// every other provider's instead of carrying two extra choice-less chunks
// that each client has to know to ignore.
let include_usage = req
.rest
.get("stream_options")
.and_then(|o| o.get("include_usage"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let canonical = ChatRequest {
routing: parse_routing(&requested_model),
@@ -260,7 +270,7 @@ async fn handler(
if stream {
let (info, raw) = state.pipeline.run_stream(canonical, &state).await?;
let headers = sse_headers(&info);
let normalized = normalize_sse(raw, requested_model);
let normalized = normalize_sse(raw, requested_model, include_usage);
return Ok((headers, axum::body::Body::from_stream(normalized)).into_response());
}
@@ -350,6 +360,7 @@ async fn handler(
fn normalize_sse(
stream: crate::providers::ChatStream,
model: String,
include_usage: bool,
) -> impl futures::Stream<Item = Result<bytes::Bytes>> + Send + 'static {
use futures::StreamExt;
async_stream::stream! {
@@ -368,6 +379,9 @@ fn normalize_sse(
std::collections::HashMap::new();
let mut tool_indices: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut emitted_tool_calls = false;
// `usage` / `cortiq_*` collected along the way, shipped after the finish.
let mut tail_extras: std::collections::BTreeMap<&str, serde_json::Value> =
std::collections::BTreeMap::new();
// Emit one normalised event; `None` means "nothing worth sending".
macro_rules! chunk {
@@ -508,20 +522,20 @@ fn normalize_sse(
}
}
// Terminal metadata rides on a choice-less chunk, exactly
// as OpenAI ships `usage` when `stream_options` asks for it.
let mut extra: Vec<(&str, serde_json::Value)> = Vec::new();
if let Some(u) = v.get("usage").filter(|u| !u.is_null()) {
extra.push(("usage", u.clone()));
}
for key in ["cortiq_cost_usd", "cortiq_estimated"] {
// Terminal metadata is held back and shipped once, after the
// stop reason — the order OpenAI uses. Emitting it mid-stream
// (as several runtimes do) puts choice-less chunks between the
// deltas and the finish, which is legal but unlike every
// reference implementation.
for key in ["usage", "cortiq_cost_usd", "cortiq_estimated"] {
if let Some(val) = v.get(key).filter(|v| !v.is_null()) {
extra.push((key, val.clone()));
tail_extras.insert(key, val.clone());
}
}
if !choices.is_empty() || !extra.is_empty() {
yield Ok(chunk!(choices, extra));
if !choices.is_empty() {
let none: Vec<(&str, serde_json::Value)> = Vec::new();
yield Ok(chunk!(choices, none));
}
}
}
@@ -563,6 +577,15 @@ fn normalize_sse(
let extra: Vec<(&str, serde_json::Value)> = Vec::new();
yield Ok(chunk!(choices, extra));
}
// One trailing chunk, and only when the client asked for usage.
if include_usage {
if let Some(usage) = tail_extras.remove("usage") {
let mut extra: Vec<(&str, serde_json::Value)> = vec![("usage", usage)];
extra.extend(tail_extras.into_iter());
let no_choices: Vec<serde_json::Value> = Vec::new();
yield Ok(chunk!(no_choices, extra));
}
}
yield Ok(bytes::Bytes::from("data: [DONE]\n\n"));
}
}
@@ -612,7 +635,7 @@ mod tests {
.map(|c| Ok(bytes::Bytes::from(c)))
.collect::<Vec<_>>(),
);
normalize_sse(Box::pin(upstream), "cortiq-auto".into())
normalize_sse(Box::pin(upstream), "cortiq-auto".into(), false)
.map(|b| String::from_utf8_lossy(&b.unwrap()).to_string())
.collect::<Vec<_>>()
.await
@@ -667,6 +690,63 @@ mod tests {
);
}
async fn drain_with_usage(chunks: Vec<&'static str>, include_usage: bool) -> String {
use futures::StreamExt;
let upstream = futures::stream::iter(
chunks
.into_iter()
.map(|c| Ok(bytes::Bytes::from(c)))
.collect::<Vec<_>>(),
);
normalize_sse(Box::pin(upstream), "cortiq-auto".into(), include_usage)
.map(|b| String::from_utf8_lossy(&b.unwrap()).to_string())
.collect::<Vec<_>>()
.await
.join("")
}
/// The upstream ships usage BEFORE the stop reason and the gateway adds a
/// cost chunk of its own. A client that did not ask for usage must see
/// neither — the same stream shape every hosted provider sends.
const USAGE_STREAM: &[&str] = &[
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\n",
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[],\"cortiq_cost_usd\":0.0}\n\n",
"data: [DONE]\n\n",
];
#[tokio::test]
async fn without_stream_options_no_choice_less_chunks_are_sent() {
let out = drain_with_usage(USAGE_STREAM.to_vec(), false).await;
assert!(!out.contains("\"usage\""), "unrequested usage chunk: {out}");
assert!(!out.contains("cortiq_cost_usd"), "stray cost chunk: {out}");
assert!(
!out.contains("\"choices\":[]"),
"choice-less chunk leaked: {out}"
);
assert!(out.contains("\"finish_reason\":\"stop\""));
assert!(out.trim_end().ends_with("data: [DONE]"));
}
#[tokio::test]
async fn requested_usage_is_one_chunk_after_the_finish_reason() {
let out = drain_with_usage(USAGE_STREAM.to_vec(), true).await;
assert_eq!(
out.matches("\"usage\"").count(),
1,
"usage must ship once: {out}"
);
let finish = out.find("\"finish_reason\"").expect("finish chunk");
let usage = out.find("\"usage\"").expect("usage chunk");
assert!(usage > finish, "usage must follow the stop reason: {out}");
assert!(
out.contains("cortiq_cost_usd"),
"cost folded into usage chunk: {out}"
);
assert!(out.trim_end().ends_with("data: [DONE]"));
}
#[tokio::test]
async fn ordinary_text_is_untouched_by_the_tool_filter() {
let out = drain(vec![
@@ -685,7 +765,7 @@ mod tests {
let upstream = futures::stream::iter(vec![Ok(bytes::Bytes::from(
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n",
))]);
let out: Vec<String> = normalize_sse(Box::pin(upstream), "cortiq-auto".into())
let out: Vec<String> = normalize_sse(Box::pin(upstream), "cortiq-auto".into(), false)
.map(|b| String::from_utf8_lossy(&b.unwrap()).to_string())
.collect()
.await;