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

@@ -395,7 +395,17 @@ async fn gateway_executes_openai_compact_openai_family_upstream_stream_via_local
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": [],
"output": [{
"type": "message",
"id": "resp_compact_openai_family_123_msg",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello Compact",
"annotations": []
}]
}],
"usage": {
"input_tokens": 2,
"output_tokens": 3,

View File

@@ -387,7 +387,17 @@ async fn gateway_executes_openai_cli_sync_upstream_stream_via_local_finalize_res
"object": "response",
"model": "gpt-5-upstream",
"status": "completed",
"output": [],
"output": [{
"type": "message",
"id": "resp_stream_001_msg",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,

View File

@@ -23,6 +23,7 @@ use aether_data_contracts::repository::candidates::{
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use base64::Engine as _;
use sha2::{Digest, Sha256};
#[tokio::test]
@@ -3380,21 +3381,19 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
"request_id": "trace-codex-cli-local-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
"content-type": "text/event-stream"
},
"body": {
"json_body": {
"id": "resp-codex-local-123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
}
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-codex-local-123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hello from Codex\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-codex-local-123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"
)
)
},
"telemetry": {
"elapsed_ms": 37
@@ -3459,6 +3458,10 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(response_json["id"], "resp-codex-local-123");
assert_eq!(
response_json["output"][0]["content"][0]["text"],
"Hello from Codex"
);
let seen_refresh_request = seen_refresh
.lock()

View File

@@ -57,6 +57,37 @@ fn sample_auth_snapshot(
.expect("auth snapshot should build")
}
fn sample_cli_auth_snapshot(
api_key_id: &str,
user_id: &str,
allowed_model: &str,
) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!([allowed_model])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:cli"])),
Some(serde_json::json!([allowed_model])),
)
.expect("auth snapshot should build")
}
fn sample_provider(provider_id: &str) -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
provider_id.to_string(),
@@ -91,6 +122,29 @@ fn sample_endpoint(endpoint_id: &str, provider_id: &str) -> StoredProviderCatalo
.expect("endpoint transport should build")
}
fn sample_codex_endpoint(endpoint_id: &str, provider_id: &str) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
endpoint_id.to_string(),
provider_id.to_string(),
"openai:cli".to_string(),
Some("openai".to_string()),
Some("cli".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://chatgpt.com/backend-api/codex".to_string(),
None,
None,
None,
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_key(key_id: &str, provider_id: &str, node_id: &str) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
key_id.to_string(),
@@ -119,6 +173,34 @@ fn sample_key(key_id: &str, provider_id: &str, node_id: &str) -> StoredProviderC
.expect("key transport should build")
}
fn sample_codex_key(key_id: &str, provider_id: &str, node_id: &str) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
key_id.to_string(),
provider_id.to_string(),
"default".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:cli"])),
"plain-upstream-key".to_string(),
None,
None,
Some(json!({"openai:cli": 1})),
None,
None,
Some(json!({
"enabled": true,
"mode": "tunnel",
"node_id": node_id,
})),
None,
)
.expect("key transport should build")
}
fn tunnel_attachment_key(node_id: &str) -> String {
format!("tunnel.attachments.{node_id}")
}
@@ -536,3 +618,471 @@ async fn gateway_forwards_public_request_to_remote_tunnel_owner_before_fallback_
owner_handle.abort();
fallback_probe_handle.abort();
}
#[tokio::test]
async fn gateway_aggregates_sync_sse_from_remote_tunnel_owner_before_returning_to_client() {
#[derive(Debug, Clone)]
struct SeenOwnerRequest {
path: String,
body: String,
trace_id: String,
gateway_marker: String,
trusted_user_id: String,
trusted_api_key_id: String,
trusted_access_allowed: String,
forwarded_by: String,
owner_instance_id: String,
}
let seen_owner = Arc::new(Mutex::new(None::<SeenOwnerRequest>));
let seen_owner_clone = Arc::clone(&seen_owner);
let owner = Router::new().route(
"/v1/responses",
any(move |request: Request| {
let seen_owner_inner = Arc::clone(&seen_owner_clone);
async move {
let (parts, body) = request.into_parts();
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
*seen_owner_inner.lock().expect("mutex should lock") = Some(SeenOwnerRequest {
path: parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/")
.to_string(),
body: String::from_utf8(raw_body.to_vec()).expect("utf-8 body"),
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
gateway_marker: parts
.headers
.get(GATEWAY_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_user_id: parts
.headers
.get(TRUSTED_AUTH_USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_api_key_id: parts
.headers
.get(TRUSTED_AUTH_API_KEY_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_access_allowed: parts
.headers
.get(TRUSTED_AUTH_ACCESS_ALLOWED_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
forwarded_by: parts
.headers
.get(TUNNEL_AFFINITY_FORWARDED_BY_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
owner_instance_id: parts
.headers
.get(TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
});
let mut response = Response::builder()
.status(StatusCode::OK)
.body(Body::from(concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-codex-affinity-123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hello from Codex\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-codex-affinity-123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n"
)))
.expect("response should build");
response.headers_mut().insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("text/event-stream"),
);
response.headers_mut().insert(
http::header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
response.headers_mut().insert(
http::header::HeaderName::from_static(GATEWAY_HEADER),
HeaderValue::from_static("gateway-b-owner"),
);
response
}
}),
);
let (owner_url, owner_handle) = start_server(owner).await;
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-cli-affinity")),
sample_cli_auth_snapshot("api-key-affinity-cli-1", "user-affinity-cli-1", "gpt-5.4"),
)]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-cli-owner")],
vec![sample_codex_endpoint(
"endpoint-cli-owner",
"provider-cli-owner",
)],
vec![sample_codex_key(
"key-cli-owner",
"provider-cli-owner",
"node-cli-owner",
)],
));
let observed_at_unix_secs = current_unix_secs();
let data_state = crate::data::GatewayDataState::with_provider_transport_reader_for_tests(
provider_catalog_repository,
"development-key",
)
.with_auth_api_key_reader(auth_repository)
.with_system_config_values_for_tests(vec![(
tunnel_attachment_key("node-cli-owner"),
serde_json::to_value(crate::tunnel::TunnelAttachmentRecord {
gateway_instance_id: "gateway-b".to_string(),
relay_base_url: owner_url.clone(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("attachment should serialize"),
)]);
let mut state = AppState::new().expect("gateway state should build");
state = state
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
state.scheduler_affinity_cache.insert(
"scheduler_affinity:api-key-affinity-cli-1:openai:cli:gpt-5.4".to_string(),
crate::cache::SchedulerAffinityTarget {
provider_id: "provider-cli-owner".to_string(),
endpoint_id: "endpoint-cli-owner".to_string(),
key_id: "key-cli-owner".to_string(),
},
Duration::from_secs(300),
100,
);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/responses"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-cli-affinity",
)
.header(TRACE_ID_HEADER, "trace-tunnel-affinity-cli-sync-1")
.json(&json!({
"model": "gpt-5.4",
"input": "hello",
"stream": false
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(GATEWAY_HEADER)
.and_then(|value| value.to_str().ok()),
Some("gateway-b-owner")
);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some("tunnel_affinity_forward")
);
assert_eq!(
response
.headers()
.get(TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER)
.and_then(|value| value.to_str().ok()),
Some("gateway-b")
);
assert!(response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("application/json")));
let body: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(body["id"], "resp-codex-affinity-123");
assert_eq!(body["object"], "response");
assert_eq!(body["status"], "completed");
assert_eq!(body["output"][0]["content"][0]["text"], "Hello from Codex");
assert_eq!(body["usage"]["total_tokens"], 3);
let owner_request = seen_owner
.lock()
.expect("mutex should lock")
.clone()
.expect("owner request should be captured");
assert_eq!(owner_request.path, "/v1/responses");
let owner_body: serde_json::Value =
serde_json::from_str(&owner_request.body).expect("owner body should parse");
assert_eq!(owner_body["model"], "gpt-5.4");
assert_eq!(owner_body["stream"], false);
assert_eq!(owner_request.trace_id, "trace-tunnel-affinity-cli-sync-1");
assert_eq!(owner_request.gateway_marker, "rust-phase3b-affinity");
assert_eq!(owner_request.trusted_user_id, "user-affinity-cli-1");
assert_eq!(owner_request.trusted_api_key_id, "api-key-affinity-cli-1");
assert_eq!(owner_request.trusted_access_allowed, "true");
assert_eq!(owner_request.forwarded_by, "gateway-a");
assert_eq!(owner_request.owner_instance_id, "gateway-b");
gateway_handle.abort();
owner_handle.abort();
}
#[tokio::test]
async fn gateway_streamifies_sync_json_from_remote_tunnel_owner_before_returning_to_client() {
#[derive(Debug, Clone)]
struct SeenOwnerRequest {
path: String,
body: String,
trace_id: String,
gateway_marker: String,
trusted_user_id: String,
trusted_api_key_id: String,
trusted_access_allowed: String,
forwarded_by: String,
owner_instance_id: String,
}
let seen_owner = Arc::new(Mutex::new(None::<SeenOwnerRequest>));
let seen_owner_clone = Arc::clone(&seen_owner);
let owner = Router::new().route(
"/v1/responses",
any(move |request: Request| {
let seen_owner_inner = Arc::clone(&seen_owner_clone);
async move {
let (parts, body) = request.into_parts();
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
*seen_owner_inner.lock().expect("mutex should lock") = Some(SeenOwnerRequest {
path: parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/")
.to_string(),
body: String::from_utf8(raw_body.to_vec()).expect("utf-8 body"),
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
gateway_marker: parts
.headers
.get(GATEWAY_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_user_id: parts
.headers
.get(TRUSTED_AUTH_USER_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_api_key_id: parts
.headers
.get(TRUSTED_AUTH_API_KEY_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
trusted_access_allowed: parts
.headers
.get(TRUSTED_AUTH_ACCESS_ALLOWED_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
forwarded_by: parts
.headers
.get(TUNNEL_AFFINITY_FORWARDED_BY_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
owner_instance_id: parts
.headers
.get(TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
});
let mut response = Response::builder()
.status(StatusCode::OK)
.body(Body::from(
serde_json::to_vec(&json!({
"id": "resp-codex-affinity-stream-123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg-codex-affinity-stream-123",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "Hello from affinity sync json",
"annotations": []
}]
}],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
}))
.expect("body should encode"),
))
.expect("response should build");
response.headers_mut().insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response.headers_mut().insert(
http::header::HeaderName::from_static(GATEWAY_HEADER),
HeaderValue::from_static("gateway-b-owner"),
);
response
}
}),
);
let (owner_url, owner_handle) = start_server(owner).await;
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-cli-affinity")),
sample_cli_auth_snapshot("api-key-affinity-cli-1", "user-affinity-cli-1", "gpt-5.4"),
)]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-cli-owner")],
vec![sample_codex_endpoint(
"endpoint-cli-owner",
"provider-cli-owner",
)],
vec![sample_codex_key(
"key-cli-owner",
"provider-cli-owner",
"node-cli-owner",
)],
));
let observed_at_unix_secs = current_unix_secs();
let data_state = crate::data::GatewayDataState::with_provider_transport_reader_for_tests(
provider_catalog_repository,
"development-key",
)
.with_auth_api_key_reader(auth_repository)
.with_system_config_values_for_tests(vec![(
tunnel_attachment_key("node-cli-owner"),
serde_json::to_value(crate::tunnel::TunnelAttachmentRecord {
gateway_instance_id: "gateway-b".to_string(),
relay_base_url: owner_url.clone(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("attachment should serialize"),
)]);
let mut state = AppState::new().expect("gateway state should build");
state = state
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
state.scheduler_affinity_cache.insert(
"scheduler_affinity:api-key-affinity-cli-1:openai:cli:gpt-5.4".to_string(),
crate::cache::SchedulerAffinityTarget {
provider_id: "provider-cli-owner".to_string(),
endpoint_id: "endpoint-cli-owner".to_string(),
key_id: "key-cli-owner".to_string(),
},
Duration::from_secs(300),
100,
);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/responses"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-cli-affinity",
)
.header(TRACE_ID_HEADER, "trace-tunnel-affinity-cli-stream-1")
.json(&json!({
"model": "gpt-5.4",
"input": "hello",
"stream": true
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(GATEWAY_HEADER)
.and_then(|value| value.to_str().ok()),
Some("gateway-b-owner")
);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some("tunnel_affinity_forward")
);
assert_eq!(
response
.headers()
.get(TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER)
.and_then(|value| value.to_str().ok()),
Some("gateway-b")
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let body = response.text().await.expect("body should read");
assert!(body.contains("event: response.output_text.delta"));
assert!(body.contains("Hello from affinity sync json"));
assert!(body.contains("event: response.completed"));
let owner_request = seen_owner
.lock()
.expect("mutex should lock")
.clone()
.expect("owner request should be captured");
assert_eq!(owner_request.path, "/v1/responses");
let owner_body: serde_json::Value =
serde_json::from_str(&owner_request.body).expect("owner body should parse");
assert_eq!(owner_body["model"], "gpt-5.4");
assert_eq!(owner_body["stream"], true);
assert_eq!(owner_request.trace_id, "trace-tunnel-affinity-cli-stream-1");
assert_eq!(owner_request.gateway_marker, "rust-phase3b-affinity");
assert_eq!(owner_request.trusted_user_id, "user-affinity-cli-1");
assert_eq!(owner_request.trusted_api_key_id, "api-key-affinity-cli-1");
assert_eq!(owner_request.trusted_access_allowed, "true");
assert_eq!(owner_request.forwarded_by, "gateway-a");
assert_eq!(owner_request.owner_instance_id, "gateway-b");
gateway_handle.abort();
owner_handle.abort();
}