feat: streaming (SSE) end-to-end

- Provider::chat_stream; OpenAI provider streams via stream_options.include_usage
- pipeline.run_stream: shared route/select, failover before first byte
- /v1/chat/completions forwards SSE verbatim with X-Cortiq-* headers; usage tapped for stats
- admin /test/stream + live streaming in Playground (stream toggle, 7 langs)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
infosave2007
2026-06-30 16:37:35 +03:00
co-authored by Claude Opus 4.8
parent 220df29888
commit b6fdf23aa6
17 changed files with 483 additions and 64 deletions
+17 -2
View File
@@ -7,8 +7,16 @@
pub mod anthropic;
pub mod openai;
use crate::error::GatewayError;
use crate::model::{ChatRequest, ChatResponse};
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::BoxStream;
/// A streaming chat response: raw Server-Sent-Events byte chunks in OpenAI wire
/// format (`data: {json}\n\n` … `data: [DONE]\n\n`). The gateway forwards these
/// to the client verbatim while tapping the final `usage` for statistics.
pub type ChatStream = BoxStream<'static, crate::error::Result<Bytes>>;
/// Model capabilities — checked at selection time (whether tools/vision are needed).
#[derive(Clone, Debug, Default)]
@@ -41,6 +49,13 @@ pub trait Provider: Send + Sync {
/// Non-streaming chat call.
async fn chat(&self, req: ChatRequest) -> crate::error::Result<ChatResponse>;
// TODO(v0.1): async fn chat_stream(...) -> Result<ChatStream> (SSE).
// TODO(v0.1): async fn embed(...) -> Result<EmbedResponse>.
/// Streaming chat call (SSE). Default: not supported (override per provider).
async fn chat_stream(&self, _req: ChatRequest) -> crate::error::Result<ChatStream> {
Err(GatewayError::UpstreamUnavailable(format!(
"streaming is not supported by provider '{}'",
self.id()
)))
}
// TODO(v0.2): async fn embed(...) -> Result<EmbedResponse>.
}
+31
View File
@@ -154,4 +154,35 @@ impl Provider for OpenAiProvider {
},
})
}
async fn chat_stream(&self, req: ChatRequest) -> Result<super::ChatStream> {
use futures::StreamExt;
let mut body = self.to_wire(&req);
body["stream"] = serde_json::Value::Bool(true);
// ask OpenAI-compatible servers to include token usage in the final chunk
body["stream_options"] = serde_json::json!({ "include_usage": true });
let mut r = self
.http
.post(format!("{}/chat/completions", self.base_url))
.json(&body);
if let Some(key) = &self.api_key {
r = r.bearer_auth(key);
}
let resp = r
.send()
.await
.map_err(|e| GatewayError::UpstreamUnavailable(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(GatewayError::UpstreamUnavailable(format!(
"provider {} returned HTTP {}: {}",
self.id, status, err_body
)));
}
let stream = resp
.bytes_stream()
.map(|res| res.map_err(|e| GatewayError::UpstreamUnavailable(e.to_string())));
Ok(Box::pin(stream))
}
}