fix: enforce provider upstream stream policy

This commit is contained in:
stabey
2026-05-08 21:00:44 +08:00
parent 8958bf5e08
commit 7e5a08e09d
29 changed files with 1518 additions and 118 deletions

View File

@@ -82,7 +82,9 @@ pub use crate::formats::shared::passthrough::{
LocalSameFormatProviderSpec,
};
pub use crate::formats::shared::request::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
force_upstream_streaming_for_provider, parse_direct_request_body,
resolve_upstream_is_stream_from_endpoint_config,
};
pub use crate::formats::shared::request_matrix::{
build_standard_request_body_from_canonical,

View File

@@ -135,10 +135,18 @@ pub fn is_openai_responses_family_format(value: &str) -> bool {
)
}
pub fn api_format_uses_body_stream_field(value: &str) -> bool {
matches!(
FormatId::parse(value).map(FormatId::canonical),
Some(FormatId::OpenAiChat | FormatId::OpenAiResponses | FormatId::ClaudeMessages)
)
}
#[cfg(test)]
mod tests {
use super::{
api_format_alias_matches, api_format_storage_aliases, normalize_api_format_alias, FormatId,
api_format_alias_matches, api_format_storage_aliases, api_format_uses_body_stream_field,
normalize_api_format_alias, FormatId,
};
#[test]
@@ -304,4 +312,22 @@ mod tests {
vec!["doubao:embedding".to_string()]
);
}
#[test]
fn body_stream_field_support_matches_provider_wire_formats() {
assert!(api_format_uses_body_stream_field("openai:chat"));
assert!(api_format_uses_body_stream_field("/v1/chat/completions"));
assert!(api_format_uses_body_stream_field("openai:responses"));
assert!(api_format_uses_body_stream_field("/v1/responses"));
assert!(api_format_uses_body_stream_field("claude:messages"));
assert!(api_format_uses_body_stream_field("/v1/messages"));
assert!(!api_format_uses_body_stream_field(
"openai:responses:compact"
));
assert!(!api_format_uses_body_stream_field("/v1/responses/compact"));
assert!(!api_format_uses_body_stream_field(
"gemini:generate_content"
));
assert!(!api_format_uses_body_stream_field("openai:embedding"));
}
}

View File

@@ -281,8 +281,9 @@ pub fn apply_openai_responses_compact_special_body_edits(
return;
};
// `/v1/responses/compact` does not accept `store`.
// `/v1/responses/compact` does not accept `store` or body-level `stream`.
body_object.remove("store");
body_object.remove("stream");
}
pub fn apply_codex_openai_responses_special_body_edits(

View File

@@ -188,6 +188,9 @@ pub fn to_raw(
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
));
if compact {
output.remove("stream");
}
output.remove("verbosity");
Some(Value::Object(output))
}

View File

