feat(stream-bridge): 支持上游 sync 响应转 SSE 流式输出,记录 client/upstream 流模式差异

- 新增 sync_to_stream 桥接模块,将非 SSE 上游响应转换为 SSE 格式回传给流式客户端
- stream_pump 检测非 SSE 响应头后缓冲整包并通过桥接逻辑重写为 SSE 帧
- proxy handler 同步支持 sync→stream 聚合与转换(覆盖 openai/claude/gemini 四种格式)
- sync_products 补全 openai:cli 的完整流式事件聚合(text delta、reasoning、tool call 等)
- usage runtime 写入 client_requested_stream / upstream_is_stream 到 request_metadata
- SQL 查询层将两个布尔字段从 request_metadata jsonb 中提取并回传给前端
- 前端 status.ts 新增 resolveUsageStreamLabelSegments,优先读取 client_requested_stream
- RequestDetailDrawer 在流式转换场景下显示"客户端→上游"两段 Badge
This commit is contained in:
fawney19
2026-04-23 21:53:50 +08:00
parent 40282c3447
commit 342d4a268c
40 changed files with 3638 additions and 97 deletions

View File

@@ -39,8 +39,8 @@ use self::execution_failures::{
handle_prefetch_stream_failure, submit_midstream_stream_failure, StreamFailureReport,
};
use crate::ai_pipeline_api::{
maybe_build_provider_private_stream_normalizer, maybe_build_stream_response_rewriter,
normalize_provider_private_report_context,
maybe_bridge_standard_sync_json_to_stream, maybe_build_provider_private_stream_normalizer,
maybe_build_stream_response_rewriter, normalize_provider_private_report_context,
};
use crate::api::response::{
attach_control_metadata_headers, build_client_response, build_client_response_from_parts,
@@ -52,7 +52,8 @@ use crate::execution_runtime::build_direct_execution_frame_stream;
#[cfg(test)]
use crate::execution_runtime::remote_compat::post_stream_plan_to_remote_execution_runtime;
use crate::execution_runtime::submission::{
resolve_core_error_background_report_kind, submit_local_core_error_or_sync_finalize,
resolve_core_error_background_report_kind, strip_utf8_bom_and_ws,
submit_local_core_error_or_sync_finalize,
};
use crate::execution_runtime::transport::{
execute_stream_plan_via_local_tunnel, DirectSyncExecutionRuntime,
@@ -481,6 +482,11 @@ fn response_headers_indicate_sse(headers: &BTreeMap<String, String>) -> bool {
.is_some_and(|value| value.to_ascii_lowercase().contains("text/event-stream"))
}
fn parse_prefetched_sync_json_body(body: &[u8]) -> Option<Value> {
let stripped = strip_utf8_bom_and_ws(body);
serde_json::from_slice::<Value>(stripped).ok()
}
fn encode_terminal_sse_error_event(failure: &StreamFailureReport) -> Result<Bytes, std::io::Error> {
let payload = failure
.to_json_string()
@@ -913,6 +919,7 @@ async fn execute_stream_from_frame_stream(
let mut prefetched_inspection_body = Vec::new();
let mut prefetched_telemetry: Option<ExecutionTelemetry> = None;
let mut reached_eof = false;
let mut sync_json_stream_bridge_active = false;
if skip_direct_finalize_prefetch {
debug!(
event_name = "execution_runtime_stream_prefetch_skipped",
@@ -1055,6 +1062,59 @@ async fn execute_stream_from_frame_stream(
StreamPrefetchInspection::NonError => {}
}
if !response_headers_indicate_sse(&headers) && (200..300).contains(&status_code)
{
if let Some(body_json) =
parse_prefetched_sync_json_body(&prefetched_inspection_body)
{
match maybe_bridge_standard_sync_json_to_stream(
&body_json,
plan.provider_api_format.as_str(),
plan.client_api_format.as_str(),
report_context.as_ref(),
) {
Ok(Some(outcome)) => {
headers.remove("content-encoding");
headers.remove("content-length");
headers.insert(
"content-type".to_string(),
"text/event-stream".to_string(),
);
stream_terminal_summary = outcome.terminal_summary;
prefetched_body.extend_from_slice(&outcome.sse_body);
prefetched_chunks.push(Bytes::from(outcome.sse_body));
sync_json_stream_bridge_active = true;
break;
}
Ok(None) => {}
Err(err) => {
let failure = build_stream_failure_report(
"execution_runtime_sync_json_stream_bridge_error",
format!(
"failed to bridge execution runtime sync json to stream: {err:?}"
),
502,
);
return handle_prefetch_stream_failure(
state,
trace_id,
decision,
&plan,
report_context,
request_id,
candidate_id,
report_kind,
headers,
prefetched_telemetry,
&provider_prefetched_body,
failure,
)
.await;
}
}
}
}
let normalized_chunk = if let Some(normalizer) =
private_stream_normalizer.as_mut()
{
@@ -1134,7 +1194,9 @@ async fn execute_stream_from_frame_stream(
prefetched_telemetry = Some(frame_telemetry);
}
StreamFramePayload::Eof { summary } => {
stream_terminal_summary = summary;
if summary.is_some() {
stream_terminal_summary = summary;
}
reached_eof = true;
break;
}
@@ -1214,6 +1276,7 @@ async fn execute_stream_from_frame_stream(
let provider_prefetched_body_for_report = provider_prefetched_body;
let prefetched_body_for_report = prefetched_body;
let prefetched_chunks_for_body = prefetched_chunks;
let sync_json_stream_bridge_active_for_report = sync_json_stream_bridge_active;
let initial_telemetry = prefetched_telemetry;
let initial_reached_eof = reached_eof;
let direct_stream_finalize_kind_owned = direct_stream_finalize_kind;
@@ -1256,10 +1319,16 @@ async fn execute_stream_from_frame_stream(
let mut buffered_body = Vec::new();
let mut provider_body_truncated = false;
let mut client_body_truncated = false;
let mut private_stream_normalizer =
maybe_build_provider_private_stream_normalizer(report_context_owned.as_ref());
let mut local_stream_rewriter =
maybe_build_stream_response_rewriter(normalized_stream_report_context_owned.as_ref());
let mut private_stream_normalizer = if sync_json_stream_bridge_active_for_report {
None
} else {
maybe_build_provider_private_stream_normalizer(report_context_owned.as_ref())
};
let mut local_stream_rewriter = if sync_json_stream_bridge_active_for_report {
None
} else {
maybe_build_stream_response_rewriter(normalized_stream_report_context_owned.as_ref())
};
append_stream_capture_bytes(
&mut provider_buffered_body,
&provider_prefetched_body_for_report,
@@ -1360,6 +1429,9 @@ async fn execute_stream_from_frame_stream(
};
match frame.payload {
StreamFramePayload::Data { chunk_b64, text } => {
if sync_json_stream_bridge_active_for_report {
continue;
}
let chunk =
match decode_stream_data_chunk(chunk_b64.as_deref(), text.as_deref()) {
Ok(chunk) => chunk,
@@ -1488,7 +1560,9 @@ async fn execute_stream_from_frame_stream(
telemetry = Some(frame_telemetry);
}
StreamFramePayload::Eof { summary } => {
stream_terminal_summary = summary;
if summary.is_some() {
stream_terminal_summary = summary;
}
break;
}
StreamFramePayload::Error { error } => {
@@ -1874,8 +1948,11 @@ mod tests {
use std::sync::Arc;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use axum::body::to_bytes;
use axum::body::{to_bytes, Body};
use axum::extract::ws::Message;
use axum::extract::Request;
use axum::routing::any;
use axum::{http::header, http::HeaderValue, Router};
use serde_json::{json, Value};
use tokio::sync::watch;
@@ -1954,6 +2031,113 @@ mod tests {
));
}
#[tokio::test]
async fn execute_execution_runtime_stream_bridges_sync_json_body_from_remote_runtime_to_sse() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
let app = Router::new().route(
"/v1/execute/stream",
any(|_request: Request| async move {
let frames = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"application/json\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"{\\\"id\\\":\\\"resp-remote-runtime-sync-json-123\\\",\\\"object\\\":\\\"response\\\",\\\"model\\\":\\\"gpt-5.4\\\",\\\"status\\\":\\\"completed\\\",\\\"output\\\":[{\\\"type\\\":\\\"message\\\",\\\"id\\\":\\\"msg-remote-runtime-sync-json-123\\\",\\\"role\\\":\\\"assistant\\\",\\\"content\\\":[{\\\"type\\\":\\\"output_text\\\",\\\"text\\\":\\\"Hello from remote runtime sync json\\\",\\\"annotations\\\":[]}]}],\\\"usage\\\":{\\\"input_tokens\\\":1,\\\"output_tokens\\\":2,\\\"total_tokens\\\":3}}\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":41}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
let mut response = axum::http::Response::new(Body::from(frames));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-ndjson"),
);
response
}),
);
axum::serve(listener, app)
.await
.expect("server should start");
});
let state = AppState::new()
.expect("app state should build")
.with_execution_runtime_override_base_url(format!("http://{addr}"));
let plan = ExecutionPlan {
request_id: "req-remote-runtime-sync-json-stream".into(),
candidate_id: Some("cand-remote-runtime-sync-json-stream".into()),
provider_name: Some("openai".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: "https://chatgpt.com/backend-api/codex/responses".into(),
headers: BTreeMap::from([
("content-type".into(), "application/json".into()),
("accept".into(), "text/event-stream".into()),
]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gpt-5.4",
"input": "hello",
"stream": true
})),
stream: true,
client_api_format: "openai:cli".into(),
provider_api_format: "openai:cli".into(),
model_name: Some("gpt-5.4".into()),
proxy: None,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
..ExecutionTimeouts::default()
}),
};
let decision = GatewayControlDecision::synthetic(
"/v1/responses",
Some("ai_public".to_string()),
Some("openai".to_string()),
Some("cli".to_string()),
Some("openai:cli".to_string()),
)
.with_execution_runtime_candidate(true);
let response = execute_execution_runtime_stream(
&state,
plan,
"trace-remote-runtime-sync-json-stream",
&decision,
"openai_cli_stream",
None,
Some(json!({
"provider_api_format": "openai:cli",
"client_api_format": "openai:cli",
})),
)
.await
.expect("execution should succeed")
.expect("execution should return a client response");
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body should read");
let text = String::from_utf8(body.to_vec()).expect("response body should be utf8");
assert!(text.contains("event: response.output_text.delta"));
assert!(text.contains("Hello from remote runtime sync json"));
assert!(text.contains("event: response.completed"));
server.abort();
}
#[tokio::test]
async fn execute_execution_runtime_stream_returns_client_error_with_local_tunnel_message_before_first_data(
) {

View File

@@ -1,4 +1,6 @@
use std::collections::BTreeMap;
use std::io::Error as IoError;
use std::time::Instant;
use aether_contracts::{
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionStreamTerminalSummary,
@@ -12,12 +14,13 @@ use serde_json::Value;
use tracing::warn;
use crate::ai_pipeline_api::{
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
StreamingStandardTerminalObserver,
maybe_bridge_standard_sync_json_to_stream, maybe_build_provider_private_stream_normalizer,
normalize_provider_private_report_context, StreamingStandardTerminalObserver,
};
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
use crate::execution_runtime::transport::DirectUpstreamResponse;
use crate::execution_runtime::DirectUpstreamStreamExecution;
use crate::GatewayError;
pub(crate) fn build_direct_execution_frame_stream(
execution: DirectUpstreamStreamExecution,
@@ -57,6 +60,113 @@ pub(crate) fn build_direct_execution_frame_stream(
let mut stream_terminal_observer = StreamingStandardTerminalObserver::default();
let mut observer_buffered = Vec::new();
if !response_headers_indicate_sse(&headers) {
let original_headers = headers.clone();
match buffer_non_sse_upstream_body(response, started_at).await {
Ok(buffered) => {
let mut response_headers = original_headers;
let mut response_body = Bytes::from(buffered.body_bytes);
let mut summary = None;
match maybe_bridge_non_sse_sync_json_to_stream(
status_code,
&response_headers,
response_body.as_ref(),
provider_api_format.as_str(),
&observer_context,
) {
Ok(Some(outcome)) => {
response_headers = rewrite_headers_for_bridged_sse_response(
&response_headers,
outcome.sse_body.len(),
);
response_body = Bytes::from(outcome.sse_body);
summary = outcome.terminal_summary;
}
Ok(None) => {}
Err(err) => {
yield Err(IoError::other(format!("{err:?}")));
return;
}
}
match encode_headers_frame(status_code, response_headers) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
if !response_body.is_empty() {
match encode_telemetry_frame(buffered.ttfb_ms, buffered.ttfb_ms, 0) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
match encode_data_frame(&response_body) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
}
match encode_telemetry_frame(
buffered.ttfb_ms,
Some(started_at.elapsed().as_millis() as u64),
buffered.upstream_bytes,
) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
match encode_stream_frame_ndjson(&StreamFrame::eof_with_summary(summary)) {
Ok(frame) => yield Ok(frame),
Err(err) => yield Err(err),
}
}
Err(BufferedUpstreamBodyError {
message,
ttfb_ms,
upstream_bytes,
}) => {
match encode_headers_frame(status_code, original_headers) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
match encode_error_frame(status_code, message) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
match encode_telemetry_frame(
ttfb_ms,
Some(started_at.elapsed().as_millis() as u64),
upstream_bytes,
) {
Ok(frame) => yield Ok(frame),
Err(err) => {
yield Err(err);
return;
}
}
match encode_stream_frame_ndjson(&StreamFrame::eof_with_summary(None)) {
Ok(frame) => yield Ok(frame),
Err(err) => yield Err(err),
}
}
}
return;
}
match encode_headers_frame(status_code, headers) {
Ok(frame) => yield Ok(frame),
Err(err) => {
@@ -206,7 +316,7 @@ pub(crate) fn build_direct_execution_frame_stream(
fn encode_headers_frame(
status_code: u16,
headers: std::collections::BTreeMap<String, String>,
headers: BTreeMap<String, String>,
) -> Result<Bytes, IoError> {
encode_stream_frame_ndjson(&StreamFrame {
frame_type: StreamFrameType::Headers,
@@ -260,6 +370,178 @@ fn encode_error_frame(status_code: u16, message: String) -> Result<Bytes, IoErro
})
}
struct BufferedUpstreamBody {
body_bytes: Vec<u8>,
ttfb_ms: Option<u64>,
upstream_bytes: u64,
}
struct BufferedUpstreamBodyError {
message: String,
ttfb_ms: Option<u64>,
upstream_bytes: u64,
}
fn response_headers_indicate_sse(headers: &BTreeMap<String, String>) -> bool {
headers
.get("content-type")
.is_some_and(|value| value.to_ascii_lowercase().contains("text/event-stream"))
}
async fn buffer_non_sse_upstream_body(
response: DirectUpstreamResponse,
started_at: Instant,
) -> Result<BufferedUpstreamBody, BufferedUpstreamBodyError> {
let mut body_bytes = Vec::new();
let mut upstream_bytes = 0u64;
let mut ttfb_ms = None;
match response {
DirectUpstreamResponse::Reqwest(response) => {
let mut bytes_stream = response.bytes_stream();
while let Some(item) = bytes_stream.next().await {
match item {
Ok(chunk) => {
if ttfb_ms.is_none() {
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
}
upstream_bytes += chunk.len() as u64;
body_bytes.extend_from_slice(&chunk);
}
Err(err) => {
let message = format_error_chain(&err);
warn!(
event_name = "stream_pump_body_read_error",
log_type = "ops",
upstream_bytes,
error = %message,
"upstream body stream read error"
);
return Err(BufferedUpstreamBodyError {
message,
ttfb_ms,
upstream_bytes,
});
}
}
}
}
DirectUpstreamResponse::LocalTunnel(mut response) => loop {
match response.next_chunk().await {
Ok(Some(chunk)) => {
if ttfb_ms.is_none() {
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
}
upstream_bytes += chunk.len() as u64;
body_bytes.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(message) => {
warn!(
event_name = "stream_pump_body_read_error",
log_type = "ops",
upstream_bytes,
error = %message,
"upstream body stream read error"
);
return Err(BufferedUpstreamBodyError {
message,
ttfb_ms,
upstream_bytes,
});
}
}
},
}
Ok(BufferedUpstreamBody {
body_bytes,
ttfb_ms,
upstream_bytes,
})
}
fn maybe_bridge_non_sse_sync_json_to_stream(
status_code: u16,
headers: &BTreeMap<String, String>,
body_bytes: &[u8],
provider_api_format: &str,
report_context: &Value,
) -> Result<Option<crate::ai_pipeline::SyncToStreamBridgeOutcome>, GatewayError> {
if !(200..300).contains(&status_code) || body_bytes.is_empty() {
return Ok(None);
}
let decoded_body_bytes = decode_non_sse_response_body_bytes(headers, body_bytes)
.unwrap_or_else(|| body_bytes.to_vec());
if !response_body_is_json(headers, &decoded_body_bytes) {
return Ok(None);
}
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or(provider_api_format);
maybe_bridge_standard_sync_json_to_stream(
&body_json,
provider_api_format,
client_api_format,
Some(report_context),
)
}
fn rewrite_headers_for_bridged_sse_response(
headers: &BTreeMap<String, String>,
body_len: usize,
) -> BTreeMap<String, String> {
let mut rewritten = headers.clone();
rewritten.remove("content-encoding");
rewritten.insert("content-type".to_string(), "text/event-stream".to_string());
rewritten.insert("content-length".to_string(), body_len.to_string());
rewritten
}
fn decode_non_sse_response_body_bytes(
headers: &BTreeMap<String, String>,
body_bytes: &[u8],
) -> Option<Vec<u8>> {
let encoding = headers
.get("content-encoding")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
match encoding.as_deref() {
Some("gzip") => {
let mut decoder = flate2::read::GzDecoder::new(body_bytes);
let mut out = Vec::new();
std::io::Read::read_to_end(&mut decoder, &mut out).ok()?;
Some(out)
}
Some("deflate") => {
let mut decoder = flate2::read::DeflateDecoder::new(body_bytes);
let mut out = Vec::new();
std::io::Read::read_to_end(&mut decoder, &mut out).ok()?;
Some(out)
}
_ => None,
}
}
fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
if headers
.get("content-type")
.map(|value| value.to_ascii_lowercase())
.is_some_and(|value| value.contains("json"))
{
return true;
}
serde_json::from_slice::<Value>(body_bytes).is_ok()
}
fn format_error_chain(err: &(dyn std::error::Error + 'static)) -> String {
let mut message = err.to_string();
let mut source = err.source();
@@ -361,6 +643,7 @@ mod tests {
use axum::extract::ws::Message;
use axum::routing::post;
use axum::{http::header, http::HeaderValue, Router};
use base64::Engine as _;
use futures_util::StreamExt;
use serde_json::Value;
use tokio::sync::watch;
@@ -568,6 +851,142 @@ mod tests {
);
}
#[tokio::test]
async fn direct_execution_frame_stream_bridges_sync_json_body_to_sse_for_standard_stream_request(
) {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
let app = Router::new().route(
"/responses",
post(|| async {
let body = serde_json::json!({
"id": "resp_sync_bridge_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_sync_bridge_123",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "Hello from buffered JSON stream",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
});
let mut response = axum::http::Response::new(Body::from(
serde_json::to_vec(&body).expect("json should encode"),
));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response
}),
);
axum::serve(listener, app)
.await
.expect("server should start");
});
let runtime = DirectSyncExecutionRuntime::new();
let execution = runtime
.execute_stream(&ExecutionPlan {
request_id: "req-sync-bridge".to_string(),
candidate_id: Some("cand-sync-bridge".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: format!("http://{addr}/responses"),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody::from_json(serde_json::json!({
"model": "gpt-5.4",
"input": "hello",
"stream": true
})),
stream: true,
client_api_format: "openai:cli".to_string(),
provider_api_format: "openai:cli".to_string(),
model_name: Some("gpt-5.4".into()),
proxy: None,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
..ExecutionTimeouts::default()
}),
})
.await
.expect("stream execution should succeed");
let frames = build_direct_execution_frame_stream(execution)
.map(|item| item.expect("frame should encode"))
.collect::<Vec<_>>()
.await
.into_iter()
.map(|bytes| String::from_utf8(bytes.to_vec()).expect("frame should be utf8"))
.collect::<Vec<_>>();
server.abort();
let header_frame: Value =
serde_json::from_str(&frames[0]).expect("headers frame should parse");
assert_eq!(
header_frame
.get("payload")
.and_then(|payload| payload.get("headers"))
.and_then(|headers| headers.get("content-type"))
.and_then(Value::as_str),
Some("text/event-stream")
);
let data_frame = frames
.iter()
.map(|line| serde_json::from_str::<Value>(line).expect("frame should parse"))
.find(|frame| frame.get("type").and_then(Value::as_str) == Some("data"))
.expect("data frame should exist");
let bridged_body = base64::engine::general_purpose::STANDARD
.decode(
data_frame
.get("payload")
.and_then(|payload| payload.get("chunk_b64"))
.and_then(Value::as_str)
.expect("chunk_b64 should exist"),
)
.expect("data frame should decode");
let bridged_text = String::from_utf8(bridged_body).expect("bridged body should be utf8");
assert!(bridged_text.contains("event: response.output_text.delta"));
assert!(bridged_text.contains("\"delta\":\"Hello from buffered JSON stream\""));
assert!(bridged_text.contains("event: response.completed"));
let eof_frame = frames
.iter()
.map(|line| serde_json::from_str::<Value>(line).expect("frame should parse"))
.find(|frame| frame.get("type").and_then(Value::as_str) == Some("eof"))
.expect("eof frame should exist");
assert_eq!(
eof_frame
.get("payload")
.and_then(|payload| payload.get("summary"))
.and_then(|summary| summary.get("response_id"))
.and_then(Value::as_str),
Some("resp_sync_bridge_123")
);
}
#[tokio::test]
async fn direct_execution_frame_stream_preserves_local_tunnel_stream_error_message() {
let state = AppState::new().expect("app state should build");

View File

@@ -327,6 +327,7 @@ pub(crate) async fn execute_stream_plan_via_local_tunnel(
fn build_stream_summary_report_context(plan: &ExecutionPlan) -> Value {
json!({
"provider_api_format": plan.provider_api_format,
"client_api_format": plan.client_api_format,
"model": plan.model_name,
})
}