fix(antigravity): wrap cross-format requests in the v1internal envelope

The gemini:generate_content URL hook rewrites any Antigravity endpoint to
/v1internal:generateContent, but only the same-format passthrough and the two
OpenAI decision paths ever built the matching envelope. A Claude Messages or
Gemini client therefore reached the standard family planner, picked up the
rewritten URL, and posted a bare Gemini body that upstream rejects with
"Invalid JSON payload received. Unknown name \"contents\"" -- four retries
across every account, then a 503 that names none of this.

Route the standard family through the shared v1internal builder the same way
gemini_cli already is, so the URL and the body come from one decision. The
OpenAI-image-to-Gemini path cannot carry an envelope at all, so it now skips
Antigravity candidates instead of sending a request upstream can only reject.

Also stop treating a configured proxy as locally unsupported. The execution
plan carries the proxy itself, and the generic and Vertex gates moved to
transport_proxy_is_locally_supported long ago; Antigravity kept rejecting on
proxy.is_some(), which no longer matches how the local runtime executes. A
proxy that resolves to no route still disqualifies the request, and transport
profiles stay unsupported because the v1internal payload never carries one.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
stabey
2026-09-04 22:53:09 +08:00
co-authored by Claude Opus 5
parent 27b0381a9a
commit a6dc43d5f6
4 changed files with 351 additions and 5 deletions
@@ -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::planner::spec_metadata::local_openai_image_spec_metadata;
use crate::ai_serving::pure::normalize_openai_image_request_with_options; 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::{ use crate::ai_serving::transport::{
build_grok_browser_headers, build_grok_upstream_url, build_openai_image_headers, build_grok_browser_headers, build_grok_upstream_url, build_openai_image_headers,
build_openai_image_upstream_url, build_standard_provider_request_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 candidate = &attempt.eligible.candidate;
let transport = &attempt.eligible.transport; let transport = &attempt.eligible.transport;
let provider_api_format = "gemini:generate_content"; 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 effective_headers = input.effective_headers(&parts.headers);
let prepared_candidate = match prepare_header_authenticated_candidate( let prepared_candidate = match prepare_header_authenticated_candidate(
@@ -4,6 +4,10 @@ use std::sync::Arc;
use aether_contracts::ResolvedTransportProfile; use aether_contracts::ResolvedTransportProfile;
use serde_json::Value; 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::{ use crate::ai_serving::planner::candidate_preparation::{
prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth, prepare_header_authenticated_candidate, prepare_header_authenticated_candidate_from_auth,
OauthPreparationContext, OauthPreparationContext,
@@ -26,6 +30,7 @@ use crate::ai_serving::planner::standard::{
openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy, openai_provider_request_contract_failure_extra_data, openai_responses_reasoning_replay_policy,
request_body_build_failure_extra_data, request_conversion_failure_extra_data, 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::{ use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body, build_kiro_provider_headers, build_kiro_provider_request_body,
is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth, is_kiro_claude_messages_transport, KiroProviderHeadersInput, KiroRequestAuth,
@@ -837,6 +842,29 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
.await); .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" if normalized_provider_api_format == "gemini:generate_content"
&& is_gemini_cli_provider_transport(transport) && 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<GatewayProviderTransportSnapshot>,
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<LocalStandardCandidatePayloadParts> {
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)] #[allow(clippy::too_many_arguments)]
async fn build_gemini_cli_cross_format_payload_parts( async fn build_gemini_cli_cross_format_payload_parts(
state: &AppState, state: &AppState,
@@ -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 = let provider_transport_standard =
read_workspace_file("crates/aether-provider/transport/src/standard/mod.rs"); read_workspace_file("crates/aether-provider/transport/src/standard/mod.rs");
for pattern in [ for pattern in [
@@ -1,6 +1,7 @@
use serde_json::Value; use serde_json::Value;
use super::super::snapshot::GatewayProviderTransportSnapshot; use super::super::snapshot::GatewayProviderTransportSnapshot;
use super::super::transport_proxy_is_locally_supported;
use super::auth::{ use super::auth::{
resolve_local_antigravity_request_auth, AntigravityRequestAuth, AntigravityRequestAuthSupport, resolve_local_antigravity_request_auth, AntigravityRequestAuth, AntigravityRequestAuthSupport,
AntigravityRequestAuthUnsupportedReason, ANTIGRAVITY_PROVIDER_TYPE, AntigravityRequestAuthUnsupportedReason, ANTIGRAVITY_PROVIDER_TYPE,
@@ -87,11 +88,11 @@ pub fn classify_local_antigravity_request_support(
AntigravityRequestSideUnsupportedReason::UnsupportedBodyRules, AntigravityRequestSideUnsupportedReason::UnsupportedBodyRules,
); );
} }
if transport.provider.proxy.is_some() // A configured proxy is carried by the execution plan itself, so it only
|| transport.endpoint.proxy.is_some() // disqualifies the local request when it cannot be resolved into a usable
|| transport.key.proxy.is_some() // snapshot. Transport profiles stay unsupported because the v1internal
|| transport.key.fingerprint.is_some() // payload never carries one.
{ if !transport_proxy_is_locally_supported(transport) || transport.key.fingerprint.is_some() {
return AntigravityRequestSideSupport::Unsupported( return AntigravityRequestSideSupport::Unsupported(
AntigravityRequestSideUnsupportedReason::UnsupportedNetworkConfig, AntigravityRequestSideUnsupportedReason::UnsupportedNetworkConfig,
); );
@@ -114,3 +115,145 @@ pub fn classify_local_antigravity_request_support(
AntigravityRequestSideSupport::Supported(AntigravityRequestSideSpec { auth, request_type }) 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));
}
}