diff --git a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs index 37cc057cb..3ca11f0d2 100644 --- a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs @@ -9,6 +9,7 @@ use crate::ai_serving::planner::candidate_preparation::{ }; use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata; use crate::ai_serving::pure::normalize_openai_image_request_with_options; +use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport; use crate::ai_serving::transport::{ build_grok_browser_headers, build_grok_upstream_url, build_openai_image_headers, build_openai_image_upstream_url, build_standard_provider_request_headers, @@ -338,6 +339,25 @@ async fn resolve_local_openai_image_to_gemini_candidate_payload_parts( let candidate = &attempt.eligible.candidate; let transport = &attempt.eligible.transport; let provider_api_format = "gemini:generate_content"; + + // The gemini:generate_content URL hook rewrites an Antigravity endpoint to + // /v1internal:, and this image path has no v1internal envelope to match it. + // Skip the candidate instead of posting a bare Gemini body that upstream + // would only reject. + if is_antigravity_provider_transport(transport) { + mark_skipped_local_openai_image_candidate( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + "transport_unsupported", + ) + .await; + return None; + } + let effective_headers = input.effective_headers(&parts.headers); let prepared_candidate = match prepare_header_authenticated_candidate( diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs index 338c26c49..1acc72a32 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs @@ -4,6 +4,10 @@ use std::sync::Arc; use aether_contracts::ResolvedTransportProfile; use serde_json::Value; +use crate::ai_serving::planner::antigravity::{ + build_antigravity_v1internal_provider_request, AntigravityV1InternalRequestError, + AntigravityV1InternalRequestInput, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, +}; use crate::ai_serving::planner::candidate_preparation::{ prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth, OauthPreparationContext, @@ -26,6 +30,7 @@ use crate::ai_serving::planner::standard::{ openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy, request_body_build_failure_extra_data, request_conversion_failure_extra_data, }; +use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport; use crate::ai_serving::transport::kiro::{ build_kiro_provider_headers, build_kiro_provider_request_body, is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth, @@ -837,6 +842,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts( .await); } + if normalized_provider_api_format == "gemini:generate_content" + && is_antigravity_provider_transport(transport) + { + return Ok(build_antigravity_cross_format_payload_parts( + state, + parts, + trace_id, + body_json, + input, + attempt, + transport, + spec_metadata.api_format, + provider_api_format, + prepared_candidate.mapped_model, + prepared_candidate.auth_header, + prepared_candidate.auth_value, + provider_request_body, + upstream_is_stream, + redaction.redacted, + ) + .await); + } + if normalized_provider_api_format == "gemini:generate_content" && is_gemini_cli_provider_transport(transport) { @@ -963,6 +991,145 @@ fn apply_transport_request_body_semantics( ) } +#[allow(clippy::too_many_arguments)] +async fn build_antigravity_cross_format_payload_parts( + state: &AppState, + parts: &http::request::Parts, + trace_id: &str, + original_body_json: &serde_json::Value, + input: &LocalStandardDecisionInput, + attempt: &LocalStandardCandidateAttempt, + transport: &Arc, + client_api_format: &str, + provider_api_format: &str, + mapped_model: String, + auth_header: String, + auth_value: String, + gemini_request_body: Value, + upstream_is_stream: bool, + request_redacted: bool, +) -> Option { + let candidate = &attempt.eligible.candidate; + let effective_headers = input.effective_headers(&parts.headers); + let resolved = + match build_antigravity_v1internal_provider_request(AntigravityV1InternalRequestInput { + state, + parts, + transport, + trace_id, + mapped_model: &mapped_model, + provider_api_format, + auth_header: &auth_header, + auth_value: &auth_value, + request_headers: effective_headers, + original_request_body: original_body_json, + gemini_request_body: &gemini_request_body, + upstream_is_stream, + same_format: false, + }) + .await + { + Ok(resolved) => resolved, + Err(AntigravityV1InternalRequestError::TransportUnsupported) => { + mark_skipped_local_standard_candidate( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + "transport_unsupported", + ) + .await; + return None; + } + Err(AntigravityV1InternalRequestError::EnvelopeUnsupported) => { + mark_skipped_local_standard_candidate_with_extra_data( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + "provider_request_body_build_failed", + request_body_build_failure_extra_data( + original_body_json, + client_api_format, + provider_api_format, + ), + ) + .await; + return None; + } + Err(AntigravityV1InternalRequestError::UpstreamUrlUnavailable) => { + mark_skipped_local_standard_candidate_with_failure_diagnostic( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + "upstream_url_missing", + CandidateFailureDiagnostic::upstream_url_missing( + client_api_format, + provider_api_format, + "standard_family_antigravity_url", + ), + ) + .await; + return None; + } + Err(AntigravityV1InternalRequestError::HeaderRulesApplyFailed) => { + mark_skipped_local_standard_candidate_with_failure_diagnostic( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + "transport_header_rules_apply_failed", + CandidateFailureDiagnostic::header_rules_apply_failed( + client_api_format, + provider_api_format, + "standard_family_antigravity_headers", + ), + ) + .await; + return None; + } + }; + + let mut provider_request_headers = resolved.headers.headers; + apply_codex_openai_special_headers( + &mut provider_request_headers, + &resolved.body, + effective_headers, + resolved.transport.provider.provider_type.as_str(), + provider_api_format, + Some(trace_id), + resolved.transport.key.decrypted_auth_config.as_deref(), + ); + request_identity_response_encoding_when_redacted( + &mut provider_request_headers, + request_redacted, + ); + + Some(LocalStandardCandidatePayloadParts { + auth_header: resolved.headers.auth_header, + auth_value: resolved.headers.auth_value, + mapped_model, + provider_api_format: provider_api_format.to_string(), + provider_request_body: resolved.body, + provider_request_headers, + upstream_url: resolved.upstream_url, + upstream_is_stream, + envelope_name: Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME), + transport: resolved.transport, + transport_profile: None, + request_redacted, + }) +} + #[allow(clippy::too_many_arguments)] async fn build_gemini_cli_cross_format_payload_parts( state: &AppState, diff --git a/apps/aether-gateway/src/tests/architecture/ai_serving.rs b/apps/aether-gateway/src/tests/architecture/ai_serving.rs index 3859d68e8..95e66231c 100644 --- a/apps/aether-gateway/src/tests/architecture/ai_serving.rs +++ b/apps/aether-gateway/src/tests/architecture/ai_serving.rs @@ -2893,6 +2893,22 @@ fn ai_serving_standard_attempts_consume_eligible_local_candidates_without_transp ); } + let standard_family_request = read_workspace_file( + "apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs", + ); + for pattern in [ + "is_antigravity_provider_transport(", + "build_antigravity_v1internal_provider_request(", + "is_gemini_cli_provider_transport(", + "build_gemini_cli_v1internal_provider_request(", + ] { + assert!( + standard_family_request.contains(pattern), + "standard family request preparation should build v1internal envelopes through {pattern} \ + so a cross-format client never posts a bare Gemini body to a v1internal URL" + ); + } + let provider_transport_standard = read_workspace_file("crates/aether-provider/transport/src/standard/mod.rs"); for pattern in [ diff --git a/crates/aether-provider/transport/src/antigravity/policy.rs b/crates/aether-provider/transport/src/antigravity/policy.rs index 27e20fb10..ea95a819c 100644 --- a/crates/aether-provider/transport/src/antigravity/policy.rs +++ b/crates/aether-provider/transport/src/antigravity/policy.rs @@ -1,6 +1,7 @@ use serde_json::Value; use super::super::snapshot::GatewayProviderTransportSnapshot; +use super::super::transport_proxy_is_locally_supported; use super::auth::{ resolve_local_antigravity_request_auth, AntigravityRequestAuth, AntigravityRequestAuthSupport, AntigravityRequestAuthUnsupportedReason, ANTIGRAVITY_PROVIDER_TYPE, @@ -87,11 +88,11 @@ pub fn classify_local_antigravity_request_support( AntigravityRequestSideUnsupportedReason::UnsupportedBodyRules, ); } - if transport.provider.proxy.is_some() - || transport.endpoint.proxy.is_some() - || transport.key.proxy.is_some() - || transport.key.fingerprint.is_some() - { + // A configured proxy is carried by the execution plan itself, so it only + // disqualifies the local request when it cannot be resolved into a usable + // snapshot. Transport profiles stay unsupported because the v1internal + // payload never carries one. + if !transport_proxy_is_locally_supported(transport) || transport.key.fingerprint.is_some() { return AntigravityRequestSideSupport::Unsupported( AntigravityRequestSideUnsupportedReason::UnsupportedNetworkConfig, ); @@ -114,3 +115,145 @@ pub fn classify_local_antigravity_request_support( AntigravityRequestSideSupport::Supported(AntigravityRequestSideSpec { auth, request_type }) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::super::request::AntigravityEnvelopeRequestType; + use super::{ + classify_local_antigravity_request_support, AntigravityRequestSideSupport, + AntigravityRequestSideUnsupportedReason, + }; + use crate::snapshot::{ + GatewayProviderTransportEndpoint, GatewayProviderTransportKey, + GatewayProviderTransportProvider, GatewayProviderTransportSnapshot, + }; + + fn sample_transport() -> GatewayProviderTransportSnapshot { + GatewayProviderTransportSnapshot { + provider: GatewayProviderTransportProvider { + id: "provider-1".to_string(), + name: "Antigravity".to_string(), + provider_type: "antigravity".to_string(), + website: None, + is_active: true, + keep_priority_on_conversion: false, + enable_format_conversion: true, + concurrent_limit: None, + max_retries: None, + proxy: None, + request_timeout_secs: None, + stream_first_byte_timeout_secs: None, + config: None, + }, + endpoint: GatewayProviderTransportEndpoint { + id: "endpoint-1".to_string(), + provider_id: "provider-1".to_string(), + api_format: "gemini:generate_content".to_string(), + api_family: Some("gemini".to_string()), + endpoint_kind: Some("generate_content".to_string()), + is_active: true, + base_url: "https://daily-cloudcode-pa.googleapis.com".to_string(), + header_rules: None, + body_rules: None, + max_retries: None, + custom_path: None, + config: None, + format_acceptance_config: None, + proxy: None, + }, + key: GatewayProviderTransportKey { + id: "key-1".to_string(), + provider_id: "provider-1".to_string(), + name: "key".to_string(), + auth_type: "oauth".to_string(), + is_active: true, + api_formats: Some(vec!["gemini:generate_content".to_string()]), + auth_type_by_format: None, + allow_auth_channel_mismatch_formats: None, + allowed_models: None, + capabilities: None, + rate_multipliers: None, + global_priority_by_format: None, + expires_at_unix_secs: None, + proxy: None, + fingerprint: None, + upstream_metadata: None, + decrypted_api_key: "__placeholder__".to_string(), + decrypted_auth_config: Some( + r#"{"provider_type":"antigravity","refresh_token":"rt","cloudaicompanionProject":"project-1"}"# + .to_string(), + ), + }, + } + } + + fn classify(transport: &GatewayProviderTransportSnapshot) -> AntigravityRequestSideSupport { + classify_local_antigravity_request_support( + transport, + &json!({"contents": []}), + AntigravityEnvelopeRequestType::Agent, + ) + } + + fn assert_unsupported_network_config(support: AntigravityRequestSideSupport) { + assert_eq!( + support, + AntigravityRequestSideSupport::Unsupported( + AntigravityRequestSideUnsupportedReason::UnsupportedNetworkConfig, + ) + ); + } + + #[test] + fn a_resolvable_tunnel_node_proxy_keeps_the_envelope_supported() { + let mut transport = sample_transport(); + transport.provider.proxy = Some(json!({ + "enabled": true, + "node_id": "702d158b-a432-4694-94cc-3bec13dbbc20", + })); + + assert!(matches!( + classify(&transport), + AntigravityRequestSideSupport::Supported(_) + )); + } + + #[test] + fn a_resolvable_url_proxy_keeps_the_envelope_supported() { + for proxy_owner in ["provider", "endpoint", "key"] { + let mut transport = sample_transport(); + let proxy = Some(json!({"enabled": true, "url": "http://127.0.0.1:17000"})); + match proxy_owner { + "provider" => transport.provider.proxy = proxy, + "endpoint" => transport.endpoint.proxy = proxy, + _ => transport.key.proxy = proxy, + } + + assert!( + matches!( + classify(&transport), + AntigravityRequestSideSupport::Supported(_) + ), + "a {proxy_owner} proxy should not disqualify the antigravity envelope" + ); + } + } + + #[test] + fn a_proxy_without_a_route_still_disqualifies_the_envelope() { + let mut transport = sample_transport(); + transport.provider.proxy = Some(json!({"enabled": true})); + + assert_unsupported_network_config(classify(&transport)); + } + + #[test] + fn a_key_fingerprint_still_disqualifies_the_envelope() { + let mut transport = sample_transport(); + transport.key.fingerprint = Some(json!({"transport_profile": "chrome"})); + + assert_unsupported_network_config(classify(&transport)); + } +}