mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Fix Codex image progress heartbeat merge regressions
This commit is contained in:
@@ -15,4 +15,5 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha1 = "0.10"
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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,
|
||||
@@ -96,7 +98,9 @@ pub use crate::formats::shared::response::{
|
||||
};
|
||||
pub use crate::formats::shared::routing::{
|
||||
is_matching_stream_http_request, is_matching_stream_request,
|
||||
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
|
||||
request_path_implies_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, sanitize_request_path,
|
||||
sanitize_request_path_and_query, sanitize_request_query_string,
|
||||
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
|
||||
};
|
||||
pub use crate::formats::shared::sse::{encode_done_sse, encode_json_sse, map_claude_stop_reason};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use http::Method;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::contracts::{
|
||||
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
|
||||
@@ -304,6 +305,67 @@ fn resolve_gemini_generate_content_plan_kind(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_path_implies_stream_request(path: &str) -> bool {
|
||||
let trimmed = path.trim();
|
||||
let path = trimmed
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or(trimmed);
|
||||
path.ends_with(":streamGenerateContent")
|
||||
}
|
||||
|
||||
pub fn sanitize_request_path(path: &str) -> Option<String> {
|
||||
let path = path
|
||||
.trim()
|
||||
.split_once('?')
|
||||
.map(|(path, _)| path)
|
||||
.unwrap_or_else(|| path.trim())
|
||||
.trim();
|
||||
(!path.is_empty()).then(|| path.to_string())
|
||||
}
|
||||
|
||||
pub fn sanitize_request_query_string(query: &str) -> Option<String> {
|
||||
let query = query.trim().trim_start_matches('?').trim();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
if request_query_key_is_safe_to_trace(key.as_ref()) {
|
||||
serializer.append_pair(key.as_ref(), value.as_ref());
|
||||
}
|
||||
}
|
||||
let sanitized = serializer.finish();
|
||||
(!sanitized.is_empty()).then_some(sanitized)
|
||||
}
|
||||
|
||||
pub fn sanitize_request_path_and_query(path: &str, query: Option<&str>) -> Option<String> {
|
||||
let trimmed = path.trim();
|
||||
let (path, embedded_query) = trimmed
|
||||
.split_once('?')
|
||||
.map(|(path, query)| (path.trim(), Some(query)))
|
||||
.unwrap_or((trimmed, None));
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized_query = query
|
||||
.and_then(sanitize_request_query_string)
|
||||
.or_else(|| embedded_query.and_then(sanitize_request_query_string));
|
||||
Some(match sanitized_query {
|
||||
Some(query) => format!("{path}?{query}"),
|
||||
None => path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_query_key_is_safe_to_trace(key: &str) -> bool {
|
||||
matches!(
|
||||
key.to_ascii_lowercase().as_str(),
|
||||
"alt" | "view" | "pagesize" | "page_size" | "limit" | "offset"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_matching_stream_request(
|
||||
plan_kind: &str,
|
||||
path: &str,
|
||||
@@ -320,7 +382,7 @@ pub fn is_matching_stream_request(
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false),
|
||||
GEMINI_CHAT_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND => {
|
||||
path.ends_with(":streamGenerateContent")
|
||||
request_path_implies_stream_request(path)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
@@ -388,7 +450,9 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
is_matching_stream_http_request, is_matching_stream_request,
|
||||
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
|
||||
request_path_implies_stream_request, resolve_execution_runtime_stream_plan_kind,
|
||||
resolve_execution_runtime_sync_plan_kind, sanitize_request_path,
|
||||
sanitize_request_path_and_query, sanitize_request_query_string,
|
||||
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
|
||||
};
|
||||
use crate::contracts::{
|
||||
@@ -609,6 +673,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_path_detection_handles_gemini_method_paths_with_query() {
|
||||
assert!(request_path_implies_stream_request(
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
));
|
||||
assert!(request_path_implies_stream_request(
|
||||
" /v1internal:streamGenerateContent?alt=sse "
|
||||
));
|
||||
assert!(!request_path_implies_stream_request(
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent?alt=sse"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_path_metadata_sanitizer_drops_sensitive_query_parameters() {
|
||||
assert_eq!(
|
||||
sanitize_request_path("/v1beta/models/gemini-2.5-pro:generateContent?key=secret")
|
||||
.as_deref(),
|
||||
Some("/v1beta/models/gemini-2.5-pro:generateContent")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_request_query_string("?key=secret&alt=sse&pageSize=10&token=hidden")
|
||||
.as_deref(),
|
||||
Some("alt=sse&pageSize=10")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_request_path_and_query(
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?key=secret&alt=sse",
|
||||
None
|
||||
)
|
||||
.as_deref(),
|
||||
Some("/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_matching_requires_openai_stream_flag() {
|
||||
assert!(!is_matching_stream_request(
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user