token streaming & reasoning display: live SSE tokens frontend to back
Nightly Build / build (push) Successful in 6m59s

- Add StreamDelta(SseDecoder) framing shared by OpenAI/Anthropic
- OpenAiClient: stream=true + reasoning_content deltas, index-based
  tool_calls accumulation, usage from final chunk
- AnthropicClient: message_start/content_block_*/message_delta events,
  thinking_delta->reasoning, input_json_delta->tool input
- TokenDelta ServerEvent variant wired through ChatHub + WS broadcast
- Frontend throttled flush (~15 Hz), pending bubble mutate-in-place,
  reasoning as collapsed-by-default <details>
- Drop streaming bubble on error/llm_failed/model_fallback
- i18n: chat.reasoning key added to en/fr/it
This commit is contained in:
2026-07-22 12:59:13 +01:00
parent e1d285e7db
commit 3343260bb0
23 changed files with 950 additions and 113 deletions
+229 -41
View File
@@ -1,8 +1,12 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use futures_util::StreamExt;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tracing::{debug, info, trace, warn};
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, ToolCall, headers_to_json, redact_key};
use crate::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, Role, SseDecoder, StreamDelta, ToolCall, headers_to_json, redact_key};
use core_api::APP_NAME;
/// OpenAI ChatGPT client (also compatible with any OpenAI-spec endpoint).
@@ -45,6 +49,205 @@ impl OpenAiClient {
fn url(&self) -> String {
format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
}
/// Shared request body for the buffered and the streaming path. Caller adds
/// `max_tokens`/`temperature`/`extra_params` afterwards via `finalize_body`.
fn base_body(&self, model: &str, messages: &[Value], tools: &[Value]) -> Value {
let mut body = json!({
"model": model,
"messages": messages,
});
if !tools.is_empty() {
// When prompt caching is enabled, tag the last tool with cache_control
// so the entire tools array is included in the Anthropic KV cache prefix.
let tools_value: Value = if self.enable_prompt_cache {
let mut tagged = tools.to_vec();
if let Some(last) = tagged.last_mut() {
last["cache_control"] = json!({"type": "ephemeral"});
}
tagged.into()
} else {
tools.into()
};
body["tools"] = tools_value;
body["tool_choice"] = "auto".into();
}
body
}
fn finalize_body(&self, mut body: Value, options: &ChatOptions) -> Value {
if let Some(t) = options.max_tokens { body["max_tokens"] = t.into(); }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
body
}
/// Request metadata for logging (shared by buffered and streaming paths).
fn logged_headers(&self) -> Value {
let mut logged_headers = json!({
"authorization": format!("Bearer {}", redact_key(&self.api_key)),
"content-type": "application/json",
});
if self.enable_prompt_cache {
logged_headers["anthropic-beta"] = "prompt-caching-2024-07-31".into();
}
logged_headers
}
async fn send_request(&self, body: &Value) -> reqwest::Result<reqwest::Response> {
let mut req = self.http.post(self.url()).bearer_auth(&self.api_key).header("X-Title", APP_NAME);
if self.enable_prompt_cache {
req = req.header("anthropic-beta", "prompt-caching-2024-07-31");
}
req.json(body).send().await
}
/// SSE streaming path behind `chat_with_tools_raw_streaming`. Accumulates
/// content/reasoning/tool-call fragments into the same `LlmTurn` the
/// buffered path would return, while forwarding text/reasoning deltas to
/// `delta_tx` (try_send, best-effort). `emitted` tracks whether any delta
/// was pushed, so the caller can distinguish a pre-stream failure (safe to
/// retry buffered) from a mid-stream one (partial output already shown).
async fn stream_chat(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: &mpsc::Sender<StreamDelta>,
emitted: &mut bool,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut body = self.base_body(&options.model, messages, tools);
body["stream"] = json!(true);
body["stream_options"] = json!({ "include_usage": true });
let body = self.finalize_body(body, options);
debug!(model = %options.model, tools = tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending streaming chat_with_tools request");
trace!(body = %body, "openai: streaming chat_with_tools request body");
let request_body = body.clone();
let request_headers = self.logged_headers();
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
if !status.is_success() {
let resp_text = http_resp.text().await?;
return Err(crate::LlmError {
status: Some(status.as_u16()),
message: format!(
"openai: HTTP {status} from {url}\nbody: {resp_text}",
url = self.url(),
),
}.into());
}
let mut content = String::new();
let mut reasoning = String::new();
// index → (id, name, arguments fragment buffer)
let mut tool_calls: BTreeMap<u64, (String, String, String)> = BTreeMap::new();
let mut finish_reason: Option<String> = None;
let mut usage: Option<Value> = None;
let mut sse = SseDecoder::new();
let mut byte_stream = http_resp.bytes_stream();
// One SSE `data:` payload. Fragments update the accumulators; text and
// reasoning also go out as deltas. Unparseable chunks are skipped —
// the assembled turn stays consistent.
let mut handle_payload = |payload: &str, emitted: &mut bool| {
if payload == "[DONE]" {
return;
}
let Ok(v) = serde_json::from_str::<Value>(payload) else { return };
if let Some(u) = v.get("usage").filter(|u| !u.is_null()) {
usage = Some(u.clone());
}
let Some(choice) = v["choices"].as_array().and_then(|a| a.first()) else { return };
if let Some(fr) = choice["finish_reason"].as_str() {
finish_reason = Some(fr.to_string());
}
let delta = &choice["delta"];
if let Some(t) = delta["content"].as_str().filter(|t| !t.is_empty()) {
content.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Text(t.to_string()));
}
// Same normalization as the buffered path: DeepSeek uses
// `reasoning_content`, MiniMax M3 and others `reasoning`.
if let Some(t) = delta["reasoning_content"].as_str()
.or_else(|| delta["reasoning"].as_str())
.filter(|t| !t.is_empty())
{
reasoning.push_str(t);
*emitted = true;
let _ = delta_tx.try_send(StreamDelta::Reasoning(t.to_string()));
}
if let Some(tc_arr) = delta["tool_calls"].as_array() {
for tc in tc_arr {
let idx = tc["index"].as_u64().unwrap_or(0);
let entry = tool_calls.entry(idx).or_default();
if let Some(id) = tc["id"].as_str() { entry.0 = id.to_string(); }
if let Some(n) = tc["function"]["name"].as_str() { entry.1 = n.to_string(); }
if let Some(a) = tc["function"]["arguments"].as_str() { entry.2.push_str(a); }
}
}
};
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk?;
for payload in sse.feed(&chunk) {
handle_payload(&payload, emitted);
}
}
for payload in sse.finish() {
handle_payload(&payload, emitted);
}
let finish = finish_reason.as_deref().unwrap_or("stop");
let input_tokens = usage.as_ref().and_then(|u| u["prompt_tokens"].as_u64()).map(|n| n as u32);
let output_tokens = usage.as_ref().and_then(|u| u["completion_tokens"].as_u64()).map(|n| n as u32);
let cache_read_tokens = usage.as_ref()
.and_then(|u| u["prompt_tokens_details"]["cached_tokens"].as_u64())
.map(|n| n as u32);
let cost = usage.as_ref().and_then(|u| u["cost"].as_f64());
let reasoning_content = if reasoning.is_empty() { None } else { Some(reasoning) };
info!(model = %options.model, ?input_tokens, ?output_tokens, finish_reason = finish, "openai: streaming response completed");
if finish == "length" {
warn!(model = %options.model, ?output_tokens, "openai: response truncated (max_tokens reached)");
}
let turn = if !tool_calls.is_empty() {
let calls = tool_calls
.into_values()
.map(|(id, name, args)| ToolCall {
id,
name,
arguments: serde_json::from_str(&args).unwrap_or(Value::Object(Default::default())),
})
.collect();
LlmTurn::ToolCalls { content, calls, input_tokens, output_tokens, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost }
} else {
let truncated = finish == "length";
LlmTurn::Message(ChatResponse { content, input_tokens, output_tokens, truncated, reasoning_content, cache_read_tokens, cache_creation_tokens: None, cost })
};
// Synthesize a buffered-shaped response body for the payload log, so a
// streamed call leaves the same debugging trail as a buffered one.
let response_body = json!({
"streamed": true,
"choices": [{ "finish_reason": finish }],
"usage": usage,
});
let raw_meta = LlmRawMeta {
request_headers: Some(request_headers),
request_body: Some(request_body),
response_headers: Some(response_headers),
response_body: Some(response_body),
};
Ok((turn, Some(raw_meta)))
}
}
#[async_trait]
@@ -123,53 +326,16 @@ impl ChatbotClient for OpenAiClient {
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut body = json!({
"model": options.model,
"messages": messages,
});
if !tools.is_empty() {
// When prompt caching is enabled, tag the last tool with cache_control
// so the entire tools array is included in the Anthropic KV cache prefix.
let tools_value: Value = if self.enable_prompt_cache {
let mut tagged = tools.to_vec();
if let Some(last) = tagged.last_mut() {
last["cache_control"] = json!({"type": "ephemeral"});
}
tagged.into()
} else {
tools.into()
};
body["tools"] = tools_value;
body["tool_choice"] = "auto".into();
}
if let Some(t) = options.max_tokens { body["max_tokens"] = t.into(); }
if let Some(t) = options.temperature { body["temperature"] = t.into(); }
self.apply_extra(&mut body);
let body = self.finalize_body(self.base_body(&options.model, messages, tools), options);
debug!(model = %options.model, tools = tools.len(), prompt_cache = self.enable_prompt_cache, "openai: sending chat_with_tools request");
trace!(body = %body, "openai: chat_with_tools request body");
// Capture request metadata for logging.
let mut logged_headers = json!({
"authorization": format!("Bearer {}", redact_key(&self.api_key)),
"content-type": "application/json",
});
if self.enable_prompt_cache {
logged_headers["anthropic-beta"] = "prompt-caching-2024-07-31".into();
}
let request_body = body.clone();
let request_headers = logged_headers;
let request_headers = self.logged_headers();
let mut req = self.http.post(self.url()).bearer_auth(&self.api_key).header("X-Title", APP_NAME);
if self.enable_prompt_cache {
req = req.header("anthropic-beta", "prompt-caching-2024-07-31");
}
let http_resp = req
.json(&body)
.send()
.await?;
let http_resp = self.send_request(&body).await?;
let response_headers = headers_to_json(http_resp.headers());
let status = http_resp.status();
@@ -262,4 +428,26 @@ impl ChatbotClient for OpenAiClient {
Ok((turn, Some(raw_meta)))
}
async fn chat_with_tools_raw_streaming(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
delta_tx: mpsc::Sender<StreamDelta>,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let mut emitted = false;
match self.stream_chat(messages, tools, options, &delta_tx, &mut emitted).await {
Ok(ok) => Ok(ok),
// Nothing was ever streamed: some OpenAI-compatible providers reject
// `stream`/`stream_options` outright — retry buffered so they keep
// working exactly as before. A mid-stream failure (deltas already
// shown) instead propagates to the model-fallback logic.
Err(e) if !emitted => {
debug!(model = %options.model, error = %e, "openai: streaming failed before any delta; retrying buffered");
self.chat_with_tools_raw(messages, tools, options).await
}
Err(e) => Err(e),
}
}
}