diff --git a/apps/aether-gateway/src/ai_serving/planner/antigravity.rs b/apps/aether-gateway/src/ai_serving/planner/antigravity.rs new file mode 100644 index 000000000..6dc684ba1 --- /dev/null +++ b/apps/aether-gateway/src/ai_serving/planner/antigravity.rs @@ -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, + 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, + 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 { + 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 = + 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, + auth: AntigravityRequestAuth, + body: Value, +} + +async fn build_antigravity_v1internal_payload( + state: &AppState, + transport: &Arc, + trace_id: &str, + mapped_model: &str, + gemini_request_body: &Value, +) -> Result { + 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, + }) +} diff --git a/apps/aether-gateway/src/ai_serving/planner/mod.rs b/apps/aether-gateway/src/ai_serving/planner/mod.rs index a58d3bd13..29d86f149 100644 --- a/apps/aether-gateway/src/ai_serving/planner/mod.rs +++ b/apps/aether-gateway/src/ai_serving/planner/mod.rs @@ -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; diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index cbfe3a8bb..ffb393206 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -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, + 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 = &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,96 @@ 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/cli/1.0.16 (aidev_client; os_type=linux; arch=arm64; auth_method=consumer)" + ); + 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!({ diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans.rs index 4964b2542..bfdc56c56 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans.rs @@ -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, + )); + } } diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs index 7c730bfc6..e6edbcf61 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs @@ -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, + 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 = &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, diff --git a/apps/aether-gateway/src/api/ai/gemini.rs b/apps/aether-gateway/src/api/ai/gemini.rs index 02037a0aa..4532d3041 100644 --- a/apps/aether-gateway/src/api/ai/gemini.rs +++ b/apps/aether-gateway/src/api/ai/gemini.rs @@ -1,6 +1,7 @@ pub(crate) fn normalized_signature(api_format: &str) -> Option<&'static str> { match crate::ai_serving::normalize_api_format_alias(api_format).as_str() { "gemini:generate_content" => Some("gemini:generate_content"), + "gemini:interactions" => Some("gemini:interactions"), "gemini:embedding" => Some("gemini:embedding"), "gemini:video" => Some("gemini:video"), "gemini:files" => Some("gemini:files"), @@ -11,6 +12,7 @@ pub(crate) fn normalized_signature(api_format: &str) -> Option<&'static str> { pub(crate) fn local_path(api_format: &str) -> Option<&'static str> { match crate::ai_serving::normalize_api_format_alias(api_format).as_str() { "gemini" | "gemini:generate_content" => Some("/v1beta/models/{model}:{action}"), + "gemini:interactions" => Some("/v1/interactions"), "gemini:embedding" => Some("/v1beta/models/{model}:{action}"), "gemini:video" => Some("/v1beta/models/{model}:predictLongRunning"), "gemini:files" => Some("/v1beta/files"), diff --git a/apps/aether-gateway/src/api/ai/registry.rs b/apps/aether-gateway/src/api/ai/registry.rs index 119eabc00..5d92375c5 100644 --- a/apps/aether-gateway/src/api/ai/registry.rs +++ b/apps/aether-gateway/src/api/ai/registry.rs @@ -17,13 +17,17 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[ "/v1/responses/compact", "/v1/images/generations", "/v1/images/edits", + "/v1/interactions", + "/v1beta/interactions", "/v1internal:loadCodeAssist", "/v1internal:fetchAvailableModels", + "/v1internal:retrieveUserQuotaSummary", "/v1internal:fetchUserInfo", "/v1internal:fetchAdminControls", "/v1internal:setUserSettings", "/v1internal:listExperiments", "/v1internal:recordCodeAssistMetrics", + "/v1internal:writeTrajectoryAcls", "/v1internal:streamGenerateContent", ]; @@ -95,6 +99,12 @@ mod tests { fn supports_data_api_endpoint_signatures_and_public_paths() { for (api_format, family, kind, path) in [ ("openai:embedding", "openai", "embedding", "/v1/embeddings"), + ( + "gemini:interactions", + "gemini", + "interactions", + "/v1/interactions", + ), ( "gemini:embedding", "gemini", diff --git a/apps/aether-gateway/src/constants.rs b/apps/aether-gateway/src/constants.rs index e3b52bcbd..8052f2001 100644 --- a/apps/aether-gateway/src/constants.rs +++ b/apps/aether-gateway/src/constants.rs @@ -141,11 +141,13 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[ "/v1beta/files/{path...}", "/v1internal:loadCodeAssist", "/v1internal:fetchAvailableModels", + "/v1internal:retrieveUserQuotaSummary", "/v1internal:fetchUserInfo", "/v1internal:fetchAdminControls", "/v1internal:setUserSettings", "/v1internal:listExperiments", "/v1internal:recordCodeAssistMetrics", + "/v1internal:writeTrajectoryAcls", "/v1internal:streamGenerateContent", "/", "/{*path}", diff --git a/apps/aether-gateway/src/control/route/ai.rs b/apps/aether-gateway/src/control/route/ai.rs index bfe95620b..ec8ba8d96 100644 --- a/apps/aether-gateway/src/control/route/ai.rs +++ b/apps/aether-gateway/src/control/route/ai.rs @@ -97,6 +97,17 @@ pub(super) fn classify_ai_public_route( "openai:video", true, )) + } else if method == http::Method::POST + && matches!(normalized_path, "/v1/interactions" | "/v1beta/interactions") + { + Some(classified_with_request_auth_channel( + "ai_public", + "gemini", + "interactions", + "api_key", + "gemini:interactions", + true, + )) } else if method == http::Method::POST && is_gemini_models_route(normalized_path) { if normalized_path.ends_with(":predictLongRunning") { Some(classified( @@ -182,11 +193,13 @@ fn classify_antigravity_v1internal_route( let (route_kind, execution_runtime_candidate) = match action { "loadCodeAssist" => ("load_code_assist", false), "fetchAvailableModels" => ("fetch_available_models", false), + "retrieveUserQuotaSummary" => ("retrieve_user_quota_summary", false), "fetchUserInfo" => ("fetch_user_info", false), "fetchAdminControls" => ("fetch_admin_controls", false), "setUserSettings" => ("set_user_settings", false), "listExperiments" => ("list_experiments", false), "recordCodeAssistMetrics" => ("record_code_assist_metrics", false), + "writeTrajectoryAcls" => ("write_trajectory_acls", false), "streamGenerateContent" => ("stream_generate_content", true), _ => return None, }; diff --git a/apps/aether-gateway/src/control/tests/ai.rs b/apps/aether-gateway/src/control/tests/ai.rs index 48f781d67..87c0097b5 100644 --- a/apps/aether-gateway/src/control/tests/ai.rs +++ b/apps/aether-gateway/src/control/tests/ai.rs @@ -274,6 +274,23 @@ fn classifies_gemini_batch_embed_contents_as_embedding_route() { assert!(decision.is_execution_runtime_candidate()); } +#[test] +fn classifies_gemini_interactions_as_interactions_route() { + let headers = headers(&[("x-goog-api-key", "gemini-key")]); + let uri: Uri = "/v1/interactions".parse().expect("uri should parse"); + let decision = + classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify"); + + assert_eq!(decision.route_family.as_deref(), Some("gemini")); + assert_eq!(decision.route_kind.as_deref(), Some("interactions")); + assert_eq!(decision.request_auth_channel.as_deref(), Some("api_key")); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("gemini:interactions") + ); + assert!(decision.is_execution_runtime_candidate()); +} + #[test] fn classifies_gemini_predict_long_running_as_video_route() { let headers = headers(&[]); @@ -302,6 +319,10 @@ fn classifies_antigravity_v1internal_control_plane_routes() { for (path, route_kind) in [ ("/v1internal:loadCodeAssist", "load_code_assist"), ("/v1internal:fetchAvailableModels", "fetch_available_models"), + ( + "/v1internal:retrieveUserQuotaSummary", + "retrieve_user_quota_summary", + ), ("/v1internal:fetchUserInfo", "fetch_user_info"), ("/v1internal:fetchAdminControls", "fetch_admin_controls"), ("/v1internal:setUserSettings", "set_user_settings"), @@ -310,6 +331,7 @@ fn classifies_antigravity_v1internal_control_plane_routes() { "/v1internal:recordCodeAssistMetrics", "record_code_assist_metrics", ), + ("/v1internal:writeTrajectoryAcls", "write_trajectory_acls"), ] { let uri: Uri = path.parse().expect("uri should parse"); let decision = classify_control_route(&http::Method::POST, &uri, &headers) diff --git a/apps/aether-gateway/src/execution_runtime/fallback.rs b/apps/aether-gateway/src/execution_runtime/fallback.rs index 9ee333206..a50e6ef30 100644 --- a/apps/aether-gateway/src/execution_runtime/fallback.rs +++ b/apps/aether-gateway/src/execution_runtime/fallback.rs @@ -179,6 +179,7 @@ pub(crate) fn resolve_core_sync_error_finalize_report_kind( "openai_responses_compact_sync" => "openai_responses_compact_sync_finalize", "claude_chat_sync" => "claude_chat_sync_finalize", "gemini_chat_sync" => "gemini_chat_sync_finalize", + "gemini_interactions_sync" => "gemini_interactions_sync_finalize", "claude_cli_sync" => "claude_cli_sync_finalize", "gemini_cli_sync" => "gemini_cli_sync_finalize", _ => return None, @@ -324,6 +325,7 @@ pub(crate) fn resolve_core_stream_error_finalize_report_kind( "openai_chat_stream" => "openai_chat_sync_finalize", "claude_chat_stream" => "claude_chat_sync_finalize", "gemini_chat_stream" => "gemini_chat_sync_finalize", + "gemini_interactions_stream" => "gemini_interactions_sync_finalize", "openai_responses_stream" => "openai_responses_sync_finalize", "openai_responses_compact_stream" => "openai_responses_compact_sync_finalize", "claude_cli_stream" => "claude_cli_sync_finalize", @@ -340,6 +342,7 @@ pub(crate) fn resolve_core_stream_direct_finalize_report_kind(plan_kind: &str) - "openai_image_stream" => "openai_image_sync_finalize", "claude_chat_stream" => "claude_chat_sync_finalize", "gemini_chat_stream" => "gemini_chat_sync_finalize", + "gemini_interactions_stream" => "gemini_interactions_sync_finalize", "openai_responses_stream" => "openai_responses_sync_finalize", "openai_responses_compact_stream" => "openai_responses_compact_sync_finalize", "claude_cli_stream" => "claude_cli_sync_finalize", diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs index fbb6f1f48..b37e1e36a 100644 --- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs @@ -2637,7 +2637,20 @@ async fn execute_execution_runtime_sync_impl( candidate_id, )?; if !usage_payload.report_kind.trim().is_empty() { - spawn_sync_report(state.clone(), usage_payload); + if status_code >= 400 { + let report_kind = usage_payload.report_kind.clone(); + if let Err(err) = submit_sync_report(state, usage_payload).await { + warn!( + event_name = "local_sync_error_report_submit_failed", + log_type = "ops", + trace_id = %trace_id, + report_kind = %report_kind, + "gateway failed to submit local sync error report before returning response: {err:?}" + ); + } + } else { + spawn_sync_report(state.clone(), usage_payload); + } } Ok(Some(response)) diff --git a/apps/aether-gateway/src/execution_runtime/tests.rs b/apps/aether-gateway/src/execution_runtime/tests.rs index 7334edccd..7a0016cc9 100644 --- a/apps/aether-gateway/src/execution_runtime/tests.rs +++ b/apps/aether-gateway/src/execution_runtime/tests.rs @@ -104,6 +104,10 @@ fn resolve_core_error_background_report_kind_maps_all_core_finalize_kinds() { ("openai_chat_sync_finalize", Some("openai_chat_sync_error")), ("claude_chat_sync_finalize", Some("claude_chat_sync_error")), ("gemini_chat_sync_finalize", Some("gemini_chat_sync_error")), + ( + "gemini_interactions_sync_finalize", + Some("gemini_interactions_sync_error"), + ), ( "openai_responses_sync_finalize", Some("openai_responses_sync_error"), @@ -151,6 +155,10 @@ fn resolve_core_success_background_report_kind_maps_all_core_finalize_kinds() { "gemini_chat_sync_finalize", Some("gemini_chat_sync_success"), ), + ( + "gemini_interactions_sync_finalize", + Some("gemini_interactions_sync_success"), + ), ( "openai_responses_sync_finalize", Some("openai_responses_sync_success"), diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs index 19ec95060..d3e7fc89e 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs @@ -36,8 +36,8 @@ use crate::provider_key_auth::{ use crate::provider_transport::antigravity::{ build_antigravity_safe_v1internal_request, build_antigravity_static_identity_headers, classify_local_antigravity_request_support, AntigravityEnvelopeRequestType, - AntigravityRequestEnvelopeSupport, AntigravityRequestSideSupport, - AntigravityRequestSideUnsupportedReason, + AntigravityRequestAuthUnsupportedReason, AntigravityRequestEnvelopeSupport, + AntigravityRequestSideSupport, AntigravityRequestSideUnsupportedReason, }; use crate::provider_transport::kiro::{ build_kiro_generate_assistant_response_url, build_kiro_provider_headers, @@ -2061,7 +2061,7 @@ async fn provider_query_execute_openai_image_test_candidate( trace_id: &str, requested_model: &str, ) -> Result { - let Some(transport) = state + let Some(mut transport) = state .read_provider_transport_snapshot(&provider.id, &candidate.endpoint.id, &candidate.key.id) .await? else { @@ -2429,7 +2429,7 @@ async fn provider_query_execute_antigravity_test_candidate( trace_id: &str, requested_model: &str, ) -> Result { - let Some(transport) = state + let Some(mut transport) = state .read_provider_transport_snapshot(&provider.id, &candidate.endpoint.id, &candidate.key.id) .await? else { @@ -2463,11 +2463,34 @@ async fn provider_query_execute_antigravity_test_candidate( )); }; - let antigravity_spec = match classify_local_antigravity_request_support( + let mut antigravity_support = classify_local_antigravity_request_support( &transport, &base_provider_request_body, AntigravityEnvelopeRequestType::EndpointTest, + ); + if matches!( + antigravity_support, + AntigravityRequestSideSupport::Unsupported( + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::MissingProjectId + ) + ) ) { + if let Some(hydrated) = state + .app() + .hydrate_antigravity_project_metadata_for_transport(&transport) + .await + { + transport = hydrated; + antigravity_support = classify_local_antigravity_request_support( + &transport, + &base_provider_request_body, + AntigravityEnvelopeRequestType::EndpointTest, + ); + } + } + + let antigravity_spec = match antigravity_support { AntigravityRequestSideSupport::Supported(spec) => spec, AntigravityRequestSideSupport::Unsupported(reason) => { let reason = provider_query_antigravity_unsupported_reason(reason); diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/adapter.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/adapter.rs index 876ebd1d9..86333dc64 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/adapter.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/adapter.rs @@ -2,7 +2,8 @@ use super::DEFAULT_PROVIDER_QUERY_TEST_MESSAGE; use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; use crate::provider_transport::antigravity::{ classify_local_antigravity_request_support, AntigravityEnvelopeRequestType, - AntigravityRequestSideSupport, AntigravityRequestSideUnsupportedReason, + AntigravityRequestAuthUnsupportedReason, AntigravityRequestSideSupport, + AntigravityRequestSideUnsupportedReason, }; use crate::provider_transport::kiro::supports_local_kiro_request_transport_with_network; use serde_json::{json, Value}; @@ -82,14 +83,14 @@ pub(super) fn provider_query_standard_test_unsupported_reason( api_format, ) } - "gemini:generate_content" | "gemini:embedding" + "gemini:generate_content" | "gemini:embedding" | "gemini:interactions" if crate::provider_transport::is_vertex_transport_context(transport) => { aether_provider_transport::vertex::local_vertex_gemini_transport_unsupported_reason_with_network( transport, ) } - "gemini:generate_content" | "gemini:embedding" => { + "gemini:generate_content" | "gemini:embedding" | "gemini:interactions" => { crate::provider_transport::policy::local_gemini_transport_unsupported_reason_with_network( transport, api_format, @@ -130,9 +131,21 @@ pub(super) fn provider_query_antigravity_unsupported_reason( AntigravityRequestSideUnsupportedReason::UnsupportedNetworkConfig => { "transport_network_config_unsupported" } - AntigravityRequestSideUnsupportedReason::UnsupportedAuth(_) => { - "transport_antigravity_auth_unsupported" - } + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::WrongProviderType, + ) => "transport_provider_type_unsupported", + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::MissingAuthConfig, + ) => "transport_antigravity_auth_config_missing", + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::InvalidAuthConfigJson, + ) => "transport_antigravity_auth_config_invalid", + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::ComplexDynamicAuthConfig, + ) => "transport_antigravity_auth_config_unsupported", + AntigravityRequestSideUnsupportedReason::UnsupportedAuth( + AntigravityRequestAuthUnsupportedReason::MissingProjectId, + ) => "transport_antigravity_project_id_missing", AntigravityRequestSideUnsupportedReason::UnsupportedEnvelope(_) => { "transport_antigravity_envelope_unsupported" } @@ -250,6 +263,7 @@ pub(super) fn provider_query_test_adapter_for_provider_api_format( | "openai:responses:compact" | "claude:messages" | "gemini:generate_content" + | "gemini:interactions" | "openai:embedding" | "gemini:embedding" | "jina:embedding" @@ -366,7 +380,7 @@ pub(super) fn provider_query_transport_supports_model_test_execution( transport, api_format, ) } - "gemini:generate_content" | "gemini:embedding" => { + "gemini:generate_content" | "gemini:embedding" | "gemini:interactions" => { if crate::provider_transport::is_vertex_transport_context(transport) { aether_provider_transport::vertex::supports_local_vertex_gemini_transport_with_network(transport) } else { diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs index 881ec8879..72618af5c 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs @@ -120,6 +120,10 @@ fn provider_query_endpoint_route_payload( ("Gemini API", "gemini_native", "generateContent", "") } "gemini:generate_content" => ("Gemini native", "gemini_native", "generateContent", ""), + "gemini:interactions" if is_gemini_api => { + ("Gemini API", "gemini_native", "interactions", "") + } + "gemini:interactions" => ("Gemini native", "gemini_native", "interactions", ""), "openai:embedding" if is_vertex && is_openai_compat => ( "Vertex AI OpenAI-compatible", "openai_compatible", diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs index 867fb69cc..4ea4bac28 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs @@ -678,6 +678,10 @@ fn provider_query_test_adapter_routes_fixed_provider_endpoint_types() { provider_query_test_adapter_for_provider_api_format("custom", "gemini:embedding"), Some(ProviderQueryTestAdapter::Standard) ); + assert_eq!( + provider_query_test_adapter_for_provider_api_format("custom", "gemini:interactions"), + Some(ProviderQueryTestAdapter::Standard) + ); assert_eq!( provider_query_test_adapter_for_provider_api_format( "aliyun", diff --git a/apps/aether-gateway/src/handlers/public/ai_public.rs b/apps/aether-gateway/src/handlers/public/ai_public.rs index dcc84a5c0..23a0e1aa4 100644 --- a/apps/aether-gateway/src/handlers/public/ai_public.rs +++ b/apps/aether-gateway/src/handlers/public/ai_public.rs @@ -926,6 +926,9 @@ fn maybe_build_local_antigravity_v1internal_response( "fetch_available_models" => { Some(Json(build_antigravity_fetch_available_models_payload()).into_response()) } + "retrieve_user_quota_summary" => { + Some(Json(build_antigravity_retrieve_user_quota_summary_payload()).into_response()) + } "fetch_user_info" => { Some(Json(build_antigravity_fetch_user_info_payload()).into_response()) } @@ -938,6 +941,7 @@ fn maybe_build_local_antigravity_v1internal_response( .into_response(), ), "record_code_assist_metrics" => Some(Json(json!({})).into_response()), + "write_trajectory_acls" => Some(Json(json!({})).into_response()), "set_user_settings" => Some(build_antigravity_set_user_settings_response(request_body)), "stream_generate_content" => None, _ => None, @@ -1042,27 +1046,42 @@ fn build_antigravity_fetch_user_info_payload() -> Value { }) } +fn build_antigravity_retrieve_user_quota_summary_payload() -> Value { + json!({ + "description": "", + "groups": [] + }) +} + fn build_antigravity_default_user_settings_payload() -> Value { json!({ - "preferredModelId": "gemini-3.1-flash-lite" + "preferredModelId": "gemini-3.5-flash-low" }) } fn build_antigravity_fetch_available_models_payload() -> Value { json!({ "models": { - "gemini-3.5-flash-low": antigravity_model_payload("gemini-3.5-flash-low", "Gemini 3.5 Flash Low"), - "gemini-3-flash-agent": antigravity_model_payload("gemini-3-flash-agent", "Gemini 3 Flash Agent"), + "gemini-pro-agent": antigravity_model_payload("gemini-pro-agent", "Gemini 3.1 Pro (High)"), + "gemini-3.1-pro-low": antigravity_model_payload("gemini-3.1-pro-low", "Gemini 3.1 Pro (Low)"), + "gemini-3-flash-agent": antigravity_model_payload("gemini-3-flash-agent", "Gemini 3.5 Flash (High)"), + "gemini-3.5-flash-low": antigravity_model_payload("gemini-3.5-flash-low", "Gemini 3.5 Flash (Medium)"), + "gemini-3.5-flash-extra-low": antigravity_model_payload("gemini-3.5-flash-extra-low", "Gemini 3.5 Flash (Low)"), + "claude-opus-4-6-thinking": antigravity_model_payload("claude-opus-4-6-thinking", "Claude Opus 4.6 (Thinking)"), + "claude-sonnet-4-6": antigravity_model_payload("claude-sonnet-4-6", "Claude Sonnet 4.6 (Thinking)"), + "gpt-oss-120b-medium": antigravity_model_payload("gpt-oss-120b-medium", "GPT-OSS 120B (Medium)"), "gemini-3.1-flash-lite": antigravity_model_payload("gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite"), - "gemini-3.1-pro-low": antigravity_model_payload("gemini-3.1-pro-low", "Gemini 3.1 Pro Low"), "gemini-3-flash": antigravity_model_payload("gemini-3-flash", "Gemini 3 Flash"), - "gemini-2.5-flash": antigravity_model_payload("gemini-2.5-flash", "Gemini 2.5 Flash"), - "gemini-2.5-flash-lite": antigravity_model_payload("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), - "gemini-2.5-flash-thinking": antigravity_model_payload("gemini-2.5-flash-thinking", "Gemini 2.5 Flash Thinking"), + "gemini-2.5-flash": antigravity_model_payload("gemini-2.5-flash", "Gemini 3.1 Flash Lite"), + "gemini-2.5-flash-lite": antigravity_model_payload("gemini-2.5-flash-lite", "Gemini 3.1 Flash Lite"), + "gemini-2.5-flash-thinking": antigravity_model_payload("gemini-2.5-flash-thinking", "Gemini 3.1 Flash Lite"), "gemini-2.5-pro": antigravity_model_payload("gemini-2.5-pro", "Gemini 2.5 Pro"), "gemini-3.1-flash-image": antigravity_model_payload("gemini-3.1-flash-image", "Gemini 3.1 Flash Image"), - "tab_flash_lite_preview": antigravity_model_payload("tab_flash_lite_preview", "Tab Flash Lite Preview"), - "tab_jump_flash_lite_preview": antigravity_model_payload("tab_jump_flash_lite_preview", "Tab Jump Flash Lite Preview"), + "gemini-3.1-pro-high": antigravity_model_payload("gemini-3.1-pro-high", "Gemini 3.1 Pro (High)"), + "chat_20706": antigravity_model_payload("chat_20706", ""), + "chat_23310": antigravity_model_payload("chat_23310", ""), + "tab_flash_lite_preview": antigravity_model_payload("tab_flash_lite_preview", ""), + "tab_jump_flash_lite_preview": antigravity_model_payload("tab_jump_flash_lite_preview", ""), "models/proactive-observer": antigravity_model_payload("models/proactive-observer", "Proactive Observer") }, "agentModelSorts": [ @@ -1071,10 +1090,14 @@ fn build_antigravity_fetch_available_models_payload() -> Value { "groups": [ { "modelIds": [ - "gemini-3.1-flash-lite", + "gemini-3.5-flash-low", "gemini-3-flash-agent", + "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", - "gemini-3.5-flash-low" + "gemini-pro-agent", + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gpt-oss-120b-medium" ] } ] @@ -1083,12 +1106,18 @@ fn build_antigravity_fetch_available_models_payload() -> Value { "audioTranscriptionModelIds": ["models/proactive-observer"], "commandModelIds": ["gemini-3-flash"], "commitMessageModelIds": ["gemini-3.1-flash-lite"], - "defaultAgentModelId": "gemini-3.1-flash-lite", - "deprecatedModelIds": {}, + "defaultAgentModelId": "gemini-3.5-flash-low", + "deprecatedModelIds": { + "gemini-3.1-pro-high": { + "newModelEnum": "MODEL_PLACEHOLDER_M16", + "newModelId": "gemini-pro-agent", + "oldModelEnum": "MODEL_PLACEHOLDER_M37" + } + }, "experimentIds": [], "imageGenerationModelIds": ["gemini-3.1-flash-image"], "mqueryModelIds": ["gemini-3.1-flash-lite"], - "tabModelIds": ["tab_flash_lite_preview", "tab_jump_flash_lite_preview"], + "tabModelIds": ["chat_20706", "chat_23310"], "tieredModelIds": { "flash": ["gemini-3-flash-agent"], "flashLite": ["gemini-3.1-flash-lite"], @@ -1100,6 +1129,11 @@ fn build_antigravity_fetch_available_models_payload() -> Value { fn antigravity_model_payload(id: &str, display_name: &str) -> Value { let model = match id { + "chat_20706" => "MODEL_CHAT_20706", + "chat_23310" => "MODEL_CHAT_23310", + "claude-opus-4-6-thinking" => "MODEL_PLACEHOLDER_M26", + "claude-sonnet-4-6" => "MODEL_PLACEHOLDER_M35", + "gpt-oss-120b-medium" => "MODEL_OPENAI_GPT_OSS_120B_MEDIUM", "gemini-2.5-flash" => "MODEL_GOOGLE_GEMINI_2_5_FLASH", "gemini-2.5-flash-lite" => "MODEL_GOOGLE_GEMINI_2_5_FLASH_LITE", "gemini-2.5-flash-thinking" => "MODEL_GOOGLE_GEMINI_2_5_FLASH_THINKING", @@ -1108,22 +1142,43 @@ fn antigravity_model_payload(id: &str, display_name: &str) -> Value { "gemini-3-flash-agent" => "MODEL_PLACEHOLDER_M132", "gemini-3.1-flash-image" => "MODEL_PLACEHOLDER_M21", "gemini-3.1-flash-lite" => "MODEL_PLACEHOLDER_M50", + "gemini-pro-agent" => "MODEL_PLACEHOLDER_M16", + "gemini-3.1-pro-high" => "MODEL_PLACEHOLDER_M37", "gemini-3.1-pro-low" => "MODEL_PLACEHOLDER_M36", "gemini-3.5-flash-low" => "MODEL_PLACEHOLDER_M20", + "gemini-3.5-flash-extra-low" => "MODEL_PLACEHOLDER_M187", "models/proactive-observer" => "MODEL_PLACEHOLDER_M70", "tab_flash_lite_preview" => "MODEL_PLACEHOLDER_M19", "tab_jump_flash_lite_preview" => "MODEL_PLACEHOLDER_M28", _ => "MODEL_PLACEHOLDER_M20", }; - json!({ - "apiProvider": "API_PROVIDER_GOOGLE_GEMINI", + let (api_provider, model_provider) = match id { + "claude-opus-4-6-thinking" | "claude-sonnet-4-6" => { + ("API_PROVIDER_ANTHROPIC_VERTEX", "MODEL_PROVIDER_ANTHROPIC") + } + "gpt-oss-120b-medium" => ("API_PROVIDER_OPENAI_VERTEX", "MODEL_PROVIDER_OPENAI"), + "chat_20706" | "chat_23310" => ("API_PROVIDER_INTERNAL", "MODEL_PROVIDER_GOOGLE"), + _ => ("API_PROVIDER_GOOGLE_GEMINI", "MODEL_PROVIDER_GOOGLE"), + }; + let mut payload = json!({ + "apiProvider": api_provider, "displayName": display_name, "maxOutputTokens": 65536, "maxTokens": 1048576, "minThinkingBudget": 32, "model": model, - "modelProvider": "MODEL_PROVIDER_GOOGLE", - "recommended": id == "gemini-3.1-flash-lite", + "modelProvider": model_provider, + "recommended": matches!( + id, + "gemini-3.5-flash-low" + | "gemini-3-flash-agent" + | "gemini-3.5-flash-extra-low" + | "gemini-3.1-pro-low" + | "gemini-pro-agent" + | "claude-sonnet-4-6" + | "claude-opus-4-6-thinking" + | "gpt-oss-120b-medium" + ), "supportedMimeTypes": { "application/json": true, "application/pdf": true, @@ -1137,7 +1192,88 @@ fn antigravity_model_payload(id: &str, display_name: &str) -> Value { "supportsVideo": true, "thinkingBudget": 4000, "tokenizerType": "LLAMA_WITH_SPECIAL" - }) + }); + if let Some(object) = payload.as_object_mut() { + match id { + "gemini-3-flash-agent" => { + object.insert("thinkingBudget".to_string(), json!(10000)); + } + "gemini-pro-agent" => { + object.insert("maxOutputTokens".to_string(), json!(65535)); + object.insert("thinkingBudget".to_string(), json!(10001)); + } + "gemini-3.1-pro-high" => { + object.insert("maxOutputTokens".to_string(), json!(65535)); + object.insert("minThinkingBudget".to_string(), json!(128)); + object.insert("thinkingBudget".to_string(), json!(10001)); + } + "gemini-3.5-flash-extra-low" => { + object.insert("thinkingBudget".to_string(), json!(1000)); + object.insert("maxOutputTokens".to_string(), json!(65536)); + } + "gemini-3.1-pro-low" => { + object.insert("maxOutputTokens".to_string(), json!(65535)); + object.insert("minThinkingBudget".to_string(), json!(128)); + object.insert("thinkingBudget".to_string(), json!(1001)); + } + "claude-sonnet-4-6" | "claude-opus-4-6-thinking" => { + object.insert("maxTokens".to_string(), json!(250000)); + object.insert("maxOutputTokens".to_string(), json!(64000)); + object.insert("thinkingBudget".to_string(), json!(1024)); + object.remove("minThinkingBudget"); + object.remove("supportsVideo"); + } + "gpt-oss-120b-medium" => { + object.insert("maxTokens".to_string(), json!(131072)); + object.insert("maxOutputTokens".to_string(), json!(32768)); + object.insert("thinkingBudget".to_string(), json!(8192)); + object.remove("minThinkingBudget"); + object.remove("supportsImages"); + object.remove("supportsVideo"); + } + "chat_20706" => { + object.insert("maxTokens".to_string(), json!(16384)); + object.remove("displayName"); + object.remove("maxOutputTokens"); + object.remove("minThinkingBudget"); + object.remove("recommended"); + object.remove("supportsImages"); + object.remove("supportsThinking"); + object.remove("supportsVideo"); + object.remove("thinkingBudget"); + object.remove("tokenizerType"); + object.remove("supportedMimeTypes"); + } + "chat_23310" => { + object.insert("maxTokens".to_string(), json!(32768)); + object.remove("displayName"); + object.remove("maxOutputTokens"); + object.remove("minThinkingBudget"); + object.remove("recommended"); + object.remove("supportsImages"); + object.remove("supportsThinking"); + object.remove("supportsVideo"); + object.remove("thinkingBudget"); + object.remove("tokenizerType"); + object.remove("supportedMimeTypes"); + } + "tab_flash_lite_preview" | "tab_jump_flash_lite_preview" => { + object.insert("maxTokens".to_string(), json!(16384)); + object.insert("maxOutputTokens".to_string(), json!(4096)); + object.remove("displayName"); + object.remove("minThinkingBudget"); + object.remove("recommended"); + object.remove("supportsImages"); + object.remove("supportsThinking"); + object.remove("supportsVideo"); + object.remove("thinkingBudget"); + object.remove("tokenizerType"); + object.remove("supportedMimeTypes"); + } + _ => {} + } + } + payload } async fn maybe_build_local_gemini_video_operations_response( diff --git a/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs b/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs index 8bc142ffd..0e68bff02 100644 --- a/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs +++ b/apps/aether-gateway/src/handlers/public/support/test_connection/route.rs @@ -102,7 +102,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response( let format_value = crate::ai_serving::normalize_api_format_alias(&format_value); if !matches!( format_value.as_str(), - "openai:chat" | "claude:messages" | "gemini:generate_content" + "openai:chat" | "claude:messages" | "gemini:generate_content" | "gemini:interactions" ) { return None; } @@ -186,6 +186,10 @@ pub(super) async fn maybe_build_local_test_connection_route_response( "parts": [{"text": "Health check"}], }], }), + "gemini:interactions" => json!({ + "model": model, + "input": "Health check", + }), _ => return None, }; if !crate::provider_transport::apply_local_body_rules( @@ -197,7 +201,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response( } let oauth_auth = match format_value.as_str() { - "openai:chat" | "claude:messages" | "gemini:generate_content" => { + "openai:chat" | "claude:messages" | "gemini:generate_content" | "gemini:interactions" => { match state.resolve_local_oauth_request_auth(&transport).await { Ok(Some(crate::provider_transport::LocalResolvedOAuthRequestAuth::Header { name, @@ -218,7 +222,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response( crate::provider_transport::auth::resolve_local_standard_auth(&transport) .or(oauth_auth.clone()) } - "gemini:generate_content" => { + "gemini:generate_content" | "gemini:interactions" => { crate::provider_transport::auth::resolve_local_gemini_auth(&transport) .or(oauth_auth.clone()) } diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 50ac1ba6d..8c9197090 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -577,6 +577,44 @@ fn model_quota_window_snapshot( Some(Value::Object(window)) } +fn canonical_antigravity_model_label(model_name: &str) -> Option<&'static str> { + match model_name.trim() { + "claude-opus-4-6-thinking" => Some("Claude Opus 4.6 (Thinking)"), + "claude-sonnet-4-6" | "claude-sonnet-4-6-thinking" => Some("Claude Sonnet 4.6 (Thinking)"), + "gemini-3-flash-agent" => Some("Gemini 3.5 Flash (High)"), + "gemini-3.5-flash-low" => Some("Gemini 3.5 Flash (Medium)"), + "gemini-3.5-flash-extra-low" => Some("Gemini 3.5 Flash (Low)"), + "gemini-3.1-pro-high" | "gemini-pro-agent" => Some("Gemini 3.1 Pro (High)"), + "gemini-3.1-pro-low" => Some("Gemini 3.1 Pro (Low)"), + "gemini-3.1-flash-image" => Some("Gemini 3.1 Flash Image"), + "gemini-3.1-flash-lite" => Some("Gemini 3.1 Flash Lite"), + "gemini-3-flash" => Some("Gemini 3 Flash"), + "gemini-2.5-pro" => Some("Gemini 2.5 Pro"), + "gemini-2.5-flash-thinking" | "gemini-2.5-flash" | "gemini-2.5-flash-lite" => { + Some("Gemini 3.1 Flash Lite") + } + "gpt-oss-120b-medium" => Some("GPT-OSS 120B (Medium)"), + "tab_flash_lite_preview" => Some("Tab Flash Lite Preview"), + "tab_jump_flash_lite_preview" => Some("Tab Jump Flash Lite Preview"), + "models/proactive-observer" => Some("Proactive Observer"), + _ => None, + } +} + +fn antigravity_model_quota_window_snapshot( + model_name: &str, + item: &Map, + observed_at_unix_secs: Option, +) -> Option { + let mut window = model_quota_window_snapshot(model_name, item, observed_at_unix_secs)?; + if let Some(label) = canonical_antigravity_model_label(model_name) { + if let Some(window) = window.as_object_mut() { + window.insert("label".to_string(), json!(label)); + } + } + Some(window) +} + fn provider_quota_metadata_string( metadata: &Map, fields: &[&str], @@ -1466,7 +1504,7 @@ fn build_antigravity_quota_status_snapshot( models .iter() .filter_map(|(model_name, item)| { - model_quota_window_snapshot( + antigravity_model_quota_window_snapshot( model_name, item.as_object()?, observed_at_unix_secs, @@ -3715,6 +3753,77 @@ mod tests { ); } + #[test] + fn sync_provider_key_quota_status_snapshot_labels_antigravity_models_by_model_id() { + let upstream_metadata = json!({ + "antigravity": { + "updated_at": 1_775_553_285u64, + "quota_by_model": { + "gemini-3.5-flash-extra-low": { + "display_name": "Gemini 3.5 Flash (Low)", + "remaining_fraction": 1.0 + }, + "gemini-3.5-flash-low": { + "display_name": "Gemini 3.5 Flash (Medium)", + "remaining_fraction": 0.75 + }, + "gemini-3-flash-agent": { + "display_name": "Gemini 3.5 Flash (High)", + "remaining_fraction": 0.5 + }, + "gemini-2.5-flash": { + "display_name": "Gemini 3.1 Flash Lite", + "remaining_fraction": 0.4 + }, + "claude-sonnet-4-6": { + "display_name": "Claude Sonnet 4.6 (Thinking)", + "remaining_fraction": 0.3 + } + } + } + }); + + let payload = sync_provider_key_quota_status_snapshot( + None, + "antigravity", + Some(&upstream_metadata), + "refresh_api", + ) + .expect("quota snapshot should sync"); + let windows = payload["quota"]["windows"] + .as_array() + .expect("quota windows should exist"); + let label_for_model = |model: &str| { + windows + .iter() + .filter_map(Value::as_object) + .find(|window| window.get("model") == Some(&json!(model))) + .and_then(|window| window.get("label")) + .cloned() + }; + + assert_eq!( + label_for_model("gemini-3.5-flash-extra-low"), + Some(json!("Gemini 3.5 Flash (Low)")) + ); + assert_eq!( + label_for_model("gemini-3.5-flash-low"), + Some(json!("Gemini 3.5 Flash (Medium)")) + ); + assert_eq!( + label_for_model("gemini-3-flash-agent"), + Some(json!("Gemini 3.5 Flash (High)")) + ); + assert_eq!( + label_for_model("gemini-2.5-flash"), + Some(json!("Gemini 3.1 Flash Lite")) + ); + assert_eq!( + label_for_model("claude-sonnet-4-6"), + Some(json!("Claude Sonnet 4.6 (Thinking)")) + ); + } + #[test] fn provider_key_status_snapshot_payload_backfills_account_block_from_oauth_invalid_reason() { let mut key = sample_catalog_key(); diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs index 65732211b..7a787497c 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs @@ -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::)); + 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,84 @@ 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, + aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT + ); + 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 +1985,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); diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs index 0575683b3..af570cfbd 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs @@ -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" @@ -1555,7 +1558,7 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str ); assert_eq!( seen_remote_execution_runtime_request.user_agent, - "antigravity" + aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); assert_eq!(seen_remote_execution_runtime_request.request_type, "agent"); assert_eq!(seen_remote_execution_runtime_request.contents_len, 1); diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs index 800c4bdeb..0a2c2ed67 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs @@ -2143,7 +2143,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_upstream_stream_via_local_ ); assert_eq!( seen_remote_execution_runtime_request.user_agent, - "antigravity" + aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); assert_eq!(seen_remote_execution_runtime_request.request_type, "agent"); assert_eq!(seen_remote_execution_runtime_request.contents_len, 0); diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs index 08ee4bb63..5e2b73e5e 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs @@ -2031,7 +2031,10 @@ async fn gateway_executes_antigravity_gemini_cli_stream_via_local_decision_gate_ "trace-antigravity-cli-oauth-local-stream-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.user_agent, + aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT + ); assert_eq!(seen_execution_runtime_request.request_type, "agent"); assert_eq!(seen_execution_runtime_request.contents_len, 0); assert!((seen_execution_runtime_request.exact_temperature - 0.2).abs() < f64::EPSILON); diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs index cab39f386..90034fe5c 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs @@ -2334,7 +2334,10 @@ async fn gateway_executes_antigravity_gemini_cli_sync_via_local_decision_gate_af "trace-antigravity-cli-oauth-local-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.user_agent, + aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT + ); assert_eq!(seen_execution_runtime_request.request_type, "agent"); assert_eq!(seen_execution_runtime_request.contents_len, 0); assert!((seen_execution_runtime_request.exact_temperature - 0.2).abs() < f64::EPSILON); diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs index 2a96bea04..eb70f7c8c 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs @@ -23,6 +23,30 @@ use crate::constants::{ }; use crate::data::GatewayDataState; +const PROVIDER_QUOTA_TEST_STACK_BYTES: usize = 16 * 1024 * 1024; + +fn run_provider_quota_test(test_name: &'static str, make_future: F) +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future + 'static, +{ + let handle = std::thread::Builder::new() + .name(test_name.to_string()) + .stack_size(PROVIDER_QUOTA_TEST_STACK_BYTES) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime should build"); + runtime.block_on(make_future()); + }) + .expect("provider quota test thread should spawn"); + + if let Err(payload) = handle.join() { + std::panic::resume_unwind(payload); + } +} + #[tokio::test] async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_admin_principal() { #[derive(Debug, Clone)] @@ -1748,8 +1772,15 @@ async fn gateway_reports_codex_quota_runtime_failures_locally_without_falling_ba upstream_handle.abort(); } -#[tokio::test] -async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_trusted_admin_principal( +#[test] +fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_trusted_admin_principal() { + run_provider_quota_test( + "gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_trusted_admin_principal", + gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_trusted_admin_principal_inner, + ); +} + +async fn gateway_refreshes_admin_provider_quota_locally_for_antigravity_with_trusted_admin_principal_inner( ) { #[derive(Debug, Clone)] struct SeenExecutionRuntimeRequest { diff --git a/apps/aether-gateway/src/tests/control/admin/provider_query.rs b/apps/aether-gateway/src/tests/control/admin/provider_query.rs index 42c88cca8..0085f12a4 100644 --- a/apps/aether-gateway/src/tests/control/admin/provider_query.rs +++ b/apps/aether-gateway/src/tests/control/admin/provider_query.rs @@ -4125,7 +4125,7 @@ async fn gateway_reports_transport_unsupported_reason_for_non_kiro_provider_impl assert_eq!( payload["error"], json!( - "Rust local provider-query model test cannot execute endpoint format gemini:generate_content (transport_antigravity_auth_unsupported)" + "Rust local provider-query model test cannot execute endpoint format gemini:generate_content (transport_antigravity_auth_config_missing)" ) ); @@ -4305,6 +4305,204 @@ async fn gateway_handles_antigravity_endpoint_test_model_locally_impl() { execution_runtime_handle.abort(); } +#[test] +fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_model() { + run_provider_query_test( + "gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_model", + gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_model_impl, + ); +} + +async fn gateway_hydrates_antigravity_project_id_from_load_code_assist_for_test_model_impl() { + let seen_urls = Arc::new(Mutex::new(Vec::::new())); + let seen_urls_clone = Arc::clone(&seen_urls); + let execution_runtime = Router::new().route( + "/v1/execute/sync", + any(move |Json(plan): Json| { + let seen_urls_inner = Arc::clone(&seen_urls_clone); + async move { + seen_urls_inner + .lock() + .expect("mutex should lock") + .push(plan.url.clone()); + if plan.url == "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + { + assert_eq!(plan.model_name.as_deref(), Some("loadCodeAssist")); + assert_eq!( + plan.headers.get("authorization").map(String::as_str), + Some("Bearer cached-antigravity-token") + ); + assert_eq!( + plan.body.json_body.as_ref().and_then(|body| body + .get("metadata") + .and_then(|metadata| metadata.get("pluginType"))), + Some(&json!("GEMINI")) + ); + return Json(json!({ + "request_id": plan.request_id, + "candidate_id": plan.candidate_id, + "status_code": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "json_body": { + "cloudaicompanionProject": { + "id": "project-from-antigravity-load-code-assist" + }, + "currentTier": { + "id": "free" + } + } + } + })); + } + + assert_eq!( + plan.url, + "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent" + ); + assert_eq!(plan.provider_id, "provider-antigravity"); + assert_eq!(plan.endpoint_id, "endpoint-antigravity-gemini"); + assert_eq!(plan.key_id, "key-antigravity-gemini"); + assert_eq!(plan.provider_api_format, "gemini:generate_content"); + assert!(!plan.stream); + assert_eq!( + plan.body.json_body.as_ref().unwrap()["project"], + json!("project-from-antigravity-load-code-assist") + ); + assert_eq!( + plan.body.json_body.as_ref().unwrap()["requestType"], + json!("endpoint_test") + ); + assert_eq!( + plan.body.json_body.as_ref().unwrap()["model"], + json!("gemini-3.1-flash-lite") + ); + Json(json!({ + "request_id": plan.request_id, + "candidate_id": plan.candidate_id, + "status_code": 200, + "headers": { + "content-type": "application/json" + }, + "body": { + "json_body": { + "response": { + "candidates": [{ + "content": { + "parts": [ + {"text": "Hello from hydrated Antigravity"} + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + }], + "modelVersion": "gemini-3.1-flash-lite", + "usageMetadata": { + "promptTokenCount": 2, + "candidatesTokenCount": 4, + "totalTokenCount": 6 + } + }, + "responseId": "resp-antigravity-hydrated-test-123" + } + }, + "telemetry": { + "elapsed_ms": 23 + } + })) + } + }), + ); + + let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await; + let mut provider = sample_provider("provider-antigravity", "Antigravity", 10); + provider.provider_type = "antigravity".to_string(); + let mut key = sample_key( + "key-antigravity-gemini", + "provider-antigravity", + "gemini:generate_content", + "cached-antigravity-token", + ); + key.auth_type = "oauth".to_string(); + key.encrypted_auth_config = Some( + aether_crypto::encrypt_python_fernet_plaintext( + DEVELOPMENT_ENCRYPTION_KEY, + r#"{"provider_type":"antigravity","refresh_token":"rt-antigravity-123"}"#, + ) + .expect("auth config should encrypt"), + ); + let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![sample_endpoint( + "endpoint-antigravity-gemini", + "provider-antigravity", + "gemini:generate_content", + "https://daily-cloudcode-pa.googleapis.com", + )], + vec![key], + )); + + let gateway = build_router_with_state( + build_state_with_execution_runtime_override(execution_runtime_url) + .with_data_state_for_tests( + GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone( + &provider_catalog_repository, + )) + .with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY), + ), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + + let response = reqwest::Client::new() + .post(format!("{gateway_url}/api/admin/provider-query/test-model")) + .header(GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&json!({ + "provider_id": "provider-antigravity", + "model": "gemini-3.1-flash-lite", + "api_format": "gemini:generate_content", + "message": "Say hello" + })) + .send() + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["success"], json!(true)); + assert_eq!( + payload["data"]["response"]["choices"][0]["message"]["content"], + json!("Hello from hydrated Antigravity") + ); + assert_eq!( + *seen_urls.lock().expect("mutex should lock"), + vec![ + "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(), + "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent".to_string(), + ] + ); + let reloaded = provider_catalog_repository + .list_keys_by_ids(&["key-antigravity-gemini".to_string()]) + .await + .expect("key should reload"); + assert_eq!( + reloaded[0] + .upstream_metadata + .as_ref() + .and_then(|metadata| metadata.get("antigravity")) + .and_then(|metadata| metadata.get("project_id")), + Some(&json!("project-from-antigravity-load-code-assist")) + ); + + gateway_handle.abort(); + execution_runtime_handle.abort(); +} + #[test] fn gateway_prefers_supported_non_kiro_endpoint_when_api_format_is_omitted() { run_provider_query_test( diff --git a/apps/aether-gateway/src/tests/control/admin/system.rs b/apps/aether-gateway/src/tests/control/admin/system.rs index 557dcee59..a5eb90068 100644 --- a/apps/aether-gateway/src/tests/control/admin/system.rs +++ b/apps/aether-gateway/src/tests/control/admin/system.rs @@ -1452,6 +1452,11 @@ async fn gateway_handles_admin_system_api_formats_locally_with_trusted_admin_pri assert!(formats.iter().any(|item| item["value"] == "jina:embedding")); assert!(formats.iter().any(|item| item["value"] == "jina:rerank")); assert!(formats.iter().any(|item| item["value"] == "gemini:video")); + let gemini_interactions = formats + .iter() + .find(|item| item["value"] == "gemini:interactions") + .expect("gemini interactions format should exist"); + assert_eq!(gemini_interactions["default_path"], "/v1/interactions"); let aliyun_embedding = formats .iter() .find(|item| item["value"] == "aliyun:multimodal_embedding") diff --git a/apps/aether-gateway/src/tests/frontdoor/ai.rs b/apps/aether-gateway/src/tests/frontdoor/ai.rs index 20c881d07..542727bf4 100644 --- a/apps/aether-gateway/src/tests/frontdoor/ai.rs +++ b/apps/aether-gateway/src/tests/frontdoor/ai.rs @@ -949,6 +949,10 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() "/v1internal:fetchAvailableModels", json!({"project": "aether-antigravity-local"}), ), + ( + "/v1internal:retrieveUserQuotaSummary", + json!({"project": "aether-antigravity-local"}), + ), ( "/v1internal:fetchUserInfo", json!({"project": "aether-antigravity-local"}), @@ -966,6 +970,10 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() "metrics": [] }), ), + ( + "/v1internal:writeTrajectoryAcls", + json!({"trajectoryId": "trajectory-ant-123"}), + ), ( "/v1internal:setUserSettings", json!({"userSettings": user_settings.clone()}), @@ -1012,14 +1020,58 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() ); } "/v1internal:fetchAvailableModels" => { - assert_eq!(payload["defaultAgentModelId"], "gemini-3.1-flash-lite"); + assert_eq!(payload["defaultAgentModelId"], "gemini-3.5-flash-low"); assert_eq!( payload["tieredModelIds"]["flash"], json!(["gemini-3-flash-agent"]) ); + assert_eq!( + payload["tieredModelIds"]["pro"], + json!(["gemini-3.1-pro-low"]) + ); + assert_eq!( + payload["models"]["gemini-3-flash-agent"]["displayName"], + "Gemini 3.5 Flash (High)" + ); assert_eq!( payload["models"]["gemini-3.5-flash-low"]["displayName"], - "Gemini 3.5 Flash Low" + "Gemini 3.5 Flash (Medium)" + ); + assert_eq!( + payload["models"]["gemini-3.5-flash-extra-low"]["displayName"], + "Gemini 3.5 Flash (Low)" + ); + assert_eq!( + payload["models"]["gemini-pro-agent"]["displayName"], + "Gemini 3.1 Pro (High)" + ); + assert_eq!( + payload["models"]["claude-opus-4-6-thinking"]["displayName"], + "Claude Opus 4.6 (Thinking)" + ); + assert_eq!( + payload["models"]["gpt-oss-120b-medium"]["displayName"], + "GPT-OSS 120B (Medium)" + ); + assert_eq!( + payload["models"]["gemini-pro-agent"]["model"], + "MODEL_PLACEHOLDER_M16" + ); + assert_eq!( + payload["models"]["gemini-3.1-pro-high"]["model"], + "MODEL_PLACEHOLDER_M37" + ); + assert_eq!( + payload["models"]["gemini-3.5-flash-extra-low"]["model"], + "MODEL_PLACEHOLDER_M187" + ); + assert_eq!( + payload["models"]["claude-sonnet-4-6"]["apiProvider"], + "API_PROVIDER_ANTHROPIC_VERTEX" + ); + assert_eq!( + payload["models"]["gpt-oss-120b-medium"]["apiProvider"], + "API_PROVIDER_OPENAI_VERTEX" ); assert_eq!( payload["models"]["gemini-3.5-flash-low"]["apiProvider"], @@ -1032,18 +1084,26 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() assert_eq!( payload["agentModelSorts"][0]["groups"][0]["modelIds"], json!([ - "gemini-3.1-flash-lite", + "gemini-3.5-flash-low", "gemini-3-flash-agent", + "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", - "gemini-3.5-flash-low" + "gemini-pro-agent", + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gpt-oss-120b-medium" ]) ); - assert_eq!(payload["deprecatedModelIds"], json!({})); + assert_eq!( + payload["deprecatedModelIds"]["gemini-3.1-pro-high"]["newModelId"], + "gemini-pro-agent" + ); assert_eq!(payload["commandModelIds"], json!(["gemini-3-flash"])); assert_eq!( payload["imageGenerationModelIds"], json!(["gemini-3.1-flash-image"]) ); + assert_eq!(payload["tabModelIds"], json!(["chat_20706", "chat_23310"])); assert_eq!(payload["mqueryModelIds"], json!(["gemini-3.1-flash-lite"])); assert_eq!( payload["webSearchModelIds"], @@ -1058,9 +1118,13 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() assert_eq!(payload["regionCode"], "US"); assert_eq!( payload["userSettings"]["preferredModelId"], - "gemini-3.1-flash-lite" + "gemini-3.5-flash-low" ); } + "/v1internal:retrieveUserQuotaSummary" => { + assert_eq!(payload["description"], ""); + assert_eq!(payload["groups"], json!([])); + } "/v1internal:fetchAdminControls" => { assert_eq!(payload, json!({})); } @@ -1071,6 +1135,9 @@ async fn gateway_handles_antigravity_v1internal_control_plane_without_proxying() "/v1internal:recordCodeAssistMetrics" => { assert_eq!(payload, json!({})); } + "/v1internal:writeTrajectoryAcls" => { + assert_eq!(payload, json!({})); + } "/v1internal:setUserSettings" => { assert_eq!(payload["userSettings"], user_settings); } diff --git a/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index c7b19c80c..58112994d 100644 --- a/crates/aether-admin/src/provider/quota.rs +++ b/crates/aether-admin/src/provider/quota.rs @@ -223,13 +223,19 @@ pub fn parse_antigravity_usage_response( ) -> Option { let models = value.get("models")?.as_object()?; let mut quota_by_model = serde_json::Map::new(); + let mut opaque_key_display_index = 1usize; for (model_id, model_value) in models { let mut payload = serde_json::Map::new(); - if let Some(display_name) = coerce_json_string( + let upstream_display_name = coerce_json_string( model_value .get("displayName") .or_else(|| model_value.get("display_name")), + ); + if let Some(display_name) = friendly_quota_display_name( + upstream_display_name, + model_id, + &mut opaque_key_display_index, ) { payload.insert("display_name".to_string(), json!(display_name)); } @@ -270,6 +276,7 @@ pub fn parse_gemini_cli_retrieve_user_quota_response( ) -> Option { let buckets = value.get("buckets")?.as_array()?; let mut quota_by_model = serde_json::Map::new(); + let mut opaque_key_display_index = 1usize; for bucket in buckets { if !bucket.is_object() { @@ -320,6 +327,12 @@ pub fn parse_gemini_cli_retrieve_user_quota_response( .or_else(|| model_id.clone()) .or_else(|| token_type.clone()) .unwrap_or_else(|| quota_key.clone()); + let display_name = friendly_quota_display_name( + Some(display_name), + "a_key, + &mut opaque_key_display_index, + ) + .unwrap_or_else(|| quota_key.clone()); let remaining_fraction = first_json_f64_by_paths( bucket, &[ @@ -456,6 +469,38 @@ pub fn parse_gemini_cli_retrieve_user_quota_response( })) } +fn is_opaque_reset_credit_quota_identifier(value: &str) -> bool { + value.trim().starts_with("RateLimitResetCredit_") +} + +fn friendly_quota_display_name( + candidate: Option, + quota_key: &str, + opaque_key_display_index: &mut usize, +) -> Option { + let candidate = candidate + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + if let Some(candidate) = candidate.as_deref() { + if !is_opaque_reset_credit_quota_identifier(candidate) { + return Some(candidate.to_string()); + } + } + + if is_opaque_reset_credit_quota_identifier(quota_key) + || candidate + .as_deref() + .is_some_and(is_opaque_reset_credit_quota_identifier) + { + let label = format!("Key-{}", *opaque_key_display_index); + *opaque_key_display_index += 1; + return Some(label); + } + + candidate +} + pub fn parse_gemini_cli_v1internal_credits_response( value: &serde_json::Value, updated_at_unix_secs: u64, @@ -1999,9 +2044,10 @@ pub fn parse_chatgpt_web_conversation_init_response( mod tests { use super::{ codex_build_invalid_state, codex_runtime_invalid_reason, - normalize_codex_reset_credit_consume_outcome, parse_chatgpt_web_conversation_init_response, - parse_codex_backend_me_response, parse_codex_wham_reset_credits_detail_response, - parse_codex_wham_usage_response, parse_gemini_cli_retrieve_user_quota_response, + normalize_codex_reset_credit_consume_outcome, parse_antigravity_usage_response, + parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response, + parse_codex_wham_reset_credits_detail_response, parse_codex_wham_usage_response, + parse_gemini_cli_retrieve_user_quota_response, parse_gemini_cli_v1internal_credits_response, parse_windsurf_model_configs_response, parse_windsurf_rate_limit_response, parse_windsurf_user_status_response, provider_auto_remove_quota_exhausted_keys, quota_refresh_success_invalid_state, @@ -2538,6 +2584,40 @@ mod tests { assert!(parsed.get("secondary_used_percent").is_none()); } + #[test] + fn parses_antigravity_usage_response_labels_opaque_reset_credit_keys() { + let parsed = parse_antigravity_usage_response( + &json!({ + "models": { + "RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb": { + "quotaInfo": { + "remainingFraction": 0.75, + "resetTime": "2030-01-01T00:00:00Z" + } + }, + "gemini-3-pro-preview": { + "displayName": "Gemini 3 Pro Preview", + "quotaInfo": { + "remainingFraction": 0.25 + } + } + } + }), + 1_777_000_000, + ) + .expect("antigravity quota should parse"); + + assert_eq!( + parsed["models"]["RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb"] + ["display_name"], + json!("Key-1") + ); + assert_eq!( + parsed["models"]["gemini-3-pro-preview"]["display_name"], + json!("Gemini 3 Pro Preview") + ); + } + #[test] fn parses_gemini_cli_retrieve_user_quota_buckets() { let parsed = parse_gemini_cli_retrieve_user_quota_response( @@ -2595,6 +2675,38 @@ mod tests { ); } + #[test] + fn parses_gemini_cli_quota_buckets_labels_opaque_reset_credit_keys() { + let parsed = parse_gemini_cli_retrieve_user_quota_response( + &json!({ + "buckets": [ + { + "tokenType": "RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb", + "remainingFraction": 0.5 + }, + { + "modelId": "RateLimitResetCredit_d18b8aac4ec2472697ad747a14975ac8", + "displayName": "RateLimitResetCredit_d18b8aac4ec2472697ad747a14975ac8", + "remainingFraction": 0.25 + } + ] + }), + 1_777_000_000, + ) + .expect("gemini cli quota should parse"); + + assert_eq!( + parsed["quota_by_model"]["RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb"] + ["display_name"], + json!("Key-1") + ); + assert_eq!( + parsed["quota_by_model"]["RateLimitResetCredit_d18b8aac4ec2472697ad747a14975ac8"] + ["display_name"], + json!("Key-2") + ); + } + #[test] fn parses_gemini_cli_v1internal_credits() { let parsed = parse_gemini_cli_v1internal_credits_response( diff --git a/crates/aether-admin/src/system.rs b/crates/aether-admin/src/system.rs index 8d0b65a23..6070c8265 100644 --- a/crates/aether-admin/src/system.rs +++ b/crates/aether-admin/src/system.rs @@ -805,6 +805,12 @@ const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[ default_path: "/v1beta/models/{model}:{action}", aliases: &["gemini", "google", "vertex"], }, + AdminApiFormatDefinition { + value: "gemini:interactions", + label: "Gemini Interactions", + default_path: "/v1/interactions", + aliases: &["gemini_interactions", "interactions"], + }, AdminApiFormatDefinition { value: "gemini:embedding", label: "Gemini Embedding", diff --git a/crates/aether-ai-formats/src/api.rs b/crates/aether-ai-formats/src/api.rs index 666f936f2..041a504ef 100644 --- a/crates/aether-ai-formats/src/api.rs +++ b/crates/aether-ai-formats/src/api.rs @@ -19,7 +19,10 @@ pub use crate::contracts::{ GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, GEMINI_FILES_LIST_PLAN_KIND, - GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, + GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND, + GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, + GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND, OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND, @@ -256,14 +259,15 @@ pub use aether_ai_formats::{ from_claude_to_canonical_response, from_gemini_to_canonical_request, from_gemini_to_canonical_response, from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request, - from_openai_responses_to_canonical_response, parse_request_pure, parse_response_pure, - request_candidate_api_format_preference, request_candidate_api_formats, - request_conversion_kind, request_conversion_requires_enable_flag, - sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, CanonicalContentBlock, - CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest, - CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole, - CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, - CanonicalUsage, ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, - FormatContext, FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind, + from_openai_responses_to_canonical_response, is_gemini_interactions_api_format, + parse_request_pure, parse_response_pure, request_candidate_api_format_preference, + request_candidate_api_formats, request_conversion_kind, + request_conversion_requires_enable_flag, sync_chat_response_conversion_kind, + sync_cli_response_conversion_kind, CanonicalContentBlock, CanonicalGenerationConfig, + CanonicalInstruction, CanonicalMessage, CanonicalRequest, CanonicalResponse, + CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason, + CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition, CanonicalUsage, + ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext, + FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind, }; diff --git a/crates/aether-ai-formats/src/contracts/mod.rs b/crates/aether-ai-formats/src/contracts/mod.rs index 7580a915e..3f7752f66 100644 --- a/crates/aether-ai-formats/src/contracts/mod.rs +++ b/crates/aether-ai-formats/src/contracts/mod.rs @@ -16,14 +16,16 @@ pub use plan_kinds::{ CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, - GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, - GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, - OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, - OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, - OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, - OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, - OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, - OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, + GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, + GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, + OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, + OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, + OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, + OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND, + OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, + OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, }; pub use report_kinds::{ core_error_background_report_kind, core_error_default_client_api_format, @@ -37,6 +39,8 @@ pub use report_kinds::{ GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND, GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND, GEMINI_CLI_SYNC_ERROR_REPORT_KIND, GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND, GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND, GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, + GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND, + GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND, OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_ERROR_REPORT_KIND, diff --git a/crates/aether-ai-formats/src/contracts/plan_kinds.rs b/crates/aether-ai-formats/src/contracts/plan_kinds.rs index 62a24bfb3..d0d4eecb7 100644 --- a/crates/aether-ai-formats/src/contracts/plan_kinds.rs +++ b/crates/aether-ai-formats/src/contracts/plan_kinds.rs @@ -14,6 +14,7 @@ pub const GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "gemini_video_cancel_sync"; pub const OPENAI_CHAT_STREAM_PLAN_KIND: &str = "openai_chat_stream"; pub const CLAUDE_CHAT_STREAM_PLAN_KIND: &str = "claude_chat_stream"; pub const GEMINI_CHAT_STREAM_PLAN_KIND: &str = "gemini_chat_stream"; +pub const GEMINI_INTERACTIONS_STREAM_PLAN_KIND: &str = "gemini_interactions_stream"; pub const OPENAI_RESPONSES_STREAM_PLAN_KIND: &str = "openai_responses_stream"; pub const OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND: &str = "openai_responses_compact_stream"; pub const CLAUDE_CLI_STREAM_PLAN_KIND: &str = "claude_cli_stream"; @@ -27,6 +28,7 @@ pub const OPENAI_RESPONSES_SYNC_PLAN_KIND: &str = "openai_responses_sync"; pub const OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND: &str = "openai_responses_compact_sync"; pub const CLAUDE_CHAT_SYNC_PLAN_KIND: &str = "claude_chat_sync"; pub const GEMINI_CHAT_SYNC_PLAN_KIND: &str = "gemini_chat_sync"; +pub const GEMINI_INTERACTIONS_SYNC_PLAN_KIND: &str = "gemini_interactions_sync"; pub const CLAUDE_CLI_SYNC_PLAN_KIND: &str = "claude_cli_sync"; pub const GEMINI_CLI_SYNC_PLAN_KIND: &str = "gemini_cli_sync"; diff --git a/crates/aether-ai-formats/src/contracts/report_kinds.rs b/crates/aether-ai-formats/src/contracts/report_kinds.rs index b1e3896ba..bb2e769e0 100644 --- a/crates/aether-ai-formats/src/contracts/report_kinds.rs +++ b/crates/aether-ai-formats/src/contracts/report_kinds.rs @@ -1,13 +1,14 @@ use crate::contracts::{ CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, - GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, - OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, + GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, + OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, }; pub const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize"; pub const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finalize"; pub const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize"; +pub const GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_interactions_sync_finalize"; pub const OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND: &str = "openai_responses_sync_finalize"; pub const OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_responses_compact_sync_finalize"; @@ -23,6 +24,7 @@ const LEGACY_OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sy pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success"; pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success"; pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success"; +pub const GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_interactions_sync_success"; pub const OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND: &str = "openai_responses_sync_success"; pub const OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_responses_compact_sync_success"; @@ -35,6 +37,8 @@ pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success"; pub const OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_chat_stream_success"; pub const CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "claude_chat_stream_success"; pub const GEMINI_CHAT_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_chat_stream_success"; +pub const GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND: &str = + "gemini_interactions_stream_success"; pub const OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND: &str = "openai_responses_stream_success"; pub const OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND: &str = "openai_responses_compact_stream_success"; @@ -45,6 +49,7 @@ pub const GEMINI_CLI_STREAM_SUCCESS_REPORT_KIND: &str = "gemini_cli_stream_succe pub const OPENAI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "openai_chat_sync_error"; pub const CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND: &str = "claude_chat_sync_error"; pub const GEMINI_CHAT_SYNC_ERROR_REPORT_KIND: &str = "gemini_chat_sync_error"; +pub const GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND: &str = "gemini_interactions_sync_error"; pub const OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND: &str = "openai_responses_sync_error"; pub const OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND: &str = "openai_responses_compact_sync_error"; @@ -58,6 +63,7 @@ pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static st OPENAI_CHAT_SYNC_PLAN_KIND => Some(OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND), CLAUDE_CHAT_SYNC_PLAN_KIND => Some(CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND), GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND), + GEMINI_INTERACTIONS_SYNC_PLAN_KIND => Some(GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND), OPENAI_RESPONSES_SYNC_PLAN_KIND => Some(OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND), OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND => { Some(OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND) @@ -75,6 +81,7 @@ pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'stati OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("openai:chat"), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("claude:messages"), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:generate_content"), + GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => Some("gemini:interactions"), OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses"), OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:responses:compact"), OPENAI_EMBEDDING_SYNC_FINALIZE_REPORT_KIND => Some("openai:embedding"), @@ -92,6 +99,9 @@ pub fn core_error_background_report_kind(report_kind: &str) -> Option<&'static s OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_ERROR_REPORT_KIND), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_ERROR_REPORT_KIND), + GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => { + Some(GEMINI_INTERACTIONS_SYNC_ERROR_REPORT_KIND) + } OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_RESPONSES_SYNC_ERROR_REPORT_KIND), OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND => { Some(OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND) @@ -115,6 +125,9 @@ pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND), CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND), GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND), + GEMINI_INTERACTIONS_SYNC_FINALIZE_REPORT_KIND => { + Some(GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND) + } OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND), OPENAI_RESPONSES_SYNC_FINALIZE_REPORT_KIND => { Some(OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND) diff --git a/crates/aether-ai-formats/src/formats/id.rs b/crates/aether-ai-formats/src/formats/id.rs index f5f96e3ac..1e064f928 100644 --- a/crates/aether-ai-formats/src/formats/id.rs +++ b/crates/aether-ai-formats/src/formats/id.rs @@ -27,6 +27,7 @@ pub enum FormatId { OpenAiRerank, ClaudeMessages, GeminiGenerateContent, + GeminiInteractions, GeminiEmbedding, JinaEmbedding, JinaRerank, @@ -51,7 +52,9 @@ impl FormatId { | Self::OpenAiEmbedding | Self::OpenAiRerank => FormatFamily::OpenAi, Self::ClaudeMessages => FormatFamily::Claude, - Self::GeminiGenerateContent | Self::GeminiEmbedding => FormatFamily::Gemini, + Self::GeminiGenerateContent | Self::GeminiInteractions | Self::GeminiEmbedding => { + FormatFamily::Gemini + } Self::JinaEmbedding | Self::JinaRerank => FormatFamily::Jina, Self::DoubaoEmbedding => FormatFamily::Doubao, Self::AliyunMultimodalEmbedding => FormatFamily::Aliyun, @@ -74,6 +77,7 @@ impl FormatId { Self::OpenAiRerank => "openai:rerank", Self::ClaudeMessages => "claude:messages", Self::GeminiGenerateContent => "gemini:generate_content", + Self::GeminiInteractions => "gemini:interactions", Self::GeminiEmbedding => "gemini:embedding", Self::JinaEmbedding => "jina:embedding", Self::JinaRerank => "jina:rerank", @@ -103,6 +107,12 @@ impl FromStr for FormatId { "openai:rerank" | "/v1/rerank" => Ok(Self::OpenAiRerank), "claude:messages" | "/v1/messages" => Ok(Self::ClaudeMessages), "gemini:generate_content" => Ok(Self::GeminiGenerateContent), + "gemini:interactions" + | "gemini:interaction" + | "gemini_interactions" + | "gemini_interaction" + | "/v1/interactions" + | "/v1beta/interactions" => Ok(Self::GeminiInteractions), "gemini:embedding" => Ok(Self::GeminiEmbedding), "jina:embedding" | "/jina/v1/embeddings" => Ok(Self::JinaEmbedding), "jina:rerank" | "/jina/v1/rerank" => Ok(Self::JinaRerank), @@ -157,7 +167,12 @@ pub fn is_openai_responses_family_format(value: &str) -> bool { pub fn api_format_uses_body_stream_field(value: &str) -> bool { matches!( FormatId::parse(value).map(FormatId::canonical), - Some(FormatId::OpenAiChat | FormatId::OpenAiResponses | FormatId::ClaudeMessages) + Some( + FormatId::OpenAiChat + | FormatId::OpenAiResponses + | FormatId::ClaudeMessages + | FormatId::GeminiInteractions, + ) ) } @@ -237,6 +252,33 @@ mod tests { } } + #[test] + fn parses_gemini_interactions_api_formats() { + use super::{FormatFamily, FormatProfile}; + + assert_eq!( + FormatId::parse("gemini:interactions"), + Some(FormatId::GeminiInteractions) + ); + assert_eq!( + FormatId::parse("gemini_interactions"), + Some(FormatId::GeminiInteractions) + ); + assert_eq!( + FormatId::parse("/v1/interactions"), + Some(FormatId::GeminiInteractions) + ); + assert_eq!( + FormatId::GeminiInteractions.to_string(), + "gemini:interactions" + ); + assert_eq!(FormatId::GeminiInteractions.family(), FormatFamily::Gemini); + assert_eq!( + FormatId::GeminiInteractions.profile(), + FormatProfile::Default + ); + } + #[test] fn parses_rerank_api_formats() { assert_eq!( @@ -293,6 +335,10 @@ mod tests { normalize_api_format_alias("GEMINI:GENERATE_CONTENT"), "gemini:generate_content" ); + assert_eq!( + normalize_api_format_alias("GEMINI_INTERACTIONS"), + "gemini:interactions" + ); assert_eq!( normalize_api_format_alias("OPENAI:EMBEDDING"), "openai:embedding" @@ -331,6 +377,10 @@ mod tests { api_format_storage_aliases("gemini:generate_content"), vec!["gemini:generate_content".to_string()] ); + assert_eq!( + api_format_storage_aliases("gemini:interactions"), + vec!["gemini:interactions".to_string()] + ); assert_eq!( api_format_storage_aliases("openai:embedding"), vec!["openai:embedding".to_string()] @@ -364,6 +414,8 @@ mod tests { assert!(api_format_uses_body_stream_field("/v1/responses")); assert!(api_format_uses_body_stream_field("claude:messages")); assert!(api_format_uses_body_stream_field("/v1/messages")); + assert!(api_format_uses_body_stream_field("gemini:interactions")); + assert!(api_format_uses_body_stream_field("/v1/interactions")); assert!(!api_format_uses_body_stream_field( "openai:responses:compact" )); diff --git a/crates/aether-ai-formats/src/formats/matrix.rs b/crates/aether-ai-formats/src/formats/matrix.rs index f563eb4ab..be66d4833 100644 --- a/crates/aether-ai-formats/src/formats/matrix.rs +++ b/crates/aether-ai-formats/src/formats/matrix.rs @@ -45,6 +45,7 @@ const EMBEDDING_CANDIDATE_API_FORMATS: &[&str] = &[ "aliyun:multimodal_embedding", ]; const RERANK_CANDIDATE_API_FORMATS: &[&str] = &["openai:rerank", "jina:rerank"]; +const GEMINI_INTERACTIONS_CANDIDATE_API_FORMATS: &[&str] = &["gemini:interactions"]; pub fn request_candidate_api_format_preference( client_api_format: &str, @@ -56,6 +57,9 @@ pub fn request_candidate_api_format_preference( if client_api_format == "openai:responses:compact" { return (provider_api_format == "openai:responses:compact").then_some((0, 0)); } + if is_gemini_interactions_api_format(client_api_format.as_str()) { + return (provider_api_format == "gemini:interactions").then_some((0, 0)); + } if is_embedding_api_format(client_api_format.as_str()) { return is_embedding_api_format(provider_api_format.as_str()).then_some(( if client_api_format == provider_api_format { @@ -105,6 +109,9 @@ pub fn request_candidate_api_formats( if client_api_format == "openai:responses:compact" { return vec!["openai:responses:compact"]; } + if is_gemini_interactions_api_format(client_api_format.as_str()) { + return GEMINI_INTERACTIONS_CANDIDATE_API_FORMATS.to_vec(); + } if is_embedding_api_format(client_api_format.as_str()) { let mut candidate_api_formats = EMBEDDING_CANDIDATE_API_FORMATS.to_vec(); candidate_api_formats.sort_by_key(|provider_api_format| { @@ -254,6 +261,10 @@ pub fn is_rerank_api_format(api_format: &str) -> bool { ) } +pub fn is_gemini_interactions_api_format(api_format: &str) -> bool { + normalize_api_format_alias(api_format) == "gemini:interactions" +} + pub fn parse_non_compact_standard_api_format( api_format: &str, ) -> Option<(&'static str, &'static str)> { @@ -270,6 +281,7 @@ pub fn api_data_format_id(api_format: &str) -> Option<&'static str> { match normalize_api_format_alias(api_format).as_str() { "claude:messages" => Some("claude"), "gemini:generate_content" => Some("gemini"), + "gemini:interactions" => Some("gemini_interactions"), "openai:chat" => Some("openai_chat"), "openai:responses" | "openai:responses:compact" => Some("openai_responses"), "openai:embedding" @@ -313,11 +325,12 @@ fn rerank_api_format_priority(api_format: &str) -> u8 { #[cfg(test)] mod tests { use super::{ - api_data_format_id, is_embedding_api_format, is_rerank_api_format, - request_candidate_api_format_preference, request_candidate_api_formats, - request_conversion_kind, request_conversion_requires_enable_flag, - sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, - RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind, + api_data_format_id, is_embedding_api_format, is_gemini_interactions_api_format, + is_rerank_api_format, request_candidate_api_format_preference, + request_candidate_api_formats, request_conversion_kind, + request_conversion_requires_enable_flag, sync_chat_response_conversion_kind, + sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, + SyncCliResponseConversionKind, }; fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind { @@ -581,6 +594,38 @@ mod tests { } } + #[test] + fn gemini_interactions_stays_same_format_only() { + assert!(is_gemini_interactions_api_format("gemini:interactions")); + assert_eq!( + request_candidate_api_formats("gemini:interactions", false), + vec!["gemini:interactions"] + ); + assert_eq!( + request_candidate_api_format_preference("gemini:interactions", "gemini:interactions"), + Some((0, 0)) + ); + assert_eq!( + request_candidate_api_format_preference( + "gemini:interactions", + "gemini:generate_content" + ), + None + ); + assert_eq!( + request_conversion_kind("gemini:interactions", "gemini:generate_content"), + None + ); + assert_eq!( + request_conversion_kind("gemini:generate_content", "gemini:interactions"), + None + ); + assert_eq!( + api_data_format_id("gemini:interactions"), + Some("gemini_interactions") + ); + } + #[test] fn rerank_candidate_registry_excludes_chat_and_embedding_formats() { assert_eq!( diff --git a/crates/aether-ai-formats/src/formats/registry.rs b/crates/aether-ai-formats/src/formats/registry.rs index 02e423269..8cef3afb0 100644 --- a/crates/aether-ai-formats/src/formats/registry.rs +++ b/crates/aether-ai-formats/src/formats/registry.rs @@ -38,6 +38,7 @@ pub fn parse_request( FormatId::GeminiEmbedding => gemini::embedding::request::from(body, ctx), FormatId::DoubaoEmbedding => doubao::embedding::request::from(body, ctx), FormatId::AliyunMultimodalEmbedding => aliyun::embedding::request::from(body, ctx), + FormatId::GeminiInteractions => None, } .ok_or_else(|| FormatError::RequestParseFailed { format: source.as_str().to_string(), @@ -71,6 +72,7 @@ fn emit_request_inner( FormatId::GeminiEmbedding => gemini::embedding::request::to(request, ctx), FormatId::DoubaoEmbedding => doubao::embedding::request::to(request, ctx), FormatId::AliyunMultimodalEmbedding => aliyun::embedding::request::to(request, ctx), + FormatId::GeminiInteractions => None, } .ok_or_else(|| FormatError::RequestEmitFailed { format: target.as_str().to_string(), @@ -194,7 +196,8 @@ pub fn parse_response( | FormatId::JinaRerank | FormatId::GeminiEmbedding | FormatId::DoubaoEmbedding - | FormatId::AliyunMultimodalEmbedding => None, + | FormatId::AliyunMultimodalEmbedding + | FormatId::GeminiInteractions => None, } .ok_or_else(|| FormatError::ResponseParseFailed { format: source.as_str().to_string(), @@ -231,7 +234,8 @@ fn emit_response_inner( | FormatId::JinaRerank | FormatId::GeminiEmbedding | FormatId::DoubaoEmbedding - | FormatId::AliyunMultimodalEmbedding => None, + | FormatId::AliyunMultimodalEmbedding + | FormatId::GeminiInteractions => None, } .ok_or_else(|| FormatError::ResponseEmitFailed { format: target.as_str().to_string(), @@ -523,6 +527,24 @@ fn standard_request_root_field_is_audited(source: FormatId, key: &str) -> bool { | "tool_config" | "tools" ), + FormatId::GeminiInteractions => matches!( + key, + "agent" + | "agent_config" + | "background" + | "generation_config" + | "input" + | "metadata" + | "model" + | "previous_interaction_id" + | "response_format" + | "safety_settings" + | "store" + | "stream" + | "system_instruction" + | "tool_config" + | "tools" + ), FormatId::OpenAiEmbedding | FormatId::OpenAiRerank | FormatId::GeminiEmbedding @@ -981,6 +1003,7 @@ fn validate_source_response_stop_enums( } FormatId::ClaudeMessages => validate_claude_response_stop_reason(body), FormatId::GeminiGenerateContent => validate_gemini_response_finish_reasons(body, target), + FormatId::GeminiInteractions => Ok(()), FormatId::OpenAiEmbedding | FormatId::OpenAiRerank | FormatId::GeminiEmbedding diff --git a/crates/aether-ai-formats/src/formats/shared/passthrough.rs b/crates/aether-ai-formats/src/formats/shared/passthrough.rs index 63de6080c..a0fef4ed8 100644 --- a/crates/aether-ai-formats/src/formats/shared/passthrough.rs +++ b/crates/aether-ai-formats/src/formats/shared/passthrough.rs @@ -2,7 +2,9 @@ use crate::contracts::{ CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, - GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, + GEMINI_EMBEDDING_SYNC_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, + GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, }; @@ -58,6 +60,13 @@ pub fn resolve_sync_spec(plan_kind: &str) -> Option family: LocalSameFormatProviderFamily::Gemini, require_streaming: false, }), + GEMINI_INTERACTIONS_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec { + api_format: "gemini:interactions", + decision_kind: GEMINI_INTERACTIONS_SYNC_PLAN_KIND, + report_kind: GEMINI_INTERACTIONS_SYNC_SUCCESS_REPORT_KIND, + family: LocalSameFormatProviderFamily::Gemini, + require_streaming: false, + }), OPENAI_EMBEDDING_SYNC_PLAN_KIND => Some(LocalSameFormatProviderSpec { api_format: "openai:embedding", decision_kind: OPENAI_EMBEDDING_SYNC_PLAN_KIND, @@ -106,6 +115,13 @@ pub fn resolve_stream_spec(plan_kind: &str) -> Option Some(LocalSameFormatProviderSpec { + api_format: "gemini:interactions", + decision_kind: GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + report_kind: GEMINI_INTERACTIONS_STREAM_SUCCESS_REPORT_KIND, + family: LocalSameFormatProviderFamily::Gemini, + require_streaming: true, + }), _ => None, } } @@ -147,6 +163,21 @@ mod tests { assert!(!spec.require_streaming); } + #[test] + fn resolves_gemini_interactions_same_format_specs() { + let sync = resolve_sync_spec("gemini_interactions_sync").expect("sync spec"); + assert_eq!(sync.api_format, "gemini:interactions"); + assert_eq!(sync.report_kind, "gemini_interactions_sync_success"); + assert_eq!(sync.family, super::LocalSameFormatProviderFamily::Gemini); + assert!(!sync.require_streaming); + + let stream = resolve_stream_spec("gemini_interactions_stream").expect("stream spec"); + assert_eq!(stream.api_format, "gemini:interactions"); + assert_eq!(stream.report_kind, "gemini_interactions_stream_success"); + assert_eq!(stream.family, super::LocalSameFormatProviderFamily::Gemini); + assert!(stream.require_streaming); + } + #[test] fn resolves_openai_rerank_sync_same_format_spec() { let spec = resolve_sync_spec("openai_rerank_sync").expect("spec"); diff --git a/crates/aether-ai-formats/src/formats/shared/routing.rs b/crates/aether-ai-formats/src/formats/shared/routing.rs index b47251ae1..ac5b02874 100644 --- a/crates/aether-ai-formats/src/formats/shared/routing.rs +++ b/crates/aether-ai-formats/src/formats/shared/routing.rs @@ -6,14 +6,16 @@ use crate::contracts::{ CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND, - GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, - GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, - OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, - OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, - OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND, - OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, - OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, - OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, + GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, + GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, + OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, + OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, + OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, + OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_SYNC_PLAN_KIND, + OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND, + OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, + OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND, }; use crate::formats::openai::image::request::is_openai_image_stream_request; @@ -69,6 +71,14 @@ pub fn resolve_execution_runtime_stream_plan_kind( )); } + if route_family == Some("gemini") + && route_kind == Some("interactions") + && *method == Method::POST + && matches!(path, "/v1/interactions" | "/v1beta/interactions") + { + return Some(GEMINI_INTERACTIONS_STREAM_PLAN_KIND); + } + if route_family == Some("antigravity") && route_kind == Some("stream_generate_content") && *method == Method::POST @@ -182,6 +192,14 @@ pub fn resolve_execution_runtime_sync_plan_kind( return Some(GEMINI_EMBEDDING_SYNC_PLAN_KIND); } + if route_family == Some("gemini") + && route_kind == Some("interactions") + && *method == Method::POST + && matches!(path, "/v1/interactions" | "/v1beta/interactions") + { + return Some(GEMINI_INTERACTIONS_SYNC_PLAN_KIND); + } + if route_family == Some("openai") && route_kind == Some("chat") && *method == Method::POST @@ -390,7 +408,8 @@ pub fn is_matching_stream_request( | OPENAI_RESPONSES_STREAM_PLAN_KIND | OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND | CLAUDE_CLI_STREAM_PLAN_KIND - | OPENAI_IMAGE_STREAM_PLAN_KIND => body_json + | OPENAI_IMAGE_STREAM_PLAN_KIND + | GEMINI_INTERACTIONS_STREAM_PLAN_KIND => body_json .get("stream") .and_then(|value| value.as_bool()) .unwrap_or(false), @@ -428,6 +447,7 @@ pub fn supports_sync_execution_decision_kind(plan_kind: &str) -> bool { | GEMINI_CHAT_SYNC_PLAN_KIND | GEMINI_CLI_SYNC_PLAN_KIND | GEMINI_EMBEDDING_SYNC_PLAN_KIND + | GEMINI_INTERACTIONS_SYNC_PLAN_KIND | GEMINI_FILES_UPLOAD_PLAN_KIND | OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND | OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND @@ -452,6 +472,7 @@ pub fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool { | OPENAI_IMAGE_STREAM_PLAN_KIND | CLAUDE_CLI_STREAM_PLAN_KIND | GEMINI_CLI_STREAM_PLAN_KIND + | GEMINI_INTERACTIONS_STREAM_PLAN_KIND | GEMINI_FILES_DOWNLOAD_PLAN_KIND | OPENAI_VIDEO_CONTENT_PLAN_KIND ) @@ -473,6 +494,7 @@ mod tests { CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_STREAM_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND, GEMINI_CLI_STREAM_PLAN_KIND, GEMINI_CLI_SYNC_PLAN_KIND, GEMINI_EMBEDDING_SYNC_PLAN_KIND, + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, GEMINI_INTERACTIONS_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, @@ -855,6 +877,62 @@ mod tests { )); } + #[test] + fn resolves_gemini_interactions_plan_kinds() { + assert_eq!( + resolve_execution_runtime_sync_plan_kind( + Some("ai_public"), + Some("gemini"), + Some("interactions"), + Some("api_key"), + &Method::POST, + "/v1/interactions", + ), + Some(GEMINI_INTERACTIONS_SYNC_PLAN_KIND) + ); + assert_eq!( + resolve_execution_runtime_stream_plan_kind( + Some("ai_public"), + Some("gemini"), + Some("interactions"), + Some("api_key"), + &Method::POST, + "/v1/interactions", + ), + Some(GEMINI_INTERACTIONS_STREAM_PLAN_KIND) + ); + assert_eq!( + resolve_execution_runtime_sync_plan_kind( + Some("ai_public"), + Some("gemini"), + Some("interactions"), + Some("api_key"), + &Method::POST, + "/v1beta/interactions", + ), + Some(GEMINI_INTERACTIONS_SYNC_PLAN_KIND) + ); + } + + #[test] + fn gemini_interactions_stream_plan_requires_body_stream_true() { + assert!(is_matching_stream_request( + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + "/v1/interactions", + &serde_json::json!({"stream": true}) + )); + assert!(!is_matching_stream_request( + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + "/v1/interactions", + &serde_json::json!({"stream": false}) + )); + assert!(!is_matching_stream_request( + GEMINI_INTERACTIONS_STREAM_PLAN_KIND, + "/v1/interactions", + &serde_json::json!({}) + )); + } + #[test] fn resolves_openai_rerank_sync_plan_kind() { assert_eq!( diff --git a/crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs index 7d2e3101d..3ba6fb4c9 100644 --- a/crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai-formats/src/formats/shared/stream_core/format_matrix.rs @@ -274,6 +274,7 @@ impl ProviderStreamParser { FormatId::OpenAiEmbedding | FormatId::OpenAiRerank | FormatId::GeminiEmbedding + | FormatId::GeminiInteractions | FormatId::JinaEmbedding | FormatId::JinaRerank | FormatId::DoubaoEmbedding @@ -365,6 +366,7 @@ impl ClientStreamEmitter { FormatId::OpenAiEmbedding | FormatId::OpenAiRerank | FormatId::GeminiEmbedding + | FormatId::GeminiInteractions | FormatId::JinaEmbedding | FormatId::JinaRerank | FormatId::DoubaoEmbedding @@ -472,7 +474,9 @@ fn parse_provider_error( parse_openai_error(payload) } FormatId::ClaudeMessages => parse_claude_error(payload), - FormatId::GeminiGenerateContent => parse_gemini_error(payload), + FormatId::GeminiGenerateContent | FormatId::GeminiInteractions => { + parse_gemini_error(payload) + } FormatId::OpenAiEmbedding | FormatId::OpenAiRerank | FormatId::GeminiEmbedding diff --git a/crates/aether-ai-formats/src/lib.rs b/crates/aether-ai-formats/src/lib.rs index 52523be2b..f49a6aee3 100644 --- a/crates/aether-ai-formats/src/lib.rs +++ b/crates/aether-ai-formats/src/lib.rs @@ -16,11 +16,11 @@ pub use formats::id::{ is_openai_responses_format, normalize_api_format_alias, FormatFamily, FormatId, FormatProfile, }; pub use formats::matrix::{ - is_embedding_api_format, is_rerank_api_format, request_candidate_api_format_preference, - request_candidate_api_formats, request_conversion_kind, - request_conversion_requires_enable_flag, sync_chat_response_conversion_kind, - sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, - SyncCliResponseConversionKind, + is_embedding_api_format, is_gemini_interactions_api_format, is_rerank_api_format, + request_candidate_api_format_preference, request_candidate_api_formats, + request_conversion_kind, request_conversion_requires_enable_flag, + sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind, + SyncChatResponseConversionKind, SyncCliResponseConversionKind, }; pub use formats::registry::{ build_stream_transcoder, convert_request, convert_request_pure, diff --git a/crates/aether-ai-serving/src/surface_spec.rs b/crates/aether-ai-serving/src/surface_spec.rs index 181bcc0ae..3f0d228a6 100644 --- a/crates/aether-ai-serving/src/surface_spec.rs +++ b/crates/aether-ai-serving/src/surface_spec.rs @@ -142,6 +142,17 @@ pub fn extract_ai_standard_requested_model(body_json: &Value) -> Option .map(ToOwned::to_owned) } +pub fn extract_ai_gemini_body_requested_target(body_json: &Value) -> Option { + extract_ai_standard_requested_model(body_json).or_else(|| { + body_json + .get("agent") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + pub fn extract_ai_requested_model_from_request_path( request_path: &str, body_json: &Value, @@ -150,7 +161,7 @@ pub fn extract_ai_requested_model_from_request_path( match family { AiRequestedModelFamily::Standard => extract_ai_standard_requested_model(body_json), AiRequestedModelFamily::Gemini => extract_ai_gemini_model_from_path(request_path) - .or_else(|| extract_ai_standard_requested_model(body_json)), + .or_else(|| extract_ai_gemini_body_requested_target(body_json)), } } @@ -245,4 +256,18 @@ mod tests { assert_eq!(requested_model.as_deref(), Some("gemini-cli")); } + + #[test] + fn gemini_requested_model_parser_uses_body_agent_when_path_has_no_model() { + let requested_model = extract_ai_requested_model_from_request_path( + "/v1/interactions", + &serde_json::json!({ "agent": " antigravity-preview-05-2026 " }), + AiRequestedModelFamily::Gemini, + ); + + assert_eq!( + requested_model.as_deref(), + Some("antigravity-preview-05-2026") + ); + } } diff --git a/crates/aether-data/backfills/postgres/20260517012000_sync_legacy_enabled_active_flags.sql b/crates/aether-data/backfills/postgres/20260517012000_sync_legacy_enabled_active_flags.sql new file mode 100644 index 000000000..382da687d --- /dev/null +++ b/crates/aether-data/backfills/postgres/20260517012000_sync_legacy_enabled_active_flags.sql @@ -0,0 +1,47 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'providers' + AND column_name = 'enabled' + ) THEN + UPDATE public.providers + SET + is_active = enabled, + updated_at = NOW() + WHERE enabled IS NOT NULL + AND is_active IS DISTINCT FROM enabled; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'provider_endpoints' + AND column_name = 'enabled' + ) THEN + UPDATE public.provider_endpoints + SET + is_active = enabled, + updated_at = NOW() + WHERE enabled IS NOT NULL + AND is_active IS DISTINCT FROM enabled; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'models' + AND column_name = 'enabled' + ) THEN + UPDATE public.models + SET + is_active = enabled, + updated_at = NOW() + WHERE enabled IS NOT NULL + AND is_active IS DISTINCT FROM enabled; + END IF; +END $$; diff --git a/crates/aether-data/src/lifecycle/backfill.rs b/crates/aether-data/src/lifecycle/backfill.rs index f8fb87160..b9d42cdc3 100644 --- a/crates/aether-data/src/lifecycle/backfill.rs +++ b/crates/aether-data/src/lifecycle/backfill.rs @@ -312,7 +312,8 @@ mod tests { 20260422110000, 20260422120000, 20260504120000, - 20260505120000 + 20260505120000, + 20260517012000 ] ); } @@ -328,7 +329,12 @@ mod tests { .collect::>(); assert_eq!( versions, - vec![20260422120000, 20260504120000, 20260505120000] + vec![ + 20260422120000, + 20260504120000, + 20260505120000, + 20260517012000 + ] ); } @@ -697,11 +703,12 @@ mod tests { let pending_before = pending_backfills(&pool) .await .expect("pending backfills should load"); - assert_eq!(pending_before.len(), 4); + assert_eq!(pending_before.len(), 5); assert_eq!(pending_before[0].version, 20260422110000); assert_eq!(pending_before[1].version, 20260422120000); assert_eq!(pending_before[2].version, 20260504120000); assert_eq!(pending_before[3].version, 20260505120000); + assert_eq!(pending_before[4].version, 20260517012000); run_backfills(&pool) .await @@ -723,7 +730,8 @@ mod tests { 20260422110000, 20260422120000, 20260504120000, - 20260505120000 + 20260505120000, + 20260517012000 ] ); @@ -1385,6 +1393,6 @@ mod tests { .fetch_one(&pool) .await .expect("backfill count should load"); - assert_eq!(applied_count, 3); + assert_eq!(applied_count, 5); } } diff --git a/crates/aether-model-fetch/src/transport.rs b/crates/aether-model-fetch/src/transport.rs index 9da7f1a5d..1e18e2705 100644 --- a/crates/aether-model-fetch/src/transport.rs +++ b/crates/aether-model-fetch/src/transport.rs @@ -727,6 +727,7 @@ mod tests { build_gemini_cli_load_code_assist_plan, build_kiro_list_available_models_plan, build_models_fetch_execution_plan, build_standard_models_fetch_execution_plan, build_vertex_models_fetch_execution_plan, ModelFetchTransportRuntime, + ANTIGRAVITY_REQUEST_USER_AGENT, }; struct TestRuntime { @@ -1053,7 +1054,7 @@ mod tests { ); assert_eq!( plan.headers.get("user-agent").map(String::as_str), - Some("antigravity") + Some(ANTIGRAVITY_REQUEST_USER_AGENT) ); assert_eq!( plan.headers.get("x-client-name").map(String::as_str), @@ -1100,7 +1101,7 @@ mod tests { ); assert_eq!( plan.headers.get("user-agent").map(String::as_str), - Some("antigravity") + Some(ANTIGRAVITY_REQUEST_USER_AGENT) ); assert_eq!( plan.headers.get("x-client-name").map(String::as_str), diff --git a/crates/aether-provider-transport/src/antigravity/auth.rs b/crates/aether-provider-transport/src/antigravity/auth.rs index ce5dfc998..52925427d 100644 --- a/crates/aether-provider-transport/src/antigravity/auth.rs +++ b/crates/aether-provider-transport/src/antigravity/auth.rs @@ -5,7 +5,8 @@ use serde_json::Value; use super::super::snapshot::GatewayProviderTransportSnapshot; pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity"; -pub const ANTIGRAVITY_REQUEST_USER_AGENT: &str = "antigravity"; +pub const ANTIGRAVITY_REQUEST_USER_AGENT: &str = + "antigravity/cli/1.0.16 (aidev_client; os_type=linux; arch=arm64; auth_method=consumer)"; const ANTIGRAVITY_CLIENT_NAME: &str = "antigravity"; const ANTIGRAVITY_GOOG_API_CLIENT: &str = "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"; @@ -120,6 +121,10 @@ pub fn build_antigravity_static_client_headers( String::from("x-goog-api-client"), String::from(ANTIGRAVITY_GOOG_API_CLIENT), ), + ( + String::from("user-agent"), + String::from(ANTIGRAVITY_REQUEST_USER_AGENT), + ), ]); if let Some(client_version) = client_version @@ -267,8 +272,8 @@ mod tests { use serde_json::json; use super::{ - resolve_local_antigravity_request_auth, AntigravityRequestAuth, - AntigravityRequestAuthSupport, + build_antigravity_static_client_headers, resolve_local_antigravity_request_auth, + AntigravityRequestAuth, AntigravityRequestAuthSupport, ANTIGRAVITY_REQUEST_USER_AGENT, }; use crate::snapshot::{ GatewayProviderTransportEndpoint, GatewayProviderTransportKey, @@ -376,4 +381,26 @@ mod tests { }) ); } + + #[test] + fn static_client_headers_use_native_antigravity_cli_user_agent() { + let headers = build_antigravity_static_client_headers(Some("1.0.16"), Some("session-abc")); + + assert_eq!( + headers.get("user-agent").map(String::as_str), + Some(ANTIGRAVITY_REQUEST_USER_AGENT) + ); + assert_eq!( + headers.get("x-client-name").map(String::as_str), + Some("antigravity") + ); + assert_eq!( + headers.get("x-client-version").map(String::as_str), + Some("1.0.16") + ); + assert_eq!( + headers.get("x-vscode-sessionid").map(String::as_str), + Some("session-abc") + ); + } } diff --git a/crates/aether-provider-transport/src/antigravity/request.rs b/crates/aether-provider-transport/src/antigravity/request.rs index 6c97e6b20..0dd136cc5 100644 --- a/crates/aether-provider-transport/src/antigravity/request.rs +++ b/crates/aether-provider-transport/src/antigravity/request.rs @@ -141,6 +141,7 @@ mod tests { build_antigravity_safe_v1internal_request, classify_antigravity_safe_request_body, AntigravityEnvelopeRequestType, AntigravityRequestAuth, AntigravityRequestEnvelopeSupport, }; + use crate::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT; fn sample_auth() -> AntigravityRequestAuth { AntigravityRequestAuth { @@ -228,7 +229,7 @@ mod tests { assert_eq!(envelope["project"], "project-ant-123"); assert_eq!(envelope["requestId"], "request-ant-agent-123"); assert_eq!(envelope["model"], "gemini-3.5-flash-low"); - assert_eq!(envelope["userAgent"], "antigravity"); + assert_eq!(envelope["userAgent"], ANTIGRAVITY_REQUEST_USER_AGENT); assert_eq!(envelope["requestType"], "agent"); assert!(envelope["request"].get("model").is_none()); assert!(envelope["request"].get("safetySettings").is_none()); diff --git a/crates/aether-provider-transport/src/conversion.rs b/crates/aether-provider-transport/src/conversion.rs index 48b7fb930..c4fd5ccbb 100644 --- a/crates/aether-provider-transport/src/conversion.rs +++ b/crates/aether-provider-transport/src/conversion.rs @@ -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); diff --git a/crates/aether-provider-transport/src/request_url/mod.rs b/crates/aether-provider-transport/src/request_url/mod.rs index e9d5b4ac4..67c62acb4 100644 --- a/crates/aether-provider-transport/src/request_url/mod.rs +++ b/crates/aether-provider-transport/src/request_url/mod.rs @@ -150,6 +150,9 @@ fn build_transport_request_url_inner( params.request_query, gemini_embedding_batch, ), + "gemini:interactions" => { + build_gemini_interactions_url(&transport.endpoint.base_url, params.request_query) + } "doubao:embedding" => build_passthrough_path_url( &transport.endpoint.base_url, "/embeddings", @@ -345,7 +348,9 @@ fn build_transport_hook_url( _ => {} } - if is_antigravity_provider_transport(transport) { + if is_antigravity_provider_transport(transport) + && normalized_provider_api_format == "gemini:generate_content" + { let query = params.request_query.map(|raw| { form_urlencoded::parse(raw.as_bytes()) .into_owned() @@ -398,7 +403,8 @@ fn build_path_params( } let provider_api_format = aether_ai_formats::normalize_api_format_alias(params.provider_api_format); - if provider_api_format.starts_with("gemini:") { + if provider_api_format == "gemini:generate_content" || provider_api_format == "gemini:embedding" + { path_params.insert( "action", if provider_api_format == "gemini:embedding" { @@ -445,6 +451,10 @@ fn build_provider_rerank_v1_url(upstream_base_url: &str, query: Option<&str>) -> build_provider_api_root_url(upstream_base_url, "/rerank", query) } +fn build_gemini_interactions_url(upstream_base_url: &str, query: Option<&str>) -> Option { + build_passthrough_path_url(upstream_base_url, "/v1/interactions", query, &["key"]) +} + fn build_provider_api_root_url( upstream_base_url: &str, path: &str, @@ -1116,6 +1126,76 @@ mod tests { ); } + #[test] + fn gemini_interactions_request_url_uses_stable_v1_endpoint() { + let gemini = sample_transport( + "gemini", + "gemini:interactions", + "https://generativelanguage.googleapis.com", + None, + ); + let versioned = sample_transport( + "gemini", + "gemini:interactions", + "https://generativelanguage.googleapis.com/v1", + None, + ); + + assert_eq!( + build_transport_request_url( + &gemini, + TransportRequestUrlParams { + provider_api_format: "gemini:interactions", + mapped_model: Some("gemini-3.5-flash"), + upstream_is_stream: false, + request_query: Some("key=client-key&trace=1"), + kiro_api_region: None, + }, + ) + .as_deref(), + Some("https://generativelanguage.googleapis.com/v1/interactions?trace=1") + ); + assert_eq!( + build_transport_request_url( + &versioned, + TransportRequestUrlParams { + provider_api_format: "gemini:interactions", + mapped_model: Some("gemini-3.5-flash"), + upstream_is_stream: true, + request_query: Some("key=client-key&trace=2"), + kiro_api_region: None, + }, + ) + .as_deref(), + Some("https://generativelanguage.googleapis.com/v1/interactions?trace=2") + ); + } + + #[test] + fn antigravity_hook_is_limited_to_generate_content() { + let transport = sample_transport( + "antigravity", + "gemini:interactions", + "https://daily-cloudcode-pa.googleapis.com", + None, + ); + + assert_eq!( + build_transport_request_url( + &transport, + TransportRequestUrlParams { + provider_api_format: "gemini:interactions", + mapped_model: Some("gemini-3.5-flash"), + upstream_is_stream: false, + request_query: Some("key=client-key"), + kiro_api_region: None, + }, + ) + .as_deref(), + Some("https://daily-cloudcode-pa.googleapis.com/v1/interactions") + ); + } + #[test] fn embedding_request_url_preserves_google_openai_compat_roots() { let developer_api_openai = sample_transport( diff --git a/crates/aether-provider-transport/src/same_format_provider/mod.rs b/crates/aether-provider-transport/src/same_format_provider/mod.rs index 5faaa9032..d65a24519 100644 --- a/crates/aether-provider-transport/src/same_format_provider/mod.rs +++ b/crates/aether-provider-transport/src/same_format_provider/mod.rs @@ -265,13 +265,39 @@ fn build_same_format_provider_request_body_inner( } } SameFormatProviderFamily::Gemini => { - 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", + if aether_ai_formats::api_format_alias_matches( + input.provider_api_format, + "gemini:interactions", + ) { + let rewrite_field = if provider_request_body.contains_key("model") { + "model" + } else if provider_request_body.contains_key("agent") { + "agent" + } else { + "model" + }; + let previous_value = provider_request_body.get(rewrite_field).cloned(); + provider_request_body.insert( + rewrite_field.to_string(), + Value::String(input.mapped_model.to_string()), ); + if previous_value != Some(Value::String(input.mapped_model.to_string())) { + record_compatibility_edit( + &mut compatibility_edits, + rewrite_field, + SameFormatProviderCompatibilityEditAction::RuntimeRewrite, + "rewrote Gemini Interactions routing field to mapped upstream target", + ); + } + } else { + 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", + ); + } } } } @@ -577,6 +603,7 @@ pub fn same_format_provider_transport_unsupported_reason_for_trace( "openai:responses:compact" => "openai:responses:compact", "claude:messages" => "claude:messages", "gemini:generate_content" => "gemini:generate_content", + "gemini:interactions" => "gemini:interactions", _ => return Some("transport_api_format_unsupported"), }; let behavior = classify_same_format_provider_request_behavior( @@ -1210,6 +1237,62 @@ mod tests { assert!(body.get("stream").is_none()); } + #[test] + fn same_format_gemini_interactions_body_keeps_body_routing_target() { + let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput { + body_json: &json!({ + "model": "client-model", + "input": [{"role": "user", "content": "hello"}], + "stream": true + }), + mapped_model: "gemini-3.5-flash", + client_api_format: "gemini:interactions", + provider_api_format: "gemini:interactions", + source_model: Some("client-model"), + family: SameFormatProviderFamily::Gemini, + body_rules: None, + request_headers: None, + upstream_is_stream: true, + force_body_stream_field: false, + kiro_auth_config: None, + is_claude_code: false, + enable_model_directives: false, + }) + .expect("body should build"); + + assert_eq!(body.get("model"), Some(&json!("gemini-3.5-flash"))); + assert_eq!(body.get("stream"), Some(&json!(true))); + } + + #[test] + fn same_format_gemini_interactions_body_rewrites_agent_target() { + let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput { + body_json: &json!({ + "agent": "antigravity-preview-05-2026", + "input": "hello" + }), + mapped_model: "antigravity-preview-05-2026", + client_api_format: "gemini:interactions", + provider_api_format: "gemini:interactions", + source_model: None, + 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("body should build"); + + assert_eq!( + body.get("agent"), + Some(&json!("antigravity-preview-05-2026")) + ); + assert!(body.get("model").is_none()); + } + #[test] fn same_format_gemini_body_strips_function_response_id_for_upstream() { let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput { diff --git a/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts b/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts index 8dbe52f81..44122b355 100644 --- a/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts +++ b/frontend/src/api/endpoints/types/__tests__/api-format.spec.ts @@ -17,6 +17,7 @@ describe('api format display helpers', () => { expect(normalizeApiFormatAlias('GEMINI_GENERATE_CONTENT')).toBe(API_FORMATS.GEMINI_GENERATE_CONTENT) expect(normalizeApiFormatAlias('OPENAI_EMBEDDING')).toBe(API_FORMATS.OPENAI_EMBEDDING) expect(normalizeApiFormatAlias('OPENAI_RERANK')).toBe(API_FORMATS.OPENAI_RERANK) + expect(normalizeApiFormatAlias('GEMINI_INTERACTIONS')).toBe(API_FORMATS.GEMINI_INTERACTIONS) expect(normalizeApiFormatAlias('GEMINI_EMBEDDING')).toBe(API_FORMATS.GEMINI_EMBEDDING) expect(normalizeApiFormatAlias('JINA_EMBEDDING')).toBe(API_FORMATS.JINA_EMBEDDING) expect(normalizeApiFormatAlias('JINA_RERANK')).toBe(API_FORMATS.JINA_RERANK) @@ -33,6 +34,8 @@ describe('api format display helpers', () => { }) it('formats embedding api format ids distinctly from chat formats', () => { + expect(formatApiFormat(API_FORMATS.GEMINI_INTERACTIONS)).toBe('Gemini Interactions') + expect(formatApiFormatShort(API_FORMATS.GEMINI_INTERACTIONS)).toBe('GI') expect(formatApiFormat(API_FORMATS.OPENAI_EMBEDDING)).toBe('OpenAI Embedding') expect(formatApiFormat(API_FORMATS.GEMINI_EMBEDDING)).toBe('Gemini Embedding') expect(formatApiFormat(API_FORMATS.JINA_EMBEDDING)).toBe('Jina Embedding') @@ -73,6 +76,7 @@ describe('api format display helpers', () => { API_FORMATS.OPENAI_RESPONSES, API_FORMATS.OPENAI_EMBEDDING, API_FORMATS.OPENAI_RERANK, + API_FORMATS.GEMINI_INTERACTIONS, API_FORMATS.GEMINI_EMBEDDING, API_FORMATS.JINA_EMBEDDING, API_FORMATS.JINA_RERANK, @@ -82,6 +86,7 @@ describe('api format display helpers', () => { API_FORMATS.OPENAI_RESPONSES, API_FORMATS.OPENAI_EMBEDDING, API_FORMATS.OPENAI_RERANK, + API_FORMATS.GEMINI_INTERACTIONS, API_FORMATS.GEMINI_EMBEDDING, API_FORMATS.JINA_EMBEDDING, API_FORMATS.JINA_RERANK, @@ -97,12 +102,14 @@ describe('api format display helpers', () => { API_FORMATS.OPENAI_EMBEDDING, API_FORMATS.OPENAI_RERANK, API_FORMATS.GEMINI_GENERATE_CONTENT, + API_FORMATS.GEMINI_INTERACTIONS, API_FORMATS.OPENAI, ])).toEqual([ API_FORMATS.OPENAI, API_FORMATS.OPENAI_EMBEDDING, API_FORMATS.OPENAI_RERANK, API_FORMATS.GEMINI_GENERATE_CONTENT, + API_FORMATS.GEMINI_INTERACTIONS, API_FORMATS.GEMINI_EMBEDDING, ]) }) diff --git a/frontend/src/api/endpoints/types/api-format.ts b/frontend/src/api/endpoints/types/api-format.ts index e947e982b..bae97e933 100644 --- a/frontend/src/api/endpoints/types/api-format.ts +++ b/frontend/src/api/endpoints/types/api-format.ts @@ -12,6 +12,7 @@ export const API_FORMATS = { OPENAI_RERANK: 'openai:rerank', GEMINI: 'gemini:generate_content', GEMINI_GENERATE_CONTENT: 'gemini:generate_content', + GEMINI_INTERACTIONS: 'gemini:interactions', GEMINI_VIDEO: 'gemini:video', GEMINI_FILES: 'gemini:files', GEMINI_EMBEDDING: 'gemini:embedding', @@ -34,6 +35,7 @@ export const API_FORMAT_LABELS: Record = { [API_FORMATS.OPENAI_EMBEDDING]: 'OpenAI Embedding', [API_FORMATS.OPENAI_RERANK]: 'OpenAI Rerank', [API_FORMATS.GEMINI_GENERATE_CONTENT]: 'Gemini Generate Content', + [API_FORMATS.GEMINI_INTERACTIONS]: 'Gemini Interactions', [API_FORMATS.GEMINI_VIDEO]: 'Gemini Video', [API_FORMATS.GEMINI_FILES]: 'Gemini Files', [API_FORMATS.GEMINI_EMBEDDING]: 'Gemini Embedding', @@ -52,6 +54,7 @@ export const API_FORMAT_LABELS: Record = { OPENAI_RERANK: 'OpenAI Rerank', GEMINI: 'Gemini Generate Content', GEMINI_GENERATE_CONTENT: 'Gemini Generate Content', + GEMINI_INTERACTIONS: 'Gemini Interactions', GEMINI_VIDEO: 'Gemini Video', GEMINI_FILES: 'Gemini Files', GEMINI_EMBEDDING: 'Gemini Embedding', @@ -72,6 +75,7 @@ export const API_FORMAT_SHORT: Record = { [API_FORMATS.OPENAI_RERANK]: 'ORR', [API_FORMATS.CLAUDE_MESSAGES]: 'CM', [API_FORMATS.GEMINI_GENERATE_CONTENT]: 'G', + [API_FORMATS.GEMINI_INTERACTIONS]: 'GI', [API_FORMATS.GEMINI_VIDEO]: 'GV', [API_FORMATS.GEMINI_FILES]: 'GF', [API_FORMATS.GEMINI_EMBEDDING]: 'GE', @@ -90,6 +94,7 @@ export const API_FORMAT_SHORT: Record = { CLAUDE_MESSAGES: 'CM', GEMINI: 'G', GEMINI_GENERATE_CONTENT: 'G', + GEMINI_INTERACTIONS: 'GI', GEMINI_VIDEO: 'GV', GEMINI_FILES: 'GF', GEMINI_EMBEDDING: 'GE', @@ -110,6 +115,7 @@ export const API_FORMAT_ORDER: string[] = [ API_FORMATS.OPENAI_VIDEO, API_FORMATS.CLAUDE_MESSAGES, API_FORMATS.GEMINI_GENERATE_CONTENT, + API_FORMATS.GEMINI_INTERACTIONS, API_FORMATS.GEMINI_EMBEDDING, API_FORMATS.GEMINI_VIDEO, API_FORMATS.GEMINI_FILES, @@ -136,6 +142,7 @@ export const API_FORMAT_KIND_LABELS: Record = { 'responses:compact': 'Responses Compact', messages: 'Messages', generate_content: 'Generate Content', + interactions: 'Interactions', image: 'Image', video: 'Video', files: 'Files', @@ -178,6 +185,8 @@ export function normalizeApiFormatAlias(format: string | null | undefined): stri case 'GEMINI': case 'GEMINI_GENERATE_CONTENT': return API_FORMATS.GEMINI_GENERATE_CONTENT + case 'GEMINI_INTERACTIONS': + return API_FORMATS.GEMINI_INTERACTIONS case 'GEMINI_VIDEO': return API_FORMATS.GEMINI_VIDEO case 'GEMINI_FILES': diff --git a/frontend/src/api/endpoints/types/provider.ts b/frontend/src/api/endpoints/types/provider.ts index 793aa9c75..d0580dcea 100644 --- a/frontend/src/api/endpoints/types/provider.ts +++ b/frontend/src/api/endpoints/types/provider.ts @@ -344,9 +344,16 @@ export interface CodexUpstreamMetadata { } export interface AntigravityModelQuota { - remaining_fraction: number // 剩余比例 (0.0-1.0) - used_percent: number // 已用百分比 (0.0-100.0) - reset_time?: string // RFC3339 + remaining_fraction?: number | string | null // 剩余比例 (0.0-1.0) + used_percent?: number | string | null // 已用百分比 (0.0-100.0) + remaining?: number | string | null + total?: number | string | null + reset_time?: string | null // RFC3339 + reset_at?: number | string | null + display_name?: string | null + model_id?: string | null + token_type?: string | null + is_exhausted?: boolean | null } export interface AntigravityUpstreamMetadata { diff --git a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue index 34413f093..b180d67d8 100644 --- a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue +++ b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue @@ -34,9 +34,10 @@ - {{ item.model }} + {{ item.label }} @@ -52,12 +53,14 @@ :key="item.model" >
- - {{ item.label }} - +
+
+ {{ item.label }} +
+
{{ item.remainingPercent.toFixed(1) }}% @@ -115,6 +118,11 @@ import { testModel } from '@/api/endpoints/providers' import type { UpstreamMetadata, QuotaStatusSnapshot, QuotaWindowSnapshot } from '@/api/endpoints/types' import { useToast } from '@/composables/useToast' import { parseApiError } from '@/utils/errorParser' +import { + compareAntigravityQuotaItems, + dedupeAntigravityQuotaItemsByLabel, + resolveAntigravityQuotaLabel, +} from '@/features/providers/utils/antigravityQuota' const props = defineProps<{ open: boolean @@ -165,6 +173,25 @@ function getQuotaWindowLiveResetSeconds( return null } +function coercePercent(value: unknown): number | null { + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) return null + return Math.min(Math.max(numericValue, 0), 100) +} + +function coerceRemainingFraction(value: unknown): number | null { + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) return null + return Math.min(Math.max(numericValue, 0), 1) +} + +function secondsUntilUnixReset(resetAt: unknown): number | null { + const numericResetAt = Number(resetAt) + if (!Number.isFinite(numericResetAt) || numericResetAt <= 0) return null + const now = Math.floor(Date.now() / 1000) + return Math.max(Math.floor(numericResetAt - now), 0) +} + function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] { if (!quota) return [] @@ -175,6 +202,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin ? quota.windows.filter(window => String(window?.scope || '').trim().toLowerCase() === 'model') : [] if (windows.length === 0) return [] + const opaqueDisplayIndex = { value: 1 } const items = windows .map((window) => { @@ -196,7 +224,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin return { model, - label: String(window.label || window.model || model), + label: resolveAntigravityQuotaLabel(model, window.label || window.model, opaqueDisplayIndex), usedPercent, remainingPercent, resetSeconds: getQuotaWindowLiveResetSeconds(quota, window), @@ -204,8 +232,8 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin }) .filter((item): item is QuotaItem => item !== null) - items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model)) - return items + items.sort(compareAntigravityQuotaItems) + return dedupeAntigravityQuotaItemsByLabel(items) } const items = computed(() => { @@ -218,26 +246,26 @@ const items = computed(() => { if (!quotaByModel || typeof quotaByModel !== 'object') return [] const result: QuotaItem[] = [] + const opaqueDisplayIndex = { value: 1 } for (const [model, rawInfo] of Object.entries(quotaByModel)) { if (!model) continue const info = (rawInfo || {}) as Record - let usedPercent = Number(info['used_percent']) - if (!Number.isFinite(usedPercent)) { - const remainingFraction = Number(info['remaining_fraction']) - if (Number.isFinite(remainingFraction)) { + let usedPercent = coercePercent(info['used_percent']) + if (usedPercent === null) { + const remainingFraction = coerceRemainingFraction(info['remaining_fraction']) + if (remainingFraction !== null) { usedPercent = (1 - remainingFraction) * 100 } else { continue } } - if (usedPercent < 0) usedPercent = 0 - if (usedPercent > 100) usedPercent = 100 + usedPercent = coercePercent(usedPercent) ?? 0 const remainingPercent = Math.max(100 - usedPercent, 0) - let resetSeconds: number | null = null + let resetSeconds = secondsUntilUnixReset(info['reset_at']) const resetTime = info['reset_time'] if (typeof resetTime === 'string' && resetTime.trim()) { const ts = Date.parse(resetTime.trim()) @@ -247,11 +275,17 @@ const items = computed(() => { } } - result.push({ model, label: model, usedPercent, remainingPercent, resetSeconds }) + result.push({ + model, + label: resolveAntigravityQuotaLabel(model, info['display_name'], opaqueDisplayIndex), + usedPercent, + remainingPercent, + resetSeconds, + }) } - result.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model)) - return result + result.sort(compareAntigravityQuotaItems) + return dedupeAntigravityQuotaItemsByLabel(result) }) async function handleTestModel(modelName: string) { diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 2f1496ceb..aaa318121 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -361,31 +361,39 @@ />
+
@@ -949,6 +957,11 @@ import ProviderMonthlyQuotaCard from '@/features/providers/components/ProviderMo import ProviderQuotaProgressRow from '@/features/providers/components/ProviderQuotaProgressRow.vue' import ProviderQuotaSectionHeader from '@/features/providers/components/ProviderQuotaSectionHeader.vue' import { useProxyNodesStore } from '@/stores/proxy-nodes' +import { + compareAntigravityQuotaItems, + dedupeAntigravityQuotaItemsByLabel, + resolveAntigravityQuotaLabel, +} from '@/features/providers/utils/antigravityQuota' import { deleteEndpointKey, recoverKeyHealth, @@ -3348,53 +3361,72 @@ function secondsUntilReset(resetTime: string): number | null { return diff > 0 ? diff : 0 } +function secondsUntilUnixReset(resetAt: number | string | null | undefined): number | null { + const numericResetAt = Number(resetAt) + if (!Number.isFinite(numericResetAt) || numericResetAt <= 0) return null + const now = Math.floor(Date.now() / 1000) + return Math.max(Math.floor(numericResetAt - now), 0) +} + +function coerceAntigravityPercent(value: number | string | null | undefined): number | undefined { + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) return undefined + return Math.min(Math.max(numericValue, 0), 100) +} + +function coerceAntigravityRemainingFraction(value: number | string | null | undefined): number | undefined { + const numericValue = Number(value) + if (!Number.isFinite(numericValue)) return undefined + return Math.min(Math.max(numericValue, 0), 1) +} + function getAntigravityQuotaItems(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaItem[] { const quotaByModel = metadata?.antigravity?.quota_by_model if (!quotaByModel || typeof quotaByModel !== 'object') return [] const items: AntigravityQuotaItem[] = [] + const opaqueDisplayIndex = { value: 1 } for (const [model, rawInfo] of Object.entries(quotaByModel)) { if (!model) continue const info: Partial = rawInfo || {} - let usedPercent = Number(info.used_percent) - if (!Number.isFinite(usedPercent)) { - const remainingFraction = Number(info.remaining_fraction) - if (Number.isFinite(remainingFraction)) { + let usedPercent = coerceAntigravityPercent(info.used_percent) + if (usedPercent === undefined) { + const remainingFraction = coerceAntigravityRemainingFraction(info.remaining_fraction) + if (remainingFraction !== undefined) { usedPercent = (1 - remainingFraction) * 100 } else { continue } } - if (usedPercent < 0) usedPercent = 0 - if (usedPercent > 100) usedPercent = 100 + usedPercent = coerceAntigravityPercent(usedPercent) ?? 0 const remainingPercent = Math.max(100 - usedPercent, 0) - let resetSeconds: number | null = null + let resetSeconds = secondsUntilUnixReset(info.reset_at) if (typeof info.reset_time === 'string' && info.reset_time.trim()) { - resetSeconds = secondsUntilReset(info.reset_time.trim()) + resetSeconds = secondsUntilReset(info.reset_time.trim()) ?? resetSeconds } items.push({ model, - label: model, + label: resolveAntigravityQuotaLabel(model, info.display_name, opaqueDisplayIndex), usedPercent, remainingPercent, resetSeconds, }) } - // 按“最紧张”(已用最多)优先排序,便于快速定位额度风险;完整列表通过滚动展示 - items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model)) - return items + items.sort(compareAntigravityQuotaItems) + return dedupeAntigravityQuotaItemsByLabel(items) } function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQuotaItem[] { const quota = getQuotaSnapshotForProvider(key, 'antigravity') const windows = getQuotaWindowByScope(quota, 'model') if (!quota || windows.length === 0) return [] + const opaqueDisplayIndex = { value: 1 } const items = windows .map((window) => { @@ -3418,7 +3450,11 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ return { model, - label: String(window.label || window.model || model), + label: resolveAntigravityQuotaLabel( + model, + window.label || window.model, + opaqueDisplayIndex, + ), usedPercent: normalizedUsedPercent, remainingPercent: normalizedRemainingPercent, resetSeconds: getQuotaWindowLiveResetSeconds(quota, window), @@ -3426,124 +3462,24 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ }) .filter((item): item is AntigravityQuotaItem => item !== null) - items.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model)) - return items + items.sort(compareAntigravityQuotaItems) + return dedupeAntigravityQuotaItemsByLabel(items) } -// Antigravity 配额分组定义(按匹配优先级排列,具体规则在前) -interface AntigravityQuotaGroup { - key: string - label: string - match: (model: string) => boolean -} +const ANTIGRAVITY_QUOTA_PREVIEW_LIMIT = 6 -const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroup[] = [ - { key: 'claude', label: 'Claude', match: m => m.includes('claude') }, - { key: 'gemini-2.5', label: 'Gemini 2.5', match: m => m.includes('gemini-2.5') || m.includes('gemini-2-5') }, - { key: 'gemini-3', label: 'Gemini 3', match: m => m.includes('gemini-3') && !m.includes('image') }, - { key: 'gemini-3-image', label: 'Gemini 3 Image', match: m => m.includes('gemini-3') && m.includes('image') }, -] - -interface AntigravityQuotaSummaryItem { - key: string - label: string - usedPercent: number // 组内最高已用百分比(最紧张) - remainingPercent: number // 100 - usedPercent - resetSeconds: number | null -} - -function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefined): AntigravityQuotaSummaryItem[] { - const items = getAntigravityQuotaItems(metadata) - if (!items.length) return [] - - // 将每个模型归入分组 - const groupMap = new Map() - - for (const item of items) { - const model = item.model.toLowerCase() - const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model)) - if (!group) continue - - const existing = groupMap.get(group.key) - if (!existing) { - groupMap.set(group.key, { - label: group.label, - maxUsed: item.usedPercent, - resetSeconds: item.resetSeconds, - }) - } else { - if (item.usedPercent > existing.maxUsed) { - existing.maxUsed = item.usedPercent - } - if (existing.resetSeconds === null) { - existing.resetSeconds = item.resetSeconds - } else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) { - existing.resetSeconds = item.resetSeconds - } - } - } - - // 按 ANTIGRAVITY_QUOTA_GROUPS 定义的顺序输出 - const result: AntigravityQuotaSummaryItem[] = [] - for (const group of ANTIGRAVITY_QUOTA_GROUPS) { - const data = groupMap.get(group.key) - if (!data) continue - result.push({ - key: group.key, - label: data.label, - usedPercent: data.maxUsed, - remainingPercent: Math.max(100 - data.maxUsed, 0), - resetSeconds: data.resetSeconds, - }) - } - return result -} - -function getAntigravityQuotaSummaryForKey(key: EndpointAPIKey): AntigravityQuotaSummaryItem[] { +function getAntigravityQuotaItemsForKey(key: EndpointAPIKey): AntigravityQuotaItem[] { const snapshotItems = getAntigravityQuotaItemsFromSnapshot(key) - if (snapshotItems.length > 0) { - const groupMap = new Map() + if (snapshotItems.length > 0) return snapshotItems + return getAntigravityQuotaItems(key.upstream_metadata) +} - for (const item of snapshotItems) { - const model = item.model.toLowerCase() - const group = ANTIGRAVITY_QUOTA_GROUPS.find(g => g.match(model)) - if (!group) continue +function getAntigravityQuotaPreviewForKey(key: EndpointAPIKey): AntigravityQuotaItem[] { + return getAntigravityQuotaItemsForKey(key).slice(0, ANTIGRAVITY_QUOTA_PREVIEW_LIMIT) +} - const existing = groupMap.get(group.key) - if (!existing) { - groupMap.set(group.key, { - label: group.label, - maxUsed: item.usedPercent, - resetSeconds: item.resetSeconds, - }) - } else { - if (item.usedPercent > existing.maxUsed) { - existing.maxUsed = item.usedPercent - } - if (existing.resetSeconds === null) { - existing.resetSeconds = item.resetSeconds - } else if (item.resetSeconds !== null && item.resetSeconds < existing.resetSeconds) { - existing.resetSeconds = item.resetSeconds - } - } - } - - const result: AntigravityQuotaSummaryItem[] = [] - for (const group of ANTIGRAVITY_QUOTA_GROUPS) { - const data = groupMap.get(group.key) - if (!data) continue - result.push({ - key: group.key, - label: data.label, - usedPercent: data.maxUsed, - remainingPercent: Math.max(100 - data.maxUsed, 0), - resetSeconds: data.resetSeconds, - }) - } - return result - } - - return getAntigravityQuotaSummary(key.upstream_metadata) +function getAntigravityQuotaHiddenCountForKey(key: EndpointAPIKey): number { + return Math.max(getAntigravityQuotaItemsForKey(key).length - ANTIGRAVITY_QUOTA_PREVIEW_LIMIT, 0) } function getResetCountdownText( diff --git a/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts b/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts new file mode 100644 index 000000000..2ad4a4616 --- /dev/null +++ b/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, h } from 'vue' + +import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue' +import type { UpstreamMetadata } from '@/api/endpoints/types' + +vi.mock('@/components/ui', async () => { + const { defineComponent, h } = await import('vue') + + const passthrough = (name: string) => defineComponent({ + name, + setup(_, { slots }) { + return () => h('div', [ + slots.headerActions?.(), + slots.default?.(), + slots.footer?.(), + ]) + }, + }) + + return { + Dialog: passthrough('DialogStub'), + DropdownMenu: passthrough('DropdownMenuStub'), + DropdownMenuTrigger: passthrough('DropdownMenuTriggerStub'), + DropdownMenuContent: passthrough('DropdownMenuContentStub'), + DropdownMenuItem: defineComponent({ + name: 'DropdownMenuItemStub', + emits: ['select'], + setup(_, { emit, slots }) { + return () => h('button', { type: 'button', onClick: () => emit('select') }, slots.default?.()) + }, + }), + } +}) + +vi.mock('@/components/ui/button.vue', async () => { + const { defineComponent, h } = await import('vue') + + return { + default: defineComponent({ + name: 'ButtonStub', + setup(_, { attrs, slots }) { + return () => h('button', { ...attrs, type: 'button' }, slots.default?.()) + }, + }), + } +}) + +vi.mock('lucide-vue-next', async () => { + const { defineComponent, h } = await import('vue') + const Icon = defineComponent({ + name: 'IconStub', + setup() { + return () => h('span') + }, + }) + + return { + BarChart3: Icon, + Loader2: Icon, + Play: Icon, + } +}) + +vi.mock('@/api/endpoints/providers', () => ({ + testModel: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + error: vi.fn(), + success: vi.fn(), + }), +})) + +vi.mock('@/utils/errorParser', () => ({ + parseApiError: (value: unknown) => String(value), +})) + +function mount(metadata: UpstreamMetadata) { + const root = document.createElement('div') + document.body.appendChild(root) + + const app = createApp(defineComponent({ + setup() { + return () => h(AntigravityQuotaDialog, { + open: true, + metadata, + keyName: 'Key-1', + }) + }, + })) + app.mount(root) + + return { + root, + unmount: () => { + app.unmount() + root.remove() + }, + } +} + +describe('AntigravityQuotaDialog', () => { + it('renders opaque quota identifiers with concise visible labels', () => { + const rawIdentifier = 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb' + const { root, unmount } = mount({ + antigravity: { + quota_by_model: { + [rawIdentifier]: { + display_name: rawIdentifier, + remaining_fraction: 0.25, + used_percent: 75, + }, + }, + }, + }) + + expect(root.textContent).toContain('Key-1') + expect(root.textContent).not.toContain(rawIdentifier) + expect(root.textContent).toContain('25.0%') + + unmount() + }) + + it('orders important Gemini and Claude quota rows before low-priority rows', () => { + const { root, unmount } = mount({ + antigravity: { + quota_by_model: { + tab_flash_lite_preview: { + display_name: 'Tab Flash Lite Preview', + remaining_fraction: 0.01, + used_percent: 99, + }, + 'gemini-3-flash-agent': { + display_name: 'Gemini 3.5 Flash (High)', + remaining_fraction: 0.9, + used_percent: 10, + }, + 'gemini-pro-agent': { + display_name: 'gemini-pro-agent', + remaining_fraction: 0.95, + used_percent: 5, + }, + 'gemini-3.5-flash-low': { + display_name: 'Gemini 3.5 Flash (Medium)', + remaining_fraction: 0.8, + used_percent: 20, + }, + 'claude-opus-4-6-thinking': { + display_name: 'Claude Opus 4.6 (Thinking)', + remaining_fraction: 1, + used_percent: 0, + }, + chat_20706: { + display_name: 'chat_20706', + remaining_fraction: 0, + used_percent: 100, + }, + }, + }, + }) + const text = root.textContent || '' + + expect(text).not.toContain('gemini-pro-agent') + expect(text).not.toContain('claude-opus-4-6-thinking') + expect(text.indexOf('Claude Opus 4.6 (Thinking)')).toBeLessThan(text.indexOf('Gemini 3.1 Pro (High)')) + expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)')) + expect(text.indexOf('Gemini 3.5 Flash (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (Medium)')) + expect(text.indexOf('Gemini 3.5 Flash (Medium)')).toBeLessThan(text.indexOf('Tab Flash Lite Preview')) + expect(text.indexOf('Tab Flash Lite Preview')).toBeLessThan(text.indexOf('chat_20706')) + + unmount() + }) + + it('renders one row for duplicate quota labels and keeps the preferred active bucket', () => { + const { root, unmount } = mount({ + antigravity: { + quota_by_model: { + 'gemini-3.1-pro-high': { + display_name: 'Gemini 3.1 Pro (High)', + remaining_fraction: 0.95, + used_percent: 5, + }, + 'gemini-pro-agent': { + display_name: 'Gemini 3.1 Pro (High)', + remaining_fraction: 0.4, + used_percent: 60, + }, + 'gemini-3-flash-agent': { + display_name: 'Gemini 3.5 Flash (High)', + remaining_fraction: 0.9, + used_percent: 10, + }, + }, + }, + }) + const text = root.textContent || '' + + expect(text.match(/Gemini 3\.1 Pro \(High\)/g)).toHaveLength(1) + expect(text).toContain('40.0%') + expect(text).not.toContain('95.0%') + expect(text.indexOf('Gemini 3.1 Pro (High)')).toBeLessThan(text.indexOf('Gemini 3.5 Flash (High)')) + + unmount() + }) +}) diff --git a/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts b/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts index ecc416d60..183ebb5bd 100644 --- a/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts +++ b/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts @@ -5,6 +5,7 @@ import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from '../endpoint-d const apiFormats = [ { value: 'openai:chat', default_path: '/v1/chat/completions' }, { value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' }, + { value: 'gemini:interactions', default_path: '/v1/interactions' }, { value: 'gemini:embedding', default_path: '/v1beta/models/{model}:embedContent' }, { value: 'gemini:video', default_path: '/v1beta/models/{model}:predictLongRunning' }, { value: 'openai:responses', default_path: '/v1/responses' }, @@ -31,6 +32,12 @@ describe('endpoint default paths', () => { apiFormats, })).toBe('/models/{model}:embedContent') + expect(getDefaultEndpointPath({ + apiFormat: 'gemini:interactions', + providerType: 'custom', + apiFormats, + })).toBe('/interactions') + expect(getDefaultEndpointPath({ apiFormat: 'gemini:video', providerType: 'custom', @@ -218,6 +225,11 @@ describe('endpoint default paths', () => { baseUrl: 'https://generativelanguage.googleapis.com', })).toBe('https://generativelanguage.googleapis.com/v1beta') + expect(getDefaultEndpointBaseUrl({ + apiFormat: 'gemini:interactions', + baseUrl: 'https://generativelanguage.googleapis.com', + })).toBe('https://generativelanguage.googleapis.com/v1') + expect(getDefaultEndpointBaseUrl({ apiFormat: 'gemini:video', baseUrl: 'https://generativelanguage.googleapis.com', diff --git a/frontend/src/features/providers/components/endpoint-default-paths.ts b/frontend/src/features/providers/components/endpoint-default-paths.ts index e3f6087f7..0fcd451d0 100644 --- a/frontend/src/features/providers/components/endpoint-default-paths.ts +++ b/frontend/src/features/providers/components/endpoint-default-paths.ts @@ -98,11 +98,17 @@ function usesVersionedApiRootByDefault(apiFormat: string): boolean { || apiFormat === 'jina:rerank' || apiFormat === 'claude:messages' || apiFormat === 'gemini:generate_content' + || apiFormat === 'gemini:interactions' || apiFormat === 'gemini:embedding' || apiFormat === 'gemini:video' } function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' { + if ( + apiFormat === 'gemini:interactions' + ) { + return '/v1' + } if ( apiFormat === 'gemini:generate_content' || apiFormat === 'gemini:embedding' @@ -116,6 +122,7 @@ function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' { function skipsVersionedApiRootDefault(apiFormat: string, baseUrl: string): boolean { if ( apiFormat === 'gemini:generate_content' + || apiFormat === 'gemini:interactions' || apiFormat === 'gemini:embedding' || apiFormat === 'gemini:video' ) { diff --git a/frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts b/frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts index 9622695c9..afab2e00e 100644 --- a/frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts +++ b/frontend/src/features/providers/components/provider-tabs/__tests__/model-test-request.spec.ts @@ -86,6 +86,17 @@ describe('buildDefaultModelTestRequestBody', () => { expect(body.messages).toBeUndefined() }) + it('uses Gemini Interactions payloads for interaction endpoints', () => { + const body = JSON.parse(buildDefaultModelTestRequestBody('gemini-3.5-flash', 'gemini:interactions')) + + expect(body).toEqual({ + model: 'gemini-3.5-flash', + input: 'Hello! This is a test message.', + stream: true, + }) + expect(body.messages).toBeUndefined() + }) + it('uses image generation tools for image models on OpenAI Responses endpoints', () => { const body = JSON.parse(buildDefaultModelTestRequestBody( 'gpt-image-2', diff --git a/frontend/src/features/providers/components/provider-tabs/model-test-request.ts b/frontend/src/features/providers/components/provider-tabs/model-test-request.ts index 0e0b576fe..9b5fcb65f 100644 --- a/frontend/src/features/providers/components/provider-tabs/model-test-request.ts +++ b/frontend/src/features/providers/components/provider-tabs/model-test-request.ts @@ -196,6 +196,14 @@ export function buildDefaultModelTestRequestBody( }, null, 2) } + if (normalizedApiFormat === 'gemini:interactions') { + return JSON.stringify({ + model: modelName, + input: DEFAULT_MODEL_TEST_MESSAGE, + stream: true, + }, null, 2) + } + if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) { return JSON.stringify({ model: modelName, diff --git a/frontend/src/features/providers/composables/useEndpointStatus.ts b/frontend/src/features/providers/composables/useEndpointStatus.ts index 87a5a229e..0dd65645e 100644 --- a/frontend/src/features/providers/composables/useEndpointStatus.ts +++ b/frontend/src/features/providers/composables/useEndpointStatus.ts @@ -12,6 +12,7 @@ const ENDPOINT_SORT_ORDER = [ 'openai:embedding', 'openai:rerank', 'gemini:generate_content', + 'gemini:interactions', 'gemini:embedding', 'openai:video', 'gemini:video', diff --git a/frontend/src/features/providers/composables/useProviderFilters.ts b/frontend/src/features/providers/composables/useProviderFilters.ts index 7da7511be..1c5bf711e 100644 --- a/frontend/src/features/providers/composables/useProviderFilters.ts +++ b/frontend/src/features/providers/composables/useProviderFilters.ts @@ -32,6 +32,7 @@ export function useProviderFilters( { value: 'openai:embedding', label: 'OpenAI Embedding' }, { value: 'openai:rerank', label: 'OpenAI Rerank' }, { value: 'gemini:generate_content', label: 'Gemini Generate Content' }, + { value: 'gemini:interactions', label: 'Gemini Interactions' }, { value: 'gemini:embedding', label: 'Gemini Embedding' }, { value: 'jina:embedding', label: 'Jina Embedding' }, { value: 'jina:rerank', label: 'Jina Rerank' }, diff --git a/frontend/src/features/providers/utils/__tests__/antigravityQuota.spec.ts b/frontend/src/features/providers/utils/__tests__/antigravityQuota.spec.ts new file mode 100644 index 000000000..aa61e8891 --- /dev/null +++ b/frontend/src/features/providers/utils/__tests__/antigravityQuota.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' + +import { + compareAntigravityQuotaItems, + dedupeAntigravityQuotaItemsByLabel, + resolveAntigravityQuotaLabel, +} from '@/features/providers/utils/antigravityQuota' + +describe('antigravityQuota', () => { + it('uses canonical labels from model ids before stale upstream labels', () => { + const opaqueDisplayIndex = { value: 1 } + + expect( + resolveAntigravityQuotaLabel( + 'gemini-3-flash-agent', + 'Gemini 3.5 Flash (High)', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.5 Flash (High)') + expect( + resolveAntigravityQuotaLabel( + 'gemini-3.5-flash-low', + 'Gemini 3.5 Flash (Medium)', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.5 Flash (Medium)') + expect( + resolveAntigravityQuotaLabel( + 'gemini-3.5-flash-extra-low', + 'Gemini 3.5 Flash (Low)', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.5 Flash (Low)') + expect( + resolveAntigravityQuotaLabel( + 'gemini-pro-agent', + 'gemini-pro-agent', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.1 Pro (High)') + expect( + resolveAntigravityQuotaLabel( + 'gemini-3.1-pro-high', + 'gemini-3.1-pro-high', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.1 Pro (High)') + expect( + resolveAntigravityQuotaLabel( + 'gemini-2.5-flash', + 'Gemini 3.1 Flash Lite', + opaqueDisplayIndex, + ), + ).toBe('Gemini 3.1 Flash Lite') + expect( + resolveAntigravityQuotaLabel( + 'claude-sonnet-4-6', + 'Claude Sonnet 4.6 (Thinking)', + opaqueDisplayIndex, + ), + ).toBe('Claude Sonnet 4.6 (Thinking)') + expect( + resolveAntigravityQuotaLabel( + 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb', + 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb', + opaqueDisplayIndex, + ), + ).toBe('Key-1') + }) + + it('keeps latest Gemini and Claude quota buckets before low-priority buckets', () => { + const items = [ + { + model: 'tab_flash_lite_preview', + label: 'Tab Flash Lite Preview', + remainingPercent: 1, + resetSeconds: 10, + }, + { + model: 'gemini-3.5-flash-extra-low', + label: 'Gemini 3.5 Flash (Low)', + remainingPercent: 95, + resetSeconds: null, + }, + { + model: 'gemini-3.5-flash-low', + label: 'Gemini 3.5 Flash (Medium)', + remainingPercent: 90, + resetSeconds: null, + }, + { + model: 'gemini-3-flash-agent', + label: 'Gemini 3.5 Flash (High)', + remainingPercent: 80, + resetSeconds: null, + }, + { + model: 'gemini-pro-agent', + label: 'Gemini 3.1 Pro (High)', + remainingPercent: 70, + resetSeconds: null, + }, + { + model: 'chat_20706', + label: 'chat_20706', + remainingPercent: 0, + resetSeconds: 1, + }, + { + model: 'claude-opus-4-6-thinking', + label: 'Claude Opus 4.6 (Thinking)', + remainingPercent: 100, + resetSeconds: null, + }, + { + model: 'gemini-2.5-flash-lite', + label: 'Gemini 3.1 Flash Lite', + remainingPercent: 5, + resetSeconds: 5, + }, + ].sort(compareAntigravityQuotaItems) + + expect(items.map(item => item.model)).toEqual([ + 'claude-opus-4-6-thinking', + 'gemini-pro-agent', + 'gemini-3-flash-agent', + 'gemini-3.5-flash-low', + 'gemini-3.5-flash-extra-low', + 'gemini-2.5-flash-lite', + 'tab_flash_lite_preview', + 'chat_20706', + ]) + }) + + it('deduplicates display labels while keeping the current preferred quota bucket', () => { + const items = dedupeAntigravityQuotaItemsByLabel([ + { + model: 'gemini-3.1-pro-high', + label: 'Gemini 3.1 Pro (High)', + remainingPercent: 95, + resetSeconds: null, + }, + { + model: 'gemini-pro-agent', + label: 'Gemini 3.1 Pro (High)', + remainingPercent: 40, + resetSeconds: null, + }, + { + model: 'claude-sonnet-4-6-thinking', + label: 'Claude Sonnet 4.6 (Thinking)', + remainingPercent: 95, + resetSeconds: null, + }, + { + model: 'claude-sonnet-4-6', + label: 'Claude Sonnet 4.6 (Thinking)', + remainingPercent: 40, + resetSeconds: null, + }, + { + model: 'gemini-3-flash-agent', + label: 'Gemini 3.5 Flash (High)', + remainingPercent: 80, + resetSeconds: null, + }, + ]) + + expect(items.map(item => item.model)).toEqual([ + 'claude-sonnet-4-6', + 'gemini-pro-agent', + 'gemini-3-flash-agent', + ]) + }) +}) diff --git a/frontend/src/features/providers/utils/antigravityQuota.ts b/frontend/src/features/providers/utils/antigravityQuota.ts new file mode 100644 index 000000000..981ad1252 --- /dev/null +++ b/frontend/src/features/providers/utils/antigravityQuota.ts @@ -0,0 +1,117 @@ +export interface AntigravityQuotaSortableItem { + model: string + label: string + remainingPercent: number + resetSeconds: number | null +} + +const ANTIGRAVITY_MODEL_LABELS: Record = { + 'gemini-pro-agent': 'Gemini 3.1 Pro (High)', + 'gemini-3.1-pro-high': 'Gemini 3.1 Pro (High)', + 'gemini-3.1-pro-low': 'Gemini 3.1 Pro (Low)', + 'gemini-3-flash-agent': 'Gemini 3.5 Flash (High)', + 'gemini-3.5-flash-low': 'Gemini 3.5 Flash (Medium)', + 'gemini-3.5-flash-extra-low': 'Gemini 3.5 Flash (Low)', + 'claude-opus-4-6-thinking': 'Claude Opus 4.6 (Thinking)', + 'claude-sonnet-4-6': 'Claude Sonnet 4.6 (Thinking)', + 'claude-sonnet-4-6-thinking': 'Claude Sonnet 4.6 (Thinking)', + 'gemini-3.1-flash-image': 'Gemini 3.1 Flash Image', + 'gemini-3.1-flash-lite': 'Gemini 3.1 Flash Lite', + 'gemini-3-flash': 'Gemini 3 Flash', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-2.5-flash-thinking': 'Gemini 3.1 Flash Lite', + 'gemini-2.5-flash': 'Gemini 3.1 Flash Lite', + 'gemini-2.5-flash-lite': 'Gemini 3.1 Flash Lite', + 'gpt-oss-120b-medium': 'GPT-OSS 120B (Medium)', + 'tab_flash_lite_preview': 'Tab Flash Lite Preview', + 'tab_jump_flash_lite_preview': 'Tab Jump Flash Lite Preview', + 'models/proactive-observer': 'Proactive Observer', +} + +const ANTIGRAVITY_MODEL_PRIORITY: Record = { + 'claude-opus-4-6-thinking': 10, + 'claude-sonnet-4-6': 20, + 'claude-sonnet-4-6-thinking': 25, + 'gemini-pro-agent': 30, + 'gemini-3.1-pro-high': 35, + 'gemini-3.1-pro-low': 40, + 'gemini-3-flash-agent': 50, + 'gemini-3.5-flash-low': 60, + 'gemini-3.5-flash-extra-low': 70, + 'gemini-3.1-flash-image': 80, + 'gemini-3.1-flash-lite': 90, + 'gemini-3-flash': 180, + 'gemini-2.5-pro': 300, + 'gemini-2.5-flash-thinking': 310, + 'gemini-2.5-flash': 320, + 'gemini-2.5-flash-lite': 330, + 'gpt-oss-120b-medium': 700, + 'models/proactive-observer': 780, + 'tab_flash_lite_preview': 800, + 'tab_jump_flash_lite_preview': 810, +} + +export function isOpaqueAntigravityQuotaIdentifier(value: string): boolean { + return value.trim().startsWith('RateLimitResetCredit_') +} + +export function resolveAntigravityQuotaLabel( + model: string, + rawLabel: unknown, + opaqueDisplayIndex: { value: number }, +): string { + const normalizedModel = model.trim() + const canonical = ANTIGRAVITY_MODEL_LABELS[normalizedModel] + if (canonical) return canonical + + const candidate = String(rawLabel || '').trim() + if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate + if (isOpaqueAntigravityQuotaIdentifier(normalizedModel) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) { + const label = `Key-${opaqueDisplayIndex.value}` + opaqueDisplayIndex.value += 1 + return label + } + return candidate || normalizedModel +} + +function getAntigravityModelPriority(model: string): number { + const normalizedModel = model.trim() + const explicit = ANTIGRAVITY_MODEL_PRIORITY[normalizedModel] + if (explicit !== undefined) return explicit + if (normalizedModel.startsWith('claude-')) return 30 + if (normalizedModel.startsWith('gemini-3.')) return 200 + if (normalizedModel.startsWith('gemini-2.')) return 390 + if (normalizedModel.startsWith('gemini-')) return 490 + if (normalizedModel.startsWith('gpt-oss-')) return 700 + if (normalizedModel.startsWith('models/')) return 780 + if (normalizedModel.startsWith('tab_')) return 800 + if (normalizedModel.startsWith('chat_')) return 900 + if (isOpaqueAntigravityQuotaIdentifier(normalizedModel)) return 950 + return 850 +} + +export function compareAntigravityQuotaItems( + a: T, + b: T, +): number { + return (getAntigravityModelPriority(a.model) - getAntigravityModelPriority(b.model)) + || ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY)) + || (a.remainingPercent - b.remainingPercent) + || a.label.localeCompare(b.label) + || a.model.localeCompare(b.model) +} + +export function dedupeAntigravityQuotaItemsByLabel( + items: T[], +): T[] { + const selectedByLabel = new Map() + for (const item of items) { + const label = item.label.trim() + if (!label) continue + const selected = selectedByLabel.get(label) + if (!selected || compareAntigravityQuotaItems(item, selected) < 0) { + selectedByLabel.set(label, item) + } + } + return Array.from(selectedByLabel.values()).sort(compareAntigravityQuotaItems) +} diff --git a/frontend/src/mocks/data.ts b/frontend/src/mocks/data.ts index c9581ae14..9d9a74c2a 100644 --- a/frontend/src/mocks/data.ts +++ b/frontend/src/mocks/data.ts @@ -1192,6 +1192,7 @@ export const MOCK_API_FORMATS = { { value: 'openai:image', label: 'OpenAI Image', default_path: '/v1/images/generations', aliases: [] }, { value: 'openai:video', label: 'OpenAI Video', default_path: '/v1/videos', aliases: [] }, { value: 'gemini:generate_content', label: 'Gemini Generate Content', default_path: '/v1beta/models/{model}:{action}', aliases: [] }, + { value: 'gemini:interactions', label: 'Gemini Interactions', default_path: '/v1/interactions', aliases: [] }, { value: 'gemini:embedding', label: 'Gemini Embedding', default_path: '/v1beta/models/{model}:embedContent', aliases: [] }, { value: 'gemini:video', label: 'Gemini Video', default_path: '/v1beta/models/{model}:predictLongRunning', aliases: [] }, { value: 'jina:embedding', label: 'Jina Embedding', default_path: '/v1/embeddings', aliases: [] }, diff --git a/frontend/src/mocks/handler.ts b/frontend/src/mocks/handler.ts index 1cb50c4d6..bd2046d98 100644 --- a/frontend/src/mocks/handler.ts +++ b/frontend/src/mocks/handler.ts @@ -533,6 +533,7 @@ function mockApiFormatDisplayName(apiFormat: string) { const labels: Record = { 'claude:messages': 'Claude Messages', 'gemini:generate_content': 'Gemini Generate Content', + 'gemini:interactions': 'Gemini Interactions', 'openai:chat': 'OpenAI Chat', 'openai:embedding': 'OpenAI Embedding' }