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
@@ -9,9 +9,14 @@ use crate::{usage::GatewaySyncReportRequest, GatewayError};
pub(crate) mod stream;
#[path = "sync_finalize.rs"]
pub(crate) mod sync;
#[path = "sync_to_stream.rs"]
pub(crate) mod sync_to_stream;
pub(crate) use stream::LocalStreamRewriter;
pub(crate) use sync::LocalCoreSyncFinalizeOutcome;
pub(crate) use sync_to_stream::{
maybe_bridge_standard_sync_json_to_stream, SyncToStreamBridgeOutcome,
};
pub(crate) fn maybe_build_sync_finalize_outcome(
trace_id: &str,
@@ -0,0 +1,335 @@
use aether_contracts::{ExecutionStreamTerminalSummary, StandardizedUsage};
use serde_json::{json, Value};
use crate::ai_pipeline::{
convert_claude_cli_response_to_openai_cli, convert_gemini_cli_response_to_openai_cli,
convert_openai_chat_response_to_openai_cli, ClaudeClientEmitter, GeminiClientEmitter,
OpenAIChatClientEmitter, OpenAICliClientEmitter, OpenAICliProviderState,
};
use crate::GatewayError;
pub(crate) struct SyncToStreamBridgeOutcome {
pub(crate) sse_body: Vec<u8>,
pub(crate) terminal_summary: Option<ExecutionStreamTerminalSummary>,
}
pub(crate) fn maybe_bridge_standard_sync_json_to_stream(
provider_body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: Option<&Value>,
) -> Result<Option<SyncToStreamBridgeOutcome>, GatewayError> {
let provider_api_format = normalize_api_format(provider_api_format);
let client_api_format = normalize_api_format(client_api_format);
if !is_standard_api_format(provider_api_format.as_str())
|| !is_standard_api_format(client_api_format.as_str())
{
return Ok(None);
}
let bridge_context = build_bridge_report_context(
report_context,
provider_api_format.as_str(),
client_api_format.as_str(),
);
let Some(openai_cli_response) = convert_provider_sync_response_to_openai_cli(
provider_body_json,
provider_api_format.as_str(),
&bridge_context,
) else {
return Ok(None);
};
let terminal_summary = build_terminal_summary_from_openai_cli_response(&openai_cli_response);
let canonical_frames =
build_canonical_frames_from_openai_cli_response(&openai_cli_response, &bridge_context)?;
let sse_body =
emit_client_stream_from_canonical_frames(canonical_frames, client_api_format.as_str())?;
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary,
}))
}
fn normalize_api_format(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn is_standard_api_format(value: &str) -> bool {
matches!(
value,
"openai:chat"
| "openai:cli"
| "openai:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
fn build_bridge_report_context(
report_context: Option<&Value>,
provider_api_format: &str,
client_api_format: &str,
) -> Value {
let mut context = report_context
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
let object = context
.as_object_mut()
.expect("bridge report context should stay object");
object
.entry("provider_api_format".to_string())
.or_insert_with(|| Value::String(provider_api_format.to_string()));
object
.entry("client_api_format".to_string())
.or_insert_with(|| Value::String(client_api_format.to_string()));
context
}
fn convert_provider_sync_response_to_openai_cli(
provider_body_json: &Value,
provider_api_format: &str,
report_context: &Value,
) -> Option<Value> {
match provider_api_format {
"openai:cli" | "openai:compact" => Some(provider_body_json.clone()),
"openai:chat" => {
convert_openai_chat_response_to_openai_cli(provider_body_json, report_context, false)
}
"claude:chat" | "claude:cli" => {
convert_claude_cli_response_to_openai_cli(provider_body_json, report_context)
}
"gemini:chat" | "gemini:cli" => {
convert_gemini_cli_response_to_openai_cli(provider_body_json, report_context)
}
_ => None,
}
}
fn build_canonical_frames_from_openai_cli_response(
openai_cli_response: &Value,
report_context: &Value,
) -> Result<Vec<crate::ai_pipeline::CanonicalStreamFrame>, GatewayError> {
let mut state = OpenAICliProviderState::default();
let line = format!(
"data: {}\n",
serde_json::to_string(&json!({
"type": "response.completed",
"response": openai_cli_response,
}))
.map_err(|err| GatewayError::Internal(err.to_string()))?
);
let mut frames = state
.push_line(report_context, line.into_bytes())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
frames.extend(
state
.finish(report_context)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
Ok(frames)
}
fn emit_client_stream_from_canonical_frames(
canonical_frames: Vec<crate::ai_pipeline::CanonicalStreamFrame>,
client_api_format: &str,
) -> Result<Vec<u8>, GatewayError> {
match client_api_format {
"openai:chat" => {
let mut emitter = OpenAIChatClientEmitter::default();
emit_with_openai_chat_emitter(&mut emitter, canonical_frames)
}
"openai:cli" | "openai:compact" => {
let mut emitter = OpenAICliClientEmitter::default();
emit_with_openai_cli_emitter(&mut emitter, canonical_frames)
}
"claude:chat" | "claude:cli" => {
let mut emitter = ClaudeClientEmitter::default();
emit_with_claude_emitter(&mut emitter, canonical_frames)
}
"gemini:chat" | "gemini:cli" => {
let mut emitter = GeminiClientEmitter::default();
emit_with_gemini_emitter(&mut emitter, canonical_frames)
}
_ => Ok(Vec::new()),
}
}
fn emit_with_openai_chat_emitter(
emitter: &mut OpenAIChatClientEmitter,
canonical_frames: Vec<crate::ai_pipeline::CanonicalStreamFrame>,
) -> Result<Vec<u8>, GatewayError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
Ok(output)
}
fn emit_with_openai_cli_emitter(
emitter: &mut OpenAICliClientEmitter,
canonical_frames: Vec<crate::ai_pipeline::CanonicalStreamFrame>,
) -> Result<Vec<u8>, GatewayError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
Ok(output)
}
fn emit_with_claude_emitter(
emitter: &mut ClaudeClientEmitter,
canonical_frames: Vec<crate::ai_pipeline::CanonicalStreamFrame>,
) -> Result<Vec<u8>, GatewayError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
Ok(output)
}
fn emit_with_gemini_emitter(
emitter: &mut GeminiClientEmitter,
canonical_frames: Vec<crate::ai_pipeline::CanonicalStreamFrame>,
) -> Result<Vec<u8>, GatewayError> {
let mut output = Vec::new();
for frame in canonical_frames {
output.extend(
emitter
.emit(frame)
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
}
output.extend(
emitter
.finish()
.map_err(|err| GatewayError::Internal(err.to_string()))?,
);
Ok(output)
}
fn build_terminal_summary_from_openai_cli_response(
openai_cli_response: &Value,
) -> Option<ExecutionStreamTerminalSummary> {
let response = openai_cli_response.as_object()?;
let response_id = response
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let model = response
.get("model")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let finish_reason = response
.get("output")
.and_then(Value::as_array)
.map(|output| resolve_openai_cli_finish_reason(output))
.filter(|value| !value.trim().is_empty());
let standardized_usage = response
.get("usage")
.and_then(standardized_usage_from_openai_usage);
Some(ExecutionStreamTerminalSummary {
standardized_usage,
finish_reason,
response_id,
model,
observed_finish: true,
parser_error: None,
})
}
fn resolve_openai_cli_finish_reason(output: &[Value]) -> String {
let has_tool_calls = output.iter().filter_map(Value::as_object).any(|item| {
item.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "function_call")
});
if has_tool_calls {
"tool_calls".to_string()
} else {
"stop".to_string()
}
}
fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsage> {
let usage = value.as_object()?;
let input_tokens = usage
.get("input_tokens")
.or_else(|| usage.get("prompt_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let output_tokens = usage
.get("output_tokens")
.or_else(|| usage.get("completion_tokens"))
.and_then(Value::as_i64)
.unwrap_or(0);
let cache_creation_tokens = usage
.get("cache_creation_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_creation_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(Value::as_i64)
.or_else(|| {
usage
.get("input_tokens_details")
.or_else(|| usage.get("prompt_tokens_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("cached_tokens"))
.and_then(Value::as_i64)
})
.unwrap_or(0);
let total_tokens = usage.get("total_tokens").and_then(Value::as_i64).unwrap_or(
input_tokens
.saturating_add(output_tokens)
.saturating_add(cache_creation_tokens)
.saturating_add(cache_read_tokens),
);
let mut standardized_usage = StandardizedUsage::new();
standardized_usage.input_tokens = input_tokens;
standardized_usage.output_tokens = output_tokens;
standardized_usage.cache_creation_tokens = cache_creation_tokens;
standardized_usage.cache_read_tokens = cache_read_tokens;
standardized_usage
.dimensions
.insert("total_tokens".to_string(), json!(total_tokens));
Some(standardized_usage.normalize_cache_creation_breakdown())
}
@@ -1,11 +1,112 @@
use serde_json::json;
use crate::ai_pipeline::maybe_bridge_standard_sync_json_to_stream;
use super::maybe_build_local_stream_rewriter;
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
}
#[test]
fn standard_sync_bridge_converts_openai_chat_sync_json_to_openai_chat_sse() {
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"id": "chatcmpl_sync_123",
"object": "chat.completion",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello from sync bridge"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3
}
}),
"openai:chat",
"openai:chat",
None,
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let output_text = utf8(outcome.sse_body);
assert!(output_text.contains("\"object\":\"chat.completion.chunk\""));
assert!(output_text.contains("\"role\":\"assistant\""));
assert!(output_text.contains("\"content\":\"Hello from sync bridge\""));
assert!(output_text.contains("\"finish_reason\":\"stop\""));
assert!(output_text.contains("data: [DONE]"));
let summary = outcome
.terminal_summary
.expect("terminal summary should exist");
assert_eq!(summary.response_id.as_deref(), Some("resp_sync_123"));
assert_eq!(summary.model.as_deref(), Some("gpt-5.4"));
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
assert_eq!(
summary
.standardized_usage
.as_ref()
.and_then(|usage| usage.dimensions.get("total_tokens"))
.cloned(),
Some(json!(3))
);
}
#[test]
fn standard_sync_bridge_converts_claude_sync_json_to_openai_cli_sse() {
let report_context = json!({
"mapped_model": "claude-sonnet-4-5",
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"id": "msg_sync_123",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{
"type": "text",
"text": "Hello from Claude sync"
}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 2,
"output_tokens": 3
}
}),
"claude:chat",
"openai:cli",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let output_text = utf8(outcome.sse_body);
assert!(output_text.contains("event: response.created"));
assert!(output_text.contains("event: response.output_text.delta"));
assert!(output_text.contains("event: response.completed"));
assert!(output_text.contains("\"text\":\"Hello from Claude sync\""));
let summary = outcome
.terminal_summary
.expect("terminal summary should exist");
assert_eq!(summary.response_id.as_deref(), Some("msg_sync_123"));
assert_eq!(summary.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
assert_eq!(
summary
.standardized_usage
.as_ref()
.and_then(|usage| usage.dimensions.get("total_tokens"))
.cloned(),
Some(json!(5))
);
}
#[test]
fn antigravity_stream_rewriter_unwraps_and_injects_tool_ids() {
let report_context = json!({
@@ -178,7 +178,57 @@ fn aggregates_openai_cli_stream_completed_event_to_final_response() {
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": [],
"output": [{
"type": "message",
"id": "resp_123_msg",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3,
},
})
);
}
#[test]
fn aggregates_openai_cli_stream_tool_call_events_to_final_response() {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_tool_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"id\":\"call_123\",\"call_id\":\"call_123\",\"name\":\"get_weather\",\"arguments\":\"\",\"status\":\"in_progress\"}}\n\n",
"event: response.function_call_arguments.delta\n",
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":1,\"item_id\":\"call_123\",\"call_id\":\"call_123\",\"delta\":\"{\\\"city\\\":\\\"SF\\\"}\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_tool_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let result =
aggregate_openai_cli_stream_sync_response(body.as_bytes()).expect("result should exist");
assert_eq!(
result,
json!({
"id": "resp_tool_123",
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "function_call",
"id": "call_123",
"call_id": "call_123",
"name": "get_weather",
"arguments": "{\"city\":\"SF\"}",
"status": "in_progress",
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
@@ -1328,6 +1378,56 @@ fn local_finalize_handles_openai_compact_openai_family_stream_response_even_when
assert_eq!(provider_body["status"], "completed");
}
#[test]
fn local_finalize_preserves_provider_stream_body_for_same_format_stream_aggregated_to_sync() {
let body = concat!(
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hello sync\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cli_samefmt_stream_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_123\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello sync\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-cli-samefmt-stream-123".to_string(),
report_kind: "openai_cli_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:cli",
"provider_api_format": "openai:cli",
"model": "gpt-5.4",
"mapped_model": "gpt-5.4",
"needs_conversion": false,
"has_envelope": false,
"upstream_is_stream": true,
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes())),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-cli-samefmt-stream-123",
&test_decision(),
&payload,
)
.expect("local finalize should succeed")
.expect("local finalize should match");
let report = outcome
.background_report
.expect("same-format stream finalize should downgrade to success report");
let client_body = report.client_body_json.expect("client body should exist");
assert!(report.body_json.is_none());
assert_eq!(
report.body_base64,
Some(base64::engine::general_purpose::STANDARD.encode(body.as_bytes()))
);
assert_eq!(client_body["object"], "response");
assert_eq!(client_body["status"], "completed");
assert_eq!(client_body["output"][0]["content"][0]["text"], "Hello sync");
}
#[test]
fn local_finalize_handles_openai_chat_cross_format_sync_response_from_claude() {
let payload = GatewaySyncReportRequest {
+3 -2
View File
@@ -19,8 +19,9 @@ pub(crate) use self::adaptation::{
};
pub(crate) use self::finalize::common::LocalCoreSyncFinalizeOutcome;
pub(crate) use self::finalize::internal::{
maybe_build_stream_response_rewriter, maybe_build_sync_finalize_outcome,
maybe_compile_sync_finalize_response,
maybe_bridge_standard_sync_json_to_stream, maybe_build_stream_response_rewriter,
maybe_build_sync_finalize_outcome, maybe_compile_sync_finalize_response,
SyncToStreamBridgeOutcome,
};
pub(crate) use self::planner::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
@@ -101,7 +101,11 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
original_headers: &parts.headers,
original_request_body_json: Some(body_json),
original_request_body_base64: None,
client_requested_stream: spec_metadata.require_streaming,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream,
has_envelope: resolved.is_kiro || resolved.is_antigravity,
needs_conversion: false,
extra_fields,
@@ -27,6 +27,7 @@ pub(crate) struct LocalExecutionReportContextParts<'a> {
pub(crate) original_request_body_json: Option<&'a Value>,
pub(crate) original_request_body_base64: Option<&'a str>,
pub(crate) client_requested_stream: bool,
pub(crate) upstream_is_stream: bool,
pub(crate) has_envelope: bool,
pub(crate) needs_conversion: bool,
pub(crate) extra_fields: Map<String, Value>,
@@ -122,6 +123,10 @@ pub(crate) fn build_local_execution_report_context(
"client_requested_stream".to_string(),
Value::Bool(parts.client_requested_stream),
);
object.insert(
"upstream_is_stream".to_string(),
Value::Bool(parts.upstream_is_stream),
);
object.insert("has_envelope".to_string(), Value::Bool(parts.has_envelope));
object.insert(
"needs_conversion".to_string(),
@@ -87,6 +87,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
original_request_body_json: Some(body_json),
original_request_body_base64: resolved.provider_request_body_base64.as_deref(),
client_requested_stream: spec_metadata.require_streaming,
upstream_is_stream: spec_metadata.require_streaming,
has_envelope: false,
needs_conversion: false,
extra_fields,
@@ -79,6 +79,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
original_request_body_json: Some(body_json),
original_request_body_base64: body_base64,
client_requested_stream: spec_metadata.require_streaming,
upstream_is_stream: spec_metadata.require_streaming,
has_envelope: false,
needs_conversion: false,
extra_fields,
@@ -70,6 +70,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
original_request_body_json: Some(body_json),
original_request_body_base64: None,
client_requested_stream: false,
upstream_is_stream: false,
has_envelope: false,
needs_conversion: false,
extra_fields,
@@ -75,7 +75,11 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
original_headers: &parts.headers,
original_request_body_json: Some(body_json),
original_request_body_base64: None,
client_requested_stream: spec_metadata.require_streaming,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream,
has_envelope: false,
needs_conversion: true,
extra_fields,
@@ -92,7 +92,11 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
original_headers: &parts.headers,
original_request_body_json: Some(body_json),
original_request_body_base64: None,
client_requested_stream: upstream_is_stream,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
upstream_is_stream,
has_envelope: false,
needs_conversion: matches!(
resolved.conversion_mode,
@@ -96,7 +96,11 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
original_headers: &parts.headers,
original_request_body_json: Some(body_json),
original_request_body_base64: None,
client_requested_stream: spec_metadata.require_streaming,
client_requested_stream: body_json
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream,
has_envelope: resolved.is_antigravity,
needs_conversion: matches!(
resolved.conversion_mode,