diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 5aa5998ee..46e235f70 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -469,10 +469,11 @@ pub(super) fn decode_request_object( }); } let model = model.to_owned(); - let stream = object - .get("stream") - .and_then(Value::as_bool) - .unwrap_or(true); + // 缺省值只决定下游交付方式,不补写正文或改变 Provider 的上游流式执行。 + let stream = match object.get("stream") { + Some(value) => value.as_bool().unwrap_or(true), + None => matches!(source, RequestDecodeSource::WebSocketFrame), + }; let store = object .get("store") .and_then(Value::as_bool) diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 8853e3375..c2d3f14ad 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -523,6 +523,72 @@ async fn http_request_with_body( (response, observed) } +#[tokio::test] +async fn http_requests_should_honor_stream_preference() { + let admin = crate::admin::AdminTestFixture::new().await.services; + for stream in [None, Some(false), Some(true)] { + let trace = Arc::new(Trace::default()); + let streaming = stream == Some(true); + let session = if streaming { + FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), + NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)), + NextStep::FinalizeSuccess, + ], + ) + } else { + FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]) + }; + let execution = Arc::new(SessionExecution { + client: authenticated_client_for_provider("sk_stream_test", "openai"), + session: Mutex::new(Some(Box::new(session))), + middleware: None, + }); + let mut body = json!({"model": "model-a", "input": "hello"}); + if let Some(stream) = stream { + body["stream"] = json!(stream); + } + let response = crate::openai::api_router_with_admin_and_execution(admin.clone(), execution) + .oneshot( + Request::post("/v1/responses") + .header(AUTHORIZATION, "Bearer sk_stream_test") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK, "stream={stream:?}"); + assert_eq!( + response.headers()[CONTENT_TYPE], + if streaming { + "text/event-stream" + } else { + "application/json" + }, + "stream={stream:?}", + ); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + if streaming { + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("event: response.completed\n")); + assert!(body.ends_with("data: [DONE]\n\n")); + } else { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + json!({ + "id": "resp_test", "model": "public-model", "status": "completed", "output": [] + }), + ); + assert_eq!(trace.snapshot(), vec!["collect", "commit"]); + } + assert!(!trace.is_cancelled()); + } +} + #[tokio::test] async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() { let body = json!({ diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index b06202ebe..18f96b79b 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -119,6 +119,26 @@ fn decoder_should_preserve_the_openai_body_and_only_derive_stable_routing_facts( assert!(decoded.metadata().store()); } +#[test] +fn decoder_should_default_omitted_http_stream_to_json_without_rewriting_body() { + let body = json!({"model": "smart-code", "input": "hello"}); + let decoded = generate_request(body.clone()); + + assert!(!decoded.metadata().stream()); + assert_eq!(openai_wire_body(&decoded), body.as_object().unwrap()); +} + +#[test] +fn decoder_should_preserve_explicit_http_stream_values() { + for stream in [false, true] { + let body = json!({"model": "smart-code", "input": "hello", "stream": stream}); + let decoded = generate_request(body.clone()); + + assert_eq!(decoded.metadata().stream(), stream); + assert_eq!(openai_wire_body(&decoded), body.as_object().unwrap()); + } +} + #[test] fn decoder_should_preserve_opaque_client_model_values() { for model in [ @@ -631,6 +651,7 @@ fn decoder_should_leave_openai_semantic_validation_to_the_upstream() { "future_official_field": [1, 2, 3] })); + assert!(decoded.metadata().stream()); assert_eq!( Value::Object(openai_wire_body(&decoded).clone()), json!({