mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user