diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs index e0d44d283..5c2fbfe31 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs @@ -4,7 +4,7 @@ use crate::ai_serving::transport::apply_standard_provider_request_body_rules_wit use crate::ai_serving::{ apply_codex_openai_responses_chat_body_edits, apply_openai_responses_compact_special_body_edits, - build_cross_format_openai_chat_request_body_with_model_directives as surface_build_cross_format_openai_chat_request_body, + build_cross_format_openai_chat_request_body_with_provider_context as surface_build_cross_format_openai_chat_request_body, build_local_openai_chat_request_body_with_model_directives as surface_build_local_openai_chat_request_body, GatewayProviderTransportSnapshot, }; @@ -70,23 +70,15 @@ pub(crate) fn build_cross_format_openai_chat_request_body( request_headers: &http::HeaderMap, enable_model_directives: bool, ) -> Option { - let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content" - { - aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( - body_json, "openai:chat", mapped_model, provider_type, provider_api_format, - "", upstream_is_stream, None, user_api_key_id, enable_model_directives, - )? - } else { - surface_build_cross_format_openai_chat_request_body( - body_json, - mapped_model, - provider_api_format, - upstream_is_stream, - enable_model_directives, - )? - }; + let provider_request_body = surface_build_cross_format_openai_chat_request_body( + body_json, + mapped_model, + provider_type, + provider_api_format, + upstream_is_stream, + enable_model_directives, + user_api_key_id, + )?; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs index 724ec4a94..5a138ac83 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs @@ -3,7 +3,7 @@ use serde_json::Value; use crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers; use crate::ai_serving::{ apply_openai_responses_compact_special_body_edits, - build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope as surface_build_cross_format_openai_responses_request_body, + build_cross_format_openai_responses_request_body_with_provider_context as surface_build_cross_format_openai_responses_request_body, build_local_openai_responses_request_body_with_model_directives as surface_build_local_openai_responses_request_body, GatewayProviderTransportSnapshot, }; @@ -214,25 +214,16 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_ model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>, enable_model_directives: bool, ) -> Option { - let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content" - { - aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( - body_json, client_api_format, mapped_model, provider_type, provider_api_format, - "", upstream_is_stream, None, history_scope, enable_model_directives, - )? - } else { - surface_build_cross_format_openai_responses_request_body( - body_json, - mapped_model, - client_api_format, - provider_api_format, - upstream_is_stream, - enable_model_directives, - history_scope, - )? - }; + let provider_request_body = surface_build_cross_format_openai_responses_request_body( + body_json, + mapped_model, + client_api_format, + provider_type, + provider_api_format, + upstream_is_stream, + enable_model_directives, + history_scope, + )?; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index 42c45f186..41542a1f2 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -17,9 +17,11 @@ pub(crate) use aether_ai_formats::api::{ build_codex_model_catalog_metadata, build_codex_openai_image_api_provider_request_body, build_core_error_body_for_client_format, build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_request_body_with_model_directives, + build_cross_format_openai_chat_request_body_with_provider_context, build_cross_format_openai_responses_request_body, build_cross_format_openai_responses_request_body_with_model_directives, build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope, + build_cross_format_openai_responses_request_body_with_provider_context, build_gemini_image_request_body_from_openai_image_request, build_gemini_image_response_from_openai_image_response, build_gemini_image_response_from_openai_responses_image_response, build_generated_tool_call_id, diff --git a/crates/aether-ai/formats/src/api.rs b/crates/aether-ai/formats/src/api.rs index 1f0a310bd..fbbd51933 100644 --- a/crates/aether-ai/formats/src/api.rs +++ b/crates/aether-ai/formats/src/api.rs @@ -224,9 +224,11 @@ pub use crate::formats::{ standard_normalize::{ build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_request_body_with_model_directives, + build_cross_format_openai_chat_request_body_with_provider_context, build_cross_format_openai_responses_request_body, build_cross_format_openai_responses_request_body_with_model_directives, build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope, + build_cross_format_openai_responses_request_body_with_provider_context, build_local_openai_chat_request_body, build_local_openai_chat_request_body_with_model_directives, build_local_openai_responses_request_body, diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index 29d5fbc93..9bb764655 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -23,6 +23,17 @@ use crate::formats::shared::standard_normalize::{ is_claude_messages_shaped_body_on_openai_chat_endpoint, }; +/// Tool schema preservation is a format-conversion policy, shared by the +/// standard matrix and provider-aware Chat/Responses entry points. +pub(super) fn preserves_gemini_tool_schemas( + provider_type: &str, + provider_api_format: &str, +) -> bool { + provider_type.trim().eq_ignore_ascii_case("antigravity") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "gemini:generate_content" +} + #[allow(clippy::too_many_arguments)] pub fn build_standard_request_body( body_json: &Value, @@ -132,9 +143,7 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and .with_request_path(request_path) .with_upstream_stream(upstream_is_stream); format_context.preserve_gemini_tool_schemas = - provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content"; + preserves_gemini_tool_schemas(provider_type, provider_api_format); if let Some(history_scope) = user_api_key_id { format_context = format_context.with_history_scope(history_scope); } diff --git a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs index c0e0062a6..45c344161 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs @@ -167,6 +167,40 @@ pub fn build_cross_format_openai_chat_request_body( ) } +/// Provider-aware entry point for gateway Chat planners. Keep private schema +/// conversion policy in the format crate while retaining legacy behavior elsewhere. +pub fn build_cross_format_openai_chat_request_body_with_provider_context( + body_json: &Value, + mapped_model: &str, + provider_type: &str, + provider_api_format: &str, + upstream_is_stream: bool, + enable_model_directives: bool, + history_scope: Option<&str>, +) -> Option { + if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) { + return super::standard_matrix::build_standard_request_body_with_model_directives( + body_json, + "openai:chat", + mapped_model, + provider_type, + provider_api_format, + "", + upstream_is_stream, + None, + history_scope, + enable_model_directives, + ); + } + build_cross_format_openai_chat_request_body_with_model_directives( + body_json, + mapped_model, + provider_api_format, + upstream_is_stream, + enable_model_directives, + ) +} + pub fn build_cross_format_openai_chat_request_body_with_model_directives( body_json: &Value, mapped_model: &str, @@ -342,6 +376,44 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives( ) } +/// Provider-aware Responses entry point; preserve history scoping and defer +/// private tool schema lowering without exposing provider policy to the gateway. +#[allow(clippy::too_many_arguments)] +pub fn build_cross_format_openai_responses_request_body_with_provider_context( + body_json: &Value, + mapped_model: &str, + client_api_format: &str, + provider_type: &str, + provider_api_format: &str, + upstream_is_stream: bool, + enable_model_directives: bool, + history_scope: Option<&str>, +) -> Option { + if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) { + return super::standard_matrix::build_standard_request_body_with_model_directives( + body_json, + client_api_format, + mapped_model, + provider_type, + provider_api_format, + "", + upstream_is_stream, + None, + history_scope, + enable_model_directives, + ); + } + build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( + body_json, + mapped_model, + client_api_format, + provider_api_format, + upstream_is_stream, + enable_model_directives, + history_scope, + ) +} + pub fn build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( body_json: &Value, mapped_model: &str, @@ -433,6 +505,145 @@ mod tests { }; use serde_json::{json, Value}; + #[test] + fn provider_context_builders_preserve_private_schemas_and_legacy_routes() { + use crate::api::{ + build_cross_format_openai_chat_request_body_with_provider_context as chat, + build_cross_format_openai_responses_request_body_with_provider_context as responses, + }; + let schema = json!({"type":"object", "properties":{"mode":{"const":"fast"}}}); + let chat_input = json!({"model":"client", "messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"function","function":{"name":"probe","parameters":schema}}]}); + let responses_input = json!({"model":"client", "input":"hi", + "tools":[{"type":"function","name":"probe","parameters":schema}]}); + for provider in ["antigravity", " AnTiGrAvItY ", "gemini", "openai"] { + for target in [ + "gemini:generate_content", + "claude:messages", + "openai:responses", + ] { + for stream in [false, true] { + for directives in [false, true] { + for input in [&chat_input, &responses_input] { + let actual = chat( + input, + "claude-test", + provider, + target, + stream, + directives, + Some("seam-test"), + ); + let expected = + if super::super::standard_matrix::preserves_gemini_tool_schemas( + provider, target, + ) { + super::super::standard_matrix::build_standard_request_body_with_model_directives( + input, "openai:chat", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives) + } else { + super::build_cross_format_openai_chat_request_body_with_model_directives( + input, "claude-test", target, stream, directives) + }; + assert!(actual.is_some(), "chat {provider} {target}"); + assert_eq!(actual, expected); + if target == "gemini:generate_content" { + assert_eq!( + actual.unwrap()["tools"][0]["functionDeclarations"][0] + ["parameters"] + == schema, + provider.trim().eq_ignore_ascii_case("antigravity") + ); + } + } + let actual = responses( + &responses_input, + "claude-test", + "openai:responses", + provider, + target, + stream, + directives, + Some("seam-test"), + ); + let expected = + if super::super::standard_matrix::preserves_gemini_tool_schemas( + provider, target, + ) { + super::super::standard_matrix::build_standard_request_body_with_model_directives( + &responses_input, "openai:responses", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives) + } else { + super::build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( + &responses_input, "claude-test", "openai:responses", target, stream, directives, Some("seam-test")) + }; + // Same-format Responses uses the local builder, not this cross-format API. + assert_eq!( + actual.is_some(), + target != "openai:responses", + "responses {provider} {target}" + ); + assert_eq!(actual, expected); + } + } + } + } + } + + #[test] + fn provider_context_builders_keep_scoped_responses_history() { + use crate::api::{ + build_cross_format_openai_chat_request_body_with_provider_context as chat, + build_cross_format_openai_responses_request_body_with_provider_context as responses, + record_converted_response_history, + }; + let response_id = "resp_provider_context_seam_history"; + let scope = "provider-context-seam-history"; + record_converted_response_history(&json!({ + "needs_conversion":true, "client_api_format":"openai:responses", + "provider_api_format":"openai:chat", "api_key_id":scope, + "original_request_body":{"model":"client", "input":"first"} + }), &json!({"id":response_id, "status":"completed", "output":[{ + "type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"remembered"}] + }]})).expect("seed scoped history"); + let input = json!({"model":"client", "previous_response_id":response_id, "input":"second"}); + for use_chat in [false, true] { + let build = |history_scope| { + if use_chat { + chat( + &input, + "claude-test", + "antigravity", + "gemini:generate_content", + true, + false, + history_scope, + ) + } else { + responses( + &input, + "claude-test", + "openai:responses", + "antigravity", + "gemini:generate_content", + true, + false, + history_scope, + ) + } + }; + if use_chat { + // The legacy Chat alternate-shape path does not hydrate scoped + // Responses history. Preserve that behavior during this refactor. + assert!(build(Some(scope)).is_none()); + continue; + } + let output = build(Some(scope)).expect("expand scoped history"); + assert_eq!(output["contents"][0]["parts"][0]["text"], "first"); + assert_eq!(output["contents"][1]["parts"][0]["text"], "remembered"); + assert_eq!(output["contents"][2]["parts"][0]["text"], "second"); + assert!(build(Some("different-seam-key")).is_none()); + } + } + fn object_keys(value: &Value) -> Vec<&str> { value .as_object()