@@ -1,5 +1,14 @@
use base64::Engine as _;
use crate::formats::id::api_format_uses_body_stream_field;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpstreamStreamPolicy {
Auto,
ForceStream,
ForceNonStream,
}
pub fn parse_direct_request_body(
is_json_request: bool,
body_bytes: &[u8],
@@ -29,9 +38,130 @@ pub fn force_upstream_streaming_for_provider(
&& aether_ai_formats::is_openai_responses_format(provider_api_format)
}
pub(crate) fn parse_upstream_stream_policy(
value: Option<&serde_json::Value>,
) -> UpstreamStreamPolicy {
let Some(value) = value else {
return UpstreamStreamPolicy::Auto;
};
if let Some(value) = value.as_bool() {
return if value {
UpstreamStreamPolicy::ForceStream
} else {
UpstreamStreamPolicy::ForceNonStream
};
}
let serde_json::Value::String(value) = value else {
return UpstreamStreamPolicy::Auto;
};
let raw = value.trim().to_ascii_lowercase();
match raw.as_str() {
"" | "auto" | "follow" | "client" | "default" => UpstreamStreamPolicy::Auto,
"force_stream" | "stream" | "sse" | "true" | "1" | "yes" => {
UpstreamStreamPolicy::ForceStream
}
"force_non_stream" | "force_sync" | "non_stream" | "sync" | "false" | "0" | "no" => {
UpstreamStreamPolicy::ForceNonStream
}
_ => UpstreamStreamPolicy::Auto,
}
}
pub(crate) fn upstream_stream_policy_from_endpoint_config(
endpoint_config: Option<&serde_json::Value>,
) -> UpstreamStreamPolicy {
let Some(config) = endpoint_config.and_then(serde_json::Value::as_object) else {
return UpstreamStreamPolicy::Auto;
};
for key in [
"upstream_stream_policy",
"upstreamStreamPolicy",
"upstream_stream",
] {
if let Some(value) = config.get(key) {
return parse_upstream_stream_policy(Some(value));
}
}
UpstreamStreamPolicy::Auto
}
pub fn endpoint_config_forces_upstream_stream_policy(
endpoint_config: Option<&serde_json::Value>,
) -> bool {
matches!(
upstream_stream_policy_from_endpoint_config(endpoint_config),
UpstreamStreamPolicy::ForceStream | UpstreamStreamPolicy::ForceNonStream
)
}
/// Resolves the upstream provider transport mode.
///
/// `client_is_stream` means the request landed on a streaming surface or should
/// be treated as streaming; the original JSON body may not have had
/// `"stream": true`.
pub(crate) fn resolve_upstream_is_stream(
client_is_stream: bool,
hard_requires_streaming: bool,
policy: UpstreamStreamPolicy,
) -> bool {
// ForceStream is unconditional, while ForceNonStream yields to hard
// stream-only constraints such as Kiro or Codex OpenAI Responses.
match policy {
UpstreamStreamPolicy::ForceStream => true,
UpstreamStreamPolicy::ForceNonStream => hard_requires_streaming,
UpstreamStreamPolicy::Auto => hard_requires_streaming || client_is_stream,
}
}
pub fn enforce_request_body_stream_field(
body: &mut serde_json::Value,
provider_api_format: &str,
upstream_is_stream: bool,
require_body_stream_field: bool,
) {
let Some(body_object) = body.as_object_mut() else {
return;
};
if !api_format_uses_body_stream_field(provider_api_format) {
body_object.remove("stream");
return;
}
// Final-body fallback catches body rules, directive patches, and other
// provider-body mutations that introduce `stream`.
if upstream_is_stream || require_body_stream_field || body_object.contains_key("stream") {
body_object.insert(
"stream".to_string(),
serde_json::Value::Bool(upstream_is_stream),
);
} else {
body_object.remove("stream");
}
}
pub fn resolve_upstream_is_stream_from_endpoint_config(
endpoint_config: Option<&serde_json::Value>,
client_is_stream: bool,
hard_requires_streaming: bool,
) -> bool {
resolve_upstream_is_stream(
client_is_stream,
hard_requires_streaming,
upstream_stream_policy_from_endpoint_config(endpoint_config),
)
}
#[cfg(test)]
mod tests {
use super::{force_upstream_streaming_for_provider, parse_direct_request_body};
use super::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
force_upstream_streaming_for_provider, parse_direct_request_body,
parse_upstream_stream_policy, resolve_upstream_is_stream,
resolve_upstream_is_stream_from_endpoint_config,
upstream_stream_policy_from_endpoint_config, UpstreamStreamPolicy,
};
use serde_json::json;
#[test]
fn parses_empty_json_body_as_empty_object() {
@@ -77,4 +207,177 @@ mod tests {
"openai:responses"
));
}
#[test]
fn parses_python_compatible_upstream_stream_policy_values() {
assert_eq!(
parse_upstream_stream_policy(None),
UpstreamStreamPolicy::Auto
);
for value in [
json!(""),
json!("auto"),
json!("follow"),
json!("client"),
json!("default"),
json!("unknown"),
] {
assert_eq!(
parse_upstream_stream_policy(Some(&value)),
UpstreamStreamPolicy::Auto
);
}
for value in [
json!(true),
json!("force_stream"),
json!("stream"),
json!("sse"),
json!("true"),
json!("1"),
json!("yes"),
] {
assert_eq!(
parse_upstream_stream_policy(Some(&value)),
UpstreamStreamPolicy::ForceStream
);
}
for value in [
json!(false),
json!("force_non_stream"),
json!("force_sync"),
json!("non_stream"),
json!("sync"),
json!("false"),
json!("0"),
json!("no"),
] {
assert_eq!(
parse_upstream_stream_policy(Some(&value)),
UpstreamStreamPolicy::ForceNonStream
);
}
}
#[test]
fn parses_non_string_non_bool_policy_values_as_auto() {
for value in [json!(1), json!(0), json!(null), json!({}), json!([])] {
assert_eq!(
parse_upstream_stream_policy(Some(&value)),
UpstreamStreamPolicy::Auto
);
}
}
#[test]
fn enforces_request_body_stream_field_for_stream_and_streamless_formats() {
let mut openai_chat = json!({"stream": true});
enforce_request_body_stream_field(&mut openai_chat, "openai:chat", false, false);
assert_eq!(openai_chat.get("stream"), Some(&json!(false)));
let mut ordinary_sync = json!({"messages": []});
enforce_request_body_stream_field(&mut ordinary_sync, "openai:chat", false, false);
assert!(ordinary_sync.get("stream").is_none());
let mut forced_sync = json!({"messages": []});
enforce_request_body_stream_field(&mut forced_sync, "openai:chat", false, true);
assert_eq!(forced_sync.get("stream"), Some(&json!(false)));
let mut compact = json!({"stream": true});
enforce_request_body_stream_field(&mut compact, "openai:responses:compact", true, true);
assert!(compact.get("stream").is_none());
}
#[test]
fn reads_endpoint_policy_keys_in_python_compatible_order() {
assert_eq!(
upstream_stream_policy_from_endpoint_config(Some(&json!({
"upstream_stream_policy": "force_non_stream",
"upstreamStreamPolicy": "force_stream",
"upstream_stream": "force_stream"
}))),
UpstreamStreamPolicy::ForceNonStream
);
assert_eq!(
upstream_stream_policy_from_endpoint_config(Some(&json!({
"upstreamStreamPolicy": "force_stream"
}))),
UpstreamStreamPolicy::ForceStream
);
assert_eq!(
upstream_stream_policy_from_endpoint_config(Some(&json!({
"upstream_stream": false
}))),
UpstreamStreamPolicy::ForceNonStream
);
}
#[test]
fn detects_forced_endpoint_policy_values() {
assert!(endpoint_config_forces_upstream_stream_policy(Some(
&json!({"upstream_stream_policy": "force_stream"})
)));
assert!(endpoint_config_forces_upstream_stream_policy(Some(
&json!({"upstream_stream_policy": "force_non_stream"})
)));
assert!(!endpoint_config_forces_upstream_stream_policy(Some(
&json!({"upstream_stream_policy": "auto"})
)));
assert!(!endpoint_config_forces_upstream_stream_policy(None));
}
#[test]
fn resolves_upstream_stream_policy_against_client_mode_and_hard_constraints() {
assert!(resolve_upstream_is_stream(
false,
false,
UpstreamStreamPolicy::ForceStream
));
assert!(!resolve_upstream_is_stream(
true,
false,
UpstreamStreamPolicy::ForceNonStream
));
assert!(resolve_upstream_is_stream(
true,
true,
UpstreamStreamPolicy::ForceNonStream
));
assert!(!resolve_upstream_is_stream(
false,
false,
UpstreamStreamPolicy::Auto
));
assert!(resolve_upstream_is_stream(
true,
false,
UpstreamStreamPolicy::Auto
));
assert!(resolve_upstream_is_stream(
false,
true,
UpstreamStreamPolicy::Auto
));
}
#[test]
fn resolves_endpoint_policy_config_to_upstream_mode() {
assert!(resolve_upstream_is_stream_from_endpoint_config(
Some(&json!({"upstream_stream_policy": "force_stream"})),
false,
false,
));
assert!(!resolve_upstream_is_stream_from_endpoint_config(
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
true,
false,
));
assert!(resolve_upstream_is_stream_from_endpoint_config(
Some(&json!({"upstream_stream_policy": "auto"})),
true,
false,
));
assert!(!resolve_upstream_is_stream_from_endpoint_config(
None, false, false,
));
}
}

