mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 20:19:51 +08:00
Merge branch 'fawney19:main' into main
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::headers::{
|
||||
should_skip_upstream_complete_passthrough_header, should_skip_upstream_passthrough_header,
|
||||
normalize_upstream_accept_encoding, should_skip_upstream_complete_passthrough_header,
|
||||
should_skip_upstream_passthrough_header,
|
||||
};
|
||||
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
@@ -21,20 +22,18 @@ fn collect_passthrough_headers(
|
||||
if should_skip_upstream_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
let Some(value) = normalize_passthrough_header_value(&key, value) else {
|
||||
continue;
|
||||
}
|
||||
out.insert(key, value.to_string());
|
||||
};
|
||||
out.insert(key, value);
|
||||
}
|
||||
|
||||
for (key, value) in extra_headers {
|
||||
let normalized_key = key.to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
let Some(value) = normalize_passthrough_header_value(&normalized_key, value) else {
|
||||
continue;
|
||||
}
|
||||
out.insert(normalized_key, value.to_string());
|
||||
};
|
||||
out.insert(normalized_key, value);
|
||||
}
|
||||
|
||||
out
|
||||
@@ -53,25 +52,36 @@ fn collect_complete_passthrough_headers(
|
||||
if should_skip_upstream_complete_passthrough_header(&key) {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
let Some(value) = normalize_passthrough_header_value(&key, value) else {
|
||||
continue;
|
||||
}
|
||||
out.insert(key, value.to_string());
|
||||
};
|
||||
out.insert(key, value);
|
||||
}
|
||||
|
||||
for (key, value) in extra_headers {
|
||||
let normalized_key = key.to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
let Some(value) = normalize_passthrough_header_value(&normalized_key, value) else {
|
||||
continue;
|
||||
}
|
||||
out.insert(normalized_key, value.to_string());
|
||||
};
|
||||
out.insert(normalized_key, value);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_passthrough_header_value(key: &str, value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if key.eq_ignore_ascii_case("accept-encoding") {
|
||||
return normalize_upstream_accept_encoding(value);
|
||||
}
|
||||
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
pub fn build_passthrough_headers(
|
||||
headers: &http::HeaderMap,
|
||||
extra_headers: &BTreeMap<String, String>,
|
||||
@@ -312,7 +322,8 @@ fn bearer_auth_value(secret: &str) -> String {
|
||||
mod tests {
|
||||
use super::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
resolve_local_openai_bearer_auth, resolve_local_standard_auth,
|
||||
build_openai_passthrough_headers, resolve_local_openai_bearer_auth,
|
||||
resolve_local_standard_auth,
|
||||
};
|
||||
use crate::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
@@ -417,6 +428,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_headers_preserve_supported_response_compression() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
http::HeaderValue::from_static("gzip, br"),
|
||||
);
|
||||
|
||||
let built = build_openai_passthrough_headers(
|
||||
&headers,
|
||||
"authorization",
|
||||
"Bearer upstream",
|
||||
&BTreeMap::new(),
|
||||
Some("application/json"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
built.get("accept-encoding").map(String::as_str),
|
||||
Some("gzip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_passthrough_headers_preserve_explicit_anthropic_version_override() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
|
||||
@@ -592,6 +592,52 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_pair_requires_provider_or_endpoint_enablement() {
|
||||
let disabled = transport_snapshot("custom", "openai:responses", "bearer", false, None);
|
||||
assert!(!request_conversion_enabled_for_transport(
|
||||
&disabled,
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
));
|
||||
assert_eq!(
|
||||
candidate_transport_pair_skip_reason(&disabled, "claude:messages"),
|
||||
Some("format_conversion_disabled")
|
||||
);
|
||||
|
||||
let provider_enabled =
|
||||
transport_snapshot("custom", "openai:responses", "bearer", true, None);
|
||||
assert!(request_conversion_enabled_for_transport(
|
||||
&provider_enabled,
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
));
|
||||
assert_eq!(
|
||||
candidate_transport_pair_skip_reason(&provider_enabled, "claude:messages"),
|
||||
None
|
||||
);
|
||||
|
||||
let endpoint_enabled = transport_snapshot(
|
||||
"custom",
|
||||
"openai:responses",
|
||||
"bearer",
|
||||
false,
|
||||
Some(json!({
|
||||
"enabled": true,
|
||||
"accept_formats": ["claude:messages"],
|
||||
})),
|
||||
);
|
||||
assert!(request_conversion_enabled_for_transport(
|
||||
&endpoint_enabled,
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
));
|
||||
assert_eq!(
|
||||
candidate_transport_pair_skip_reason(&endpoint_enabled, "claude:messages"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_gemini_transport_supports_cross_format_conversion_with_query_auth() {
|
||||
let transport = transport_snapshot(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||
|
||||
pub fn should_skip_request_header(name: &str) -> bool {
|
||||
@@ -42,7 +44,6 @@ pub fn should_skip_upstream_passthrough_header(name: &str) -> bool {
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "connection"
|
||||
| "accept-encoding"
|
||||
| "content-encoding"
|
||||
| "x-real-ip"
|
||||
| "x-real-proto"
|
||||
@@ -68,7 +69,6 @@ pub(crate) fn should_skip_upstream_complete_passthrough_header(name: &str) -> bo
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "connection"
|
||||
| "accept-encoding"
|
||||
| "content-encoding"
|
||||
| "x-real-ip"
|
||||
| "x-real-proto"
|
||||
@@ -80,13 +80,102 @@ pub(crate) fn should_skip_upstream_complete_passthrough_header(name: &str) -> bo
|
||||
) || should_skip_request_header(name)
|
||||
}
|
||||
|
||||
pub fn normalize_upstream_accept_encoding(value: &str) -> Option<String> {
|
||||
let mut accepted = Vec::new();
|
||||
let mut wildcard_allowed = false;
|
||||
let mut gzip_disabled = false;
|
||||
let mut deflate_disabled = false;
|
||||
let mut identity_disabled = false;
|
||||
|
||||
for item in value.split(',') {
|
||||
let Some((token, normalized_item, enabled)) = parse_accept_encoding_item(item) else {
|
||||
continue;
|
||||
};
|
||||
match token.as_str() {
|
||||
"gzip" if enabled => accepted.push(normalized_item),
|
||||
"gzip" => gzip_disabled = true,
|
||||
"deflate" if enabled => accepted.push(normalized_item),
|
||||
"deflate" => deflate_disabled = true,
|
||||
"identity" if enabled => accepted.push(normalized_item),
|
||||
"identity" => identity_disabled = true,
|
||||
"*" if enabled => wildcard_allowed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !accepted.is_empty() {
|
||||
return Some(accepted.join(", "));
|
||||
}
|
||||
|
||||
if wildcard_allowed && !gzip_disabled {
|
||||
Some("gzip".to_string())
|
||||
} else if wildcard_allowed && !deflate_disabled {
|
||||
Some("deflate".to_string())
|
||||
} else if wildcard_allowed && !identity_disabled {
|
||||
Some("identity".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_accept_encoding_item(raw_item: &str) -> Option<(String, String, bool)> {
|
||||
let mut parts = raw_item.trim().split(';');
|
||||
let token = parts.next()?.trim().to_ascii_lowercase();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut enabled = true;
|
||||
let mut normalized = token.clone();
|
||||
for raw_param in parts {
|
||||
let param = raw_param.trim();
|
||||
if param.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((name, value)) = param.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if name.trim().eq_ignore_ascii_case("q") {
|
||||
let value = value.trim();
|
||||
if q_value_is_zero(value) {
|
||||
enabled = false;
|
||||
continue;
|
||||
}
|
||||
normalized.push_str(";q=");
|
||||
normalized.push_str(value);
|
||||
}
|
||||
}
|
||||
|
||||
Some((token, normalized, enabled))
|
||||
}
|
||||
|
||||
fn q_value_is_zero(value: &str) -> bool {
|
||||
value
|
||||
.trim_matches('"')
|
||||
.parse::<f32>()
|
||||
.is_ok_and(|q| q <= 0.0)
|
||||
}
|
||||
|
||||
pub fn force_identity_accept_encoding(headers: &mut BTreeMap<String, String>) {
|
||||
if let Some(existing_key) = headers
|
||||
.keys()
|
||||
.find(|key| key.eq_ignore_ascii_case("accept-encoding"))
|
||||
.cloned()
|
||||
{
|
||||
headers.remove(&existing_key);
|
||||
}
|
||||
headers.insert("accept-encoding".to_string(), "identity".to_string());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
force_identity_accept_encoding, normalize_upstream_accept_encoding,
|
||||
should_skip_request_header, should_skip_upstream_complete_passthrough_header,
|
||||
should_skip_upstream_passthrough_header,
|
||||
};
|
||||
use aether_contracts::USAGE_SERVER_NOW_UNIX_MS_HEADER;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn strips_all_stainless_headers() {
|
||||
@@ -111,6 +200,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_is_not_classified_as_hop_by_hop_passthrough_skip() {
|
||||
assert!(!should_skip_upstream_passthrough_header("accept-encoding"));
|
||||
assert!(!should_skip_upstream_complete_passthrough_header(
|
||||
"accept-encoding"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_accept_encoding_to_supported_upstream_codecs() {
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("gzip, br").as_deref(),
|
||||
Some("gzip")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("br, deflate").as_deref(),
|
||||
Some("deflate")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("identity").as_deref(),
|
||||
Some("identity")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("gzip;q=0.5, br").as_deref(),
|
||||
Some("gzip;q=0.5")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("gzip;q=0, br, deflate").as_deref(),
|
||||
Some("deflate")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("gzip;q=0, br").as_deref(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_upstream_accept_encoding("*").as_deref(),
|
||||
Some("gzip")
|
||||
);
|
||||
assert_eq!(normalize_upstream_accept_encoding("br"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_accept_encoding_replaces_existing_casing() {
|
||||
let mut headers = BTreeMap::from([("Accept-Encoding".to_string(), "gzip".to_string())]);
|
||||
|
||||
force_identity_accept_encoding(&mut headers);
|
||||
|
||||
assert_eq!(
|
||||
headers.get("accept-encoding").map(String::as_str),
|
||||
Some("identity")
|
||||
);
|
||||
assert!(!headers.contains_key("Accept-Encoding"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_anthropic_and_claude_cli_identity_headers() {
|
||||
let anthropic = [
|
||||
|
||||
@@ -112,14 +112,16 @@ pub use rules::{
|
||||
};
|
||||
pub use same_format_provider::{
|
||||
build_same_format_provider_headers, build_same_format_provider_request_body,
|
||||
build_same_format_provider_request_body_with_compatibility_report,
|
||||
build_same_format_provider_upstream_url, classify_same_format_provider_request_behavior,
|
||||
resolve_same_format_provider_direct_auth, same_format_provider_transport_supported,
|
||||
same_format_provider_transport_unsupported_reason,
|
||||
same_format_provider_transport_unsupported_reason_for_trace,
|
||||
should_try_same_format_provider_oauth_auth, SameFormatProviderFamily,
|
||||
should_try_same_format_provider_oauth_auth, SameFormatProviderCompatibilityEdit,
|
||||
SameFormatProviderCompatibilityEditAction, SameFormatProviderFamily,
|
||||
SameFormatProviderHeadersInput, SameFormatProviderRequestBehavior,
|
||||
SameFormatProviderRequestBehaviorParams, SameFormatProviderRequestBodyInput,
|
||||
SameFormatProviderUpstreamUrlParams,
|
||||
SameFormatProviderRequestBodyOutput, SameFormatProviderUpstreamUrlParams,
|
||||
};
|
||||
pub use snapshot::{
|
||||
read_provider_transport_snapshot, GatewayProviderTransportSnapshot,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::antigravity::is_antigravity_provider_transport;
|
||||
@@ -75,6 +76,29 @@ pub struct SameFormatProviderRequestBodyInput<'a> {
|
||||
pub enable_model_directives: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SameFormatProviderRequestBodyOutput {
|
||||
pub body: Value,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub compatibility_edits: Vec<SameFormatProviderCompatibilityEdit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SameFormatProviderCompatibilityEdit {
|
||||
pub field: String,
|
||||
pub action: SameFormatProviderCompatibilityEditAction,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SameFormatProviderCompatibilityEditAction {
|
||||
RuntimeRewrite,
|
||||
ProviderCompatibilityRewrite,
|
||||
ProviderEnvelope,
|
||||
OperatorRule,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SameFormatProviderUpstreamUrlParams<'a> {
|
||||
pub provider_api_format: &'a str,
|
||||
@@ -158,15 +182,41 @@ pub fn classify_same_format_provider_request_behavior(
|
||||
|
||||
pub fn build_same_format_provider_request_body(
|
||||
input: SameFormatProviderRequestBodyInput<'_>,
|
||||
) -> Option<Value> {
|
||||
build_same_format_provider_request_body_inner(input, None)
|
||||
}
|
||||
|
||||
pub fn build_same_format_provider_request_body_with_compatibility_report(
|
||||
input: SameFormatProviderRequestBodyInput<'_>,
|
||||
) -> Option<SameFormatProviderRequestBodyOutput> {
|
||||
let mut compatibility_edits = Vec::new();
|
||||
let body =
|
||||
build_same_format_provider_request_body_inner(input, Some(&mut compatibility_edits))?;
|
||||
Some(SameFormatProviderRequestBodyOutput {
|
||||
body,
|
||||
compatibility_edits,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_same_format_provider_request_body_inner(
|
||||
input: SameFormatProviderRequestBodyInput<'_>,
|
||||
mut compatibility_edits: Option<&mut Vec<SameFormatProviderCompatibilityEdit>>,
|
||||
) -> Option<Value> {
|
||||
if let Some(kiro_auth_config) = input.kiro_auth_config {
|
||||
return build_kiro_provider_request_body(
|
||||
let body = build_kiro_provider_request_body(
|
||||
input.body_json,
|
||||
input.mapped_model,
|
||||
kiro_auth_config,
|
||||
input.body_rules,
|
||||
input.request_headers,
|
||||
)?;
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"$",
|
||||
SameFormatProviderCompatibilityEditAction::ProviderEnvelope,
|
||||
"wrapped same-format request in Kiro provider envelope",
|
||||
);
|
||||
return Some(body);
|
||||
}
|
||||
|
||||
if embedding_multimodal_input_requires_aliyun_provider(
|
||||
@@ -188,41 +238,82 @@ pub fn build_same_format_provider_request_body(
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
)
|
||||
} else {
|
||||
aether_ai_formats::convert_request(
|
||||
aether_ai_formats::convert_request_pure(
|
||||
input.client_api_format,
|
||||
input.provider_api_format,
|
||||
input.body_json,
|
||||
&aether_ai_formats::FormatContext::default().with_mapped_model(input.mapped_model),
|
||||
)
|
||||
.ok()?
|
||||
.value
|
||||
.as_object()?
|
||||
.clone()
|
||||
};
|
||||
match input.family {
|
||||
SameFormatProviderFamily::Standard => {
|
||||
let previous_model = provider_request_body.get("model").cloned();
|
||||
provider_request_body.insert(
|
||||
"model".to_string(),
|
||||
Value::String(input.mapped_model.to_string()),
|
||||
);
|
||||
if previous_model != Some(Value::String(input.mapped_model.to_string())) {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"model",
|
||||
SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
"rewrote request model to mapped upstream model",
|
||||
);
|
||||
}
|
||||
}
|
||||
SameFormatProviderFamily::Gemini => {
|
||||
provider_request_body.remove("model");
|
||||
if provider_request_body.remove("model").is_some() {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"model",
|
||||
SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
"removed top-level model because Gemini model is carried by the upstream URL",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut provider_request_body = Value::Object(provider_request_body);
|
||||
if input.is_claude_code {
|
||||
let before = compatibility_edits
|
||||
.is_some()
|
||||
.then(|| provider_request_body.clone());
|
||||
crate::claude_code::sanitize_claude_code_request_body(&mut provider_request_body);
|
||||
if before.is_some_and(|before| before != provider_request_body) {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"body",
|
||||
SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite,
|
||||
"sanitized Claude Code request body for provider compatibility",
|
||||
);
|
||||
}
|
||||
}
|
||||
if input.enable_model_directives {
|
||||
if let Some(source_model) = input.source_model {
|
||||
let before = compatibility_edits
|
||||
.is_some()
|
||||
.then(|| provider_request_body.clone());
|
||||
aether_ai_formats::apply_model_directive_overrides_from_model(
|
||||
&mut provider_request_body,
|
||||
input.provider_api_format,
|
||||
input.mapped_model,
|
||||
source_model,
|
||||
);
|
||||
if before.is_some_and(|before| before != provider_request_body) {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"model_directives",
|
||||
SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
"applied model directive request body overrides",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let before_body_rules = compatibility_edits
|
||||
.is_some()
|
||||
.then(|| provider_request_body.clone());
|
||||
if !apply_local_body_rules_with_request_headers(
|
||||
&mut provider_request_body,
|
||||
input.body_rules,
|
||||
@@ -231,28 +322,73 @@ pub fn build_same_format_provider_request_body(
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if before_body_rules.is_some_and(|before| before != provider_request_body) {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"body_rules",
|
||||
SameFormatProviderCompatibilityEditAction::OperatorRule,
|
||||
"applied configured provider body rules",
|
||||
);
|
||||
}
|
||||
if matches!(input.family, SameFormatProviderFamily::Gemini)
|
||||
&& aether_ai_formats::api_format_alias_matches(
|
||||
input.provider_api_format,
|
||||
"gemini:generate_content",
|
||||
)
|
||||
{
|
||||
strip_gemini_function_response_ids(&mut provider_request_body);
|
||||
let stripped = strip_gemini_function_response_ids(&mut provider_request_body);
|
||||
if stripped > 0 {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"contents[].parts[].functionResponse.id",
|
||||
SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite,
|
||||
format!(
|
||||
"stripped {stripped} Gemini functionResponse id field(s) rejected by upstreams"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let require_body_stream_field = input.force_body_stream_field
|
||||
|| input
|
||||
.body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("stream"));
|
||||
let previous_stream = provider_request_body.get("stream").cloned();
|
||||
aether_ai_formats::enforce_request_body_stream_field(
|
||||
&mut provider_request_body,
|
||||
input.provider_api_format,
|
||||
input.upstream_is_stream,
|
||||
require_body_stream_field,
|
||||
);
|
||||
if previous_stream != provider_request_body.get("stream").cloned() {
|
||||
record_compatibility_edit(
|
||||
&mut compatibility_edits,
|
||||
"stream",
|
||||
SameFormatProviderCompatibilityEditAction::RuntimeRewrite,
|
||||
format!(
|
||||
"enforced provider stream policy with upstream_is_stream={}",
|
||||
input.upstream_is_stream
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
fn record_compatibility_edit(
|
||||
edits: &mut Option<&mut Vec<SameFormatProviderCompatibilityEdit>>,
|
||||
field: impl Into<String>,
|
||||
action: SameFormatProviderCompatibilityEditAction,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
if let Some(edits) = edits.as_mut() {
|
||||
edits.push(SameFormatProviderCompatibilityEdit {
|
||||
field: field.into(),
|
||||
action,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn embedding_multimodal_input_requires_aliyun_provider(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
@@ -278,25 +414,31 @@ fn embedding_content_is_multimodal(value: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_gemini_function_response_ids(value: &mut Value) {
|
||||
fn strip_gemini_function_response_ids(value: &mut Value) -> usize {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut stripped = 0;
|
||||
for key in ["functionResponse", "function_response"] {
|
||||
if let Some(function_response) = object.get_mut(key).and_then(Value::as_object_mut)
|
||||
{
|
||||
function_response.remove("id");
|
||||
if function_response.remove("id").is_some() {
|
||||
stripped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in object.values_mut() {
|
||||
strip_gemini_function_response_ids(child);
|
||||
stripped += strip_gemini_function_response_ids(child);
|
||||
}
|
||||
stripped
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut stripped = 0;
|
||||
for item in items {
|
||||
strip_gemini_function_response_ids(item);
|
||||
stripped += strip_gemini_function_response_ids(item);
|
||||
}
|
||||
stripped
|
||||
}
|
||||
_ => {}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,6 +1024,61 @@ mod tests {
|
||||
assert_eq!(body.get("stream"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_standard_body_preserves_fields_that_cross_format_would_block() {
|
||||
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
|
||||
body_json: &json!({
|
||||
"model": "client-model",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"n": 2,
|
||||
"reasoning_effort": "max",
|
||||
"unknown_vendor_field": {"keep": true}
|
||||
}),
|
||||
mapped_model: "client-model",
|
||||
client_api_format: "openai:chat",
|
||||
provider_api_format: "/v1/chat/completions",
|
||||
source_model: Some("client-model"),
|
||||
family: SameFormatProviderFamily::Standard,
|
||||
body_rules: None,
|
||||
request_headers: None,
|
||||
upstream_is_stream: false,
|
||||
force_body_stream_field: false,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: false,
|
||||
})
|
||||
.expect("same-format body should bypass canonical conversion");
|
||||
|
||||
assert_eq!(body["n"], 2);
|
||||
assert_eq!(body["reasoning_effort"], "max");
|
||||
assert_eq!(body["unknown_vendor_field"]["keep"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_standard_body_fails_closed_for_lossy_chat_fields() {
|
||||
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
|
||||
body_json: &json!({
|
||||
"model": "client-model",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"n": 2
|
||||
}),
|
||||
mapped_model: "upstream-model",
|
||||
client_api_format: "openai:chat",
|
||||
provider_api_format: "openai:responses",
|
||||
source_model: Some("client-model"),
|
||||
family: SameFormatProviderFamily::Standard,
|
||||
body_rules: None,
|
||||
request_headers: None,
|
||||
upstream_is_stream: false,
|
||||
force_body_stream_field: false,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: false,
|
||||
});
|
||||
|
||||
assert!(body.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_embedding_body_rejects_multimodal_for_openai_like_provider() {
|
||||
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
|
||||
@@ -1073,6 +1270,77 @@ mod tests {
|
||||
assert_eq!(function_response["name"], "lookup_snake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_body_report_records_provider_compatibility_edits() {
|
||||
let output = build_same_format_provider_request_body_with_compatibility_report(
|
||||
SameFormatProviderRequestBodyInput {
|
||||
body_json: &json!({
|
||||
"model": "client-model",
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"id": "call_123",
|
||||
"name": "lookup",
|
||||
"response": {"ok": true}
|
||||
}
|
||||
},
|
||||
{
|
||||
"function_response": {
|
||||
"id": "call_456",
|
||||
"name": "lookup_snake",
|
||||
"response": {"ok": true}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stream": true
|
||||
}),
|
||||
mapped_model: "gemini-upstream",
|
||||
client_api_format: "gemini:generate_content",
|
||||
provider_api_format: "gemini:generate_content",
|
||||
source_model: Some("client-model"),
|
||||
family: SameFormatProviderFamily::Gemini,
|
||||
body_rules: None,
|
||||
request_headers: None,
|
||||
upstream_is_stream: false,
|
||||
force_body_stream_field: false,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: false,
|
||||
},
|
||||
)
|
||||
.expect("same-format body should build");
|
||||
|
||||
assert!(output.body.get("model").is_none());
|
||||
assert!(output.body.get("stream").is_none());
|
||||
assert!(output
|
||||
.body
|
||||
.pointer("/contents/0/parts/0/functionResponse/id")
|
||||
.is_none());
|
||||
assert!(output
|
||||
.body
|
||||
.pointer("/contents/0/parts/1/function_response/id")
|
||||
.is_none());
|
||||
assert!(output.compatibility_edits.iter().any(|edit| {
|
||||
edit.field == "model"
|
||||
&& edit.action == SameFormatProviderCompatibilityEditAction::RuntimeRewrite
|
||||
}));
|
||||
assert!(output.compatibility_edits.iter().any(|edit| {
|
||||
edit.field == "stream"
|
||||
&& edit.action == SameFormatProviderCompatibilityEditAction::RuntimeRewrite
|
||||
}));
|
||||
assert!(output.compatibility_edits.iter().any(|edit| {
|
||||
edit.field == "contents[].parts[].functionResponse.id"
|
||||
&& edit.action
|
||||
== SameFormatProviderCompatibilityEditAction::ProviderCompatibilityRewrite
|
||||
&& edit.detail.contains("2")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_stream_policy_wins_after_body_rules() {
|
||||
let body_rules = json!([
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::auth::{
|
||||
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
|
||||
build_openai_passthrough_headers, build_passthrough_headers, ensure_upstream_auth_header,
|
||||
};
|
||||
use crate::headers::force_identity_accept_encoding;
|
||||
use crate::rules::{
|
||||
apply_local_body_rules, apply_local_body_rules_with_request_headers,
|
||||
apply_local_header_rules_with_request_headers,
|
||||
@@ -146,6 +147,10 @@ pub fn build_standard_plan_fallback_headers(
|
||||
}
|
||||
}
|
||||
|
||||
if input.upstream_is_stream {
|
||||
force_identity_accept_encoding(&mut headers);
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
@@ -272,6 +277,7 @@ pub fn build_standard_provider_request_headers(
|
||||
headers
|
||||
.entry("accept".to_string())
|
||||
.or_insert_with(|| "text/event-stream".to_string());
|
||||
force_identity_accept_encoding(&mut headers);
|
||||
}
|
||||
|
||||
Some(StandardProviderRequestHeaders {
|
||||
@@ -360,6 +366,10 @@ mod tests {
|
||||
fn builds_same_format_headers_with_complete_passthrough_and_stream_accept() {
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert("x-client", "demo".parse().expect("header"));
|
||||
request_headers.insert(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
"gzip, br".parse().expect("header"),
|
||||
);
|
||||
let transport = sample_transport("openai:chat");
|
||||
let resolved =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
@@ -387,9 +397,43 @@ mod tests {
|
||||
resolved.headers.get("accept"),
|
||||
Some(&"text/event-stream".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.headers.get("accept-encoding"),
|
||||
Some(&"identity".to_string())
|
||||
);
|
||||
assert_eq!(resolved.headers.get("x-client"), Some(&"demo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sync_headers_preserving_supported_accept_encoding() {
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
"gzip, br".parse().expect("header"),
|
||||
);
|
||||
let transport = sample_transport("openai:chat");
|
||||
let resolved =
|
||||
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
||||
transport: &transport,
|
||||
provider_api_format: "openai:chat",
|
||||
same_format: true,
|
||||
headers: &request_headers,
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer secret",
|
||||
extra_headers: &BTreeMap::new(),
|
||||
header_rules: None,
|
||||
provider_request_body: &json!({"model":"gpt-5"}),
|
||||
original_request_body: &json!({"model":"gpt-5"}),
|
||||
upstream_is_stream: false,
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
resolved.headers.get("accept-encoding"),
|
||||
Some(&"gzip".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_header_rules_after_base_headers() {
|
||||
let transport = sample_transport("claude:messages");
|
||||
@@ -473,6 +517,10 @@ mod tests {
|
||||
fn stream_fallback_headers_treat_wildcard_accept_as_absent() {
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert(http::header::ACCEPT, "*/*".parse().expect("header"));
|
||||
request_headers.insert(
|
||||
http::header::ACCEPT_ENCODING,
|
||||
"gzip, br".parse().expect("header"),
|
||||
);
|
||||
|
||||
let headers = build_standard_plan_fallback_headers(StandardPlanFallbackHeadersInput {
|
||||
request_headers: &request_headers,
|
||||
@@ -492,6 +540,10 @@ mod tests {
|
||||
headers.get("accept"),
|
||||
Some(&"text/event-stream".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("accept-encoding"),
|
||||
Some(&"identity".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user