完善 Antigravity 与 Gemini 跨格式兼容

This commit is contained in:
MMEXA
2026-07-08 22:34:29 +08:00
parent b1ed6b24b0
commit f9c8ec41f4
8 changed files with 961 additions and 175 deletions
@@ -0,0 +1,169 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::Value;
use crate::ai_serving::transport::antigravity::{
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
classify_local_antigravity_request_support, AntigravityEnvelopeRequestType,
AntigravityRequestAuth, AntigravityRequestAuthUnsupportedReason,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
AntigravityRequestSideUnsupportedReason,
};
use crate::ai_serving::transport::{
build_standard_provider_request_headers, GatewayProviderTransportSnapshot,
StandardProviderRequestHeaders, StandardProviderRequestHeadersInput,
};
use crate::AppState;
pub(crate) const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
pub(crate) enum AntigravityV1InternalRequestError {
TransportUnsupported,
EnvelopeUnsupported,
UpstreamUrlUnavailable,
HeaderRulesApplyFailed,
}
pub(crate) struct AntigravityV1InternalRequestInput<'a> {
pub(crate) state: &'a AppState,
pub(crate) parts: &'a http::request::Parts,
pub(crate) transport: &'a Arc<GatewayProviderTransportSnapshot>,
pub(crate) trace_id: &'a str,
pub(crate) mapped_model: &'a str,
pub(crate) provider_api_format: &'a str,
pub(crate) auth_header: &'a str,
pub(crate) auth_value: &'a str,
pub(crate) request_headers: &'a http::HeaderMap,
pub(crate) original_request_body: &'a Value,
pub(crate) gemini_request_body: &'a Value,
pub(crate) upstream_is_stream: bool,
pub(crate) same_format: bool,
}
pub(crate) struct AntigravityV1InternalRequest {
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
pub(crate) body: Value,
pub(crate) headers: StandardProviderRequestHeaders,
pub(crate) upstream_url: String,
}
pub(crate) async fn build_antigravity_v1internal_provider_request(
input: AntigravityV1InternalRequestInput<'_>,
) -> Result<AntigravityV1InternalRequest, AntigravityV1InternalRequestError> {
let payload = build_antigravity_v1internal_payload(
input.state,
input.transport,
input.trace_id,
input.mapped_model,
input.gemini_request_body,
)
.await?;
let upstream_url = crate::ai_serving::build_provider_transport_request_url_for_request_body(
&payload.transport,
input.provider_api_format,
Some(input.mapped_model),
input.upstream_is_stream,
input.parts.uri.query(),
None,
Some(&payload.body),
)
.ok_or(AntigravityV1InternalRequestError::UpstreamUrlUnavailable)?;
let extra_headers: BTreeMap<String, String> =
build_antigravity_static_identity_headers(&payload.auth);
let mut headers =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &payload.transport,
provider_api_format: input.provider_api_format,
same_format: input.same_format,
headers: input.request_headers,
auth_header: input.auth_header,
auth_value: input.auth_value,
extra_headers: &extra_headers,
header_rules: payload.transport.endpoint.header_rules.as_ref(),
provider_request_body: &payload.body,
original_request_body: input.original_request_body,
upstream_is_stream: input.upstream_is_stream,
})
.ok_or(AntigravityV1InternalRequestError::HeaderRulesApplyFailed)?;
headers
.headers
.insert("accept".to_string(), "text/event-stream".to_string());
Ok(AntigravityV1InternalRequest {
transport: payload.transport,
body: payload.body,
headers,
upstream_url,
})
}
struct AntigravityV1InternalPayload {
transport: Arc<GatewayProviderTransportSnapshot>,
auth: AntigravityRequestAuth,
body: Value,
}
async fn build_antigravity_v1internal_payload(
state: &AppState,
transport: &Arc<GatewayProviderTransportSnapshot>,
trace_id: &str,
mapped_model: &str,
gemini_request_body: &Value,
) -> Result<AntigravityV1InternalPayload, AntigravityV1InternalRequestError> {
let mut resolved_transport = Arc::clone(transport);
let mut antigravity_support = classify_local_antigravity_request_support(
&resolved_transport,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
);
if matches!(
antigravity_support,
AntigravityRequestSideSupport::Unsupported(
AntigravityRequestSideUnsupportedReason::UnsupportedAuth(
AntigravityRequestAuthUnsupportedReason::MissingProjectId
)
)
) {
if let Some(hydrated) = state
.hydrate_antigravity_project_metadata_for_transport(&resolved_transport)
.await
{
resolved_transport = Arc::new(hydrated);
antigravity_support = classify_local_antigravity_request_support(
&resolved_transport,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
);
}
}
let auth = match antigravity_support {
AntigravityRequestSideSupport::Supported(spec) => spec.auth,
AntigravityRequestSideSupport::Unsupported(_) => {
return Err(AntigravityV1InternalRequestError::TransportUnsupported);
}
};
let body = match build_antigravity_safe_v1internal_request(
&auth,
trace_id,
mapped_model,
gemini_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
return Err(AntigravityV1InternalRequestError::EnvelopeUnsupported);
}
};
Ok(AntigravityV1InternalPayload {
transport: resolved_transport,
auth,
body,
})
}
@@ -1,6 +1,7 @@
use crate::ai_serving::{AiExecutionDecision, AiExecutionPlanPayload, GatewayControlDecision};
use crate::{AppState, GatewayError};
mod antigravity;
mod candidate_affinity_cache;
mod candidate_materialization;
mod candidate_metadata;
@@ -4,6 +4,10 @@ use std::sync::Arc;
use aether_contracts::ResolvedTransportProfile;
use serde_json::{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,
@@ -27,6 +31,7 @@ use crate::ai_serving::planner::standard::{
build_local_openai_chat_upstream_url, 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::auth::resolve_local_openai_bearer_auth;
use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -183,6 +188,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.provider_type
.trim()
.eq_ignore_ascii_case("grok");
let is_antigravity = is_antigravity_provider_transport(transport);
let is_gemini_cli = is_gemini_cli_provider_transport(transport);
if is_grok && is_grok_text_provider_api_format(provider_api_format) {
@@ -549,7 +555,9 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
transport,
conversion_kind,
) {
if !(is_gemini_cli && normalized_provider_api_format == "gemini:generate_content") {
if !((is_antigravity || is_gemini_cli)
&& normalized_provider_api_format == "gemini:generate_content")
{
mark_skipped_local_openai_chat_candidate(
state,
input,
@@ -757,6 +765,28 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
)
.await);
}
if provider_api_format == "gemini:generate_content" && is_antigravity {
return Ok(build_antigravity_openai_chat_cross_format_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
decision_kind,
transport,
provider_api_format.as_str(),
prepared_candidate.mapped_model,
prepared_candidate.auth_header,
prepared_candidate.auth_value,
provider_request_body,
upstream_is_stream,
redaction.redacted,
)
.await);
}
let Some(upstream_url) = build_cross_format_openai_chat_upstream_url(
parts,
@@ -857,6 +887,158 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
}))
}
#[allow(clippy::too_many_arguments)]
async fn build_antigravity_openai_chat_cross_format_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
original_body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
decision_kind: &str,
transport: &Arc<GatewayProviderTransportSnapshot>,
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<LocalOpenAiChatCandidatePayloadParts> {
let candidate = &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_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_unsupported",
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::EnvelopeUnsupported) => {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
CandidateFailureDiagnostic::envelope_build_failed(
"openai:chat",
provider_api_format,
"openai_chat_antigravity_envelope",
),
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::UpstreamUrlUnavailable) => {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
CandidateFailureDiagnostic::upstream_url_missing(
"openai:chat",
provider_api_format,
"openai_chat_antigravity_url",
),
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::HeaderRulesApplyFailed) => {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
CandidateFailureDiagnostic::header_rules_apply_failed(
"openai:chat",
provider_api_format,
"openai_chat_antigravity_headers",
),
)
.await;
return None;
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_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,
);
let resolved_report_kind = if decision_kind == OPENAI_CHAT_STREAM_PLAN_KIND {
"openai_chat_stream_success".to_string()
} else {
"openai_chat_sync_finalize".to_string()
};
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats("openai:chat", provider_api_format);
Some(LocalOpenAiChatCandidatePayloadParts {
client_api_format: "openai:chat".to_string(),
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,
execution_strategy,
conversion_mode,
report_kind: resolved_report_kind,
envelope_name: Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
transport: resolved.transport,
request_redacted,
transport_profile: None,
image_request_summary: None,
})
}
#[allow(clippy::too_many_arguments)]
async fn build_gemini_cli_openai_chat_cross_format_payload_parts(
state: &AppState,
@@ -1923,6 +2105,39 @@ mod tests {
}
}
fn sample_antigravity_transport() -> GatewayProviderTransportSnapshot {
let mut transport = sample_gemini_cli_transport();
transport.provider.name = "antigravity".to_string();
transport.provider.provider_type = "antigravity".to_string();
transport.endpoint.base_url = "https://antigravity.googleapis.com".to_string();
transport.endpoint.custom_path = None;
transport.key.auth_type = "oauth".to_string();
transport.key.decrypted_api_key = "__placeholder__".to_string();
transport.key.upstream_metadata = None;
transport.key.decrypted_auth_config = Some(
json!({
"provider_type": "antigravity",
"project_id": "test-antigravity-project",
"client_version": "1.2.3",
"session_id": "sess-antigravity-chat",
"access_token_import_temporary": true,
"headers": {
"Authorization": "Bearer imported-antigravity-token"
}
})
.to_string(),
);
transport
}
fn sample_antigravity_eligible() -> EligibleLocalExecutionCandidate {
let mut eligible = sample_gemini_cli_eligible();
eligible.candidate.provider_name = "antigravity".to_string();
eligible.candidate.provider_type = "antigravity".to_string();
eligible.transport = Arc::new(sample_antigravity_transport());
eligible
}
#[tokio::test]
async fn openai_chat_to_gemini_cli_wraps_cross_format_body_in_v1internal_envelope() {
let state = AppState::new().expect("state should build");
@@ -1989,6 +2204,93 @@ mod tests {
.is_some());
}
#[tokio::test]
async fn openai_chat_to_antigravity_wraps_cross_format_body_in_v1internal_envelope() {
let state = AppState::new().expect("state should build");
let request = http::Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(http::header::CONTENT_TYPE, "application/json")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body_json = json!({
"model": "gemini-3.5-flash",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
});
let payload = resolve_local_openai_chat_candidate_payload_parts(
&state,
&parts,
"trace-openai-chat-antigravity",
&body_json,
&sample_input(),
None,
&sample_antigravity_eligible(),
0,
"candidate-0",
OPENAI_CHAT_STREAM_PLAN_KIND,
"openai_chat_stream_success",
true,
)
.await
.expect("candidate resolution should not fail")
.expect("antigravity candidate should build a payload");
assert_eq!(
payload.upstream_url,
"https://antigravity.googleapis.com/v1internal:streamGenerateContent?alt=sse"
);
assert_eq!(payload.envelope_name, Some("antigravity:v1internal"));
assert_eq!(
payload
.provider_request_headers
.get("authorization")
.map(String::as_str),
Some("Bearer imported-antigravity-token")
);
assert_eq!(
payload
.provider_request_headers
.get("x-client-name")
.map(String::as_str),
Some("antigravity")
);
assert_eq!(
payload
.provider_request_headers
.get("x-client-version")
.map(String::as_str),
Some("1.2.3")
);
assert_eq!(
payload
.provider_request_headers
.get("x-vscode-sessionid")
.map(String::as_str),
Some("sess-antigravity-chat")
);
assert_eq!(
payload.provider_request_body["project"],
"test-antigravity-project"
);
assert_eq!(
payload.provider_request_body["requestId"],
"trace-openai-chat-antigravity"
);
assert_eq!(payload.provider_request_body["model"], "gemini-2.5-pro");
assert_eq!(payload.provider_request_body["userAgent"], "antigravity");
assert_eq!(payload.provider_request_body["requestType"], "agent");
assert!(payload.provider_request_body.get("contents").is_none());
assert!(payload.provider_request_body["request"]
.get("contents")
.is_some());
assert!(payload.provider_request_body["request"]
.get("model")
.is_none());
}
#[test]
fn chatgpt_web_chat_image_bridge_body_uses_internal_web_shape() {
let body_json = json!({
@@ -31,11 +31,12 @@ pub(super) fn openai_chat_upstream_is_stream_for_candidate(
crate::ai_serving::transport::kiro::is_kiro_claude_messages_transport(
transport,
provider_api_format,
) || openai_chat_gemini_cli_client_stream_requires_upstream_streaming(
transport,
provider_api_format,
client_is_stream,
);
) || openai_chat_antigravity_requires_upstream_streaming(transport, provider_api_format)
|| openai_chat_gemini_cli_client_stream_requires_upstream_streaming(
transport,
provider_api_format,
client_is_stream,
);
resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
@@ -45,6 +46,15 @@ pub(super) fn openai_chat_upstream_is_stream_for_candidate(
)
}
fn openai_chat_antigravity_requires_upstream_streaming(
transport: &GatewayProviderTransportSnapshot,
provider_api_format: &str,
) -> bool {
crate::ai_serving::transport::antigravity::is_antigravity_provider_transport(transport)
&& crate::ai_serving::normalize_api_format_alias(provider_api_format)
== "gemini:generate_content"
}
fn openai_chat_gemini_cli_client_stream_requires_upstream_streaming(
transport: &GatewayProviderTransportSnapshot,
provider_api_format: &str,
@@ -201,4 +211,24 @@ mod tests {
false,
));
}
#[test]
fn openai_chat_policy_resolver_preserves_antigravity_streaming_envelope() {
let antigravity = sample_transport(
"antigravity",
"gemini:generate_content",
Some(json!({"upstream_stream_policy": "force_non_stream"})),
);
assert!(openai_chat_upstream_is_stream_for_candidate(
&antigravity,
"gemini:generate_content",
false,
));
assert!(openai_chat_upstream_is_stream_for_candidate(
&antigravity,
"gemini:generate_content",
true,
));
}
}
@@ -5,6 +5,10 @@ use aether_contracts::ResolvedTransportProfile;
use serde_json::{json, Value};
use tracing::debug;
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,
@@ -29,13 +33,7 @@ use crate::ai_serving::planner::standard::{
build_local_openai_responses_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::{
build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers,
classify_local_antigravity_request_support, is_antigravity_provider_transport,
AntigravityEnvelopeRequestType, AntigravityRequestAuthUnsupportedReason,
AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport,
AntigravityRequestSideUnsupportedReason,
};
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
};
@@ -73,8 +71,6 @@ use super::support::{
};
use super::LocalOpenAiResponsesSpec;
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";
fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
matches!(
crate::ai_serving::normalize_api_format_alias(provider_api_format).as_str(),
@@ -120,7 +116,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
let provider_api_format = eligible.provider_api_format.as_str();
let normalized_provider_api_format =
crate::ai_serving::normalize_api_format_alias(provider_api_format);
let mut transport = Arc::clone(&eligible.transport);
let transport = Arc::clone(&eligible.transport);
let transport_profile = crate::ai_serving::transport::resolve_transport_profile(&transport);
let is_antigravity = is_antigravity_provider_transport(&transport);
let is_gemini_cli = is_gemini_cli_provider_transport(&transport);
@@ -415,82 +411,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
provider_api_format,
Some(body_json),
);
let antigravity_auth = if is_antigravity {
let mut antigravity_support = classify_local_antigravity_request_support(
&transport,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
);
if matches!(
antigravity_support,
AntigravityRequestSideSupport::Unsupported(
AntigravityRequestSideUnsupportedReason::UnsupportedAuth(
AntigravityRequestAuthUnsupportedReason::MissingProjectId
)
)
) {
if let Some(hydrated) = state
.hydrate_antigravity_project_metadata_for_transport(&transport)
.await
{
transport = Arc::new(hydrated);
antigravity_support = classify_local_antigravity_request_support(
&transport,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
);
}
}
match antigravity_support {
AntigravityRequestSideSupport::Supported(spec) => Some(spec.auth),
AntigravityRequestSideSupport::Unsupported(_) => {
mark_skipped_local_openai_responses_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_unsupported",
)
.await;
return Ok(None);
}
}
} else {
None
};
let provider_request_body = if let Some(antigravity_auth) = antigravity_auth.as_ref() {
match build_antigravity_safe_v1internal_request(
antigravity_auth,
trace_id,
&mapped_model,
&base_provider_request_body,
AntigravityEnvelopeRequestType::Agent,
) {
AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope,
AntigravityRequestEnvelopeSupport::Unsupported(_) => {
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
CandidateFailureDiagnostic::envelope_build_failed(
spec_metadata.api_format,
provider_api_format,
"openai_responses_antigravity_envelope",
),
)
.await;
return Ok(None);
}
}
} else {
base_provider_request_body
};
let provider_request_body = base_provider_request_body;
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_openai_responses_payload_parts(
@@ -562,6 +483,28 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
)
.await);
}
if is_antigravity {
return Ok(build_antigravity_openai_responses_payload_parts(
state,
parts,
trace_id,
body_json,
input,
eligible,
candidate_index,
candidate_id,
spec_metadata.api_format,
&transport,
provider_api_format,
mapped_model,
auth_header,
auth_value,
provider_request_body,
upstream_is_stream,
redaction.redacted,
)
.await);
}
let Some(upstream_url) = (if is_grok && is_grok_text_provider_api_format(provider_api_format) {
Some(build_grok_upstream_url(&transport, GROK_CHAT_PATH))
@@ -598,10 +541,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
.await;
return Ok(None);
};
let extra_headers = antigravity_auth
.as_ref()
.map(build_antigravity_static_identity_headers)
.unwrap_or_default();
let extra_headers = BTreeMap::new();
let resolved_headers = if is_grok && is_grok_text_provider_api_format(provider_api_format) {
let Some(headers) = build_grok_browser_headers(GrokHeaderInput {
transport: &transport,
@@ -728,13 +668,8 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
upstream_url,
execution_strategy,
conversion_mode,
is_antigravity: is_antigravity
|| antigravity_auth.is_some() && ANTIGRAVITY_ENVELOPE_NAME == "antigravity:v1internal",
envelope_name: if is_antigravity || antigravity_auth.is_some() {
Some(ANTIGRAVITY_ENVELOPE_NAME)
} else {
None
},
is_antigravity: false,
envelope_name: None,
upstream_is_stream,
transport: Arc::clone(&transport),
transport_profile,
@@ -743,6 +678,160 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
}))
}
#[allow(clippy::too_many_arguments)]
async fn build_antigravity_openai_responses_payload_parts(
state: &AppState,
parts: &http::request::Parts,
trace_id: &str,
original_body_json: &serde_json::Value,
input: &LocalOpenAiResponsesDecisionInput,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
client_api_format: &str,
transport: &Arc<GatewayProviderTransportSnapshot>,
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<LocalOpenAiResponsesCandidatePayloadParts> {
let candidate = &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: api_format_alias_matches(provider_api_format, client_api_format),
})
.await
{
Ok(resolved) => resolved,
Err(AntigravityV1InternalRequestError::TransportUnsupported) => {
mark_skipped_local_openai_responses_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_unsupported",
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::EnvelopeUnsupported) => {
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
CandidateFailureDiagnostic::envelope_build_failed(
client_api_format,
provider_api_format,
"openai_responses_antigravity_envelope",
),
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::UpstreamUrlUnavailable) => {
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"upstream_url_missing",
CandidateFailureDiagnostic::upstream_url_missing(
client_api_format,
provider_api_format,
"openai_responses_antigravity_url",
),
)
.await;
return None;
}
Err(AntigravityV1InternalRequestError::HeaderRulesApplyFailed) => {
mark_skipped_local_openai_responses_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"transport_header_rules_apply_failed",
CandidateFailureDiagnostic::header_rules_apply_failed(
client_api_format,
provider_api_format,
"openai_responses_antigravity_headers",
),
)
.await;
return None;
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_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(),
);
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
resolved.transport.key.decrypted_auth_config.as_deref(),
);
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
request_identity_response_encoding_when_redacted(
&mut provider_request_headers,
request_redacted,
);
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats(client_api_format, provider_api_format);
Some(LocalOpenAiResponsesCandidatePayloadParts {
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,
execution_strategy,
conversion_mode,
is_antigravity: true,
envelope_name: Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
upstream_is_stream,
transport: Arc::clone(&resolved.transport),
transport_profile: crate::ai_serving::transport::resolve_transport_profile(
&resolved.transport,
),
image_request_summary: None,
request_redacted,
})
}
#[allow(clippy::too_many_arguments)]
async fn build_gemini_cli_openai_responses_payload_parts(
state: &AppState,
@@ -1469,15 +1469,36 @@ async fn gateway_executes_openai_chat_cross_format_tool_use_upstream_stream_via_
}
#[test]
fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_transport_unsupported() {
fn gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_finalize_response() {
run_openai_chat_finalize_test(
"gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_transport_unsupported",
gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_transport_unsupported_impl,
"gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_finalize_response",
gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_finalize_response_impl,
);
}
async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_transport_unsupported_impl(
async fn gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_finalize_response_impl(
) {
use base64::Engine as _;
#[derive(Debug, Clone)]
struct SeenExecutionRuntimeSyncRequest {
trace_id: String,
url: String,
accept: String,
authorization: String,
x_client_name: String,
x_client_version: String,
x_vscode_sessionid: String,
x_goog_api_client: String,
project: String,
request_id: String,
model: String,
user_agent: String,
request_type: String,
contents_len: usize,
request_has_model: bool,
}
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
@@ -1582,11 +1603,11 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
)
.expect("endpoint should build")
.with_transport_fields(
"https://generativelanguage.googleapis.com".to_string(),
"https://antigravity.googleapis.com".to_string(),
None,
None,
Some(2),
Some("/v1beta/models/claude-sonnet-4-5:generateContent".to_string()),
None,
None,
None,
None,
@@ -1595,6 +1616,11 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
}
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
let encrypted_auth_config = encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
r#"{"provider_type":"antigravity","project_id":"project-antigravity-chat-local-1","client_version":"1.2.3","session_id":"sess-antigravity-chat-local-123","access_token_import_temporary":true,"headers":{"Authorization":"Bearer imported-antigravity-chat-token"}}"#,
)
.expect("auth config should encrypt");
StoredProviderCatalogKey::new(
"key-openai-chat-antigravity-finalize-local-1".to_string(),
"provider-openai-chat-antigravity-finalize-local-1".to_string(),
@@ -1606,12 +1632,9 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
.expect("key should build")
.with_transport_fields(
Some(serde_json::json!(["gemini:generate_content"])),
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
"sk-upstream-openai-chat-antigravity-finalize",
)
.expect("api key should encrypt"),
None,
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
.expect("api key should encrypt"),
Some(encrypted_auth_config),
None,
Some(serde_json::json!({"gemini:generate_content": 1})),
None,
@@ -1632,6 +1655,8 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
let plan_hits_clone = Arc::clone(&plan_hits);
let public_hits = Arc::new(Mutex::new(0usize));
let public_hits_clone = Arc::clone(&public_hits);
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeSyncRequest>));
let seen_execution_runtime_clone = Arc::clone(&seen_execution_runtime);
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
@@ -1692,42 +1717,130 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(|_request: Request| async move {
Json(json!({
"request_id": "trace-openai-chat-antigravity-sync-123",
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"response": {
"responseId": "resp-antigravity-chat-sync-123",
"candidates": [{
"content": {
"parts": [
{"text": "Need a tool."},
{"functionCall": {"name": "get_weather", "args": {"city": "SF"}}}
],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}],
"modelVersion": "claude-sonnet-4-5",
"usageMetadata": {
"promptTokenCount": 2,
"candidatesTokenCount": 3,
"totalTokenCount": 5
}
},
"responseId": "resp-antigravity-chat-sync-123"
any(move |request: Request| {
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
async move {
let (parts, body) = request.into_parts();
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&raw_body).expect("execution runtime payload should parse");
*seen_execution_runtime_inner.lock().expect("mutex should lock") =
Some(SeenExecutionRuntimeSyncRequest {
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
url: payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
accept: payload
.get("headers")
.and_then(|value| value.get("accept"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_name: payload
.get("headers")
.and_then(|value| value.get("x-client-name"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_version: payload
.get("headers")
.and_then(|value| value.get("x-client-version"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_vscode_sessionid: payload
.get("headers")
.and_then(|value| value.get("x-vscode-sessionid"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_goog_api_client: payload
.get("headers")
.and_then(|value| value.get("x-goog-api-client"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
project: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("project"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
request_id: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("requestId"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
user_agent: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("userAgent"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
request_type: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("requestType"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
contents_len: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("request"))
.and_then(|value| value.get("contents"))
.and_then(|value| value.as_array())
.map(Vec::len)
.unwrap_or_default(),
request_has_model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("request"))
.and_then(|value| value.get("model"))
.is_some(),
});
Json(json!({
"request_id": "trace-openai-chat-antigravity-sync-123",
"status_code": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": {
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
concat!(
"data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello Antigravity Chat\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"modelVersion\":\"claude-sonnet-4-5\",\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}},\"responseId\":\"resp-antigravity-chat-sync-123\"}\n\n"
)
)
},
"telemetry": {
"elapsed_ms": 29
}
},
"telemetry": {
"elapsed_ms": 29
}
}))
}))
}
}),
);
@@ -1779,31 +1892,81 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
.await
.expect("request should succeed");
let elapsed = started_at.elapsed();
let response_status = response.status();
let execution_path = response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let miss_reason = response
.headers()
.get(LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let response_body = response.text().await.expect("body should read");
assert_eq!(response_status, StatusCode::OK);
let response_json: serde_json::Value =
serde_json::from_str(&response_body).expect("body should parse");
assert_eq!(
response_status,
StatusCode::SERVICE_UNAVAILABLE,
"unexpected gateway response: path={execution_path} miss={miss_reason} body={response_body}"
response_json,
json!({
"id": "resp-local-stream",
"object": "chat.completion",
"model": "claude-sonnet-4-5",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello Antigravity Chat"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 2,
"completion_tokens": 3,
"total_tokens": 5
}
})
);
assert!(
elapsed < std::time::Duration::from_millis(3_500),
"response took unexpectedly long for local finalize path: elapsed={elapsed:?} finalize_hits={} report_hits={}",
*finalize_hits.lock().expect("mutex should lock"),
*report_hits.lock().expect("mutex should lock"),
);
let seen_execution_runtime_request = seen_execution_runtime
.lock()
.expect("mutex should lock")
.clone()
.expect("execution runtime request should be captured");
assert_eq!(
seen_execution_runtime_request.trace_id,
"trace-openai-chat-antigravity-sync-123"
);
assert_eq!(
execution_path, "local_execution_runtime_miss",
"unexpected execution path: miss={miss_reason} body={response_body}"
seen_execution_runtime_request.url,
"https://antigravity.googleapis.com/v1internal:streamGenerateContent?alt=sse"
);
assert_eq!(miss_reason, "all_candidates_skipped");
assert_eq!(seen_execution_runtime_request.accept, "text/event-stream");
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer imported-antigravity-chat-token"
);
assert_eq!(seen_execution_runtime_request.x_client_name, "antigravity");
assert_eq!(seen_execution_runtime_request.x_client_version, "1.2.3");
assert_eq!(
seen_execution_runtime_request.x_vscode_sessionid,
"sess-antigravity-chat-local-123"
);
assert_eq!(
seen_execution_runtime_request.x_goog_api_client,
"gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"
);
assert_eq!(
seen_execution_runtime_request.project,
"project-antigravity-chat-local-1"
);
assert_eq!(
seen_execution_runtime_request.request_id,
"trace-openai-chat-antigravity-sync-123"
);
assert_eq!(seen_execution_runtime_request.model, "claude-sonnet-4-5");
assert_eq!(seen_execution_runtime_request.user_agent, "antigravity");
assert_eq!(seen_execution_runtime_request.request_type, "agent");
assert_eq!(seen_execution_runtime_request.contents_len, 1);
assert!(!seen_execution_runtime_request.request_has_model);
let mut stored_candidates = Vec::new();
for _ in 0..50 {
@@ -1819,11 +1982,8 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped);
assert_eq!(
stored_candidates[0].skip_reason.as_deref(),
Some("transport_provider_type_unsupported")
);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Success);
assert_eq!(stored_candidates[0].skip_reason.as_deref(), None);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(*report_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*finalize_hits.lock().expect("mutex should lock"), 0);
@@ -1520,7 +1520,10 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str
seen_remote_execution_runtime_request.url,
"https://antigravity.googleapis.com/v1internal:streamGenerateContent?alt=sse"
);
assert_eq!(seen_remote_execution_runtime_request.accept, "*/*");
assert_eq!(
seen_remote_execution_runtime_request.accept,
"text/event-stream"
);
assert_eq!(
seen_remote_execution_runtime_request.authorization,
"Bearer refreshed-antigravity-cli-access-token"
@@ -4,6 +4,7 @@ use aether_ai_formats::formats::matrix::{
};
use aether_ai_formats::normalize_api_format_alias;
use crate::antigravity::is_antigravity_provider_transport;
use crate::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
};
@@ -132,6 +133,11 @@ pub fn request_conversion_transport_unsupported_reason(
{
return local_windsurf_request_transport_unsupported_reason_with_network(transport);
}
if is_antigravity_provider_transport(transport)
&& normalize_api_format_alias(&transport.endpoint.api_format) == "gemini:generate_content"
{
return None;
}
match normalize_api_format_alias(&transport.endpoint.api_format).as_str() {
"openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
@@ -658,6 +664,32 @@ mod tests {
);
}
#[test]
fn antigravity_gemini_transport_supports_standard_cross_format_conversion_via_envelope() {
let transport = transport_snapshot(
"antigravity",
"gemini:generate_content",
"oauth",
true,
None,
);
assert!(request_pair_allowed_for_transport(
&transport,
"openai:chat",
"gemini:generate_content"
));
assert!(request_pair_allowed_for_transport(
&transport,
"openai:responses",
"gemini:generate_content"
));
assert!(request_conversion_transport_supported(
&transport,
RequestConversionKind::ToGeminiStandard
));
}
#[test]
fn vertex_gemini_embedding_transport_supports_openai_embedding_conversion() {
let transport = transport_snapshot("vertex_ai", "gemini:embedding", "api_key", true, None);