View File

@@ -128,6 +128,18 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
&mut provider_request_body,
provider_api_format,
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"))
|| provider_request_body
.as_object()
.is_some_and(|object| object.contains_key("stream"));
crate::formats::shared::request::enforce_request_body_stream_field(
&mut provider_request_body,
provider_api_format,
upstream_is_stream,
require_body_stream_field,
);
Some(provider_request_body)
}
@@ -302,13 +314,23 @@ mod tests {
fn assert_stream_flag(provider_api_format: &str, upstream_is_stream: bool, converted: &Value) {
match provider_api_format {
"openai:chat" | "openai:responses" | "claude:messages" => {
assert_eq!(
converted
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(false),
upstream_is_stream,
"{provider_api_format} stream flag should follow upstream_is_stream"
if upstream_is_stream {
assert_eq!(
converted.get("stream").and_then(Value::as_bool),
Some(true),
"{provider_api_format} stream flag should be true for upstream streaming"
);
} else {
assert!(
converted.get("stream").is_none(),
"{provider_api_format} should not gain stream:false for ordinary sync requests"
);
}
}
"openai:responses:compact" => {
assert!(
converted.get("stream").is_none(),
"openai responses compact keeps stream out of the request body"
);
}
"gemini:generate_content" => {
@@ -321,6 +343,93 @@ mod tests {
}
}
fn assert_explicit_stream_flag(
provider_api_format: &str,
upstream_is_stream: bool,
converted: &Value,
) {
match provider_api_format {
"openai:chat" | "openai:responses" | "claude:messages" => {
assert_eq!(
converted.get("stream").and_then(Value::as_bool),
Some(upstream_is_stream),
"{provider_api_format} stream flag should follow upstream_is_stream"
);
}
"openai:responses:compact" | "gemini:generate_content" => {
assert!(converted.get("stream").is_none());
}
other => panic!("unexpected provider api format: {other}"),
}
}
#[test]
fn standard_request_body_overrides_client_stream_true_for_non_stream_upstream() {
let request = json!({
"model": "source-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
});
for provider_api_format in [
"openai:chat",
"openai:responses",
"openai:responses:compact",
"claude:messages",
"gemini:generate_content",
] {
let converted = build_standard_request_body(
&request,
"openai:chat",
"mapped-model",
"custom",
provider_api_format,
"/v1/chat/completions",
false,
None,
None,
)
.unwrap_or_else(|| panic!("openai:chat -> {provider_api_format} should build"));
assert_explicit_stream_flag(provider_api_format, false, &converted);
}
}
#[test]
fn standard_request_body_stream_policy_wins_after_body_rules() {
let request = json!({
"model": "source-model",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
});
let body_rules = json!([
{"action":"set","path":"stream","value":true}
]);
for provider_api_format in [
"openai:chat",
"openai:responses",
"openai:responses:compact",
"claude:messages",
"gemini:generate_content",
] {
let converted = build_standard_request_body(
&request,
"openai:chat",
"mapped-model",
"custom",
provider_api_format,
"/v1/chat/completions",
false,
Some(&body_rules),
None,
)
.unwrap_or_else(|| panic!("openai:chat -> {provider_api_format} should build"));
assert_explicit_stream_flag(provider_api_format, false, &converted);
}
}
fn codex_default_body_rules() -> Value {
json!([
{"action":"drop","path":"max_output_tokens"},

View File

@@ -50,14 +50,24 @@ pub fn build_local_openai_chat_request_body_with_model_directives(
}
}
}
Some(with_model_directive_overrides(
let mut provider_request_body = with_model_directive_overrides(
Value::Object(provider_request_body),
"openai:chat",
mapped_model,
body_json,
None,
enable_model_directives,
))
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"));
crate::formats::shared::request::enforce_request_body_stream_field(
&mut provider_request_body,
"openai:chat",
upstream_is_stream,
require_body_stream_field,
);
Some(provider_request_body)
}
pub fn build_cross_format_openai_chat_request_body(
@@ -104,14 +114,24 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
}
_ => return None,
};
Some(with_model_directive_overrides(
let mut provider_request_body = with_model_directive_overrides(
provider_request_body,
provider_api_format,
mapped_model,
body_json,
None,
enable_model_directives,
))
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"));
crate::formats::shared::request::enforce_request_body_stream_field(
&mut provider_request_body,
provider_api_format,
upstream_is_stream,
require_body_stream_field,
);
Some(provider_request_body)
}
pub fn build_local_openai_responses_request_body(
@@ -143,14 +163,24 @@ pub fn build_local_openai_responses_request_body_with_model_directives(
if require_streaming {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
Some(with_model_directive_overrides(
let mut provider_request_body = with_model_directive_overrides(
Value::Object(provider_request_body),
"openai:responses",
mapped_model,
body_json,
None,
enable_model_directives,
))
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"));
crate::formats::shared::request::enforce_request_body_stream_field(
&mut provider_request_body,
"openai:responses",
require_streaming,
require_body_stream_field,
);
Some(provider_request_body)
}
pub fn build_cross_format_openai_responses_request_body(
@@ -208,14 +238,24 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
upstream_is_stream,
)?,
};
Some(with_model_directive_overrides(
let mut provider_request_body = with_model_directive_overrides(
provider_request_body,
provider_api_format,
mapped_model,
body_json,
None,
enable_model_directives,
))
);
let require_body_stream_field = body_json
.as_object()
.is_some_and(|object| object.contains_key("stream"));
crate::formats::shared::request::enforce_request_body_stream_field(
&mut provider_request_body,
provider_api_format,
upstream_is_stream,
require_body_stream_field,
);
Some(provider_request_body)
}
fn with_model_directive_overrides(
@@ -324,6 +364,108 @@ mod tests {
);
}
#[test]
fn local_openai_chat_request_body_overrides_client_stream_for_non_stream_upstream() {
let body_json = json!({
"model": "gpt-5",
"messages": [{
"role": "user",
"content": "hello"
}],
"stream": true
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["stream"], false);
}
#[test]
fn local_openai_responses_request_body_overrides_client_stream_for_non_stream_upstream() {
let body_json = json!({
"model": "gpt-5",
"input": "hello",
"stream": true
});
let provider_request_body =
build_local_openai_responses_request_body(&body_json, "gpt-5-upstream", false)
.expect("openai responses body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["stream"], false);
}
#[test]
fn cross_format_openai_chat_request_body_overrides_client_stream_for_non_stream_upstream() {
let body_json = json!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
});
let claude = build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"claude-sonnet-4-5",
"claude:messages",
false,
false,
)
.expect("claude body should build");
assert_eq!(claude["stream"], false);
let responses = build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
"openai:responses",
false,
false,
)
.expect("responses body should build");
assert_eq!(responses["stream"], false);
}
#[test]
fn cross_format_openai_chat_request_body_does_not_add_stream_false_for_plain_sync_body() {
let body_json = json!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "hello"}]
});
let claude = build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"claude-sonnet-4-5",
"claude:messages",
false,
false,
)
.expect("claude body should build");
assert!(claude.get("stream").is_none());
}
#[test]
fn cross_format_openai_responses_body_overrides_client_stream_for_non_stream_upstream() {
let body_json = json!({
"model": "gpt-5",
"input": "hello",
"stream": true
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"claude-sonnet-4-5",
"openai:responses",
"claude:messages",
false,
)
.expect("claude body should build");
assert_eq!(provider_request_body["stream"], false);
}
#[test]
fn local_openai_chat_request_body_applies_reasoning_effort_suffix() {
let body_json = json!({

View File

@@ -8,9 +8,9 @@ pub mod provider_compat;
pub use formats::context::{FormatContext, FormatError};
pub use formats::id::{
api_format_alias_matches, api_format_storage_aliases, is_openai_responses_compact_format,
is_openai_responses_family_format, is_openai_responses_format, normalize_api_format_alias,
FormatFamily, FormatId, FormatProfile,
api_format_alias_matches, api_format_storage_aliases, api_format_uses_body_stream_field,
is_openai_responses_compact_format, is_openai_responses_family_format,
is_openai_responses_format, normalize_api_format_alias, FormatFamily, FormatId, FormatProfile,
};
pub use formats::matrix::{
is_embedding_api_format, is_rerank_api_format, request_candidate_api_format_preference,
@@ -27,6 +27,10 @@ pub use formats::shared::model_directives::{
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort,
};
pub use formats::shared::request::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
resolve_upstream_is_stream_from_endpoint_config,
};
pub use protocol::canonical::{
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
canonical_to_claude_request, canonical_to_claude_response, canonical_to_embedding_response,