From 80a6579766de4a2d09ce3e98d62b6e997622e54e Mon Sep 17 00:00:00 2001 From: MMEXA Date: Sun, 5 Jul 2026 01:28:57 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E6=94=AF=E6=8C=81=20Gemini=20Interaction?= =?UTF-8?q?s=20=E4=B8=8E=20Antigravity=20=E9=85=8D=E9=A2=9D=E7=B2=BE?= =?UTF-8?q?=E7=BB=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/aether-gateway/src/api/ai/gemini.rs | 2 + apps/aether-gateway/src/api/ai/registry.rs | 8 + apps/aether-gateway/src/control/route/ai.rs | 11 + apps/aether-gateway/src/control/tests/ai.rs | 17 ++ .../src/execution_runtime/fallback.rs | 3 + .../src/execution_runtime/tests.rs | 8 + .../query/models/model_test/adapter.rs | 7 +- .../query/models/model_test/summary.rs | 4 + .../provider/query/models/model_test/tests.rs | 4 + .../public/support/test_connection/route.rs | 10 +- .../src/tests/control/admin/system.rs | 5 + crates/aether-admin/src/provider/quota.rs | 114 +++++++- crates/aether-admin/src/system.rs | 6 + crates/aether-ai-formats/src/api.rs | 24 +- crates/aether-ai-formats/src/contracts/mod.rs | 20 +- .../src/contracts/plan_kinds.rs | 2 + .../src/contracts/report_kinds.rs | 17 +- crates/aether-ai-formats/src/formats/id.rs | 56 +++- .../aether-ai-formats/src/formats/matrix.rs | 55 +++- .../aether-ai-formats/src/formats/registry.rs | 27 +- .../src/formats/shared/passthrough.rs | 33 ++- .../src/formats/shared/routing.rs | 96 ++++++- .../shared/stream_core/format_matrix.rs | 6 +- crates/aether-ai-formats/src/lib.rs | 10 +- crates/aether-ai-serving/src/surface_spec.rs | 27 +- .../src/request_url/mod.rs | 84 +++++- .../src/same_format_provider/mod.rs | 95 ++++++- .../types/__tests__/api-format.spec.ts | 7 + .../src/api/endpoints/types/api-format.ts | 9 + frontend/src/api/endpoints/types/provider.ts | 13 +- .../components/AntigravityQuotaDialog.vue | 98 +++++-- .../components/ProviderDetailDrawer.vue | 253 ++++++++---------- .../__tests__/AntigravityQuotaDialog.spec.ts | 124 +++++++++ .../__tests__/endpoint-default-paths.spec.ts | 12 + .../components/endpoint-default-paths.ts | 7 + .../__tests__/model-test-request.spec.ts | 11 + .../provider-tabs/model-test-request.ts | 8 + .../composables/useEndpointStatus.ts | 1 + .../composables/useProviderFilters.ts | 1 + frontend/src/mocks/data.ts | 1 + frontend/src/mocks/handler.ts | 1 + 41 files changed, 1072 insertions(+), 225 deletions(-) create mode 100644 frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts 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..1e8817351 100644 --- a/apps/aether-gateway/src/api/ai/registry.rs +++ b/apps/aether-gateway/src/api/ai/registry.rs @@ -17,6 +17,8 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[ "/v1/responses/compact", "/v1/images/generations", "/v1/images/edits", + "/v1/interactions", + "/v1beta/interactions", "/v1internal:loadCodeAssist", "/v1internal:fetchAvailableModels", "/v1internal:fetchUserInfo", @@ -95,6 +97,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/control/route/ai.rs b/apps/aether-gateway/src/control/route/ai.rs index bfe95620b..fda3614e7 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( diff --git a/apps/aether-gateway/src/control/tests/ai.rs b/apps/aether-gateway/src/control/tests/ai.rs index 48f781d67..31dc1f082 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(&[]); 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/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/adapter.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/adapter.rs index 876ebd1d9..e1dc489af 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 @@ -82,14 +82,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, @@ -250,6 +250,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 +367,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/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/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/crates/aether-admin/src/provider/quota.rs b/crates/aether-admin/src/provider/quota.rs index c7b19c80c..bc37f4335 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, @@ -2001,6 +2046,7 @@ mod tests { 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_antigravity_usage_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, @@ -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-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..b408b4fc6 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,21 @@ :key="item.model" >
- - {{ item.label }} - +
+
+ {{ item.label }} +
+
+ {{ item.model }} +
+
{{ item.remainingPercent.toFixed(1) }}% @@ -165,6 +175,51 @@ 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 sortQuotaItems(a: QuotaItem, b: QuotaItem): number { + return (a.remainingPercent - b.remainingPercent) + || ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY)) + || a.label.localeCompare(b.label) + || a.model.localeCompare(b.model) +} + +function isOpaqueAntigravityQuotaIdentifier(value: string): boolean { + return value.trim().startsWith('RateLimitResetCredit_') +} + +function resolveQuotaLabel( + model: string, + rawLabel: unknown, + opaqueDisplayIndex: { value: number }, +): string { + const candidate = String(rawLabel || '').trim() + if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate + if (isOpaqueAntigravityQuotaIdentifier(model) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) { + const label = `Key-${opaqueDisplayIndex.value}` + opaqueDisplayIndex.value += 1 + return label + } + return candidate || model +} + function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] { if (!quota) return [] @@ -175,6 +230,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 +252,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin return { model, - label: String(window.label || window.model || model), + label: resolveQuotaLabel(model, window.label || window.model, opaqueDisplayIndex), usedPercent, remainingPercent, resetSeconds: getQuotaWindowLiveResetSeconds(quota, window), @@ -204,7 +260,7 @@ 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)) + items.sort(sortQuotaItems) return items } @@ -218,26 +274,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,10 +303,16 @@ const items = computed(() => { } } - result.push({ model, label: model, usedPercent, remainingPercent, resetSeconds }) + result.push({ + model, + label: resolveQuotaLabel(model, info['display_name'], opaqueDisplayIndex), + usedPercent, + remainingPercent, + resetSeconds, + }) } - result.sort((a, b) => (b.usedPercent - a.usedPercent) || a.model.localeCompare(b.model)) + result.sort(sortQuotaItems) return result }) diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 2f1496ceb..0934fcee8 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -361,31 +361,49 @@ />
+
@@ -3348,46 +3366,90 @@ 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 antigravityQuotaItemSort(a: AntigravityQuotaItem, b: AntigravityQuotaItem): number { + return (a.remainingPercent - b.remainingPercent) + || ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY)) + || a.label.localeCompare(b.label) + || a.model.localeCompare(b.model) +} + +function isOpaqueAntigravityQuotaIdentifier(value: string): boolean { + return value.trim().startsWith('RateLimitResetCredit_') +} + +function resolveAntigravityQuotaLabel( + model: string, + rawLabel: unknown, + opaqueDisplayIndex: { value: number }, +): string { + const candidate = String(rawLabel || '').trim() + if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate + if (isOpaqueAntigravityQuotaIdentifier(model) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) { + const label = `Key-${opaqueDisplayIndex.value}` + opaqueDisplayIndex.value += 1 + return label + } + return candidate || model +} + 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)) + items.sort(antigravityQuotaItemSort) return items } @@ -3395,6 +3457,7 @@ function getAntigravityQuotaItemsFromSnapshot(key: EndpointAPIKey): AntigravityQ 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 +3481,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 +3493,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)) + items.sort(antigravityQuotaItemSort) return 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..3f755ff10 --- /dev/null +++ b/frontend/src/features/providers/components/__tests__/AntigravityQuotaDialog.spec.ts @@ -0,0 +1,124 @@ +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 model display names without losing raw model identifiers', () => { + const { root, unmount } = mount({ + antigravity: { + quota_by_model: { + 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb': { + display_name: 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb', + remaining_fraction: 0.25, + used_percent: 75, + }, + }, + }, + }) + + expect(root.textContent).toContain('Key-1') + expect(root.textContent).toContain('RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb') + expect(root.textContent).toContain('25.0%') + + 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/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' } From accd250226290ea91050c4eb5789c53535dc1507 Mon Sep 17 00:00:00 2001 From: MMEXA Date: Sun, 5 Jul 2026 06:24:56 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Antigravity=20?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E6=A8=A1=E5=9E=8B=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/handlers/shared/catalog.rs | 71 ++++++++++++++++++- .../tests/control/admin/endpoints/quota.rs | 35 ++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 50ac1ba6d..48bebadcd 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -577,6 +577,29 @@ fn model_quota_window_snapshot( Some(Value::Object(window)) } +fn canonical_antigravity_model_label(model_name: &str) -> Option<&'static str> { + match model_name.trim() { + "gemini-3.5-flash-extra-low" => Some("Gemini 3.5 Flash Extra Low"), + "gemini-3.5-flash-low" => Some("Gemini 3.5 Flash Low"), + "gemini-3-flash-agent" => Some("Gemini 3 Flash Agent"), + _ => 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 +1489,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 +3738,52 @@ mod tests { ); } + #[test] + fn sync_provider_key_quota_status_snapshot_labels_antigravity_flash_tiers_by_model_id() { + let upstream_metadata = json!({ + "antigravity": { + "updated_at": 1_775_553_285u64, + "quota_by_model": { + "gemini-3.5-flash-extra-low": { "remaining_fraction": 1.0 }, + "gemini-3.5-flash-low": { "remaining_fraction": 0.75 }, + "gemini-3-flash-agent": { "remaining_fraction": 0.5 } + } + } + }); + + 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 Extra Low")) + ); + assert_eq!( + label_for_model("gemini-3.5-flash-low"), + Some(json!("Gemini 3.5 Flash Low")) + ); + assert_eq!( + label_for_model("gemini-3-flash-agent"), + Some(json!("Gemini 3 Flash Agent")) + ); + } + #[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/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 { From 9ec48ab6b928ac1051643f237ec5caff06a4cef3 Mon Sep 17 00:00:00 2001 From: MMEXA Date: Sun, 5 Jul 2026 06:34:06 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=20Antigravity=20?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E5=B1=95=E7=A4=BA=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/handlers/shared/catalog.rs | 36 ++++++- .../components/AntigravityQuotaDialog.vue | 45 ++------ .../components/ProviderDetailDrawer.vue | 41 ++----- .../__tests__/AntigravityQuotaDialog.spec.ts | 45 +++++++- .../utils/__tests__/antigravityQuota.spec.ts | 77 ++++++++++++++ .../providers/utils/antigravityQuota.ts | 100 ++++++++++++++++++ 6 files changed, 266 insertions(+), 78 deletions(-) create mode 100644 frontend/src/features/providers/utils/__tests__/antigravityQuota.spec.ts create mode 100644 frontend/src/features/providers/utils/antigravityQuota.ts diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 48bebadcd..3368a2dd6 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -579,9 +579,25 @@ fn model_quota_window_snapshot( 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" => Some("Claude Sonnet 4.6"), "gemini-3.5-flash-extra-low" => Some("Gemini 3.5 Flash Extra Low"), "gemini-3.5-flash-low" => Some("Gemini 3.5 Flash Low"), + "gemini-3.1-pro-high" => Some("Gemini 3.1 Pro High"), + "gemini-3.1-pro-low" => Some("Gemini 3.1 Pro Low"), + "gemini-pro-agent" => Some("Gemini Pro Agent"), + "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-agent" => Some("Gemini 3 Flash Agent"), + "gemini-3-flash" => Some("Gemini 3 Flash"), + "gemini-2.5-pro" => Some("Gemini 2.5 Pro"), + "gemini-2.5-flash-thinking" => Some("Gemini 2.5 Flash Thinking"), + "gemini-2.5-flash" => Some("Gemini 2.5 Flash"), + "gemini-2.5-flash-lite" => Some("Gemini 2.5 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, } } @@ -3739,14 +3755,22 @@ mod tests { } #[test] - fn sync_provider_key_quota_status_snapshot_labels_antigravity_flash_tiers_by_model_id() { + 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": { "remaining_fraction": 1.0 }, "gemini-3.5-flash-low": { "remaining_fraction": 0.75 }, - "gemini-3-flash-agent": { "remaining_fraction": 0.5 } + "gemini-3-flash-agent": { "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 + } } } }); @@ -3782,6 +3806,14 @@ mod tests { label_for_model("gemini-3-flash-agent"), Some(json!("Gemini 3 Flash Agent")) ); + assert_eq!( + label_for_model("gemini-2.5-flash"), + Some(json!("Gemini 2.5 Flash")) + ); + assert_eq!( + label_for_model("claude-sonnet-4-6"), + Some(json!("Claude Sonnet 4.6")) + ); } #[test] diff --git a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue index b408b4fc6..363ef9d9a 100644 --- a/frontend/src/features/providers/components/AntigravityQuotaDialog.vue +++ b/frontend/src/features/providers/components/AntigravityQuotaDialog.vue @@ -60,13 +60,6 @@ > {{ item.label }} -
- {{ item.model }} -
{{ item.remainingPercent.toFixed(1) }}% @@ -125,6 +118,10 @@ 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, + resolveAntigravityQuotaLabel, +} from '@/features/providers/utils/antigravityQuota' const props = defineProps<{ open: boolean @@ -194,32 +191,6 @@ function secondsUntilUnixReset(resetAt: unknown): number | null { return Math.max(Math.floor(numericResetAt - now), 0) } -function sortQuotaItems(a: QuotaItem, b: QuotaItem): number { - return (a.remainingPercent - b.remainingPercent) - || ((a.resetSeconds ?? Number.POSITIVE_INFINITY) - (b.resetSeconds ?? Number.POSITIVE_INFINITY)) - || a.label.localeCompare(b.label) - || a.model.localeCompare(b.model) -} - -function isOpaqueAntigravityQuotaIdentifier(value: string): boolean { - return value.trim().startsWith('RateLimitResetCredit_') -} - -function resolveQuotaLabel( - model: string, - rawLabel: unknown, - opaqueDisplayIndex: { value: number }, -): string { - const candidate = String(rawLabel || '').trim() - if (candidate && !isOpaqueAntigravityQuotaIdentifier(candidate)) return candidate - if (isOpaqueAntigravityQuotaIdentifier(model) || (candidate && isOpaqueAntigravityQuotaIdentifier(candidate))) { - const label = `Key-${opaqueDisplayIndex.value}` - opaqueDisplayIndex.value += 1 - return label - } - return candidate || model -} - function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefined): QuotaItem[] { if (!quota) return [] @@ -252,7 +223,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin return { model, - label: resolveQuotaLabel(model, window.label || window.model, opaqueDisplayIndex), + label: resolveAntigravityQuotaLabel(model, window.label || window.model, opaqueDisplayIndex), usedPercent, remainingPercent, resetSeconds: getQuotaWindowLiveResetSeconds(quota, window), @@ -260,7 +231,7 @@ function buildItemsFromQuotaSnapshot(quota: QuotaStatusSnapshot | null | undefin }) .filter((item): item is QuotaItem => item !== null) - items.sort(sortQuotaItems) + items.sort(compareAntigravityQuotaItems) return items } @@ -305,14 +276,14 @@ const items = computed(() => { result.push({ model, - label: resolveQuotaLabel(model, info['display_name'], opaqueDisplayIndex), + label: resolveAntigravityQuotaLabel(model, info['display_name'], opaqueDisplayIndex), usedPercent, remainingPercent, resetSeconds, }) } - result.sort(sortQuotaItems) + result.sort(compareAntigravityQuotaItems) return result }) diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 0934fcee8..b07bcd094 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -372,13 +372,6 @@ >