mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
支持 Gemini Interactions 与 Antigravity 配额精细化
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(&[]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -223,13 +223,19 @@ pub fn parse_antigravity_usage_response(
|
||||
) -> Option<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<String>,
|
||||
quota_key: &str,
|
||||
opaque_key_display_index: &mut usize,
|
||||
) -> Option<String> {
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<LocalSameFormatProviderSpec>
|
||||
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<LocalSameFormatProviderSpe
|
||||
family: LocalSameFormatProviderFamily::Gemini,
|
||||
require_streaming: true,
|
||||
}),
|
||||
GEMINI_INTERACTIONS_STREAM_PLAN_KIND => 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");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -142,6 +142,17 @@ pub fn extract_ai_standard_requested_model(body_json: &Value) -> Option<String>
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn extract_ai_gemini_body_requested_target(body_json: &Value) -> Option<String> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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<string, string> = {
|
||||
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<string, string> = {
|
||||
[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<string, string> = {
|
||||
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<string, string> = {
|
||||
'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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,9 +34,10 @@
|
||||
<DropdownMenuItem
|
||||
v-for="item in items"
|
||||
:key="item.model"
|
||||
:title="item.model"
|
||||
@select="handleTestModel(item.model)"
|
||||
>
|
||||
{{ item.model }}
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -52,12 +53,21 @@
|
||||
:key="item.model"
|
||||
>
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span
|
||||
class="text-muted-foreground truncate mr-2 min-w-0 flex-1"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1 mr-2">
|
||||
<div
|
||||
class="text-muted-foreground truncate"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.label !== item.model"
|
||||
class="text-[9px] leading-tight text-muted-foreground/55 truncate font-mono"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.model }}
|
||||
</div>
|
||||
</div>
|
||||
<span :class="getQuotaRemainingClass(item.usedPercent)">
|
||||
{{ item.remainingPercent.toFixed(1) }}%
|
||||
</span>
|
||||
@@ -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<QuotaItem[]>(() => {
|
||||
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<string, unknown>
|
||||
|
||||
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<QuotaItem[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -361,31 +361,49 @@
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<ProviderQuotaProgressRow
|
||||
v-for="group in getAntigravityQuotaSummaryForKey(key)"
|
||||
:key="group.key"
|
||||
:label="group.label"
|
||||
:used-percent="group.usedPercent"
|
||||
:remaining-percent="group.remainingPercent"
|
||||
:meter-class="getQuotaRemainingClass(group.usedPercent)"
|
||||
:bar-class="getQuotaRemainingBarColor(group.usedPercent)"
|
||||
v-for="item in getAntigravityQuotaPreviewForKey(key)"
|
||||
:key="item.model"
|
||||
:label="item.label"
|
||||
:title="item.model"
|
||||
:used-percent="item.usedPercent"
|
||||
:remaining-percent="item.remainingPercent"
|
||||
:meter-class="getQuotaRemainingClass(item.usedPercent)"
|
||||
:bar-class="getQuotaRemainingBarColor(item.usedPercent)"
|
||||
>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="group.resetSeconds !== null || group.usedPercent > 0"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
<template v-if="group.resetSeconds !== null && group.resetSeconds > 0">
|
||||
{{ formatResetTime(group.resetSeconds) }}{{ legacyT('后重置') }}
|
||||
</template>
|
||||
<template v-else-if="group.resetSeconds !== null && group.resetSeconds <= 0">
|
||||
{{ legacyT('已重置') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ legacyT('重置时间未知') }}
|
||||
</template>
|
||||
<div class="mt-0.5 space-y-0.5">
|
||||
<div
|
||||
v-if="item.label !== item.model"
|
||||
class="text-[9px] text-muted-foreground/55 truncate font-mono"
|
||||
:title="item.model"
|
||||
>
|
||||
{{ item.model }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.resetSeconds !== null || item.usedPercent > 0"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<template v-if="item.resetSeconds !== null && item.resetSeconds > 0">
|
||||
{{ formatResetTime(item.resetSeconds) }}{{ legacyT('后重置') }}
|
||||
</template>
|
||||
<template v-else-if="item.resetSeconds !== null && item.resetSeconds <= 0">
|
||||
{{ legacyT('已重置') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ legacyT('重置时间未知') }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ProviderQuotaProgressRow>
|
||||
<button
|
||||
v-if="getAntigravityQuotaHiddenCountForKey(key) > 0"
|
||||
type="button"
|
||||
class="col-span-2 text-left text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="openAntigravityQuotaDialog(key)"
|
||||
>
|
||||
{{ legacyT('另有') }} {{ getAntigravityQuotaHiddenCountForKey(key) }} {{ legacyT('个模型,查看全部') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -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<AntigravityModelQuota> = 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<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
||||
|
||||
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<string, { label: string, maxUsed: number, resetSeconds: number | null }>()
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
) {
|
||||
|
||||
+11
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ const ENDPOINT_SORT_ORDER = [
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'gemini:generate_content',
|
||||
'gemini:interactions',
|
||||
'gemini:embedding',
|
||||
'openai:video',
|
||||
'gemini:video',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -533,6 +533,7 @@ function mockApiFormatDisplayName(apiFormat: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'claude:messages': 'Claude Messages',
|
||||
'gemini:generate_content': 'Gemini Generate Content',
|
||||
'gemini:interactions': 'Gemini Interactions',
|
||||
'openai:chat': 'OpenAI Chat',
|
||||
'openai:embedding': 'OpenAI Embedding'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user